diff --git a/.gitattributes b/.gitattributes index d321e2a91..2e8a45ca5 100644 --- a/.gitattributes +++ b/.gitattributes @@ -6,6 +6,9 @@ mobile/openapi/**/*.dart linguist-generated=true mobile/lib/**/*.g.dart -diff -merge mobile/lib/**/*.g.dart linguist-generated=true +mobile/lib/**/*.drift.dart -diff -merge +mobile/lib/**/*.drift.dart linguist-generated=true + open-api/typescript-sdk/fetch-client.ts -diff -merge open-api/typescript-sdk/fetch-client.ts linguist-generated=true diff --git a/.vscode/settings.json b/.vscode/settings.json index 49dbf3944..49692809b 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -39,6 +39,7 @@ ], "explorer.fileNesting.enabled": true, "explorer.fileNesting.patterns": { - "*.ts": "${capture}.spec.ts,${capture}.mock.ts" + "*.ts": "${capture}.spec.ts,${capture}.mock.ts", + "*.dart": "${capture}.g.dart,${capture}.gr.dart,${capture}.drift.dart" } } \ No newline at end of file diff --git a/mobile/analysis_options.yaml b/mobile/analysis_options.yaml index 085449756..07c6f65b7 100644 --- a/mobile/analysis_options.yaml +++ b/mobile/analysis_options.yaml @@ -36,6 +36,8 @@ analyzer: exclude: - openapi/** - lib/generated_plugin_registrant.dart + - lib/**/*.g.dart + - lib/**/*.drift.dart plugins: - custom_lint diff --git a/mobile/build.yaml b/mobile/build.yaml new file mode 100644 index 000000000..d5de77a37 --- /dev/null +++ b/mobile/build.yaml @@ -0,0 +1,24 @@ +targets: + $default: + builders: + #drift @DriftDatabase() + drift_dev: + # Disable default builder to use modular builder instead + enabled: false + drift_dev:analyzer: + enabled: true + options: &drift_options + store_date_time_values_as_text: true + named_parameters: true + write_from_json_string_constructor: false + data_class_to_companions: false + # Required for make-migrations + databases: + main: lib/infrastructure/repositories/db.repository.dart + generate_for: &drift_generate_for + - lib/infrastructure/entities/*.dart + - lib/infrastructure/repositories/db.repository.dart + drift_dev:modular: + enabled: true + options: *drift_options + generate_for: *drift_generate_for \ No newline at end of file diff --git a/mobile/drift_schemas/main/drift_schema_v1.json b/mobile/drift_schemas/main/drift_schema_v1.json new file mode 100644 index 000000000..1870ef477 Binary files /dev/null and b/mobile/drift_schemas/main/drift_schema_v1.json differ diff --git a/mobile/lib/domain/models/user.model.dart b/mobile/lib/domain/models/user.model.dart index ad241a8c4..abf2e5620 100644 --- a/mobile/lib/domain/models/user.model.dart +++ b/mobile/lib/domain/models/user.model.dart @@ -1,32 +1,4 @@ -import 'dart:ui'; - -enum AvatarColor { - // do not change this order or reuse indices for other purposes, adding is OK - primary, - pink, - red, - yellow, - blue, - green, - purple, - orange, - gray, - amber; - - Color toColor({bool isDarkTheme = false}) => switch (this) { - AvatarColor.primary => - isDarkTheme ? const Color(0xFFABCBFA) : const Color(0xFF4250AF), - AvatarColor.pink => const Color.fromARGB(255, 244, 114, 182), - AvatarColor.red => const Color.fromARGB(255, 239, 68, 68), - AvatarColor.yellow => const Color.fromARGB(255, 234, 179, 8), - AvatarColor.blue => const Color.fromARGB(255, 59, 130, 246), - AvatarColor.green => const Color.fromARGB(255, 22, 163, 74), - AvatarColor.purple => const Color.fromARGB(255, 147, 51, 234), - AvatarColor.orange => const Color.fromARGB(255, 234, 88, 12), - AvatarColor.gray => const Color.fromARGB(255, 75, 85, 99), - AvatarColor.amber => const Color.fromARGB(255, 217, 119, 6), - }; -} +import 'package:immich_mobile/domain/models/user_metadata.model.dart'; // TODO: Rename to User once Isar is removed class UserDto { diff --git a/mobile/lib/domain/models/user_metadata.model.dart b/mobile/lib/domain/models/user_metadata.model.dart new file mode 100644 index 000000000..158638442 --- /dev/null +++ b/mobile/lib/domain/models/user_metadata.model.dart @@ -0,0 +1,105 @@ +import 'dart:ui'; + +enum AvatarColor { + // do not change this order or reuse indices for other purposes, adding is OK + primary("primary"), + pink("pink"), + red("red"), + yellow("yellow"), + blue("blue"), + green("green"), + purple("purple"), + orange("orange"), + gray("gray"), + amber("amber"); + + final String value; + const AvatarColor(this.value); + + Color toColor({bool isDarkTheme = false}) => switch (this) { + AvatarColor.primary => + isDarkTheme ? const Color(0xFFABCBFA) : const Color(0xFF4250AF), + AvatarColor.pink => const Color.fromARGB(255, 244, 114, 182), + AvatarColor.red => const Color.fromARGB(255, 239, 68, 68), + AvatarColor.yellow => const Color.fromARGB(255, 234, 179, 8), + AvatarColor.blue => const Color.fromARGB(255, 59, 130, 246), + AvatarColor.green => const Color.fromARGB(255, 22, 163, 74), + AvatarColor.purple => const Color.fromARGB(255, 147, 51, 234), + AvatarColor.orange => const Color.fromARGB(255, 234, 88, 12), + AvatarColor.gray => const Color.fromARGB(255, 75, 85, 99), + AvatarColor.amber => const Color.fromARGB(255, 217, 119, 6), + }; +} + +class UserPreferences { + final bool foldersEnabled; + final bool memoriesEnabled; + final bool peopleEnabled; + final bool ratingsEnabled; + final bool sharedLinksEnabled; + final bool tagsEnabled; + final AvatarColor userAvatarColor; + final bool showSupportBadge; + + const UserPreferences({ + this.foldersEnabled = false, + this.memoriesEnabled = true, + this.peopleEnabled = true, + this.ratingsEnabled = false, + this.sharedLinksEnabled = true, + this.tagsEnabled = false, + this.userAvatarColor = AvatarColor.primary, + this.showSupportBadge = true, + }); + + UserPreferences copyWith({ + bool? foldersEnabled, + bool? memoriesEnabled, + bool? peopleEnabled, + bool? ratingsEnabled, + bool? sharedLinksEnabled, + bool? tagsEnabled, + AvatarColor? userAvatarColor, + bool? showSupportBadge, + }) { + return UserPreferences( + foldersEnabled: foldersEnabled ?? this.foldersEnabled, + memoriesEnabled: memoriesEnabled ?? this.memoriesEnabled, + peopleEnabled: peopleEnabled ?? this.peopleEnabled, + ratingsEnabled: ratingsEnabled ?? this.ratingsEnabled, + sharedLinksEnabled: sharedLinksEnabled ?? this.sharedLinksEnabled, + tagsEnabled: tagsEnabled ?? this.tagsEnabled, + userAvatarColor: userAvatarColor ?? this.userAvatarColor, + showSupportBadge: showSupportBadge ?? this.showSupportBadge, + ); + } + + Map toMap() { + final preferences = {}; + preferences["folders-Enabled"] = foldersEnabled; + preferences["memories-Enabled"] = memoriesEnabled; + preferences["people-Enabled"] = peopleEnabled; + preferences["ratings-Enabled"] = ratingsEnabled; + preferences["sharedLinks-Enabled"] = sharedLinksEnabled; + preferences["tags-Enabled"] = tagsEnabled; + preferences["avatar-Color"] = userAvatarColor.value; + preferences["purchase-ShowSupportBadge"] = showSupportBadge; + return preferences; + } + + factory UserPreferences.fromMap(Map map) { + return UserPreferences( + foldersEnabled: map["folders-Enabled"] as bool? ?? false, + memoriesEnabled: map["memories-Enabled"] as bool? ?? true, + peopleEnabled: map["people-Enabled"] as bool? ?? true, + ratingsEnabled: map["ratings-Enabled"] as bool? ?? false, + sharedLinksEnabled: map["sharedLinks-Enabled"] as bool? ?? true, + tagsEnabled: map["tags-Enabled"] as bool? ?? false, + userAvatarColor: AvatarColor.values.firstWhere( + (e) => e.value == map["avatar-Color"] as String?, + orElse: () => AvatarColor.primary, + ), + showSupportBadge: map["purchase-ShowSupportBadge"] as bool? ?? true, + ); + } +} diff --git a/mobile/lib/infrastructure/entities/partner.entity.dart b/mobile/lib/infrastructure/entities/partner.entity.dart new file mode 100644 index 000000000..b7925a8ee --- /dev/null +++ b/mobile/lib/infrastructure/entities/partner.entity.dart @@ -0,0 +1,18 @@ +import 'package:drift/drift.dart'; +import 'package:immich_mobile/infrastructure/entities/user.entity.dart'; +import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart'; + +class PartnerEntity extends Table with DriftDefaultsMixin { + const PartnerEntity(); + + BlobColumn get sharedById => + blob().references(UserEntity, #id, onDelete: KeyAction.cascade)(); + + BlobColumn get sharedWithId => + blob().references(UserEntity, #id, onDelete: KeyAction.cascade)(); + + BoolColumn get inTimeline => boolean().withDefault(const Constant(false))(); + + @override + Set get primaryKey => {sharedById, sharedWithId}; +} diff --git a/mobile/lib/infrastructure/entities/partner.entity.drift.dart b/mobile/lib/infrastructure/entities/partner.entity.drift.dart new file mode 100644 index 000000000..974a9e3c3 Binary files /dev/null and b/mobile/lib/infrastructure/entities/partner.entity.drift.dart differ diff --git a/mobile/lib/infrastructure/entities/user.entity.dart b/mobile/lib/infrastructure/entities/user.entity.dart index 710856d9f..955b2267d 100644 --- a/mobile/lib/infrastructure/entities/user.entity.dart +++ b/mobile/lib/infrastructure/entities/user.entity.dart @@ -1,4 +1,7 @@ +import 'package:drift/drift.dart' hide Index; import 'package:immich_mobile/domain/models/user.model.dart'; +import 'package:immich_mobile/domain/models/user_metadata.model.dart'; +import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart'; import 'package:immich_mobile/utils/hash.dart'; import 'package:isar/isar.dart'; @@ -71,3 +74,20 @@ class User { quotaSizeInBytes: quotaSizeInBytes, ); } + +class UserEntity extends Table with DriftDefaultsMixin { + const UserEntity(); + + BlobColumn get id => blob()(); + TextColumn get name => text()(); + BoolColumn get isAdmin => boolean().withDefault(const Constant(false))(); + TextColumn get email => text()(); + TextColumn get profileImagePath => text().nullable()(); + DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)(); + // Quota + IntColumn get quotaSizeInBytes => integer().nullable()(); + IntColumn get quotaUsageInBytes => integer().withDefault(const Constant(0))(); + + @override + Set get primaryKey => {id}; +} diff --git a/mobile/lib/infrastructure/entities/user.entity.drift.dart b/mobile/lib/infrastructure/entities/user.entity.drift.dart new file mode 100644 index 000000000..474746a79 Binary files /dev/null and b/mobile/lib/infrastructure/entities/user.entity.drift.dart differ diff --git a/mobile/lib/infrastructure/entities/user_metadata.entity.dart b/mobile/lib/infrastructure/entities/user_metadata.entity.dart new file mode 100644 index 000000000..ebbfeebad --- /dev/null +++ b/mobile/lib/infrastructure/entities/user_metadata.entity.dart @@ -0,0 +1,21 @@ +import 'package:drift/drift.dart'; +import 'package:immich_mobile/domain/models/user_metadata.model.dart'; +import 'package:immich_mobile/infrastructure/entities/user.entity.dart'; +import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart'; + +class UserMetadataEntity extends Table with DriftDefaultsMixin { + const UserMetadataEntity(); + + BlobColumn get userId => + blob().references(UserEntity, #id, onDelete: KeyAction.cascade)(); + TextColumn get preferences => text().map(userPreferenceConverter)(); + + @override + Set get primaryKey => {userId}; +} + +final JsonTypeConverter2 + userPreferenceConverter = TypeConverter.json2( + fromJson: (json) => UserPreferences.fromMap(json as Map), + toJson: (pref) => pref.toMap(), +); diff --git a/mobile/lib/infrastructure/entities/user_metadata.entity.drift.dart b/mobile/lib/infrastructure/entities/user_metadata.entity.drift.dart new file mode 100644 index 000000000..9829fd1ac Binary files /dev/null and b/mobile/lib/infrastructure/entities/user_metadata.entity.drift.dart differ diff --git a/mobile/lib/infrastructure/repositories/db.repository.dart b/mobile/lib/infrastructure/repositories/db.repository.dart index 74e182bde..997714e1b 100644 --- a/mobile/lib/infrastructure/repositories/db.repository.dart +++ b/mobile/lib/infrastructure/repositories/db.repository.dart @@ -1,8 +1,15 @@ import 'dart:async'; +import 'package:drift/drift.dart'; +import 'package:drift_flutter/drift_flutter.dart'; import 'package:immich_mobile/domain/interfaces/db.interface.dart'; +import 'package:immich_mobile/infrastructure/entities/partner.entity.dart'; +import 'package:immich_mobile/infrastructure/entities/user.entity.dart'; +import 'package:immich_mobile/infrastructure/entities/user_metadata.entity.dart'; import 'package:isar/isar.dart'; +import 'db.repository.drift.dart'; + // #zoneTxn is the symbol used by Isar to mark a transaction within the current zone // ref: isar/isar_common.dart const Symbol _kzoneTxn = #zoneTxn; @@ -17,3 +24,35 @@ class IsarDatabaseRepository implements IDatabaseRepository { Future transaction(Future Function() callback) => Zone.current[_kzoneTxn] == null ? _db.writeTxn(callback) : callback(); } + +@DriftDatabase(tables: [UserEntity, UserMetadataEntity, PartnerEntity]) +class Drift extends $Drift implements IDatabaseRepository { + Drift([QueryExecutor? executor]) + : super( + executor ?? + driftDatabase( + name: 'immich', + native: const DriftNativeOptions(shareAcrossIsolates: true), + ), + ); + + @override + int get schemaVersion => 1; + + @override + MigrationStrategy get migration => MigrationStrategy( + beforeOpen: (details) async { + await customStatement('PRAGMA journal_mode = WAL'); + await customStatement('PRAGMA foreign_keys = ON'); + }, + ); +} + +class DriftDatabaseRepository implements IDatabaseRepository { + final Drift _db; + const DriftDatabaseRepository(this._db); + + @override + Future transaction(Future Function() callback) => + _db.transaction(callback); +} diff --git a/mobile/lib/infrastructure/repositories/db.repository.drift.dart b/mobile/lib/infrastructure/repositories/db.repository.drift.dart new file mode 100644 index 000000000..a4c2b31dc Binary files /dev/null and b/mobile/lib/infrastructure/repositories/db.repository.drift.dart differ diff --git a/mobile/lib/infrastructure/utils/drift_default.mixin.dart b/mobile/lib/infrastructure/utils/drift_default.mixin.dart new file mode 100644 index 000000000..1ba4589ed --- /dev/null +++ b/mobile/lib/infrastructure/utils/drift_default.mixin.dart @@ -0,0 +1,9 @@ +import 'package:drift/drift.dart'; + +mixin DriftDefaultsMixin on Table { + @override + bool get isStrict => true; + + @override + bool get withoutRowId => true; +} diff --git a/mobile/lib/infrastructure/utils/exif.converter.dart b/mobile/lib/infrastructure/utils/exif.converter.dart index 0f6e2b029..eb9945f45 100644 --- a/mobile/lib/infrastructure/utils/exif.converter.dart +++ b/mobile/lib/infrastructure/utils/exif.converter.dart @@ -1,6 +1,7 @@ import 'package:immich_mobile/domain/models/exif.model.dart'; import 'package:openapi/api.dart'; +// TODO: Move to repository once all classes are refactored abstract final class ExifDtoConverter { static ExifInfo fromDto(ExifResponseDto dto) { return ExifInfo( diff --git a/mobile/lib/infrastructure/utils/user.converter.dart b/mobile/lib/infrastructure/utils/user.converter.dart index fcf7ede51..eb7b24737 100644 --- a/mobile/lib/infrastructure/utils/user.converter.dart +++ b/mobile/lib/infrastructure/utils/user.converter.dart @@ -1,6 +1,8 @@ import 'package:immich_mobile/domain/models/user.model.dart'; +import 'package:immich_mobile/domain/models/user_metadata.model.dart'; import 'package:openapi/api.dart'; +// TODO: Move to repository once all classes are refactored abstract final class UserConverter { /// Base user dto used where the complete user object is not required static UserDto fromSimpleUserDto(UserResponseDto dto) => UserDto( diff --git a/mobile/lib/widgets/common/user_circle_avatar.dart b/mobile/lib/widgets/common/user_circle_avatar.dart index b3727e832..8866cb01b 100644 --- a/mobile/lib/widgets/common/user_circle_avatar.dart +++ b/mobile/lib/widgets/common/user_circle_avatar.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/models/user.model.dart'; +import 'package:immich_mobile/domain/models/user_metadata.model.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/services/api.service.dart'; diff --git a/mobile/makefile b/mobile/makefile index 43bc59c7d..0931d6c16 100644 --- a/mobile/makefile +++ b/mobile/makefile @@ -14,3 +14,6 @@ create_splash: build_release_android: flutter build appbundle + +migrations: + dart run drift_dev make-migrations diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 9c841a870..e79d9f408 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -206,6 +206,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + charcode: + dependency: transitive + description: + name: charcode + sha256: fb0f1107cac15a5ea6ef0a6ef71a807b9e4267c713bb93e00e92d737cc8dbd8a + url: "https://pub.dev" + source: hosted + version: "1.4.0" checked_yaml: dependency: transitive description: @@ -382,6 +390,30 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.2" + drift: + dependency: "direct main" + description: + name: drift + sha256: "14a61af39d4584faf1d73b5b35e4b758a43008cf4c0fdb0576ec8e7032c0d9a5" + url: "https://pub.dev" + source: hosted + version: "2.26.0" + drift_dev: + dependency: "direct dev" + description: + name: drift_dev + sha256: "0d3f8b33b76cf1c6a82ee34d9511c40957549c4674b8f1688609e6d6c7306588" + url: "https://pub.dev" + source: hosted + version: "2.26.0" + drift_flutter: + dependency: "direct main" + description: + name: drift_flutter + sha256: "0cadbf3b8733409a6cf61d18ba2e94e149df81df7de26f48ae0695b48fd71922" + url: "https://pub.dev" + source: hosted + version: "0.2.4" dynamic_color: dependency: "direct main" description: @@ -1288,6 +1320,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.5.0" + recase: + dependency: transitive + description: + name: recase + sha256: e4eb4ec2dcdee52dcf99cb4ceabaffc631d7424ee55e56f280bc039737f89213 + url: "https://pub.dev" + source: hosted + version: "4.1.0" riverpod: dependency: transitive description: @@ -1549,6 +1589,30 @@ packages: url: "https://pub.dev" source: hosted version: "2.4.0" + sqlite3: + dependency: transitive + description: + name: sqlite3 + sha256: "310af39c40dd0bb2058538333c9d9840a2725ae0b9f77e4fd09ad6696aa8f66e" + url: "https://pub.dev" + source: hosted + version: "2.7.5" + sqlite3_flutter_libs: + dependency: transitive + description: + name: sqlite3_flutter_libs + sha256: "7adb4cc96dc08648a5eb1d80a7619070796ca6db03901ff2b6dcb15ee30468f3" + url: "https://pub.dev" + source: hosted + version: "0.5.31" + sqlparser: + dependency: transitive + description: + name: sqlparser + sha256: "27dd0a9f0c02e22ac0eb42a23df9ea079ce69b52bb4a3b478d64e0ef34a263ee" + url: "https://pub.dev" + source: hosted + version: "0.41.0" stack_trace: dependency: transitive description: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index a778f804d..d4ab110a3 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -73,6 +73,9 @@ dependencies: isar_flutter_libs: # contains Isar Core version: *isar_version hosted: https://pub.isar-community.dev/ + # DB + drift: ^2.23.1 + drift_flutter: ^0.2.4 dependency_overrides: analyzer: ^6.0.0 @@ -99,6 +102,8 @@ dev_dependencies: immich_mobile_immich_lint: path: './immich_lint' fake_async: ^1.3.1 + # Drift generator + drift_dev: ^2.23.1 flutter: uses-material-design: true diff --git a/mobile/test/fixtures/user.stub.dart b/mobile/test/fixtures/user.stub.dart index 1dfec9b4b..764342520 100644 --- a/mobile/test/fixtures/user.stub.dart +++ b/mobile/test/fixtures/user.stub.dart @@ -1,4 +1,5 @@ import 'package:immich_mobile/domain/models/user.model.dart'; +import 'package:immich_mobile/domain/models/user_metadata.model.dart'; abstract final class UserStub { const UserStub._();