more refactors and logs page handling

This commit is contained in:
shenlong-tanwen 2024-10-23 02:30:46 +05:30
parent 8f47645cdb
commit a0afea04d8
90 changed files with 2386 additions and 584 deletions

View File

@ -18,51 +18,68 @@ dart_code_metrics:
- recommended - recommended
rules: rules:
# Common # Common
- avoid-accessing-collections-by-constant-index - arguments-ordering:
last:
- child
- avoid-accessing-other-classes-private-members - avoid-accessing-other-classes-private-members
- avoid-cascade-after-if-null - avoid-assigning-to-static-field
- avoid-assignments-as-conditions
- avoid-async-call-in-sync-function
- avoid-collapsible-if - avoid-collapsible-if
- avoid-collection-methods-with-unrelated-types - avoid-collection-equality-checks
- avoid-double-slash-imports - avoid-collection-mutating-methods
- avoid-duplicate-cascades - avoid-complex-loop-conditions
- avoid-duplicate-patterns - avoid-declaring-call-method
- avoid-generics-shadowing - avoid-extensions-on-records
- avoid-function-type-in-records
- avoid-global-state - avoid-global-state
- avoid-inverted-boolean-checks
- avoid-late-final-reassignment
- avoid-local-functions
- avoid-negated-conditions
- avoid-nested-streams-and-futures
- avoid-referencing-subclasses
- binary-expression-operand-order
- move-variable-outside-iteration
- prefer-abstract-final-static-class
- prefer-early-return
- prefer-first
- prefer-immediate-return
- prefer-last
- prefer-simpler-boolean-expressions
- prefer-type-over-var
- use-existing-variable
# Flutter # Flutter
- always-remove-listener
- avoid-border-all - avoid-border-all
- avoid-empty-setstate
- avoid-expanded-as-spacer - avoid-expanded-as-spacer
- avoid-incomplete-copy-with
- avoid-inherited-widget-in-initstate - avoid-inherited-widget-in-initstate
- avoid-late-context - avoid-late-context
- avoid-recursive-widget-calls
- avoid-returning-widgets - avoid-returning-widgets
- avoid-shrink-wrap-in-lists - avoid-shrink-wrap-in-lists
- avoid-single-child-column-or-row - avoid-single-child-column-or-row
- avoid-state-constructors
- avoid-stateless-widget-initialized-fields - avoid-stateless-widget-initialized-fields
- avoid-unnecessary-overrides-in-state
- avoid-unnecessary-stateful-widgets
- avoid-wrapping-in-padding - avoid-wrapping-in-padding
- dispose-fields
- prefer-const-border-radius - prefer-const-border-radius
- prefer-correct-callback-field-name: false
- prefer-correct-edge-insets-constructor - prefer-correct-edge-insets-constructor
- prefer-dedicated-media-query-methods
- prefer-define-hero-tag - prefer-define-hero-tag
- prefer-extracting-callbacks - prefer-extracting-callbacks
- prefer-for-loop-in-children
- prefer-match-file-name: false
- prefer-single-widget-per-file:
ignore-private-widgets: true
exclude:
- lib/presentation/**/*.page.dart
- prefer-sliver-prefix - prefer-sliver-prefix
- prefer-text-rich - prefer-text-rich
- prefer-using-list-view - prefer-using-list-view
- proper-super-calls - prefer-widget-private-members:
- use-setstate-synchronously ignore-static: true
- prefer-match-file-name: false # get-it
- avoid-passing-self-as-argument: - avoid-functions-in-register-singleton
exclude: # bloc
- lib/domain/repositories/** - avoid-empty-build-when
- prefer-single-widget-per-file: - avoid-passing-bloc-to-bloc
ignore-private-widgets: true
- prefer-correct-callback-field-name: false
custom_lint: custom_lint:
rules: rules:

View File

@ -37,6 +37,10 @@
} }
}, },
"logs": { "logs": {
"title": "Logs" "title": "Logs",
"no_logs": "No logs available"
},
"common": {
"loading": "Loading"
} }
} }

View File

@ -9,6 +9,8 @@ PODS:
- path_provider_foundation (0.0.1): - path_provider_foundation (0.0.1):
- Flutter - Flutter
- FlutterMacOS - FlutterMacOS
- permission_handler_apple (9.3.0):
- Flutter
- photo_manager (2.0.0): - photo_manager (2.0.0):
- Flutter - Flutter
- FlutterMacOS - FlutterMacOS
@ -42,6 +44,7 @@ DEPENDENCIES:
- flutter_web_auth_2 (from `.symlinks/plugins/flutter_web_auth_2/ios`) - flutter_web_auth_2 (from `.symlinks/plugins/flutter_web_auth_2/ios`)
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
- photo_manager (from `.symlinks/plugins/photo_manager/ios`) - photo_manager (from `.symlinks/plugins/photo_manager/ios`)
- sqflite (from `.symlinks/plugins/sqflite/darwin`) - sqflite (from `.symlinks/plugins/sqflite/darwin`)
- sqlite3_flutter_libs (from `.symlinks/plugins/sqlite3_flutter_libs/ios`) - sqlite3_flutter_libs (from `.symlinks/plugins/sqlite3_flutter_libs/ios`)
@ -62,6 +65,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/package_info_plus/ios" :path: ".symlinks/plugins/package_info_plus/ios"
path_provider_foundation: path_provider_foundation:
:path: ".symlinks/plugins/path_provider_foundation/darwin" :path: ".symlinks/plugins/path_provider_foundation/darwin"
permission_handler_apple:
:path: ".symlinks/plugins/permission_handler_apple/ios"
photo_manager: photo_manager:
:path: ".symlinks/plugins/photo_manager/ios" :path: ".symlinks/plugins/photo_manager/ios"
sqflite: sqflite:
@ -72,11 +77,12 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/url_launcher_ios/ios" :path: ".symlinks/plugins/url_launcher_ios/ios"
SPEC CHECKSUMS: SPEC CHECKSUMS:
device_info_plus: 97af1d7e84681a90d0693e63169a5d50e0839a0d device_info_plus: bf2e3232933866d73fe290f2942f2156cdd10342
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
flutter_web_auth_2: 051cf9f5dc366f31b5dcc4e2952c2b954767be8a flutter_web_auth_2: 06d500582775790a0d4c323222fcb6d7990f9603
package_info_plus: 58f0028419748fad15bf008b270aaa8e54380b1c package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4
path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46 path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2
photo_manager: ff695c7a1dd5bc379974953a2b5c0a293f7c4c8a photo_manager: ff695c7a1dd5bc379974953a2b5c0a293f7c4c8a
sqflite: 673a0e54cc04b7d6dba8d24fb8095b31c3a99eec sqflite: 673a0e54cc04b7d6dba8d24fb8095b31c3a99eec
sqlite3: 0bb0e6389d824e40296f531b858a2a0b71c0d2fb sqlite3: 0bb0e6389d824e40296f531b858a2a0b71c0d2fb

View File

@ -193,6 +193,7 @@
9705A1C41CF9048500538489 /* Embed Frameworks */, 9705A1C41CF9048500538489 /* Embed Frameworks */,
3B06AD1E1E4923F5004D2608 /* Thin Binary */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */,
25B7EF9822CD7FF54B86E39D /* [CP] Embed Pods Frameworks */, 25B7EF9822CD7FF54B86E39D /* [CP] Embed Pods Frameworks */,
4D9CA4C7FEB0AD3C30F4C600 /* [CP] Copy Pods Resources */,
); );
buildRules = ( buildRules = (
); );
@ -297,6 +298,23 @@
shellPath = /bin/sh; shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
}; };
4D9CA4C7FEB0AD3C30F4C600 /* [CP] Copy Pods Resources */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
);
name = "[CP] Copy Pods Resources";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
showEnvVarsInLog = 0;
};
9740EEB61CF901F6004384FC /* Run Script */ = { 9740EEB61CF901F6004384FC /* Run Script */ = {
isa = PBXShellScriptBuildPhase; isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1; alwaysOutOfDate = 1;

View File

@ -4,14 +4,14 @@ import 'package:immich_mobile/domain/models/album.model.dart';
abstract interface class IAlbumRepository { abstract interface class IAlbumRepository {
/// Inserts a new album into the DB or updates if existing and returns the updated data /// Inserts a new album into the DB or updates if existing and returns the updated data
FutureOr<Album?> upsert(Album album); Future<Album?> upsert(Album album);
/// Fetch all albums /// Fetch all albums
FutureOr<List<Album>> getAll({bool localOnly, bool remoteOnly}); Future<List<Album>> getAll({bool localOnly, bool remoteOnly});
/// Removes album with the given [id] /// Removes album with the given [id]
FutureOr<void> deleteId(int id); Future<void> deleteId(int id);
/// Removes all albums /// Removes all albums
FutureOr<void> deleteAll(); Future<void> deleteAll();
} }

View File

@ -4,17 +4,17 @@ import 'package:immich_mobile/domain/models/asset.model.dart';
abstract interface class IAlbumToAssetRepository { abstract interface class IAlbumToAssetRepository {
/// Link a list of assetIds to the given albumId /// Link a list of assetIds to the given albumId
FutureOr<bool> addAssetIds(int albumId, Iterable<int> assetIds); Future<bool> addAssetIds(int albumId, Iterable<int> assetIds);
/// Returns assets that are only part of the given album and nothing else /// Returns assets that are only part of the given album and nothing else
FutureOr<List<int>> getAssetIdsOnlyInAlbum(int albumId); Future<List<int>> getAssetIdsOnlyInAlbum(int albumId);
/// Returns the assets for the given [albumId] /// Returns the assets for the given [albumId]
FutureOr<List<Asset>> getAssetsForAlbum(int albumId); Future<List<Asset>> getAssetsForAlbum(int albumId);
/// Removes album with the given [albumId] /// Removes album with the given [albumId]
FutureOr<void> deleteAlbumId(int albumId); Future<void> deleteAlbumId(int albumId);
/// Removes all album to asset mappings /// Removes all album to asset mappings
FutureOr<void> deleteAll(); Future<void> deleteAll();
} }

View File

@ -4,11 +4,11 @@ import 'package:immich_mobile/domain/models/album_etag.model.dart';
abstract interface class IAlbumETagRepository { abstract interface class IAlbumETagRepository {
/// Inserts or updates the album etag for the given [albumId] /// Inserts or updates the album etag for the given [albumId]
FutureOr<bool> upsert(AlbumETag albumETag); Future<bool> upsert(AlbumETag albumETag);
/// Fetches the album etag for the given [albumId] /// Fetches the album etag for the given [albumId]
FutureOr<AlbumETag?> get(int albumId); Future<AlbumETag?> get(int albumId);
/// Removes all album eTags /// Removes all album eTags
FutureOr<void> deleteAll(); Future<void> deleteAll();
} }

View File

@ -4,23 +4,23 @@ import 'package:immich_mobile/domain/models/asset.model.dart';
abstract interface class IAssetRepository { abstract interface class IAssetRepository {
/// Batch upsert asset /// Batch upsert asset
FutureOr<bool> upsertAll(Iterable<Asset> assets); Future<bool> upsertAll(Iterable<Asset> assets);
/// Removes assets with the [localIds] /// Removes assets with the [localIds]
FutureOr<List<Asset>> getForLocalIds(Iterable<String> localIds); Future<List<Asset>> getForLocalIds(Iterable<String> localIds);
/// Removes assets with the [remoteIds] /// Removes assets with the [remoteIds]
FutureOr<List<Asset>> getForRemoteIds(Iterable<String> remoteIds); Future<List<Asset>> getForRemoteIds(Iterable<String> remoteIds);
/// Get assets with the [hashes] /// Get assets with the [hashes]
FutureOr<List<Asset>> getForHashes(Iterable<String> hashes); Future<List<Asset>> getForHashes(Iterable<String> hashes);
/// Fetch assets from the [offset] with the [limit] /// Fetch assets from the [offset] with the [limit]
FutureOr<List<Asset>> getAll({int? offset, int? limit}); Future<List<Asset>> getAll({int? offset, int? limit});
/// Removes assets with the given [ids] /// Removes assets with the given [ids]
FutureOr<void> deleteIds(Iterable<int> ids); Future<void> deleteIds(Iterable<int> ids);
/// Removes all assets /// Removes all assets
FutureOr<bool> deleteAll(); Future<bool> deleteAll();
} }

View File

@ -5,13 +5,13 @@ import 'package:immich_mobile/domain/models/asset.model.dart';
abstract interface class IDeviceAlbumRepository { abstract interface class IDeviceAlbumRepository {
/// Fetches all [Album] from device /// Fetches all [Album] from device
FutureOr<List<Album>> getAll(); Future<List<Album>> getAll();
/// Returns the number of asset in the album /// Returns the number of asset in the album
FutureOr<int> getAssetCount(String albumId); Future<int> getAssetCount(String albumId);
/// Fetches assets belong to the albumId /// Fetches assets belong to the albumId
FutureOr<List<Asset>> getAssetsForAlbum( Future<List<Asset>> getAssetsForAlbum(
String albumId, { String albumId, {
int start = 0, int start = 0,
int end = 0x7fffffffffffffff, int end = 0x7fffffffffffffff,

View File

@ -8,10 +8,10 @@ import 'package:immich_mobile/utils/constants/globals.dart';
abstract interface class IDeviceAssetRepository<T> { abstract interface class IDeviceAssetRepository<T> {
/// Fetches the [File] for the given [assetId] /// Fetches the [File] for the given [assetId]
FutureOr<File?> getOriginalFile(String assetId); Future<File?> getOriginalFile(String assetId);
/// Fetches the thumbnail for the given [assetId] /// Fetches the thumbnail for the given [assetId]
FutureOr<Uint8List?> getThumbnail( Future<Uint8List?> getThumbnail(
String assetId, { String assetId, {
int width = kGridThumbnailSize, int width = kGridThumbnailSize,
int height = kGridThumbnailSize, int height = kGridThumbnailSize,

View File

@ -4,11 +4,11 @@ import 'package:immich_mobile/domain/models/device_asset_hash.model.dart';
abstract interface class IDeviceAssetToHashRepository { abstract interface class IDeviceAssetToHashRepository {
/// Add a new device asset to hash entry /// Add a new device asset to hash entry
FutureOr<bool> upsertAll(Iterable<DeviceAssetToHash> assetHash); Future<bool> upsertAll(Iterable<DeviceAssetToHash> assetHash);
// Gets the asset with the local ID from the device // Gets the asset with the local ID from the device
FutureOr<List<DeviceAssetToHash>> getForIds(Iterable<String> localIds); Future<List<DeviceAssetToHash>> getForIds(Iterable<String> localIds);
/// Removes assets with the given [ids] /// Removes assets with the given [ids]
FutureOr<void> deleteIds(Iterable<int> ids); Future<void> deleteIds(Iterable<int> ids);
} }

View File

@ -4,17 +4,17 @@ import 'package:immich_mobile/domain/models/log.model.dart';
abstract interface class ILogRepository { abstract interface class ILogRepository {
/// Inserts a new log into the DB /// Inserts a new log into the DB
FutureOr<bool> create(LogMessage log); Future<bool> create(LogMessage log);
/// Bulk insert logs into DB /// Bulk insert logs into DB
FutureOr<bool> createAll(Iterable<LogMessage> log); Future<bool> createAll(Iterable<LogMessage> log);
/// Fetches all logs /// Fetches all logs
FutureOr<List<LogMessage>> getAll(); Future<List<LogMessage>> getAll();
/// Clears all logs /// Clears all logs
FutureOr<bool> deleteAll(); Future<bool> deleteAll();
/// Truncates the logs to the most recent [limit]. Defaults to recent 250 logs /// Truncates the logs to the most recent [limit]. Defaults to recent 250 logs
FutureOr<void> truncate({int limit = 250}); Future<void> truncate({int limit = 250});
} }

View File

@ -13,15 +13,15 @@ abstract class IStoreConverter<T, U> {
} }
abstract interface class IStoreRepository { abstract interface class IStoreRepository {
FutureOr<bool> upsert<T, U>(StoreKey<T, U> key, T value); Future<bool> upsert<T, U>(StoreKey<T, U> key, T value);
FutureOr<T> get<T, U>(StoreKey<T, U> key); Future<T> get<T, U>(StoreKey<T, U> key);
FutureOr<T?> tryGet<T, U>(StoreKey<T, U> key); Future<T?> tryGet<T, U>(StoreKey<T, U> key);
Stream<T?> watch<T, U>(StoreKey<T, U> key); Stream<T?> watch<T, U>(StoreKey<T, U> key);
FutureOr<void> delete(StoreKey key); Future<void> delete(StoreKey key);
FutureOr<void> deleteAll(); Future<void> deleteAll();
} }

View File

@ -4,11 +4,11 @@ import 'package:immich_mobile/domain/models/user.model.dart';
abstract interface class IUserRepository { abstract interface class IUserRepository {
/// Insert user /// Insert user
FutureOr<bool> upsert(User user); Future<bool> upsert(User user);
/// Fetches user /// Fetches user
FutureOr<User?> getForId(String userId); Future<User?> getForId(String userId);
/// Removes all users /// Removes all users
FutureOr<void> deleteAll(); Future<void> deleteAll();
} }

View File

@ -71,8 +71,8 @@ class Asset {
createdTime: createdTime ?? this.createdTime, createdTime: createdTime ?? this.createdTime,
modifiedTime: modifiedTime ?? this.modifiedTime, modifiedTime: modifiedTime ?? this.modifiedTime,
duration: duration ?? this.duration, duration: duration ?? this.duration,
localId: localId != null ? localId() : this.localId, localId: localId == null ? this.localId : localId(),
remoteId: remoteId != null ? remoteId() : this.remoteId, remoteId: remoteId == null ? this.remoteId : remoteId(),
livePhotoVideoId: livePhotoVideoId ?? this.livePhotoVideoId, livePhotoVideoId: livePhotoVideoId ?? this.livePhotoVideoId,
); );
} }
@ -89,20 +89,20 @@ class Asset {
if (newAsset.modifiedTime.isAfter(existingAsset.modifiedTime)) { if (newAsset.modifiedTime.isAfter(existingAsset.modifiedTime)) {
return newAsset.copyWith( return newAsset.copyWith(
id: newAsset.id ?? existingAsset.id, id: newAsset.id ?? existingAsset.id,
height: newAsset.height ?? existingAsset.height,
width: newAsset.width ?? existingAsset.width,
createdTime: oldestCreationTime,
localId: () => existingAsset.localId ?? newAsset.localId, localId: () => existingAsset.localId ?? newAsset.localId,
remoteId: () => existingAsset.remoteId ?? newAsset.remoteId, remoteId: () => existingAsset.remoteId ?? newAsset.remoteId,
width: newAsset.width ?? existingAsset.width,
height: newAsset.height ?? existingAsset.height,
createdTime: oldestCreationTime,
); );
} }
return existingAsset.copyWith( return existingAsset.copyWith(
height: existingAsset.height ?? newAsset.height,
width: existingAsset.width ?? newAsset.width,
createdTime: oldestCreationTime,
localId: () => existingAsset.localId ?? newAsset.localId, localId: () => existingAsset.localId ?? newAsset.localId,
remoteId: () => existingAsset.remoteId ?? newAsset.remoteId, remoteId: () => existingAsset.remoteId ?? newAsset.remoteId,
width: existingAsset.width ?? newAsset.width,
height: existingAsset.height ?? newAsset.height,
createdTime: oldestCreationTime,
); );
} }

View File

@ -4,8 +4,9 @@ import 'package:immich_mobile/domain/models/render_list_element.model.dart';
class RenderList { class RenderList {
final List<RenderListElement> elements; final List<RenderListElement> elements;
late final int totalCount; late final int totalCount;
final DateTime modifiedTime;
RenderList({required this.elements}) { RenderList({required this.elements, required this.modifiedTime}) {
final lastAssetElement = final lastAssetElement =
elements.whereType<RenderListAssetElement>().lastOrNull; elements.whereType<RenderListAssetElement>().lastOrNull;
if (lastAssetElement == null) { if (lastAssetElement == null) {
@ -16,6 +17,21 @@ class RenderList {
} }
factory RenderList.empty() { factory RenderList.empty() {
return RenderList(elements: []); return RenderList(elements: [], modifiedTime: DateTime.now());
} }
@override
String toString() =>
'RenderList(totalCount: $totalCount, modifiedTime: $modifiedTime)';
@override
bool operator ==(covariant RenderList other) {
if (identical(this, other)) return true;
return other.totalCount == totalCount &&
other.modifiedTime.isAtSameMomentAs(modifiedTime);
}
@override
int get hashCode => totalCount.hashCode ^ modifiedTime.hashCode;
} }

View File

@ -13,7 +13,7 @@ class AlbumRepository with LogMixin implements IAlbumRepository {
const AlbumRepository({required DriftDatabaseRepository db}) : _db = db; const AlbumRepository({required DriftDatabaseRepository db}) : _db = db;
@override @override
FutureOr<Album?> upsert(Album album) async { Future<Album?> upsert(Album album) async {
try { try {
final albumData = _toEntity(album); final albumData = _toEntity(album);
final data = await _db.album.insertReturningOrNull( final data = await _db.album.insertReturningOrNull(
@ -30,7 +30,7 @@ class AlbumRepository with LogMixin implements IAlbumRepository {
} }
@override @override
FutureOr<List<Album>> getAll({ Future<List<Album>> getAll({
bool localOnly = false, bool localOnly = false,
bool remoteOnly = false, bool remoteOnly = false,
}) async { }) async {
@ -49,12 +49,12 @@ class AlbumRepository with LogMixin implements IAlbumRepository {
} }
@override @override
FutureOr<void> deleteId(int id) async { Future<void> deleteId(int id) async {
await _db.album.deleteWhere((row) => row.id.equals(id)); await _db.album.deleteWhere((row) => row.id.equals(id));
} }
@override @override
FutureOr<void> deleteAll() async { Future<void> deleteAll() async {
await _db.album.deleteAll(); await _db.album.deleteAll();
} }
} }
@ -62,11 +62,11 @@ class AlbumRepository with LogMixin implements IAlbumRepository {
AlbumCompanion _toEntity(Album album) { AlbumCompanion _toEntity(Album album) {
return AlbumCompanion.insert( return AlbumCompanion.insert(
id: Value.absentIfNull(album.id), id: Value.absentIfNull(album.id),
localId: Value(album.localId),
remoteId: Value(album.remoteId),
name: album.name, name: album.name,
modifiedTime: Value(album.modifiedTime), modifiedTime: Value(album.modifiedTime),
thumbnailAssetId: Value(album.thumbnailAssetId), thumbnailAssetId: Value(album.thumbnailAssetId),
localId: Value(album.localId),
remoteId: Value(album.remoteId),
); );
} }

View File

@ -15,7 +15,7 @@ class AlbumToAssetRepository with LogMixin implements IAlbumToAssetRepository {
: _db = db; : _db = db;
@override @override
FutureOr<bool> addAssetIds(int albumId, Iterable<int> assetIds) async { Future<bool> addAssetIds(int albumId, Iterable<int> assetIds) async {
try { try {
await _db.albumToAsset.insertAll( await _db.albumToAsset.insertAll(
assetIds.map( assetIds.map(
@ -33,14 +33,14 @@ class AlbumToAssetRepository with LogMixin implements IAlbumToAssetRepository {
} }
@override @override
FutureOr<List<int>> getAssetIdsOnlyInAlbum(int albumId) async { Future<List<int>> getAssetIdsOnlyInAlbum(int albumId) async {
final assetId = _db.asset.id; final assetId = _db.asset.id;
final query = _db.asset.selectOnly() final query = _db.asset.selectOnly()
..addColumns([assetId]) ..addColumns([assetId])
..join([ ..join([
innerJoin( innerJoin(
_db.albumToAsset, _db.albumToAsset,
_db.albumToAsset.assetId.equalsExp(_db.asset.id) & _db.albumToAsset.assetId.equalsExp(assetId) &
_db.asset.remoteId.isNull(), _db.asset.remoteId.isNull(),
useColumns: false, useColumns: false,
), ),
@ -55,7 +55,7 @@ class AlbumToAssetRepository with LogMixin implements IAlbumToAssetRepository {
} }
@override @override
FutureOr<List<Asset>> getAssetsForAlbum(int albumId) async { Future<List<Asset>> getAssetsForAlbum(int albumId) async {
final query = _db.asset.select().join([ final query = _db.asset.select().join([
innerJoin( innerJoin(
_db.albumToAsset, _db.albumToAsset,
@ -72,12 +72,12 @@ class AlbumToAssetRepository with LogMixin implements IAlbumToAssetRepository {
} }
@override @override
FutureOr<void> deleteAlbumId(int albumId) async { Future<void> deleteAlbumId(int albumId) async {
await _db.albumToAsset.deleteWhere((row) => row.albumId.equals(albumId)); await _db.albumToAsset.deleteWhere((row) => row.albumId.equals(albumId));
} }
@override @override
FutureOr<void> deleteAll() async { Future<void> deleteAll() async {
await _db.albumToAsset.deleteAll(); await _db.albumToAsset.deleteAll();
} }
} }

View File

@ -13,7 +13,7 @@ class AlbumETagRepository with LogMixin implements IAlbumETagRepository {
const AlbumETagRepository({required DriftDatabaseRepository db}) : _db = db; const AlbumETagRepository({required DriftDatabaseRepository db}) : _db = db;
@override @override
FutureOr<bool> upsert(AlbumETag albumETag) async { Future<bool> upsert(AlbumETag albumETag) async {
try { try {
final entity = _toEntity(albumETag); final entity = _toEntity(albumETag);
await _db.albumETag.insertOne( await _db.albumETag.insertOne(
@ -28,14 +28,14 @@ class AlbumETagRepository with LogMixin implements IAlbumETagRepository {
} }
@override @override
FutureOr<AlbumETag?> get(int albumId) async { Future<AlbumETag?> get(int albumId) async {
final query = _db.albumETag.select() final query = _db.albumETag.select()
..where((r) => r.albumId.equals(albumId)); ..where((r) => r.albumId.equals(albumId));
return await query.map(_toModel).getSingleOrNull(); return await query.map(_toModel).getSingleOrNull();
} }
@override @override
FutureOr<void> deleteAll() async { Future<void> deleteAll() async {
await _db.albumETag.deleteAll(); await _db.albumETag.deleteAll();
} }
} }
@ -43,17 +43,17 @@ class AlbumETagRepository with LogMixin implements IAlbumETagRepository {
AlbumETagCompanion _toEntity(AlbumETag albumETag) { AlbumETagCompanion _toEntity(AlbumETag albumETag) {
return AlbumETagCompanion.insert( return AlbumETagCompanion.insert(
id: Value.absentIfNull(albumETag.id), id: Value.absentIfNull(albumETag.id),
modifiedTime: Value(albumETag.modifiedTime),
albumId: albumETag.albumId, albumId: albumETag.albumId,
modifiedTime: Value(albumETag.modifiedTime),
assetCount: Value(albumETag.assetCount), assetCount: Value(albumETag.assetCount),
); );
} }
AlbumETag _toModel(AlbumETagData albumETag) { AlbumETag _toModel(AlbumETagData albumETag) {
return AlbumETag( return AlbumETag(
id: albumETag.id,
albumId: albumETag.albumId, albumId: albumETag.albumId,
assetCount: albumETag.assetCount, assetCount: albumETag.assetCount,
modifiedTime: albumETag.modifiedTime, modifiedTime: albumETag.modifiedTime,
id: albumETag.id,
); );
} }

View File

@ -32,16 +32,16 @@ class SyncApiRepository with LogMixin implements ISyncApiRepository {
} }
Asset _fromAssetResponseDto(AssetResponseDto dto) => Asset( Asset _fromAssetResponseDto(AssetResponseDto dto) => Asset(
remoteId: dto.id, name: dto.originalFileName,
createdTime: dto.fileCreatedAt, hash: dto.checksum,
duration: dto.duration.tryParseInt() ?? 0,
height: dto.exifInfo?.exifImageHeight?.toInt(), height: dto.exifInfo?.exifImageHeight?.toInt(),
width: dto.exifInfo?.exifImageWidth?.toInt(), width: dto.exifInfo?.exifImageWidth?.toInt(),
hash: dto.checksum,
name: dto.originalFileName,
livePhotoVideoId: dto.livePhotoVideoId,
modifiedTime: dto.fileModifiedAt,
type: _toAssetType(dto.type), type: _toAssetType(dto.type),
createdTime: dto.fileCreatedAt,
modifiedTime: dto.fileModifiedAt,
duration: dto.duration.tryParseInt() ?? 0,
remoteId: dto.id,
livePhotoVideoId: dto.livePhotoVideoId,
); );
AssetType _toAssetType(AssetTypeEnum type) => switch (type) { AssetType _toAssetType(AssetTypeEnum type) => switch (type) {

View File

@ -16,16 +16,16 @@ class AssetRepository with LogMixin implements IAssetRepository {
@override @override
Future<bool> upsertAll(Iterable<Asset> assets) async { Future<bool> upsertAll(Iterable<Asset> assets) async {
try { try {
await _db.batch((batch) { await _db.txn(() async => await _db.batch((batch) {
final rows = assets.map(_toEntity); final rows = assets.map(_toEntity);
for (final row in rows) { for (final row in rows) {
batch.insert( batch.insert(
_db.asset, _db.asset,
row, row,
onConflict: DoUpdate((_) => row, target: [_db.asset.hash]), onConflict: DoUpdate((_) => row, target: [_db.asset.hash]),
); );
} }
}); }));
return true; return true;
} catch (e, s) { } catch (e, s) {
@ -85,7 +85,7 @@ class AssetRepository with LogMixin implements IAssetRepository {
} }
@override @override
FutureOr<void> deleteIds(Iterable<int> ids) async { Future<void> deleteIds(Iterable<int> ids) async {
await _db.asset.deleteWhere((row) => row.id.isIn(ids)); await _db.asset.deleteWhere((row) => row.id.isIn(ids));
} }
} }
@ -93,16 +93,16 @@ class AssetRepository with LogMixin implements IAssetRepository {
AssetCompanion _toEntity(Asset asset) { AssetCompanion _toEntity(Asset asset) {
return AssetCompanion.insert( return AssetCompanion.insert(
id: Value.absentIfNull(asset.id), id: Value.absentIfNull(asset.id),
localId: Value(asset.localId),
remoteId: Value(asset.remoteId),
name: asset.name, name: asset.name,
hash: asset.hash, hash: asset.hash,
height: Value(asset.height), height: Value(asset.height),
width: Value(asset.width), width: Value(asset.width),
type: asset.type, type: asset.type,
createdTime: asset.createdTime, createdTime: asset.createdTime,
duration: Value(asset.duration),
modifiedTime: Value(asset.modifiedTime), modifiedTime: Value(asset.modifiedTime),
duration: Value(asset.duration),
localId: Value(asset.localId),
remoteId: Value(asset.remoteId),
livePhotoVideoId: Value(asset.livePhotoVideoId), livePhotoVideoId: Value(asset.livePhotoVideoId),
); );
} }

View File

@ -44,6 +44,8 @@ class DriftDatabaseRepository extends $DriftDatabaseRepository
@override @override
MigrationStrategy get migration => MigrationStrategy( MigrationStrategy get migration => MigrationStrategy(
onCreate: (m) => m.createAll(), onCreate: (m) => m.createAll(),
// ignore: no-empty-block
onUpgrade: (m, from, to) async {},
beforeOpen: (details) async { beforeOpen: (details) async {
if (kDebugMode) { if (kDebugMode) {
await validateDatabaseSchema(); await validateDatabaseSchema();
@ -52,8 +54,6 @@ class DriftDatabaseRepository extends $DriftDatabaseRepository
await customStatement('PRAGMA journal_mode = WAL'); await customStatement('PRAGMA journal_mode = WAL');
await customStatement('PRAGMA foreign_keys = ON'); await customStatement('PRAGMA foreign_keys = ON');
}, },
// ignore: no-empty-block
onUpgrade: (m, from, to) async {},
); );
@override @override

View File

@ -52,17 +52,17 @@ class DeviceAlbumRepository with LogMixin implements IDeviceAlbumRepository {
return await AssetPathEntity.fromId( return await AssetPathEntity.fromId(
albumId, albumId,
filterOption: FilterOptionGroup( filterOption: FilterOptionGroup(
containsPathModified: true,
orders: orderByModificationDate
? [const OrderOption(type: OrderOptionType.updateDate)]
: [],
imageOption: const FilterOption(needTitle: true), imageOption: const FilterOption(needTitle: true),
videoOption: const FilterOption(needTitle: true), videoOption: const FilterOption(needTitle: true),
containsPathModified: true,
updateTimeCond: DateTimeCond( updateTimeCond: DateTimeCond(
min: modifiedFrom ?? DateTime.utc(-271820), min: modifiedFrom ?? DateTime.utc(-271820),
max: modifiedUntil ?? DateTime.utc(275760), max: modifiedUntil ?? DateTime.utc(275760),
ignore: modifiedFrom != null || modifiedUntil != null, ignore: modifiedFrom != null || modifiedUntil != null,
), ),
orders: orderByModificationDate
? [const OrderOption(type: OrderOptionType.updateDate)]
: [],
), ),
); );
} }

View File

@ -16,25 +16,23 @@ class DeviceAssetRepository
@override @override
Future<Asset> toAsset(ph.AssetEntity entity) async { Future<Asset> toAsset(ph.AssetEntity entity) async {
var asset = Asset( return Asset(
hash: '',
name: await entity.titleAsync, name: await entity.titleAsync,
type: _toAssetType(entity.type), hash: '',
createdTime: entity.createDateTime,
modifiedTime: entity.modifiedDateTime,
duration: entity.duration,
height: entity.height, height: entity.height,
width: entity.width, width: entity.width,
type: _toAssetType(entity.type),
createdTime: entity.createDateTime.year == 1970
? entity.modifiedDateTime
: entity.createDateTime,
modifiedTime: entity.modifiedDateTime,
duration: entity.duration,
localId: entity.id, localId: entity.id,
); );
if (asset.createdTime.year == 1970) {
asset = asset.copyWith(createdTime: asset.modifiedTime);
}
return asset;
} }
@override @override
FutureOr<File?> getOriginalFile(String localId) async { Future<File?> getOriginalFile(String localId) async {
try { try {
final entity = await ph.AssetEntity.fromId(localId); final entity = await ph.AssetEntity.fromId(localId);
if (entity == null) { if (entity == null) {
@ -49,7 +47,7 @@ class DeviceAssetRepository
} }
@override @override
FutureOr<Uint8List?> getThumbnail( Future<Uint8List?> getThumbnail(
String assetId, { String assetId, {
int width = kGridThumbnailSize, int width = kGridThumbnailSize,
int height = kGridThumbnailSize, int height = kGridThumbnailSize,

View File

@ -16,12 +16,13 @@ class DeviceAssetToHashRepository
: _db = db; : _db = db;
@override @override
FutureOr<bool> upsertAll(Iterable<DeviceAssetToHash> assetHash) async { Future<bool> upsertAll(Iterable<DeviceAssetToHash> assetHash) async {
try { try {
await _db.batch((batch) => batch.insertAllOnConflictUpdate( await _db.txn(() async =>
_db.deviceAssetToHash, await _db.batch((batch) => batch.insertAllOnConflictUpdate(
assetHash.map(_toEntity), _db.deviceAssetToHash,
)); assetHash.map(_toEntity),
)));
return true; return true;
} catch (e, s) { } catch (e, s) {
@ -38,7 +39,7 @@ class DeviceAssetToHashRepository
} }
@override @override
FutureOr<void> deleteIds(Iterable<int> ids) async { Future<void> deleteIds(Iterable<int> ids) async {
await _db.deviceAssetToHash.deleteWhere((row) => row.id.isIn(ids)); await _db.deviceAssetToHash.deleteWhere((row) => row.id.isIn(ids));
} }
} }

View File

@ -14,7 +14,10 @@ class LogRepository implements ILogRepository {
@override @override
Future<List<LogMessage>> getAll() async { Future<List<LogMessage>> getAll() async {
return await _db.managers.logs.map(_toModel).get(); return await _db.managers.logs
.orderBy((o) => o.createdAt.desc())
.map(_toModel)
.get();
} }
@override @override
@ -23,14 +26,14 @@ class LogRepository implements ILogRepository {
if (totalCount > limit) { if (totalCount > limit) {
final rowsToDelete = totalCount - limit; final rowsToDelete = totalCount - limit;
await _db.managers.logs await _db.managers.logs
.orderBy((o) => o.createdAt.desc()) .orderBy((o) => o.createdAt.asc())
.limit(rowsToDelete) .limit(rowsToDelete)
.delete(); .delete();
} }
} }
@override @override
FutureOr<bool> create(LogMessage log) async { Future<bool> create(LogMessage log) async {
try { try {
await _db.logs.insertOne(_toEntity(log)); await _db.logs.insertOne(_toEntity(log));
return true; return true;
@ -41,11 +44,11 @@ class LogRepository implements ILogRepository {
} }
@override @override
FutureOr<bool> createAll(Iterable<LogMessage> logs) async { Future<bool> createAll(Iterable<LogMessage> logs) async {
try { try {
await _db.batch((b) { await _db.txn(() async => await _db.batch((b) {
b.insertAll(_db.logs, logs.map(_toEntity)); b.insertAll(_db.logs, logs.map(_toEntity));
}); }));
return true; return true;
} catch (e) { } catch (e) {
debugPrint("Error while adding a log to the DB - $e"); debugPrint("Error while adding a log to the DB - $e");
@ -54,7 +57,7 @@ class LogRepository implements ILogRepository {
} }
@override @override
FutureOr<bool> deleteAll() async { Future<bool> deleteAll() async {
try { try {
await _db.logs.deleteAll(); await _db.logs.deleteAll();
return true; return true;
@ -70,8 +73,8 @@ LogsCompanion _toEntity(LogMessage log) {
content: log.content, content: log.content,
level: log.level, level: log.level,
createdAt: Value(log.createdAt), createdAt: Value(log.createdAt),
error: Value(log.error),
logger: Value(log.logger), logger: Value(log.logger),
error: Value(log.error),
stack: Value(log.stack), stack: Value(log.stack),
); );
} }
@ -79,10 +82,10 @@ LogsCompanion _toEntity(LogMessage log) {
LogMessage _toModel(Log log) { LogMessage _toModel(Log log) {
return LogMessage( return LogMessage(
content: log.content, content: log.content,
createdAt: log.createdAt,
level: log.level, level: log.level,
error: log.error, createdAt: log.createdAt,
logger: log.logger, logger: log.logger,
error: log.error,
stack: log.stack, stack: log.stack,
); );
} }

View File

@ -15,22 +15,30 @@ class RenderListRepository with LogMixin implements IRenderListRepository {
Stream<RenderList> watchAll() { Stream<RenderList> watchAll() {
final assetCountExp = _db.asset.id.count(); final assetCountExp = _db.asset.id.count();
final createdTimeExp = _db.asset.createdTime; final createdTimeExp = _db.asset.createdTime;
final monthYearExp = _db.asset.createdTime.strftime('%m-%Y'); final modifiedTimeExp = _db.asset.modifiedTime.max();
final monthYearExp = createdTimeExp.strftime('%m-%Y');
final query = _db.asset.selectOnly() final query = _db.asset.selectOnly()
..addColumns([assetCountExp, createdTimeExp]) ..addColumns([assetCountExp, createdTimeExp, modifiedTimeExp])
..groupBy([monthYearExp]) ..groupBy([monthYearExp])
..orderBy([OrderingTerm.desc(createdTimeExp)]); ..orderBy([OrderingTerm.desc(createdTimeExp)]);
int lastAssetOffset = 0; int lastAssetOffset = 0;
DateTime recentModifiedTime = DateTime(1);
return query return query
.expand((row) { .expand((row) {
final createdTime = row.read<DateTime>(createdTimeExp)!; final createdTime = row.read<DateTime>(createdTimeExp)!;
final assetCount = row.read(assetCountExp)!; final assetCount = row.read(assetCountExp)!;
final modifiedTime = row.read(modifiedTimeExp)!;
final assetOffset = lastAssetOffset; final assetOffset = lastAssetOffset;
lastAssetOffset += assetCount; lastAssetOffset += assetCount;
// Get the recent modifed time. This is used to prevent unnecessary grid updates
if (modifiedTime.isAfter(recentModifiedTime)) {
recentModifiedTime = modifiedTime;
}
return [ return [
RenderListMonthHeaderElement(date: createdTime), RenderListMonthHeaderElement(date: createdTime),
RenderListAssetElement( RenderListAssetElement(
@ -44,7 +52,9 @@ class RenderListRepository with LogMixin implements IRenderListRepository {
.map((elements) { .map((elements) {
// Resets the value in closure so the watch refresh will work properly // Resets the value in closure so the watch refresh will work properly
lastAssetOffset = 0; lastAssetOffset = 0;
return RenderList(elements: elements); final modified = recentModifiedTime;
recentModifiedTime = DateTime(1);
return RenderList(elements: elements, modifiedTime: modified);
}); });
} }
} }

View File

@ -13,7 +13,7 @@ class StoreRepository with LogMixin implements IStoreRepository {
const StoreRepository({required DriftDatabaseRepository db}) : _db = db; const StoreRepository({required DriftDatabaseRepository db}) : _db = db;
@override @override
FutureOr<T?> tryGet<T, U>(StoreKey<T, U> key) async { Future<T?> tryGet<T, U>(StoreKey<T, U> key) async {
final storeData = await _db.managers.store final storeData = await _db.managers.store
.filter((s) => s.id.equals(key.id)) .filter((s) => s.id.equals(key.id))
.getSingleOrNull(); .getSingleOrNull();
@ -21,7 +21,7 @@ class StoreRepository with LogMixin implements IStoreRepository {
} }
@override @override
FutureOr<T> get<T, U>(StoreKey<T, U> key) async { Future<T> get<T, U>(StoreKey<T, U> key) async {
final value = await tryGet(key); final value = await tryGet(key);
if (value == null) { if (value == null) {
throw StoreKeyNotFoundException(key); throw StoreKeyNotFoundException(key);
@ -30,16 +30,16 @@ class StoreRepository with LogMixin implements IStoreRepository {
} }
@override @override
FutureOr<bool> upsert<T, U>(StoreKey<T, U> key, T value) async { Future<bool> upsert<T, U>(StoreKey<T, U> key, T value) async {
try { try {
final storeValue = key.converter.toPrimitive(value); final storeValue = key.converter.toPrimitive(value);
final intValue = (key.type == int) ? storeValue as int : null; final intValue = (key.type == int) ? storeValue as int : null;
final stringValue = (key.type == String) ? storeValue as String : null; final stringValue = (key.type == String) ? storeValue as String : null;
await _db.into(_db.store).insertOnConflictUpdate(StoreCompanion.insert( await _db.store.insertOnConflictUpdate(StoreCompanion.insert(
id: Value(key.id), id: Value(key.id),
intValue: Value(intValue), intValue: Value(intValue),
stringValue: Value(stringValue), stringValue: Value(stringValue),
)); ));
return true; return true;
} catch (e, s) { } catch (e, s) {
log.e("Cannot set store value - ${key.name}; id - ${key.id}", e, s); log.e("Cannot set store value - ${key.name}; id - ${key.id}", e, s);
@ -48,7 +48,7 @@ class StoreRepository with LogMixin implements IStoreRepository {
} }
@override @override
FutureOr<void> delete(StoreKey key) async { Future<void> delete(StoreKey key) async {
await _db.managers.store.filter((s) => s.id.equals(key.id)).delete(); await _db.managers.store.filter((s) => s.id.equals(key.id)).delete();
} }
@ -61,11 +61,11 @@ class StoreRepository with LogMixin implements IStoreRepository {
} }
@override @override
FutureOr<void> deleteAll() async { Future<void> deleteAll() async {
await _db.managers.store.delete(); await _db.managers.store.delete();
} }
FutureOr<T?> _getValueFromStoreData<T, U>( Future<T?> _getValueFromStoreData<T, U>(
StoreKey<T, U> key, StoreKey<T, U> key,
StoreData? data, StoreData? data,
) async { ) async {

View File

@ -13,7 +13,7 @@ class UserRepository with LogMixin implements IUserRepository {
const UserRepository({required DriftDatabaseRepository db}) : _db = db; const UserRepository({required DriftDatabaseRepository db}) : _db = db;
@override @override
FutureOr<User?> getForId(String userId) async { Future<User?> getForId(String userId) async {
return await _db.managers.user return await _db.managers.user
.filter((f) => f.id.equals(userId)) .filter((f) => f.id.equals(userId))
.map(_toModel) .map(_toModel)
@ -21,23 +21,23 @@ class UserRepository with LogMixin implements IUserRepository {
} }
@override @override
FutureOr<bool> upsert(User user) async { Future<bool> upsert(User user) async {
try { try {
await _db.into(_db.user).insertOnConflictUpdate( await _db.user.insertOnConflictUpdate(
UserCompanion.insert( UserCompanion.insert(
id: user.id, id: user.id,
name: user.name, updatedAt: Value(user.updatedAt),
email: user.email, name: user.name,
profileImagePath: user.profileImagePath, email: user.email,
avatarColor: user.avatarColor, isAdmin: Value(user.isAdmin),
inTimeline: Value(user.inTimeline), quotaSizeInBytes: Value(user.quotaSizeInBytes),
isAdmin: Value(user.isAdmin), quotaUsageInBytes: Value(user.quotaSizeInBytes),
memoryEnabled: Value(user.memoryEnabled), inTimeline: Value(user.inTimeline),
quotaSizeInBytes: Value(user.quotaSizeInBytes), profileImagePath: user.profileImagePath,
quotaUsageInBytes: Value(user.quotaSizeInBytes), memoryEnabled: Value(user.memoryEnabled),
updatedAt: Value(user.updatedAt), avatarColor: user.avatarColor,
), ),
); );
return true; return true;
} catch (e, s) { } catch (e, s) {
log.e("Cannot insert User into table - $user", e, s); log.e("Cannot insert User into table - $user", e, s);
@ -46,7 +46,7 @@ class UserRepository with LogMixin implements IUserRepository {
} }
@override @override
FutureOr<void> deleteAll() async { Future<void> deleteAll() async {
await _db.user.deleteAll(); await _db.user.deleteAll();
} }
} }
@ -54,15 +54,15 @@ class UserRepository with LogMixin implements IUserRepository {
User _toModel(UserData user) { User _toModel(UserData user) {
return User( return User(
id: user.id, id: user.id,
email: user.email, updatedAt: user.updatedAt,
avatarColor: user.avatarColor,
inTimeline: user.inTimeline,
isAdmin: user.isAdmin,
memoryEnabled: user.memoryEnabled,
name: user.name, name: user.name,
profileImagePath: user.profileImagePath, email: user.email,
isAdmin: user.isAdmin,
quotaSizeInBytes: user.quotaSizeInBytes, quotaSizeInBytes: user.quotaSizeInBytes,
quotaUsageInBytes: user.quotaUsageInBytes, quotaUsageInBytes: user.quotaUsageInBytes,
updatedAt: user.updatedAt, inTimeline: user.inTimeline,
profileImagePath: user.profileImagePath,
memoryEnabled: user.memoryEnabled,
avatarColor: user.avatarColor,
); );
} }

View File

@ -51,9 +51,9 @@ class AssetSyncService with LogMixin {
); );
final assetsFromServer = await syncApiRepo.getFullSyncForUser( final assetsFromServer = await syncApiRepo.getFullSyncForUser(
lastId: lastAssetId,
limit: chunkSize, limit: chunkSize,
updatedUntil: updatedTill, updatedUntil: updatedTill,
lastId: lastAssetId,
userId: user.id, userId: user.id,
); );
if (assetsFromServer == null) { if (assetsFromServer == null) {
@ -92,8 +92,8 @@ class AssetSyncService with LogMixin {
final (toAdd, toUpdate, toRemove) = await _diffAssets( final (toAdd, toUpdate, toRemove) = await _diffAssets(
newAssets, newAssets,
existingAssets, existingAssets,
compare: compare,
isRemoteSync: isRemoteSync, isRemoteSync: isRemoteSync,
compare: compare,
); );
final assetsToAdd = toAdd.followedBy(toUpdate); final assetsToAdd = toAdd.followedBy(toUpdate);
@ -111,7 +111,7 @@ class AssetSyncService with LogMixin {
}) async { }) async {
// fast paths for trivial cases: reduces memory usage during initial sync etc. // fast paths for trivial cases: reduces memory usage during initial sync etc.
if (newAssets.isEmpty && inDb.isEmpty) { if (newAssets.isEmpty && inDb.isEmpty) {
return const (<Asset>[], <Asset>[], <Asset>[]); return (<Asset>[], <Asset>[], <Asset>[]);
} else if (newAssets.isEmpty && isRemoteSync == null) { } else if (newAssets.isEmpty && isRemoteSync == null) {
// remove all from database // remove all from database
return (const <Asset>[], const <Asset>[], inDb); return (const <Asset>[], const <Asset>[], inDb);

View File

@ -88,7 +88,8 @@ class HashService with LogMixin {
} }
assert(hashesInDB.isEmpty, "All hashes should be processed at this point"); assert(hashesInDB.isEmpty, "All hashes should be processed at this point");
_assetHashRepository.deleteIds(orphanedHashes.map((e) => e.id!).toList()); await _assetHashRepository
.deleteIds(orphanedHashes.map((e) => e.id!).toList());
return hashedAssets; return hashedAssets;
} }
@ -103,21 +104,21 @@ class HashService with LogMixin {
final hashedAssets = <Asset>[]; final hashedAssets = <Asset>[];
for (final (index, hash) in hashes.indexed) { for (final (index, hash) in hashes.indexed) {
// ignore: avoid-unsafe-collection-methods final asset = toBeHashed.elementAtOrNull(index)?.asset;
final asset = toBeHashed.elementAt(index).asset; if (asset != null && hash?.length == 20) {
if (hash?.length == 20) {
hashedAssets.add(asset.copyWith(hash: base64.encode(hash!))); hashedAssets.add(asset.copyWith(hash: base64.encode(hash!)));
} else { } else {
log.w("Failed to hash file ${asset.localId ?? '<null>'}, skipping"); log.w("Failed to hash file ${asset?.localId ?? '<null>'}, skipping");
} }
} }
// Store the cache for future retrieval // Store the cache for future retrieval
_assetHashRepository.upsertAll(hashedAssets.map((a) => DeviceAssetToHash( await _assetHashRepository
localId: a.localId!, .upsertAll(hashedAssets.map((a) => DeviceAssetToHash(
hash: a.hash, localId: a.localId!,
modifiedTime: a.modifiedTime, hash: a.hash,
))); modifiedTime: a.modifiedTime,
)));
log.v("Hashed ${hashedAssets.length}/${toBeHashed.length} assets"); log.v("Hashed ${hashedAssets.length}/${toBeHashed.length} assets");
return hashedAssets; return hashedAssets;
@ -127,8 +128,7 @@ class HashService with LogMixin {
/// Files that could not be hashed will have a `null` value /// Files that could not be hashed will have a `null` value
Future<List<Uint8List?>> _hashFiles(List<String> paths) async { Future<List<Uint8List?>> _hashFiles(List<String> paths) async {
try { try {
final hashes = await _hostService.digestFiles(paths); return await _hostService.digestFiles(paths);
return hashes;
} catch (e, s) { } catch (e, s) {
log.e("Error occured while hashing assets", e, s); log.e("Error occured while hashing assets", e, s);
} }

View File

@ -1,21 +1,21 @@
import 'package:immich_mobile/domain/entities/asset.entity.drift.dart'; import 'package:immich_mobile/domain/entities/asset.entity.drift.dart';
import 'package:immich_mobile/domain/models/asset.model.dart'; import 'package:immich_mobile/domain/models/asset.model.dart';
class DriftModelConverters { abstract final class DriftModelConverters {
static Asset toAssetModel(AssetData asset) { static Asset toAssetModel(AssetData asset) {
return Asset( return Asset(
id: asset.id, id: asset.id,
localId: asset.localId,
remoteId: asset.remoteId,
name: asset.name, name: asset.name,
type: asset.type,
hash: asset.hash, hash: asset.hash,
createdTime: asset.createdTime,
modifiedTime: asset.modifiedTime,
height: asset.height, height: asset.height,
width: asset.width, width: asset.width,
livePhotoVideoId: asset.livePhotoVideoId, type: asset.type,
createdTime: asset.createdTime,
modifiedTime: asset.modifiedTime,
duration: asset.duration, duration: asset.duration,
localId: asset.localId,
remoteId: asset.remoteId,
livePhotoVideoId: asset.livePhotoVideoId,
); );
} }
} }

View File

@ -26,14 +26,14 @@ class _ImAppState extends State<ImApp> with WidgetsBindingObserver {
builder: (_, appTheme, __) => _AppThemeBuilder( builder: (_, appTheme, __) => _AppThemeBuilder(
theme: appTheme, theme: appTheme,
builder: (ctx, lightTheme, darkTheme) => MaterialApp.router( builder: (ctx, lightTheme, darkTheme) => MaterialApp.router(
debugShowCheckedModeBanner: false, scaffoldMessengerKey: kScafMessengerKey,
locale: TranslationProvider.of(ctx).flutterLocale, routerConfig: di<AppRouter>().config(),
supportedLocales: AppLocaleUtils.supportedLocales,
localizationsDelegates: GlobalMaterialLocalizations.delegates,
theme: lightTheme, theme: lightTheme,
darkTheme: darkTheme, darkTheme: darkTheme,
routerConfig: di<AppRouter>().config(), locale: TranslationProvider.of(ctx).flutterLocale,
scaffoldMessengerKey: kScafMessengerKey, localizationsDelegates: GlobalMaterialLocalizations.delegates,
supportedLocales: AppLocaleUtils.supportedLocales,
debugShowCheckedModeBanner: false,
), ),
), ),
), ),

View File

@ -6,17 +6,17 @@ import 'package:immich_mobile/utils/log_manager.dart';
// ignore: import_rule_photo_manager // ignore: import_rule_photo_manager
import 'package:photo_manager/photo_manager.dart'; import 'package:photo_manager/photo_manager.dart';
void main() { void main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
// DI Injection // DI Injection
ServiceLocator.configureServices(); ServiceLocator.configureServices();
// Init logging // Init logging
LogManager.I.init(); await LogManager.I.init();
LogManager.setGlobalErrorCallbacks(); LogManager.setGlobalErrorCallbacks();
// Init localization // Init localization
LocaleSettings.useDeviceLocale(); LocaleSettings.useDeviceLocale();
// Clear photo_manager cache // Clear photo_manager cache
PhotoManager.clearFileCache(); await PhotoManager.clearFileCache();
runApp(const ImApp()); runApp(const ImApp());
} }

View File

@ -3,12 +3,12 @@ import 'package:pigeon/pigeon.dart';
@ConfigurePigeon(PigeonOptions( @ConfigurePigeon(PigeonOptions(
dartOut: 'lib/platform/messages.g.dart', dartOut: 'lib/platform/messages.g.dart',
dartOptions: DartOptions(), swiftOut: 'ios/Runner/Platform/Messages.g.swift',
swiftOptions: SwiftOptions(),
kotlinOut: kotlinOut:
'android/app/src/main/kotlin/com/alextran/immich/platform/Messages.g.kt', 'android/app/src/main/kotlin/com/alextran/immich/platform/Messages.g.kt',
kotlinOptions: KotlinOptions(), kotlinOptions: KotlinOptions(),
swiftOut: 'ios/Runner/Platform/Messages.g.swift', dartOptions: DartOptions(),
swiftOptions: SwiftOptions(),
)) ))
@HostApi() @HostApi()
abstract class ImHostService { abstract class ImHostService {

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,62 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/utils/extensions/async_snapshot.extension.dart';
import 'package:skeletonizer/skeletonizer.dart';
class SkeletonizedFutureBuilder<T> extends StatelessWidget {
const SkeletonizedFutureBuilder({
super.key,
required this.future,
required this.builder,
required this.loadingBuilder,
required this.errorBuilder,
this.emptyBuilder,
this.emptyWhen,
}) : assert(
(emptyBuilder == null && emptyWhen == null) ||
(emptyBuilder != null && emptyWhen != null),
"Both emptyBuilder and emptyWhen should be provided");
/// Future to listen to
final Future<T> future;
/// Callback when data is available
final Widget Function(BuildContext context, T? snapshot) builder;
/// Callback when future is loading. Expected a skeletonizer to be returned
final Widget Function(BuildContext context) loadingBuilder;
/// Callback when future resulted in an error
final Widget Function(BuildContext context, Object? error) errorBuilder;
/// Callback when data is available but is empty. Emptiness is determined based on the [emptyWhen] callback
final Widget Function(BuildContext context)? emptyBuilder;
/// Predicate to call [emptyBuilder] when the [data] passes the filter
final bool Function(T? data)? emptyWhen;
@override
Widget build(BuildContext context) {
return FutureBuilder<T>(
future: future,
builder: (ctx, snap) {
final Widget child;
if (snap.isWaiting) {
child = loadingBuilder(ctx);
} else {
if (snap.hasError) {
child = errorBuilder(ctx, snap.error);
} else {
final isEmpty = emptyWhen?.call(snap.data) ?? false;
child = isEmpty ? emptyBuilder!(ctx) : builder(ctx, snap.data);
}
}
return Skeletonizer.zone(
enabled: snap.isWaiting,
enableSwitchAnimation: true,
child: child,
);
},
);
}
}

View File

@ -0,0 +1,56 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/presentation/components/image/immich_cached_network_image.widget.dart';
import 'package:immich_mobile/presentation/components/image/transparent_image.dart';
import 'package:immich_mobile/utils/immich_image_url_helper.dart';
class ImUserAvatar extends StatelessWidget {
final User user;
final double? dimension;
final double? radius;
const ImUserAvatar({
super.key,
required this.user,
this.dimension,
this.radius,
});
@override
Widget build(BuildContext context) {
bool isDarkTheme = Theme.of(context).brightness == Brightness.dark;
final textIcon = Text(
user.name[0].toUpperCase(),
style: TextStyle(
color: isDarkTheme && user.avatarColor == UserAvatarColor.primary
? Colors.black
: Colors.white,
fontSize: 12,
fontWeight: FontWeight.bold,
),
);
return CircleAvatar(
backgroundColor: user.avatarColor.toColor(),
radius: radius,
child: user.profileImagePath.isEmpty
? textIcon
: ClipOval(
child: ImCachedNetworkImage(
imageUrl: ImImageUrlHelper.getUserAvatarUrl(user),
cacheKey: user.profileImagePath,
height: dimension,
width: dimension,
fit: BoxFit.cover,
placeholder: (_, __) => Image.memory(
kTransparentImage,
semanticLabel: 'Transparent',
),
fadeInDuration: const Duration(milliseconds: 300),
errorWidget: (_, error, stackTrace) => SizedBox.square(),
),
),
);
}
}

View File

@ -98,11 +98,11 @@ class DraggableScrollbar extends StatefulWidget {
required BoxConstraints? labelConstraints, required BoxConstraints? labelConstraints,
required bool alwaysVisibleScrollThumb, required bool alwaysVisibleScrollThumb,
}) { }) {
var scrollThumbAndLabel = labelText == null Widget scrollThumbAndLabel = labelText == null
? scrollThumb ? scrollThumb
: Row( : Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.end, mainAxisAlignment: MainAxisAlignment.end,
mainAxisSize: MainAxisSize.min,
children: [ children: [
_ScrollLabel( _ScrollLabel(
animation: labelAnimation, animation: labelAnimation,
@ -145,8 +145,8 @@ class DraggableScrollbar extends StatefulWidget {
color: backgroundColor, color: backgroundColor,
borderRadius: BorderRadius.only( borderRadius: BorderRadius.only(
topLeft: Radius.circular(height), topLeft: Radius.circular(height),
bottomLeft: Radius.circular(height),
topRight: const Radius.circular(4.0), topRight: const Radius.circular(4.0),
bottomLeft: Radius.circular(height),
bottomRight: const Radius.circular(4.0), bottomRight: const Radius.circular(4.0),
), ),
child: Container( child: Container(
@ -195,9 +195,9 @@ class _ScrollLabel extends StatelessWidget {
color: backgroundColor, color: backgroundColor,
borderRadius: const BorderRadius.all(Radius.circular(16.0)), borderRadius: const BorderRadius.all(Radius.circular(16.0)),
child: Container( child: Container(
constraints: constraints ?? _defaultConstraints,
padding: const EdgeInsets.symmetric(horizontal: 15),
alignment: Alignment.center, alignment: Alignment.center,
padding: const EdgeInsets.symmetric(horizontal: 15),
constraints: constraints ?? _defaultConstraints,
child: child, child: child,
), ),
), ),
@ -231,8 +231,8 @@ class _DraggableScrollbarState extends State<DraggableScrollbar>
_currentItem = 0; _currentItem = 0;
_thumbAnimationController = AnimationController( _thumbAnimationController = AnimationController(
vsync: this,
duration: widget.scrollbarAnimationDuration, duration: widget.scrollbarAnimationDuration,
vsync: this,
); );
_thumbAnimation = CurvedAnimation( _thumbAnimation = CurvedAnimation(
@ -241,8 +241,8 @@ class _DraggableScrollbarState extends State<DraggableScrollbar>
); );
_labelAnimationController = AnimationController( _labelAnimationController = AnimationController(
vsync: this,
duration: widget.scrollbarAnimationDuration, duration: widget.scrollbarAnimationDuration,
vsync: this,
); );
_labelAnimation = CurvedAnimation( _labelAnimation = CurvedAnimation(
@ -291,16 +291,16 @@ class _DraggableScrollbarState extends State<DraggableScrollbar>
onVerticalDragEnd: _onVerticalDragEnd, onVerticalDragEnd: _onVerticalDragEnd,
child: Container( child: Container(
alignment: Alignment.topRight, alignment: Alignment.topRight,
margin: EdgeInsets.only(top: _barOffset),
padding: widget.padding, padding: widget.padding,
margin: EdgeInsets.only(top: _barOffset),
child: widget.scrollThumbBuilder( child: widget.scrollThumbBuilder(
widget.backgroundColor, widget.backgroundColor,
widget.foregroundColor, widget.foregroundColor,
_thumbAnimation, _thumbAnimation,
_labelAnimation, _labelAnimation,
widget.heightScrollThumb, widget.heightScrollThumb,
labelText: labelText,
labelConstraints: widget.labelConstraints, labelConstraints: widget.labelConstraints,
labelText: labelText,
), ),
), ),
), ),
@ -356,7 +356,7 @@ class _DraggableScrollbarState extends State<DraggableScrollbar>
_thumbAnimationController.forward(); _thumbAnimationController.forward();
} }
final lastItemPos = itemPos; final lastItemPos = _itemPos;
if (lastItemPos < widget.maxItemCount) { if (lastItemPos < widget.maxItemCount) {
_currentItem = lastItemPos; _currentItem = lastItemPos;
} }
@ -378,7 +378,7 @@ class _DraggableScrollbarState extends State<DraggableScrollbar>
widget.scrollStateListener(true); widget.scrollStateListener(true);
} }
int get itemIndex { int get _itemIndex {
int index = 0; int index = 0;
double minDiff = 1000; double minDiff = 1000;
for (final pos in _positions) { for (final pos in _positions) {
@ -391,21 +391,21 @@ class _DraggableScrollbarState extends State<DraggableScrollbar>
return index; return index;
} }
int get itemPos => int get _itemPos =>
((_barOffset / (_barMaxScrollExtent)) * widget.maxItemCount).toInt(); ((_barOffset / (_barMaxScrollExtent)) * widget.maxItemCount).toInt();
void _jumpToBarPos() { void _jumpToBarPos() {
final lastItemPos = itemPos; final lastItemPos = _itemPos;
if (lastItemPos > widget.maxItemCount - 1) { if (lastItemPos > widget.maxItemCount - 1) {
return; return;
} }
_currentItem = itemIndex; _currentItem = _itemIndex;
widget.controller.sliverController.jumpToIndex(lastItemPos); widget.controller.sliverController.jumpToIndex(lastItemPos);
} }
Timer? _dragHaltTimer; Timer? _dragHaltTimer;
int lastTimerPos = 0; int _lastTimerPos = 0;
void _onVerticalDragUpdate(DragUpdateDetails details) { void _onVerticalDragUpdate(DragUpdateDetails details) {
setState(() { setState(() {
@ -418,9 +418,9 @@ class _DraggableScrollbarState extends State<DraggableScrollbar>
_barOffset = _barOffset =
clampDouble(_barOffset, _barMinScrollExtent, _barMaxScrollExtent); clampDouble(_barOffset, _barMinScrollExtent, _barMaxScrollExtent);
final lastItemPos = itemPos; final lastItemPos = _itemPos;
if (lastItemPos != lastTimerPos) { if (lastItemPos != _lastTimerPos) {
lastTimerPos = lastItemPos; _lastTimerPos = lastItemPos;
_dragHaltTimer?.cancel(); _dragHaltTimer?.cancel();
widget.scrollStateListener(true); widget.scrollStateListener(true);

View File

@ -26,6 +26,17 @@ class AssetGridState {
renderList: renderList ?? this.renderList, renderList: renderList ?? this.renderList,
); );
} }
@override
bool operator ==(covariant AssetGridState other) {
if (identical(this, other)) return true;
return other.renderList == renderList &&
other.isDragScrolling == isDragScrolling;
}
@override
int get hashCode => renderList.hashCode ^ isDragScrolling.hashCode;
} }
class AssetGridCubit extends Cubit<AssetGridState> { class AssetGridCubit extends Cubit<AssetGridState> {
@ -43,6 +54,9 @@ class AssetGridCubit extends Cubit<AssetGridState> {
super(AssetGridState.empty()) { super(AssetGridState.empty()) {
_renderListSubscription = _renderListSubscription =
_renderListProvider.renderStreamProvider().listen((renderList) { _renderListProvider.renderStreamProvider().listen((renderList) {
if (renderList == state.renderList) {
return;
}
_bufOffset = 0; _bufOffset = 0;
_buf = []; _buf = [];
emit(state.copyWith(renderList: renderList)); emit(state.copyWith(renderList: renderList));
@ -87,8 +101,8 @@ class AssetGridCubit extends Cubit<AssetGridState> {
// load the calculated batch (start:start+len) from the DB and put it into the buffer // load the calculated batch (start:start+len) from the DB and put it into the buffer
_buf = await _renderListProvider.renderAssetProvider( _buf = await _renderListProvider.renderAssetProvider(
offset: start,
limit: len, limit: len,
offset: start,
); );
_bufOffset = start; _bufOffset = start;

View File

@ -13,6 +13,7 @@ import 'package:intl/intl.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
part 'immich_asset_grid_header.widget.dart'; part 'immich_asset_grid_header.widget.dart';
part 'immich_asset_render_grid.widget.dart';
class ImAssetGrid extends StatefulWidget { class ImAssetGrid extends StatefulWidget {
/// The padding for the grid /// The padding for the grid
@ -76,7 +77,6 @@ class _ImAssetGridState extends State<ImAssetGrid> {
} }
final grid = FlutterListView( final grid = FlutterListView(
controller: _controller,
delegate: FlutterListViewDelegate( delegate: FlutterListViewDelegate(
(_, sectionIndex) { (_, sectionIndex) {
// ignore: avoid-unsafe-collection-methods // ignore: avoid-unsafe-collection-methods
@ -89,70 +89,46 @@ class _ImAssetGridState extends State<ImAssetGrid> {
RenderListMonthHeaderElement() => RenderListMonthHeaderElement() =>
_MonthHeader(text: section.header), _MonthHeader(text: section.header),
RenderListDayHeaderElement() => Text(section.header), RenderListDayHeaderElement() => Text(section.header),
RenderListAssetElement() => FutureBuilder( RenderListAssetElement() => _StaticGrid(
future: context.read<AssetGridCubit>().loadAssets( section: section,
section.assetOffset, isDragging: state.isDragScrolling,
section.assetCount,
),
builder: (_, assetsSnap) {
final assets = assetsSnap.data;
return GridView.builder(
physics: const NeverScrollableScrollPhysics(),
shrinkWrap: true,
addAutomaticKeepAlives: false,
cacheExtent: 100,
padding: const EdgeInsets.all(0),
gridDelegate:
const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 4,
mainAxisSpacing: 3,
crossAxisSpacing: 3,
),
itemBuilder: (_, i) {
final asset = assetsSnap.isWaiting || assets == null
? null
: assets.elementAtOrNull(i);
return SizedBox.square(
dimension: 200,
// Show Placeholder when drag scrolled
child: asset == null || state.isDragScrolling
? const ImImagePlaceholder()
: ImThumbnail(asset),
);
},
itemCount: section.assetCount,
);
},
), ),
}; };
}, },
childCount: elements.length, childCount: elements.length,
addAutomaticKeepAlives: false, addAutomaticKeepAlives: false,
), ),
controller: _controller,
); );
final EdgeInsetsGeometry? padding; final EdgeInsetsGeometry? padding;
if (widget.topPadding != null) { if (widget.topPadding == null) {
padding = EdgeInsets.only(top: widget.topPadding!);
} else {
padding = null; padding = null;
} else {
padding = EdgeInsets.only(top: widget.topPadding!);
} }
return DraggableScrollbar( return DraggableScrollbar(
foregroundColor: context.colorScheme.onSurface,
backgroundColor: context.colorScheme.surfaceContainerHighest,
scrollStateListener:
context.read<AssetGridCubit>().setDragScrolling,
controller: _controller, controller: _controller,
maxItemCount: elements.length, maxItemCount: elements.length,
scrollStateListener:
context.read<AssetGridCubit>().setDragScrolling,
backgroundColor: context.colorScheme.surfaceContainerHighest,
foregroundColor: context.colorScheme.onSurface,
padding: padding,
scrollbarAnimationDuration: Durations.medium2,
scrollbarTimeToFade: Durations.extralong4,
labelTextBuilder: (int position) => labelTextBuilder: (int position) =>
_labelBuilder(elements, position), _labelBuilder(elements, position),
labelConstraints: const BoxConstraints(maxHeight: 36), labelConstraints: const BoxConstraints(maxHeight: 36),
scrollbarAnimationDuration: Durations.medium2,
scrollbarTimeToFade: Durations.extralong4,
padding: padding,
child: grid, child: grid,
); );
}, },
// no.of elements are not equal or is modified
buildWhen: (previous, current) =>
(previous.renderList.elements.length !=
current.renderList.elements.length) ||
!previous.renderList.modifiedTime
.isAtSameMomentAs(current.renderList.modifiedTime),
); );
} }

View File

@ -10,8 +10,8 @@ class _HeaderText extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Padding( return Padding(
padding: const EdgeInsets.only( padding: const EdgeInsets.only(
top: 32.0,
left: 16.0, left: 16.0,
top: 32.0,
right: 24.0, right: 24.0,
bottom: 16.0, bottom: 16.0,
), ),
@ -40,9 +40,9 @@ class _MonthHeader extends StatelessWidget {
return _HeaderText( return _HeaderText(
text: text, text: text,
style: context.textTheme.bodyLarge?.copyWith( style: context.textTheme.bodyLarge?.copyWith(
color: context.colorScheme.onSurface,
fontSize: 24.0, fontSize: 24.0,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
color: context.colorScheme.onSurface,
), ),
); );
} }

View File

@ -0,0 +1,46 @@
part of 'immich_asset_grid.widget.dart';
class _StaticGrid extends StatelessWidget {
final RenderListAssetElement section;
final bool isDragging;
const _StaticGrid({required this.section, required this.isDragging});
@override
Widget build(BuildContext context) {
return FutureBuilder(
future: context.read<AssetGridCubit>().loadAssets(
section.assetOffset,
section.assetCount,
),
builder: (_, assetsSnap) {
final assets = assetsSnap.data;
return GridView.builder(
physics: const NeverScrollableScrollPhysics(),
shrinkWrap: true,
padding: const EdgeInsets.all(0),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 4,
mainAxisSpacing: 3,
crossAxisSpacing: 3,
),
itemBuilder: (_, i) {
final asset = assetsSnap.isWaiting || assets == null
? null
: assets.elementAtOrNull(i);
return SizedBox.square(
dimension: 200,
// Show Placeholder when drag scrolled
child: asset == null || isDragging
? const ImImagePlaceholder()
: ImThumbnail(asset),
);
},
itemCount: section.assetCount,
addAutomaticKeepAlives: false,
cacheExtent: 100,
);
},
);
}
}

View File

@ -14,8 +14,8 @@ class ImRemoteThumbnailCacheManager extends CacheManager {
: super( : super(
Config( Config(
kCacheThumbnailsKey, kCacheThumbnailsKey,
maxNrOfCacheObjects: kCacheMaxNrOfThumbnails,
stalePeriod: const Duration(days: kCacheStalePeriod), stalePeriod: const Duration(days: kCacheStalePeriod),
maxNrOfCacheObjects: kCacheMaxNrOfThumbnails,
), ),
); );
} }
@ -33,8 +33,8 @@ class ImRemoteImageCacheManager extends CacheManager {
: super( : super(
Config( Config(
kCacheFullImagesKey, kCacheFullImagesKey,
maxNrOfCacheObjects: kCacheMaxNrOfFullImages,
stalePeriod: const Duration(days: kCacheStalePeriod), stalePeriod: const Duration(days: kCacheStalePeriod),
maxNrOfCacheObjects: kCacheMaxNrOfFullImages,
), ),
); );
} }

View File

@ -19,7 +19,7 @@ class ImageLoadingException implements Exception {
/// ///
/// Credit to [flutter_cached_network_image](https://github.com/Baseflow/flutter_cached_network_image/blob/develop/cached_network_image/lib/src/image_provider/_image_loader.dart) /// Credit to [flutter_cached_network_image](https://github.com/Baseflow/flutter_cached_network_image/blob/develop/cached_network_image/lib/src/image_provider/_image_loader.dart)
/// for this wonderful implementation of their image loader /// for this wonderful implementation of their image loader
class ImageLoader { abstract final class ImageLoader {
static Future<ui.Codec> loadImageFromCache( static Future<ui.Codec> loadImageFromCache(
String uri, { String uri, {
required CacheManager cache, required CacheManager cache,
@ -28,8 +28,8 @@ class ImageLoader {
}) async { }) async {
final stream = cache.getFileStream( final stream = cache.getFileStream(
uri, uri,
withProgress: chunkEvents != null,
headers: di<ImApiClient>().headers, headers: di<ImApiClient>().headers,
withProgress: chunkEvents != null,
); );
await for (final result in stream) { await for (final result in stream) {
@ -44,8 +44,7 @@ class ImageLoader {
} else if (result is FileInfo) { } else if (result is FileInfo) {
// We have the file // We have the file
final buffer = await ui.ImmutableBuffer.fromFilePath(result.file.path); final buffer = await ui.ImmutableBuffer.fromFilePath(result.file.path);
final decoded = await decode(buffer); return await decode(buffer);
return decoded;
} }
} }

View File

@ -0,0 +1,17 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:immich_mobile/service_locator.dart';
import 'package:immich_mobile/utils/immich_api_client.dart';
class ImCachedNetworkImage extends CachedNetworkImage {
ImCachedNetworkImage({
super.key,
required super.imageUrl,
super.cacheKey,
super.height,
super.width,
super.fit,
super.placeholder,
super.fadeInDuration,
super.errorWidget,
}) : super(httpHeaders: di<ImApiClient>().headers);
}

View File

@ -12,9 +12,9 @@ class ImImagePlaceholder extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Container( return Container(
color: context.colorScheme.surfaceContainerHighest,
width: 200, width: 200,
height: 200, height: 200,
color: context.colorScheme.surfaceContainerHighest,
); );
} }
} }
@ -63,13 +63,8 @@ class ImImage extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return OctoImage( return OctoImage(
fadeInDuration: const Duration(milliseconds: 0),
fadeOutDuration: Durations.short4,
placeholderBuilder: (_) => placeholder,
image: ImImage.imageProvider(asset: asset), image: ImImage.imageProvider(asset: asset),
width: width, placeholderBuilder: (_) => placeholder,
height: height,
fit: BoxFit.cover,
errorBuilder: (_, error, stackTrace) { errorBuilder: (_, error, stackTrace) {
if (error is PlatformException && if (error is PlatformException &&
error.code == "The asset not found!") { error.code == "The asset not found!") {
@ -86,6 +81,11 @@ class ImImage extends StatelessWidget {
color: context.colorScheme.primary, color: context.colorScheme.primary,
); );
}, },
fadeOutDuration: Durations.short4,
fadeInDuration: Duration.zero,
width: width,
height: height,
fit: BoxFit.cover,
); );
} }
} }

View File

@ -4,13 +4,13 @@ import 'package:immich_mobile/utils/extensions/build_context.extension.dart';
class ImLogo extends StatelessWidget { class ImLogo extends StatelessWidget {
const ImLogo({ const ImLogo({
this.width, this.dimension,
this.filterQuality = FilterQuality.high, this.filterQuality = FilterQuality.high,
super.key, super.key,
}); });
/// The width of the image. /// The dimension of the image.
final double? width; final double? dimension;
/// The rendering quality /// The rendering quality
final FilterQuality filterQuality; final FilterQuality filterQuality;
@ -18,11 +18,12 @@ class ImLogo extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Image( return Image(
width: width,
filterQuality: filterQuality,
semanticLabel: 'Immich Logo',
image: Assets.images.immichLogo.provider(), image: Assets.images.immichLogo.provider(),
semanticLabel: 'Immich Logo',
width: dimension,
height: dimension,
isAntiAlias: true, isAntiAlias: true,
filterQuality: filterQuality,
); );
} }
} }
@ -43,10 +44,10 @@ class ImLogoText extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Image( return Image(
semanticLabel: 'Immich Logo Text',
image: (context.isDarkTheme image: (context.isDarkTheme
? Assets.images.immichTextDark.provider ? Assets.images.immichTextDark.provider
: Assets.images.immichTextLight.provider)(), : Assets.images.immichTextLight.provider)(),
semanticLabel: 'Immich Logo Text',
width: fontSize * 4, width: fontSize * 4,
filterQuality: FilterQuality.high, filterQuality: FilterQuality.high,
); );

View File

@ -77,9 +77,9 @@ class _PadAlignedIcon extends StatelessWidget {
alignment: alignment, alignment: alignment,
child: Icon( child: Icon(
icon, icon,
color: Colors.white,
size: 20, size: 20,
fill: (filled != null && filled!) ? 1 : null, fill: (filled != null && filled!) ? 1 : null,
color: Colors.white,
), ),
), ),
); );

View File

@ -49,12 +49,12 @@ class ImLocalImageProvider extends ImageProvider<ImLocalImageProvider> {
// Load a small thumbnail // Load a small thumbnail
final thumbBytes = final thumbBytes =
await di<IDeviceAssetRepository>().getThumbnail(a.localId!); await di<IDeviceAssetRepository>().getThumbnail(a.localId!);
if (thumbBytes != null) { if (thumbBytes == null) {
debugPrint("Loading thumb for ${a.name} failed");
} else {
final buffer = await ui.ImmutableBuffer.fromUint8List(thumbBytes); final buffer = await ui.ImmutableBuffer.fromUint8List(thumbBytes);
final codec = await decode(buffer); final codec = await decode(buffer);
yield codec; yield codec;
} else {
debugPrint("Loading thumb for ${a.name} failed");
} }
if (asset.isImage) { if (asset.isImage) {

View File

@ -56,12 +56,12 @@ class ImLocalThumbnailProvider extends ImageProvider<ImLocalThumbnailProvider> {
// Load a small thumbnail // Load a small thumbnail
final thumbBytes = await di<IDeviceAssetRepository>() final thumbBytes = await di<IDeviceAssetRepository>()
.getThumbnail(a.localId!, width: 32, height: 32, quality: 75); .getThumbnail(a.localId!, width: 32, height: 32, quality: 75);
if (thumbBytes != null) { if (thumbBytes == null) {
debugPrint("Loading thumb for ${a.name} failed");
} else {
final buffer = await ui.ImmutableBuffer.fromUint8List(thumbBytes); final buffer = await ui.ImmutableBuffer.fromUint8List(thumbBytes);
final codec = await decode(buffer); final codec = await decode(buffer);
yield codec; yield codec;
} else {
debugPrint("Loading thumb for ${a.name} failed");
} }
final normalThumbBytes = await di<IDeviceAssetRepository>() final normalThumbBytes = await di<IDeviceAssetRepository>()

View File

@ -0,0 +1,68 @@
import 'dart:typed_data';
final Uint8List kTransparentImage = Uint8List.fromList([
0x89,
0x50,
0x4E,
0x47,
0x0D,
0x0A,
0x1A,
0x0A,
0x00,
0x00,
0x00,
0x0D,
0x49,
0x48,
0x44,
0x52,
0x00,
0x00,
0x00,
0x01,
0x00,
0x00,
0x00,
0x01,
0x08,
0x06,
0x00,
0x00,
0x00,
0x1F,
0x15,
0xC4,
0x89,
0x00,
0x00,
0x00,
0x0A,
0x49,
0x44,
0x41,
0x54,
0x78,
0x9C,
0x63,
0x00,
0x01,
0x00,
0x00,
0x05,
0x00,
0x01,
0x0D,
0x0A,
0x2D,
0xB4,
0x00,
0x00,
0x00,
0x00,
0x49,
0x45,
0x4E,
0x44,
0xAE,
]);

View File

@ -40,33 +40,33 @@ class ImPasswordFormField extends StatefulWidget {
} }
class _ImPasswordFormFieldState extends State<ImPasswordFormField> { class _ImPasswordFormFieldState extends State<ImPasswordFormField> {
final showPassword = ValueNotifier(false); final _showPassword = ValueNotifier(false);
@override @override
void dispose() { void dispose() {
showPassword.dispose(); _showPassword.dispose();
super.dispose(); super.dispose();
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ValueListenableBuilder( return ValueListenableBuilder(
valueListenable: showPassword, valueListenable: _showPassword,
builder: (_, showPass, child) => ImTextFormField( builder: (_, showPass, child) => ImTextFormField(
controller: widget.controller, controller: widget.controller,
focusNode: widget.focusNode,
onChanged: widget.onChanged, onChanged: widget.onChanged,
shouldObscure: !showPass, shouldObscure: !showPass,
hint: widget.hint,
label: widget.label,
focusNode: widget.focusNode,
suffixIcon: IconButton( suffixIcon: IconButton(
onPressed: () => showPassword.value = !showPassword.value, onPressed: () => _showPassword.value = !_showPassword.value,
icon: Icon( icon: Icon(
showPassword.value _showPassword.value
? Symbols.visibility_off_rounded ? Symbols.visibility_off_rounded
: Symbols.visibility_rounded, : Symbols.visibility_rounded,
), ),
), ),
label: widget.label,
hint: widget.hint,
autoFillHints: const [AutofillHints.password], autoFillHints: const [AutofillHints.password],
keyboardType: TextInputType.visiblePassword, keyboardType: TextInputType.visiblePassword,
textInputAction: widget.textInputAction, textInputAction: widget.textInputAction,

View File

@ -29,18 +29,27 @@ class ImSwitchListTile<T> extends StatefulWidget {
class _ImSwitchListTileState<T> extends State<ImSwitchListTile<T>> { class _ImSwitchListTileState<T> extends State<ImSwitchListTile<T>> {
// Actual switch list state // Actual switch list state
late bool isEnabled; late bool _isEnabled;
final AppSettingService _appSettingService = di(); final AppSettingService _appSettingService = di();
Future<void> set(bool enabled) async { Future<void> _set(bool enabled) async {
if (isEnabled == enabled) return; if (_isEnabled == enabled) return;
final value = T != bool ? widget.toAppSetting!(enabled) : enabled as T; final value = T == bool ? enabled as T : widget.toAppSetting!(enabled);
if (value != null && if (value != null &&
await _appSettingService.upsert(widget.setting, value) && await _appSettingService.upsert(widget.setting, value) &&
context.mounted) { context.mounted) {
setState(() { setState(() {
isEnabled = enabled; _isEnabled = enabled;
});
}
}
Future<void> _initSetting() async {
final value = await _appSettingService.get(widget.setting);
if (context.mounted) {
setState(() {
_isEnabled = T == bool ? value as bool : widget.fromAppSetting!(value);
}); });
} }
} }
@ -48,20 +57,14 @@ class _ImSwitchListTileState<T> extends State<ImSwitchListTile<T>> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_appSettingService.get(widget.setting).then((value) { _initSetting().ignore();
if (context.mounted) {
setState(() {
isEnabled = T != bool ? widget.fromAppSetting!(value) : value as bool;
});
}
});
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SwitchListTile( return SwitchListTile(
value: isEnabled, value: _isEnabled,
onChanged: (value) => set(value), onChanged: (value) => unawaited(_set(value)),
); );
} }
} }

View File

@ -66,21 +66,21 @@ class ImTextFormField extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return TextFormField( return TextFormField(
controller: controller, controller: controller,
onChanged: onChanged,
focusNode: focusNode, focusNode: focusNode,
obscureText: shouldObscure,
validator: validator,
decoration: InputDecoration( decoration: InputDecoration(
labelText: label, labelText: label,
hintText: hint, hintText: hint,
suffixIcon: suffixIcon, suffixIcon: suffixIcon,
), ),
autofillHints: autoFillHints,
keyboardType: keyboardType, keyboardType: keyboardType,
textInputAction: textInputAction, textInputAction: textInputAction,
readOnly: isDisabled, readOnly: isDisabled,
obscureText: shouldObscure,
onChanged: onChanged,
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
onFieldSubmitted: onSubmitted, onFieldSubmitted: onSubmitted,
validator: validator,
autofillHints: autoFillHints,
); );
} }
} }

View File

@ -1,36 +1,44 @@
import 'dart:async';
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:immich_mobile/utils/extensions/build_context.extension.dart'; import 'package:immich_mobile/utils/extensions/build_context.extension.dart';
class ImAdaptiveRoutePrimaryAppBar extends StatelessWidget class ImAdaptiveRouteAppBar extends StatelessWidget
implements PreferredSizeWidget { implements PreferredSizeWidget {
const ImAdaptiveRoutePrimaryAppBar({super.key}); final String? title;
final bool isPrimary;
@override /// Passed to [AppBar] actions
Widget build(BuildContext context) { final List<Widget>? actions;
return AppBar(
leading: BackButton(onPressed: () => context.router.root.maybePop()), const ImAdaptiveRouteAppBar({
); super.key,
} this.title,
this.isPrimary = true,
this.actions,
});
@override @override
Size get preferredSize => const Size.fromHeight(kToolbarHeight); Size get preferredSize => const Size.fromHeight(kToolbarHeight);
}
// ignore: prefer-single-widget-per-file
class ImAdaptiveRouteSecondaryAppBar extends StatelessWidget
implements PreferredSizeWidget {
const ImAdaptiveRouteSecondaryAppBar({super.key});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final Widget leading;
if (isPrimary) {
leading = BackButton(
onPressed: () => unawaited(context.router.root.maybePop()),
);
} else {
leading = context.isTablet
? CloseButton(onPressed: () => unawaited(context.maybePop()))
: BackButton(onPressed: () => unawaited(context.maybePop()));
}
return AppBar( return AppBar(
leading: context.isTablet leading: leading,
? CloseButton(onPressed: () => context.maybePop()) title: title == null ? null : Text(title!),
: BackButton(onPressed: () => context.maybePop()), actions: actions,
); );
} }
@override
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
} }

View File

@ -27,7 +27,7 @@ class ImAdaptiveRouteWrapper extends StatelessWidget {
return ImAdaptiveScaffoldBody( return ImAdaptiveScaffoldBody(
primaryBody: primaryBody, primaryBody: primaryBody,
secondaryBody: secondaryBody:
ctx.topRoute.name != primaryRoute ? (_) => child : null, ctx.topRoute.name == primaryRoute ? null : (_) => child,
bodyRatio: bodyRatio, bodyRatio: bodyRatio,
); );
} }

View File

@ -21,14 +21,11 @@ class ImAdaptiveScaffoldBody extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return AdaptiveLayout( return AdaptiveLayout(
internalAnimations: false,
transitionDuration: Durations.medium2,
bodyRatio: bodyRatio,
body: SlotLayout( body: SlotLayout(
config: { config: {
Breakpoints.standard: SlotLayout.from( Breakpoints.standard: SlotLayout.from(
key: const Key('ImAdaptiveScaffold Body Standard'),
builder: primaryBody, builder: primaryBody,
key: const Key('ImAdaptiveScaffold Body Standard'),
), ),
}, },
), ),
@ -37,11 +34,14 @@ class ImAdaptiveScaffoldBody extends StatelessWidget {
/// No secondary body in mobile layouts /// No secondary body in mobile layouts
Breakpoints.small: SlotLayoutConfig.empty(), Breakpoints.small: SlotLayoutConfig.empty(),
Breakpoints.mediumAndUp: SlotLayout.from( Breakpoints.mediumAndUp: SlotLayout.from(
key: const Key('ImAdaptiveScaffold Secondary Body Medium'),
builder: secondaryBody, builder: secondaryBody,
key: const Key('ImAdaptiveScaffold Secondary Body Medium'),
), ),
}, },
), ),
bodyRatio: bodyRatio,
transitionDuration: Durations.medium2,
internalAnimations: false,
); );
} }
} }

View File

@ -26,6 +26,8 @@ class _HomePageState extends State<HomePage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final gridHasPadding = !context.isTablet && _showAppBar.value;
return Scaffold( return Scaffold(
body: BlocProvider( body: BlocProvider(
create: (_) => AssetGridCubit( create: (_) => AssetGridCubit(
@ -33,32 +35,35 @@ class _HomePageState extends State<HomePage> {
), ),
child: Stack(children: [ child: Stack(children: [
ImAssetGrid( ImAssetGrid(
topPadding: kToolbarHeight + context.mediaQueryPadding.top - 8, topPadding: gridHasPadding
), ? kToolbarHeight + context.mediaQueryPadding.top - 8
ValueListenableBuilder( : null,
valueListenable: _showAppBar,
builder: (_, shouldShow, appBar) {
final Duration duration;
if (shouldShow) {
// Animate out app bar slower
duration = Durations.short3;
} else {
// Animate in app bar faster
duration = Durations.medium2;
}
return AnimatedPositioned(
duration: duration,
curve: Curves.easeOut,
left: 0,
right: 0,
top: shouldShow
? 0
: -(kToolbarHeight + context.mediaQueryPadding.top),
child: appBar!,
);
},
child: const ImAppBar(),
), ),
if (!context.isTablet)
ValueListenableBuilder(
valueListenable: _showAppBar,
builder: (_, shouldShow, appBar) {
final Duration duration;
if (shouldShow) {
// Animate out app bar slower
duration = Durations.short3;
} else {
// Animate in app bar faster
duration = Durations.medium2;
}
return AnimatedPositioned(
left: 0,
top: shouldShow
? 0
: -(kToolbarHeight + context.mediaQueryPadding.top),
right: 0,
curve: Curves.easeOut,
duration: duration,
child: appBar!,
);
},
child: const ImAppBar(),
),
]), ]),
), ),
); );

View File

@ -1,3 +1,5 @@
import 'dart:async';
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
@ -55,15 +57,15 @@ class _LoginPageState extends State<LoginPage>
void _onLoginPageStateChange(BuildContext context, LoginPageState state) { void _onLoginPageStateChange(BuildContext context, LoginPageState state) {
if (state.isLoginSuccessful) { if (state.isLoginSuccessful) {
context.replaceRoute(const TabControllerRoute()); unawaited(context.replaceRoute(const TabControllerRoute()));
} }
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final PreferredSizeWidget? appBar; final PreferredSizeWidget? appBar;
late final Widget primaryBody; final Widget primaryBody;
late final Widget secondaryBody; final Widget secondaryBody;
Widget rotatingLogo = GestureDetector( Widget rotatingLogo = GestureDetector(
onDoubleTap: _populateDemoCredentials, onDoubleTap: _populateDemoCredentials,
@ -73,7 +75,7 @@ class _LoginPageState extends State<LoginPage>
children: [ children: [
RotationTransition( RotationTransition(
turns: _animationController, turns: _animationController,
child: const ImLogo(width: 100), child: const ImLogo(dimension: 100),
), ),
const SizedGap.lh(), const SizedGap.lh(),
const ImLogoText(), const ImLogoText(),
@ -104,7 +106,7 @@ class _LoginPageState extends State<LoginPage>
), ),
), ),
TextButton( TextButton(
onPressed: () => context.navigateRoot(const LogsRoute()), onPressed: () => unawaited(context.navigateRoot(const LogsRoute())),
child: const Text('Logs'), child: const Text('Logs'),
), ),
], ],
@ -122,7 +124,9 @@ class _LoginPageState extends State<LoginPage>
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
), ),
child: InkWell( child: InkWell(
onTap: () => launchUrl(Uri.parse(_serverUrlController.text)), onTap: () => unawaited(
launchUrl(Uri.parse(_serverUrlController.text)),
),
child: Text( child: Text(
_serverUrlController.text, _serverUrlController.text,
textAlign: TextAlign.center, textAlign: TextAlign.center,
@ -157,12 +161,12 @@ class _LoginPageState extends State<LoginPage>
bottom, bottom,
]), ]),
); );
secondaryBody = const SizedBox.shrink();
} }
return BlocListener<LoginPageCubit, LoginPageState>( return BlocListener<LoginPageCubit, LoginPageState>(
listener: _onLoginPageStateChange, listener: _onLoginPageStateChange,
child: Scaffold( child: Scaffold(
resizeToAvoidBottomInset: false,
appBar: appBar, appBar: appBar,
body: SafeArea( body: SafeArea(
child: ImAdaptiveScaffoldBody( child: ImAdaptiveScaffoldBody(
@ -170,6 +174,7 @@ class _LoginPageState extends State<LoginPage>
secondaryBody: (_) => secondaryBody, secondaryBody: (_) => secondaryBody,
), ),
), ),
resizeToAvoidBottomInset: false,
), ),
); );
} }
@ -182,13 +187,14 @@ class _MobileAppBar extends StatelessWidget implements PreferredSizeWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return AppBar( return AppBar(
automaticallyImplyLeading: false, automaticallyImplyLeading: false,
scrolledUnderElevation: 0.0,
actions: [ actions: [
IconButton( IconButton(
onPressed: () => context.navigateRoot(const SettingsRoute()), onPressed: () =>
unawaited(context.navigateRoot(const SettingsRoute())),
icon: const Icon(Symbols.settings), icon: const Icon(Symbols.settings),
), ),
], ],
scrolledUnderElevation: 0.0,
); );
} }

View File

@ -61,7 +61,7 @@ class LoginPageCubit extends Cubit<LoginPageState> with LogMixin {
// Check for /.well-known/immich // Check for /.well-known/immich
url = await loginService.resolveEndpoint(uri); url = await loginService.resolveEndpoint(uri);
di<IStoreRepository>().upsert(StoreKey.serverEndpoint, url); await di<IStoreRepository>().upsert(StoreKey.serverEndpoint, url);
await di<LoginService>().handlePostUrlResolution(url); await di<LoginService>().handlePostUrlResolution(url);
emit(state.copyWith(isServerValidated: true)); emit(state.copyWith(isServerValidated: true));

View File

@ -34,14 +34,14 @@ class LoginForm extends StatelessWidget {
builder: (_, isServerValidated) => SingleChildScrollView( builder: (_, isServerValidated) => SingleChildScrollView(
child: AnimatedSwitcher( child: AnimatedSwitcher(
duration: Durations.medium1, duration: Durations.medium1,
layoutBuilder: (current, previous) =>
current ?? (previous.lastOrNull ?? const SizedBox.shrink()),
child: isServerValidated child: isServerValidated
? _CredentialsForm( ? _CredentialsForm(
emailController: emailController, emailController: emailController,
passwordController: passwordController, passwordController: passwordController,
) )
: _ServerForm(controller: serverUrlController), : _ServerForm(controller: serverUrlController),
layoutBuilder: (current, previous) =>
current ?? (previous.lastOrNull ?? const SizedBox.shrink()),
), ),
), ),
); );
@ -75,13 +75,13 @@ class _ServerFormState extends State<_ServerForm> {
child: BlocSelector<LoginPageCubit, LoginPageState, bool>( child: BlocSelector<LoginPageCubit, LoginPageState, bool>(
selector: (model) => model.isValidationInProgress, selector: (model) => model.isValidationInProgress,
builder: (_, isValidationInProgress) => Column( builder: (_, isValidationInProgress) => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
ImTextFormField( ImTextFormField(
controller: widget.controller, controller: widget.controller,
label: context.t.login.label.endpoint,
validator: context.read<LoginPageCubit>().validateServerUrl, validator: context.read<LoginPageCubit>().validateServerUrl,
label: context.t.login.label.endpoint,
autoFillHints: const [AutofillHints.url], autoFillHints: const [AutofillHints.url],
keyboardType: TextInputType.url, keyboardType: TextInputType.url,
textInputAction: TextInputAction.go, textInputAction: TextInputAction.go,
@ -89,10 +89,10 @@ class _ServerFormState extends State<_ServerForm> {
), ),
const SizedGap.mh(), const SizedGap.mh(),
ImFilledButton( ImFilledButton(
label: context.t.login.label.next_button,
icon: Symbols.arrow_forward_rounded, icon: Symbols.arrow_forward_rounded,
onPressed: () => unawaited(_validateForm(context)), onPressed: () => unawaited(_validateForm(context)),
isDisabled: isValidationInProgress, isDisabled: isValidationInProgress,
label: context.t.login.label.next_button,
), ),
const SizedGap.mh(), const SizedGap.mh(),
if (isValidationInProgress) const ImLoadingIndicator(), if (isValidationInProgress) const ImLoadingIndicator(),
@ -117,11 +117,11 @@ class _CredentialsForm extends StatefulWidget {
} }
class _CredentialsFormState extends State<_CredentialsForm> { class _CredentialsFormState extends State<_CredentialsForm> {
final passwordFocusNode = FocusNode(); final _passwordFocusNode = FocusNode();
@override @override
void dispose() { void dispose() {
passwordFocusNode.dispose(); _passwordFocusNode.dispose();
super.dispose(); super.dispose();
} }
@ -134,28 +134,27 @@ class _CredentialsFormState extends State<_CredentialsForm> {
: ValueListenableBuilder( : ValueListenableBuilder(
valueListenable: di<ServerFeatureConfigProvider>(), valueListenable: di<ServerFeatureConfigProvider>(),
builder: (_, state, __) => Column( builder: (_, state, __) => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
if (state.features.hasPasswordLogin) ...[ if (state.features.hasPasswordLogin) ...[
ImTextFormField( ImTextFormField(
controller: widget.emailController, controller: widget.emailController,
label: context.t.login.label.email, label: context.t.login.label.email,
isDisabled: isValidationInProgress,
textInputAction: TextInputAction.next, textInputAction: TextInputAction.next,
onSubmitted: (_) => passwordFocusNode.requestFocus(), isDisabled: isValidationInProgress,
onSubmitted: (_) => _passwordFocusNode.requestFocus(),
), ),
const SizedGap.mh(), const SizedGap.mh(),
ImPasswordFormField( ImPasswordFormField(
controller: widget.passwordController, controller: widget.passwordController,
focusNode: _passwordFocusNode,
label: context.t.login.label.password, label: context.t.login.label.password,
focusNode: passwordFocusNode,
isDisabled: isValidationInProgress,
textInputAction: TextInputAction.go, textInputAction: TextInputAction.go,
isDisabled: isValidationInProgress,
), ),
const SizedGap.mh(), const SizedGap.mh(),
ImFilledButton( ImFilledButton(
label: context.t.login.label.login_button,
icon: Symbols.login_rounded, icon: Symbols.login_rounded,
onPressed: () => unawaited( onPressed: () => unawaited(
context.read<LoginPageCubit>().passwordLogin( context.read<LoginPageCubit>().passwordLogin(
@ -163,31 +162,32 @@ class _CredentialsFormState extends State<_CredentialsForm> {
password: widget.passwordController.text, password: widget.passwordController.text,
), ),
), ),
label: context.t.login.label.login_button,
), ),
// Divider when both password and oAuth login is enabled // Divider when both password and oAuth login is enabled
if (state.features.hasOAuthLogin) const Divider(), if (state.features.hasOAuthLogin) const Divider(),
], ],
if (state.features.hasOAuthLogin) if (state.features.hasOAuthLogin)
ImFilledButton( ImFilledButton(
label: state.config.oauthButtonText ??
context.t.login.label.oauth_button,
icon: Symbols.pin_rounded, icon: Symbols.pin_rounded,
onPressed: () => unawaited( onPressed: () => unawaited(
context.read<LoginPageCubit>().oAuthLogin(), context.read<LoginPageCubit>().oAuthLogin(),
), ),
label: state.config.oauthButtonText ??
context.t.login.label.oauth_button,
), ),
if (!state.features.hasPasswordLogin && if (!state.features.hasPasswordLogin &&
!state.features.hasOAuthLogin) !state.features.hasOAuthLogin)
ImFilledButton( ImFilledButton(
label: context.t.login.label.login_disabled,
isDisabled: true, isDisabled: true,
label: context.t.login.label.login_disabled,
), ),
const SizedGap.sh(), const SizedGap.sh(),
ImTextButton( ImTextButton(
label: context.t.login.label.back_button,
icon: Symbols.arrow_back_rounded, icon: Symbols.arrow_back_rounded,
onPressed: onPressed:
context.read<LoginPageCubit>().resetServerValidation, context.read<LoginPageCubit>().resetServerValidation,
label: context.t.login.label.back_button,
), ),
], ],
), ),

View File

@ -1,12 +0,0 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
@RoutePage()
class LogsPage extends StatelessWidget {
const LogsPage({super.key});
@override
Widget build(BuildContext context) {
return const Scaffold(body: Center(child: Text("Logs Page")));
}
}

View File

@ -0,0 +1,201 @@
import 'dart:async';
import 'package:auto_route/auto_route.dart';
import 'package:dynamic_color/dynamic_color.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/domain/interfaces/log.interface.dart';
import 'package:immich_mobile/domain/models/log.model.dart';
import 'package:immich_mobile/i18n/strings.g.dart';
import 'package:immich_mobile/presentation/components/common/gap.widget.dart';
import 'package:immich_mobile/presentation/components/common/skeletonized_future_builder.widget.dart';
import 'package:immich_mobile/presentation/components/scaffold/adaptive_route_appbar.widget.dart';
import 'package:immich_mobile/presentation/components/scaffold/adaptive_route_wrapper.widget.dart';
import 'package:immich_mobile/presentation/router/router.dart';
import 'package:immich_mobile/presentation/theme/app_typography.dart';
import 'package:immich_mobile/service_locator.dart';
import 'package:immich_mobile/utils/constants/size_constants.dart';
import 'package:immich_mobile/utils/extensions/build_context.extension.dart';
import 'package:immich_mobile/utils/extensions/color.extension.dart';
import 'package:immich_mobile/utils/log_manager.dart';
import 'package:intl/intl.dart';
import 'package:material_symbols_icons/material_symbols_icons.dart';
import 'package:skeletonizer/skeletonizer.dart';
@RoutePage()
class LogsWrapperPage extends StatelessWidget {
const LogsWrapperPage({super.key});
@override
Widget build(BuildContext context) {
return ImAdaptiveRouteWrapper(
primaryRoute: LogsRoute.name,
primaryBody: (_) => const LogsPage(),
bodyRatio: RatioConstants.oneThird,
);
}
}
@RoutePage()
class LogsPage extends StatefulWidget {
const LogsPage({super.key});
@override
State createState() => _LogsPageState();
}
class _LogsPageState extends State<LogsPage> {
void _onClearLogs() {
// refetch logs on clear
setState(() {
unawaited(LogManager.I.clearLogs());
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: ImAdaptiveRouteAppBar(
title: context.t.logs.title,
isPrimary: true,
actions: [
IconButton(
onPressed: _onClearLogs,
icon: Icon(Symbols.delete_rounded),
),
],
),
body: SkeletonizedFutureBuilder(
future: di<ILogRepository>().getAll(),
builder: (_, data) => _LogList(logs: data!),
loadingBuilder: (_) => const _LogListShimmer(),
errorBuilder: (_, __) => const _LogListEmpty(),
emptyBuilder: (_) => const _LogListEmpty(),
emptyWhen: (data) => data == null || data.isEmpty,
),
);
}
}
class _LogLevelIndicator extends StatelessWidget {
final LogLevel level;
const _LogLevelIndicator({required this.level});
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: switch (level) {
LogLevel.info => context.colorScheme.primary,
LogLevel.error ||
LogLevel.wtf =>
Colors.redAccent.harmonizeWith(context.colorScheme.primary),
LogLevel.warning =>
Colors.orangeAccent.harmonizeWith(context.colorScheme.primary),
LogLevel.verbose ||
LogLevel.debug =>
Colors.grey.harmonizeWith(context.colorScheme.primary),
},
shape: BoxShape.circle,
),
width: 10,
height: 10,
);
}
}
class _LogList extends StatelessWidget {
final List<LogMessage> logs;
const _LogList({required this.logs});
/// Truncate the log message to a [maxLines]] number of lines
String _truncateLogMessage(String message) {
final msg = message.split("\n").firstOrNull;
return msg?.substring(0, 75.clamp(0, msg.length)) ?? message;
}
Color _getTileColor(BuildContext context, LogLevel level) {
return switch (level) {
LogLevel.info => Colors.transparent,
LogLevel.error || LogLevel.wtf => Colors.redAccent
.harmonizeWith(context.colorScheme.primary)
.withOpacity(RatioConstants.halfQuarter),
LogLevel.warning => Colors.orangeAccent
.harmonizeWith(context.colorScheme.primary)
.withOpacity(RatioConstants.halfQuarter),
LogLevel.verbose || LogLevel.debug => context.colorScheme.primary
.harmonizeWith(context.colorScheme.primary)
.withOpacity(RatioConstants.halfQuarter),
};
}
@override
Widget build(BuildContext context) {
return ListView.separated(
itemBuilder: (_, i) {
final log = logs[i];
return ListTile(
leading: _LogLevelIndicator(level: log.level),
title: Text(
_truncateLogMessage(log.content),
style: AppTypography.bodyMedium,
),
subtitle: Text(
"at ${DateFormat("HH:mm:ss.SSS").format(log.createdAt)} in ${log.logger ?? "<NA>"}",
style: AppTypography.bodyMedium.copyWith(
color: context.colorScheme.onSurface
.darken(amount: RatioConstants.oneThird),
),
),
trailing: const Icon(Symbols.arrow_forward_ios_rounded, size: 18),
dense: true,
visualDensity: VisualDensity.compact,
tileColor: _getTileColor(context, log.level),
minLeadingWidth: 10,
);
},
separatorBuilder: (_, __) => Divider(height: 0),
itemCount: logs.length,
);
}
}
class _LogListShimmer extends StatelessWidget {
const _LogListShimmer();
@override
Widget build(BuildContext context) {
return ListView.separated(
itemBuilder: (_, __) => ListTile(
leading: Bone.circle(size: 20),
title: Bone.text(words: 3),
subtitle: Bone.text(words: 1),
),
separatorBuilder: (_, __) => Divider(height: 5, thickness: 0.5),
itemCount: 15,
);
}
}
class _LogListEmpty extends StatelessWidget {
const _LogListEmpty();
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Symbols.comments_disabled_rounded,
size: 50,
color: context.colorScheme.primary,
),
const SizedGap.mh(),
Text(context.t.logs.no_logs),
],
),
);
}
}

View File

@ -5,18 +5,18 @@ import 'package:material_symbols_icons/symbols.dart';
enum SettingSection { enum SettingSection {
general._( general._(
icon: Symbols.interests_rounded,
labelKey: 'settings.sections.general', labelKey: 'settings.sections.general',
icon: Symbols.interests_rounded,
destination: GeneralSettingsRoute(), destination: GeneralSettingsRoute(),
), ),
advance._( advance._(
icon: Symbols.build_rounded,
labelKey: 'settings.sections.advance', labelKey: 'settings.sections.advance',
icon: Symbols.build_rounded,
destination: AdvanceSettingsRoute(), destination: AdvanceSettingsRoute(),
), ),
about._( about._(
icon: Symbols.help_rounded,
labelKey: 'settings.sections.about', labelKey: 'settings.sections.about',
icon: Symbols.help_rounded,
destination: AboutSettingsRoute(), destination: AboutSettingsRoute(),
); );

View File

@ -12,14 +12,14 @@ class AboutSettingsPage extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: const ImAdaptiveRouteSecondaryAppBar(), appBar: const ImAdaptiveRouteAppBar(isPrimary: false),
body: ListTile( body: ListTile(
title: Text(context.t.settings.about.third_party_title), title: Text(context.t.settings.about.third_party_title),
subtitle: Text(context.t.settings.about.third_party_sub_title), subtitle: Text(context.t.settings.about.third_party_sub_title),
onTap: () => showLicensePage( onTap: () => showLicensePage(
context: context, context: context,
applicationName: context.t.immich, applicationName: context.t.immich,
applicationIcon: const ImLogo(width: SizeConstants.xl), applicationIcon: const ImLogo(dimension: SizeConstants.xl),
), ),
), ),
); );

View File

@ -9,7 +9,7 @@ class AdvanceSettingsPage extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return const Scaffold( return const Scaffold(
appBar: ImAdaptiveRouteSecondaryAppBar(), appBar: ImAdaptiveRouteAppBar(isPrimary: false),
body: Center(child: Text('Advanced Settings')), body: Center(child: Text('Advanced Settings')),
); );
} }

View File

@ -9,7 +9,7 @@ class GeneralSettingsPage extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return const Scaffold( return const Scaffold(
appBar: ImAdaptiveRouteSecondaryAppBar(), appBar: ImAdaptiveRouteAppBar(isPrimary: false),
body: Center(child: Text('General Settings')), body: Center(child: Text('General Settings')),
); );
} }

View File

@ -1,3 +1,5 @@
import 'dart:async';
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:immich_mobile/i18n/strings.g.dart'; import 'package:immich_mobile/i18n/strings.g.dart';
@ -15,32 +17,31 @@ class SettingsWrapperPage extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ImAdaptiveRouteWrapper( return ImAdaptiveRouteWrapper(
primaryBody: (_) => const SettingsPage(),
primaryRoute: SettingsRoute.name, primaryRoute: SettingsRoute.name,
bodyRatio: BodyRatioConstants.oneThird, primaryBody: (_) => const SettingsPage(),
bodyRatio: RatioConstants.oneThird,
); );
} }
} }
@RoutePage() @RoutePage()
// ignore: prefer-single-widget-per-file
class SettingsPage extends StatelessWidget { class SettingsPage extends StatelessWidget {
const SettingsPage({super.key}); const SettingsPage({super.key});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: const ImAdaptiveRoutePrimaryAppBar(), appBar: const ImAdaptiveRouteAppBar(isPrimary: true),
body: ListView.builder( body: ListView.builder(
itemCount: SettingSection.values.length,
itemBuilder: (_, index) { itemBuilder: (_, index) {
final section = SettingSection.values.elementAt(index); final section = SettingSection.values.elementAt(index);
return ListTile( return ListTile(
title: Text(context.t[section.labelKey]),
onTap: () => context.navigateRoot(section.destination),
leading: Icon(section.icon), leading: Icon(section.icon),
title: Text(context.t[section.labelKey]),
onTap: () => unawaited(context.navigateRoot(section.destination)),
); );
}, },
itemCount: SettingSection.values.length,
), ),
); );
} }

View File

@ -21,7 +21,6 @@ class SplashScreenWrapperPage extends AutoRouter implements AutoRouteWrapper {
} }
@RoutePage() @RoutePage()
// ignore: prefer-single-widget-per-file
class SplashScreenPage extends StatefulWidget { class SplashScreenPage extends StatefulWidget {
const SplashScreenPage({super.key}); const SplashScreenPage({super.key});
@ -63,7 +62,7 @@ class _SplashScreenState extends State<SplashScreenPage>
future: di.allReady(), future: di.allReady(),
builder: (_, snap) { builder: (_, snap) {
if (snap.hasData) { if (snap.hasData) {
_tryLogin(); unawaited(_tryLogin());
} else if (snap.hasError) { } else if (snap.hasError) {
log.wtf( log.wtf(
"Error while initializing the app", "Error while initializing the app",
@ -75,7 +74,7 @@ class _SplashScreenState extends State<SplashScreenPage>
return Center( return Center(
child: RotationTransition( child: RotationTransition(
turns: _animationController, turns: _animationController,
child: const ImLogo(width: 100), child: const ImLogo(dimension: 100),
), ),
); );
}, },

View File

@ -2,7 +2,14 @@ import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_adaptive_scaffold/flutter_adaptive_scaffold.dart'; import 'package:flutter_adaptive_scaffold/flutter_adaptive_scaffold.dart';
import 'package:immich_mobile/i18n/strings.g.dart'; import 'package:immich_mobile/i18n/strings.g.dart';
import 'package:immich_mobile/presentation/components/common/immich_navigation_rail.dart';
import 'package:immich_mobile/presentation/components/common/user_avatar.widget.dart';
import 'package:immich_mobile/presentation/components/image/immich_logo.widget.dart';
import 'package:immich_mobile/presentation/router/router.dart'; import 'package:immich_mobile/presentation/router/router.dart';
import 'package:immich_mobile/presentation/states/current_user.state.dart';
import 'package:immich_mobile/service_locator.dart';
import 'package:immich_mobile/utils/constants/size_constants.dart';
import 'package:immich_mobile/utils/extensions/build_context.extension.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
@RoutePage() @RoutePage()
@ -24,7 +31,7 @@ class TabControllerPage extends StatelessWidget {
return PopScope( return PopScope(
canPop: tabsRouter.activeIndex == 0, canPop: tabsRouter.activeIndex == 0,
onPopInvokedWithResult: (didPop, _) => onPopInvokedWithResult: (didPop, _) =>
!didPop ? tabsRouter.setActiveIndex(0) : null, didPop ? null : tabsRouter.setActiveIndex(0),
child: _TabControllerAdaptiveScaffold( child: _TabControllerAdaptiveScaffold(
body: (ctxx) => child, body: (ctxx) => child,
selectedIndex: tabsRouter.activeIndex, selectedIndex: tabsRouter.activeIndex,
@ -80,53 +87,117 @@ class _TabControllerAdaptiveScaffold extends StatelessWidget {
return Scaffold( return Scaffold(
body: AdaptiveLayout( body: AdaptiveLayout(
// No animation on layout change
transitionDuration: Duration.zero,
primaryNavigation: SlotLayout( primaryNavigation: SlotLayout(
config: <Breakpoint, SlotLayoutConfig>{ config: <Breakpoint, SlotLayoutConfig>{
Breakpoints.mediumAndUp: SlotLayout.from( Breakpoints.mediumAndUp: SlotLayout.from(
key: const Key( builder: (_) => _ImNavigationRailBuilder(
'_TabControllerAdaptiveScaffold Primary Navigation Medium',
),
builder: (_) => AdaptiveScaffold.standardNavigationRail(
selectedIndex: selectedIndex,
destinations: destinations destinations: destinations
.map((NavigationDestination destination) => .map((NavigationDestination destination) =>
AdaptiveScaffold.toRailDestination(destination)) AdaptiveScaffold.toRailDestination(destination))
.toList(), .toList(),
onDestinationSelected: onSelectedIndexChange, selectedIndex: selectedIndex,
backgroundColor: navRailTheme.backgroundColor, backgroundColor: navRailTheme.backgroundColor,
leading: ImUserAvatar(
user: di<CurrentUserProvider>().value,
dimension: SizeConstants.m,
radius: SizeConstants.m,
),
trailing: ImLogo(dimension: SizeConstants.xm),
onDestinationSelected: onSelectedIndexChange,
selectedIconTheme: navRailTheme.selectedIconTheme, selectedIconTheme: navRailTheme.selectedIconTheme,
unselectedIconTheme: navRailTheme.unselectedIconTheme, unselectedIconTheme: navRailTheme.unselectedIconTheme,
selectedLabelTextStyle: navRailTheme.selectedLabelTextStyle, selectedLabelTextStyle: navRailTheme.selectedLabelTextStyle,
unSelectedLabelTextStyle: navRailTheme.unselectedLabelTextStyle, unSelectedLabelTextStyle: navRailTheme.unselectedLabelTextStyle,
), ),
key: const Key(
'_TabControllerAdaptiveScaffold Primary Navigation Medium',
),
), ),
}, },
), ),
body: SlotLayout( body: SlotLayout(
config: { config: {
Breakpoints.standard: SlotLayout.from( Breakpoints.standard: SlotLayout.from(
key: const Key('_TabControllerAdaptiveScaffold Body'),
builder: body, builder: body,
key: const Key('_TabControllerAdaptiveScaffold Body'),
), ),
}, },
), ),
// No animation on layout change
transitionDuration: Duration.zero,
), ),
bottomNavigationBar: SlotLayout( bottomNavigationBar: SlotLayout(
config: <Breakpoint, SlotLayoutConfig>{ config: <Breakpoint, SlotLayoutConfig>{
Breakpoints.small: SlotLayout.from( Breakpoints.small: SlotLayout.from(
builder: (_) => AdaptiveScaffold.standardBottomNavigationBar(
destinations: destinations,
currentIndex: selectedIndex,
onDestinationSelected: onSelectedIndexChange,
),
key: const Key( key: const Key(
'_TabControllerAdaptiveScaffold Bottom Navigation Small', '_TabControllerAdaptiveScaffold Bottom Navigation Small',
), ),
builder: (_) => AdaptiveScaffold.standardBottomNavigationBar(
currentIndex: selectedIndex,
destinations: destinations,
onDestinationSelected: onSelectedIndexChange,
),
), ),
}, },
), ),
); );
} }
} }
class _ImNavigationRailBuilder extends StatelessWidget {
final List<NavigationRailDestination> destinations;
final int? selectedIndex;
final Color? backgroundColor;
final Function(int)? onDestinationSelected;
final IconThemeData? selectedIconTheme;
final IconThemeData? unselectedIconTheme;
final TextStyle? selectedLabelTextStyle;
final TextStyle? unSelectedLabelTextStyle;
final Widget? leading;
final Widget? trailing;
const _ImNavigationRailBuilder({
required this.destinations,
this.selectedIndex,
this.backgroundColor,
this.leading,
this.trailing,
this.onDestinationSelected,
this.selectedIconTheme,
this.unselectedIconTheme,
this.selectedLabelTextStyle,
this.unSelectedLabelTextStyle,
});
@override
Widget build(BuildContext context) {
return Builder(builder: (BuildContext _) {
return SizedBox(
width: 72,
height: context.height,
child: LayoutBuilder(
builder: (BuildContext _, BoxConstraints constraints) {
return ConstrainedBox(
constraints: BoxConstraints(minHeight: constraints.maxHeight),
child: IntrinsicHeight(
child: ImNavigationRail(
backgroundColor: backgroundColor,
leading: leading,
trailing: trailing,
destinations: destinations,
selectedIndex: selectedIndex,
onDestinationSelected: onDestinationSelected,
labelType: NavigationRailLabelType.none,
unselectedLabelTextStyle: unSelectedLabelTextStyle,
selectedLabelTextStyle: selectedLabelTextStyle,
unselectedIconTheme: unselectedIconTheme,
selectedIconTheme: selectedIconTheme,
),
),
);
},
),
);
});
}
}

View File

@ -2,7 +2,7 @@ import 'package:auto_route/auto_route.dart';
import 'package:immich_mobile/presentation/modules/home/pages/home.page.dart'; import 'package:immich_mobile/presentation/modules/home/pages/home.page.dart';
import 'package:immich_mobile/presentation/modules/library/pages/library.page.dart'; import 'package:immich_mobile/presentation/modules/library/pages/library.page.dart';
import 'package:immich_mobile/presentation/modules/login/pages/login.page.dart'; import 'package:immich_mobile/presentation/modules/login/pages/login.page.dart';
import 'package:immich_mobile/presentation/modules/logs/pages/log.page.dart'; import 'package:immich_mobile/presentation/modules/logs/pages/logs.page.dart';
import 'package:immich_mobile/presentation/modules/search/pages/search.page.dart'; import 'package:immich_mobile/presentation/modules/search/pages/search.page.dart';
import 'package:immich_mobile/presentation/modules/settings/pages/about_settings.page.dart'; import 'package:immich_mobile/presentation/modules/settings/pages/about_settings.page.dart';
import 'package:immich_mobile/presentation/modules/settings/pages/advance_settings.page.dart'; import 'package:immich_mobile/presentation/modules/settings/pages/advance_settings.page.dart';
@ -29,12 +29,15 @@ class AppRouter extends RootStackRouter {
List<AutoRoute> get routes => [ List<AutoRoute> get routes => [
AutoRoute( AutoRoute(
page: SplashScreenWrapperRoute.page, page: SplashScreenWrapperRoute.page,
initial: true,
children: [ children: [
AutoRoute(page: SplashScreenRoute.page, initial: true), AutoRoute(page: SplashScreenRoute.page, initial: true),
AutoRoute(page: LoginRoute.page), AutoRoute(page: LoginRoute.page),
], ],
initial: true,
), ),
AutoRoute(page: LogsWrapperRoute.page, children: [
AutoRoute(page: LogsRoute.page),
]),
AutoRoute(page: LogsRoute.page), AutoRoute(page: LogsRoute.page),
AutoRoute(page: TabControllerRoute.page, children: [ AutoRoute(page: TabControllerRoute.page, children: [
AutoRoute(page: HomeRoute.page), AutoRoute(page: HomeRoute.page),

View File

@ -19,7 +19,7 @@ class AppThemeProvider extends ValueNotifier<AppTheme> {
@override @override
void dispose() { void dispose() {
_appSettingSubscription.cancel(); unawaited(_appSettingSubscription.cancel());
super.dispose(); super.dispose();
} }
} }

View File

@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart'; import 'package:device_info_plus/device_info_plus.dart';
@ -17,7 +18,7 @@ enum GalleryPermissionStatus {
class GalleryPermissionProvider extends ValueNotifier<GalleryPermissionStatus> { class GalleryPermissionProvider extends ValueNotifier<GalleryPermissionStatus> {
GalleryPermissionProvider() : super(GalleryPermissionStatus.yetToRequest) { GalleryPermissionProvider() : super(GalleryPermissionStatus.yetToRequest) {
checkPermission(); unawaited(checkPermission());
} }
bool get hasPermission => value.isGranted || value.isLimited; bool get hasPermission => value.isGranted || value.isLimited;

View File

@ -1,7 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@immutable @immutable
abstract class AppColors { abstract final class AppColors {
const AppColors(); const AppColors();
/// Blue color /// Blue color
@ -25,9 +25,9 @@ abstract class AppColors {
onErrorContainer: Color(0xff410002), onErrorContainer: Color(0xff410002),
surface: Color(0xFFF0EFF4), surface: Color(0xFFF0EFF4),
onSurface: Color(0xff1a1b21), onSurface: Color(0xff1a1b21),
onSurfaceVariant: Color(0xff444651),
surfaceContainer: Color(0xfffefbff), surfaceContainer: Color(0xfffefbff),
surfaceContainerHighest: Color(0xffe0e2ef), surfaceContainerHighest: Color(0xffe0e2ef),
onSurfaceVariant: Color(0xff444651),
outline: Color(0xff747782), outline: Color(0xff747782),
outlineVariant: Color(0xffc4c6d3), outlineVariant: Color(0xffc4c6d3),
shadow: Color(0xff000000), shadow: Color(0xff000000),
@ -58,9 +58,9 @@ abstract class AppColors {
onErrorContainer: Color(0xffffb4ab), onErrorContainer: Color(0xffffb4ab),
surface: Color(0xFF15181C), surface: Color(0xFF15181C),
onSurface: Color(0xffe2e2e9), onSurface: Color(0xffe2e2e9),
onSurfaceVariant: Color(0xffc2c6d2),
surfaceContainer: Color(0xff1a1e22), surfaceContainer: Color(0xff1a1e22),
surfaceContainerHighest: Color(0xff424852), surfaceContainerHighest: Color(0xff424852),
onSurfaceVariant: Color(0xffc2c6d2),
outline: Color(0xff8c919c), outline: Color(0xff8c919c),
outlineVariant: Color(0xff424751), outlineVariant: Color(0xff424751),
shadow: Color(0xff000000), shadow: Color(0xff000000),

View File

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:immich_mobile/presentation/theme/app_colors.dart'; import 'package:immich_mobile/presentation/theme/app_colors.dart';
import 'package:immich_mobile/presentation/theme/app_typography.dart'; import 'package:immich_mobile/presentation/theme/app_typography.dart';
import 'package:immich_mobile/utils/extensions/material_state.extension.dart'; import 'package:immich_mobile/utils/extensions/material_state.extension.dart';
import 'package:material_symbols_icons/symbols.dart';
enum AppTheme { enum AppTheme {
blue._(AppColors.blueLight, AppColors.blueDark), blue._(AppColors.blueLight, AppColors.blueDark),
@ -15,9 +16,58 @@ enum AppTheme {
static ThemeData generateThemeData(ColorScheme color) { static ThemeData generateThemeData(ColorScheme color) {
return ThemeData( return ThemeData(
inputDecorationTheme: InputDecorationTheme(
hintStyle: const TextStyle(
fontSize: 16.0,
fontWeight: FontWeight.normal,
),
errorBorder: OutlineInputBorder(
borderSide: BorderSide(color: color.error),
borderRadius: const BorderRadius.all(Radius.circular(15)),
),
focusedBorder: OutlineInputBorder(
borderSide: BorderSide(color: color.primary),
borderRadius: const BorderRadius.all(Radius.circular(15)),
),
focusedErrorBorder: OutlineInputBorder(
borderSide: BorderSide(color: color.error),
borderRadius: const BorderRadius.all(Radius.circular(15)),
),
enabledBorder: OutlineInputBorder(
borderSide: BorderSide(color: color.outlineVariant),
borderRadius: const BorderRadius.all(Radius.circular(15)),
),
),
colorScheme: color, colorScheme: color,
primaryColor: color.primary, primaryColor: color.primary,
iconTheme: const IconThemeData(weight: 500, opticalSize: 24), scaffoldBackgroundColor: color.surface,
iconTheme: const IconThemeData(size: 24, weight: 500, opticalSize: 24),
textTheme: TextTheme(
displayLarge: AppTypography.displayLarge,
displayMedium: AppTypography.displayMedium,
displaySmall: AppTypography.displaySmall,
headlineLarge: AppTypography.headlineLarge,
headlineMedium: AppTypography.headlineMedium,
headlineSmall: AppTypography.headlineSmall,
titleLarge: AppTypography.titleLarge,
titleMedium: AppTypography.titleMedium,
titleSmall: AppTypography.titleSmall,
bodyLarge: AppTypography.bodyLarge,
bodyMedium: AppTypography.bodyMedium,
bodySmall: AppTypography.bodySmall,
labelLarge: AppTypography.labelLarge,
labelMedium: AppTypography.labelMedium,
labelSmall: AppTypography.labelSmall,
),
actionIconTheme: ActionIconThemeData(
backButtonIconBuilder: (_) => Icon(Symbols.arrow_back_rounded),
closeButtonIconBuilder: (_) => Icon(Symbols.close_rounded),
),
appBarTheme: AppBarTheme(
iconTheme: IconThemeData(size: 22, color: color.onSurface),
titleTextStyle:
AppTypography.titleLarge.copyWith(color: color.onSurface),
),
navigationBarTheme: NavigationBarThemeData( navigationBarTheme: NavigationBarThemeData(
backgroundColor: color.surfaceContainer, backgroundColor: color.surfaceContainer,
indicatorColor: color.primary, indicatorColor: color.primary,
@ -30,78 +80,38 @@ enum AppTheme {
}, },
), ),
), ),
scaffoldBackgroundColor: color.surface,
navigationRailTheme: NavigationRailThemeData( navigationRailTheme: NavigationRailThemeData(
backgroundColor: color.surfaceContainer, backgroundColor: color.surfaceContainer,
elevation: 3, elevation: 3,
indicatorColor: color.primary,
selectedIconTheme:
IconThemeData(weight: 500, opticalSize: 24, color: color.onPrimary),
unselectedIconTheme: IconThemeData( unselectedIconTheme: IconThemeData(
weight: 500, weight: 500,
opticalSize: 24, opticalSize: 24,
color: color.onSurface.withAlpha(175), color: color.onSurface.withAlpha(175),
), ),
selectedIconTheme:
IconThemeData(weight: 500, opticalSize: 24, color: color.onPrimary),
indicatorColor: color.primary,
), ),
inputDecorationTheme: InputDecorationTheme(
focusedBorder: OutlineInputBorder(
borderSide: BorderSide(color: color.primary),
borderRadius: const BorderRadius.all(Radius.circular(15)),
),
enabledBorder: OutlineInputBorder(
borderSide: BorderSide(color: color.outlineVariant),
borderRadius: const BorderRadius.all(Radius.circular(15)),
),
errorBorder: OutlineInputBorder(
borderRadius: const BorderRadius.all(Radius.circular(15)),
borderSide: BorderSide(color: color.error),
),
focusedErrorBorder: OutlineInputBorder(
borderRadius: const BorderRadius.all(Radius.circular(15)),
borderSide: BorderSide(color: color.error),
),
hintStyle: const TextStyle(
fontSize: 16.0,
fontWeight: FontWeight.normal,
),
),
textSelectionTheme: TextSelectionThemeData(cursorColor: color.primary),
sliderTheme: SliderThemeData( sliderTheme: SliderThemeData(
valueIndicatorColor: valueIndicatorColor:
Color.alphaBlend(color.primary.withAlpha(80), color.onSurface) Color.alphaBlend(color.primary.withAlpha(80), color.onSurface)
.withAlpha(240), .withAlpha(240),
), ),
textTheme: TextTheme(
titleLarge: AppTypography.titleLarge,
titleMedium: AppTypography.titleMedium,
titleSmall: AppTypography.titleSmall,
displayLarge: AppTypography.displayLarge,
displayMedium: AppTypography.displayMedium,
displaySmall: AppTypography.displaySmall,
headlineLarge: AppTypography.headlineLarge,
headlineMedium: AppTypography.headlineMedium,
headlineSmall: AppTypography.headlineSmall,
bodyLarge: AppTypography.bodyLarge,
bodyMedium: AppTypography.bodyMedium,
bodySmall: AppTypography.bodySmall,
labelLarge: AppTypography.labelLarge,
labelMedium: AppTypography.labelMedium,
labelSmall: AppTypography.labelSmall,
),
snackBarTheme: SnackBarThemeData( snackBarTheme: SnackBarThemeData(
elevation: 4,
behavior: SnackBarBehavior.floating,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(4.0)),
),
insetPadding: const EdgeInsets.fromLTRB(20.0, 5.0, 20.0, 25.0),
backgroundColor: backgroundColor:
Color.alphaBlend(color.primary.withAlpha(80), color.onSurface) Color.alphaBlend(color.primary.withAlpha(80), color.onSurface)
.withAlpha(240), .withAlpha(240),
actionTextColor: color.inversePrimary, actionTextColor: color.inversePrimary,
contentTextStyle: TextStyle(color: color.onInverseSurface), contentTextStyle: TextStyle(color: color.onInverseSurface),
elevation: 4,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(4.0)),
),
behavior: SnackBarBehavior.floating,
insetPadding: const EdgeInsets.fromLTRB(20.0, 5.0, 20.0, 25.0),
closeIconColor: color.onInverseSurface, closeIconColor: color.onInverseSurface,
), ),
textSelectionTheme: TextSelectionThemeData(cursorColor: color.primary),
); );
} }
} }

View File

@ -1,6 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
class AppTypography { abstract final class AppTypography {
const AppTypography(); const AppTypography();
static const TextStyle displayLarge = TextStyle( static const TextStyle displayLarge = TextStyle(
@ -30,16 +30,16 @@ class AppTypography {
); );
static const TextStyle titleLarge = TextStyle( static const TextStyle titleLarge = TextStyle(
fontSize: 22, fontSize: 20,
fontWeight: FontWeight.bold, fontWeight: FontWeight.normal,
); );
static const TextStyle titleMedium = TextStyle( static const TextStyle titleMedium = TextStyle(
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.bold, fontWeight: FontWeight.normal,
); );
static const TextStyle titleSmall = TextStyle( static const TextStyle titleSmall = TextStyle(
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.bold, fontWeight: FontWeight.normal,
); );
static const TextStyle bodyLarge = TextStyle( static const TextStyle bodyLarge = TextStyle(

View File

@ -47,7 +47,7 @@ import 'package:immich_mobile/utils/immich_api_client.dart';
final di = GetIt.I; final di = GetIt.I;
class ServiceLocator { abstract final class ServiceLocator {
const ServiceLocator._internal(); const ServiceLocator._internal();
static void _registerFactory<T extends Object>(T Function() factoryFun) { static void _registerFactory<T extends Object>(T Function() factoryFun) {
@ -118,19 +118,19 @@ class ServiceLocator {
/// API Repos /// API Repos
_registerFactory<IAlbumETagRepository>(() => AlbumETagRepository(db: di())); _registerFactory<IAlbumETagRepository>(() => AlbumETagRepository(db: di()));
_registerFactory<ISyncApiRepository>( _registerFactory<ISyncApiRepository>(
() => SyncApiRepository(syncApi: di<ImApiClient>().getSyncApi()), () => SyncApiRepository(syncApi: di<ImApiClient>().syncApi),
); );
_registerFactory<IServerApiRepository>( _registerFactory<IServerApiRepository>(
() => ServerApiRepository(serverApi: di<ImApiClient>().getServerApi()), () => ServerApiRepository(serverApi: di<ImApiClient>().serverApi),
); );
_registerFactory<IAuthenticationApiRepository>( _registerFactory<IAuthenticationApiRepository>(
() => AuthenticationApiRepository( () => AuthenticationApiRepository(
authenticationApi: di<ImApiClient>().getAuthenticationApi(), authenticationApi: di<ImApiClient>().authenticationApi,
oAuthApi: di<ImApiClient>().getOAuthApi(), oAuthApi: di<ImApiClient>().oAuthApi,
), ),
); );
_registerFactory<IUserApiRepository>( _registerFactory<IUserApiRepository>(
() => UserApiRepository(usersApi: di<ImApiClient>().getUsersApi()), () => UserApiRepository(usersApi: di<ImApiClient>().usersApi),
); );
} }
@ -144,9 +144,9 @@ class ServiceLocator {
_registerFactory<LoginService>(() => const LoginService()); _registerFactory<LoginService>(() => const LoginService());
_registerFactory<HashService>(() => HashService( _registerFactory<HashService>(() => HashService(
hostService: di(), hostService: di(),
assetToHashRepo: di(),
deviceAlbumRepo: di(),
deviceAssetRepo: di(), deviceAssetRepo: di(),
deviceAlbumRepo: di(),
assetToHashRepo: di(),
)); ));
} }

View File

@ -2,7 +2,7 @@
import 'dart:async'; import 'dart:async';
class CollectionUtil { abstract final class CollectionUtil {
const CollectionUtil(); const CollectionUtil();
static int compareToNullable<T extends Comparable>(T? a, T? b) { static int compareToNullable<T extends Comparable>(T? a, T? b) {
@ -18,22 +18,22 @@ class CollectionUtil {
/// Find the difference between the two lists [first] and [second] /// Find the difference between the two lists [first] and [second]
/// Results are passed as callbacks back to the caller during the comparison /// Results are passed as callbacks back to the caller during the comparison
static FutureOr<bool> diffLists<T>( static FutureOr<bool> diffLists<T>(
List<T> first, List<T> firstList,
List<T> second, { List<T> secondList, {
required int Function(T a, T b) compare, required int Function(T a, T b) compare,
required FutureOr<bool> Function(T a, T b) both, required FutureOr<bool> Function(T a, T b) both,
required FutureOr<void> Function(T a) onlyFirst, required FutureOr<void> Function(T a) onlyFirst,
required FutureOr<void> Function(T b) onlySecond, required FutureOr<void> Function(T b) onlySecond,
}) async { }) async {
first.sort(compare); final first =
first.uniqueConsecutive(compare); _uniqueConsecutive(List.of(firstList)..sort(compare), compare);
second.sort(compare); final second =
second.uniqueConsecutive(compare); _uniqueConsecutive(List.of(secondList)..sort(compare), compare);
bool diff = false; bool diff = false;
int i = 0, j = 0; int i = 0, j = 0;
for (; i < first.length && j < second.length;) { while (i < first.length && j < second.length) {
final int order = compare(first[i], second[j]); final int order = compare(first[i], second[j]);
if (order == 0) { if (order == 0) {
diff |= await both(first[i++], second[j++]); diff |= await both(first[i++], second[j++]);
@ -49,27 +49,25 @@ class CollectionUtil {
diff |= i < first.length || j < second.length; diff |= i < first.length || j < second.length;
for (; i < first.length; i++) { for (; i < first.length; i++) {
onlyFirst(first[i]); await onlyFirst(first[i]);
} }
for (; j < second.length; j++) { for (; j < second.length; j++) {
onlySecond(second[j]); await onlySecond(second[j]);
} }
return diff; return diff;
} }
} }
extension _ListExtension<T> on List<T> { List<T> _uniqueConsecutive<T>(List<T> list, int Function(T a, T b) compare) {
List<T> uniqueConsecutive(int Function(T a, T b) compare) { if (list.isEmpty) return list;
int i = 1, j = 1;
for (; i < length; i++) { List<T> unique = [];
if (compare(this[i - 1], this[i]) != 0) { unique.add(list.first);
if (i != j) {
this[j] = this[i]; for (int i = 1; i < list.length; i++) {
} if (compare(list[i], list[i - 1]) != 0) {
j++; unique.add(list[i]);
}
} }
length = length == 0 ? 0 : j;
return this;
} }
return unique;
} }

View File

@ -1,17 +1,23 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@immutable @immutable
class SizeConstants { abstract final class SizeConstants {
const SizeConstants._(); const SizeConstants._();
static const s = 8.0; static const s = 8.0;
static const m = 16.0; static const m = 16.0;
static const xm = 25.0;
static const l = 32.0; static const l = 32.0;
static const xl = 64.0; static const xl = 64.0;
} }
class BodyRatioConstants { abstract final class RatioConstants {
const BodyRatioConstants._(); const RatioConstants._();
// 0.3
static const oneThird = 1 / 3; static const oneThird = 1 / 3;
// 0.25
static const quarter = 1 / 4;
// 0.15
static const halfQuarter = 3 / 20;
} }

View File

@ -1,4 +1,4 @@
extension SortIterable<T> on Iterable<T> { extension SortIterable<T> on Iterable<T> {
Iterable<T> sortedBy(Comparable Function(T k) key) => Iterable<T> sortedBy(Comparable Function(T k) key) =>
toList()..sort((a, b) => key(a).compareTo(key(b))); List.of(this)..sort((a, b) => key(a).compareTo(key(b)));
} }

View File

@ -66,9 +66,9 @@ class ImApiClient extends ApiClient with LogMixin {
return res; return res;
} }
UsersApi getUsersApi() => UsersApi(this); UsersApi get usersApi => UsersApi(this);
ServerApi getServerApi() => ServerApi(this); ServerApi get serverApi => ServerApi(this);
AuthenticationApi getAuthenticationApi() => AuthenticationApi(this); AuthenticationApi get authenticationApi => AuthenticationApi(this);
OAuthApi getOAuthApi() => OAuthApi(this); OAuthApi get oAuthApi => OAuthApi(this);
SyncApi getSyncApi() => SyncApi(this); SyncApi get syncApi => SyncApi(this);
} }

View File

@ -1,4 +1,5 @@
import 'package:immich_mobile/domain/models/asset.model.dart'; import 'package:immich_mobile/domain/models/asset.model.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/service_locator.dart'; import 'package:immich_mobile/service_locator.dart';
import 'package:immich_mobile/utils/immich_api_client.dart'; import 'package:immich_mobile/utils/immich_api_client.dart';
@ -11,11 +12,15 @@ enum AssetMediaSize {
final String value; final String value;
} }
class ImImageUrlHelper { abstract final class ImImageUrlHelper {
const ImImageUrlHelper(); const ImImageUrlHelper();
static String get _serverUrl => di<ImApiClient>().basePath; static String get _serverUrl => di<ImApiClient>().basePath;
static String getUserAvatarUrl(final User user) {
return '$_serverUrl/users/${user.id}/profile-image';
}
static String getThumbnailUrl( static String getThumbnailUrl(
final Asset asset, { final Asset asset, {
AssetMediaSize type = AssetMediaSize.thumbnail, AssetMediaSize type = AssetMediaSize.thumbnail,

View File

@ -40,7 +40,7 @@ class IsolateHelper {
); );
} }
void postIsolateHandling() { Future<void> postIsolateHandling() async {
assert(_clientData != null); assert(_clientData != null);
// Reconstruct client from cached data // Reconstruct client from cached data
final client = ImApiClient(endpoint: _clientData!.endpoint); final client = ImApiClient(endpoint: _clientData!.endpoint);
@ -55,7 +55,7 @@ class IsolateHelper {
); );
// Init log manager to continue listening to log events // Init log manager to continue listening to log events
LogManager.I.init(shouldBuffer: false); await LogManager.I.init(shouldBuffer: false);
} }
static Future<T> run<T>(FutureOr<T> Function() computation) async { static Future<T> run<T>(FutureOr<T> Function() computation) async {
@ -70,10 +70,10 @@ class IsolateHelper {
DartPluginRegistrant.ensureInitialized(); DartPluginRegistrant.ensureInitialized();
// Delay to ensure the isolate is ready // Delay to ensure the isolate is ready
await Future.delayed(Durations.short2); await Future.delayed(Durations.short2);
helper.postIsolateHandling(); await helper.postIsolateHandling();
try { try {
final result = await computation(); final result = await computation();
// Delay to ensure the isolate is not killed prematurely // Wait for isolate to end; i.e, logs to be flushed
await Future.delayed(Durations.short2); await Future.delayed(Durations.short2);
return result; return result;
} finally { } finally {

View File

@ -1,3 +1,5 @@
// ignore_for_file: avoid-collection-mutating-methods
import 'dart:async'; import 'dart:async';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
@ -13,6 +15,7 @@ import 'package:logging/logging.dart' as logging;
/// in the class. /// in the class.
class LogManager { class LogManager {
LogManager._(); LogManager._();
static final LogManager _instance = LogManager._(); static final LogManager _instance = LogManager._();
static final Map<String, Logger> _loggers = <String, Logger>{}; static final Map<String, Logger> _loggers = <String, Logger>{};
@ -40,10 +43,10 @@ class LogManager {
}()); }());
final lm = LogMessage( final lm = LogMessage(
logger: record.loggerName,
content: record.message, content: record.message,
level: record.level.toLogLevel(), level: record.level.toLogLevel(),
createdAt: record.time, createdAt: record.time,
logger: record.loggerName,
error: record.error?.toString(), error: record.error?.toString(),
stack: record.stackTrace?.toString(), stack: record.stackTrace?.toString(),
); );
@ -53,7 +56,7 @@ class LogManager {
_timer ??= _timer ??=
Timer(const Duration(seconds: 5), () => _flushBufferToDatabase()); Timer(const Duration(seconds: 5), () => _flushBufferToDatabase());
} else { } else {
di<ILogRepository>().create(lm); unawaited(di<ILogRepository>().create(lm));
} }
} }
@ -61,12 +64,13 @@ class LogManager {
_timer = null; _timer = null;
final buffer = _msgBuffer; final buffer = _msgBuffer;
_msgBuffer = []; _msgBuffer = [];
di<ILogRepository>().createAll(buffer); unawaited(di<ILogRepository>().createAll(buffer));
} }
void init({bool? shouldBuffer}) { Future<void> init({bool? shouldBuffer}) async {
_shouldBuffer = shouldBuffer ?? _shouldBuffer; _shouldBuffer = shouldBuffer ?? _shouldBuffer;
_subscription = logging.Logger.root.onRecord.listen(_onLogRecord); _subscription = logging.Logger.root.onRecord.listen(_onLogRecord);
await di<ILogRepository>().truncate();
} }
Logger get(String? loggerName) => _loggers.putIfAbsent( Logger get(String? loggerName) => _loggers.putIfAbsent(
@ -80,14 +84,14 @@ class LogManager {
} }
void dispose() { void dispose() {
_subscription.cancel(); unawaited(_subscription.cancel());
} }
void clearLogs() { Future<void> clearLogs() async {
_timer?.cancel(); _timer?.cancel();
_timer = null; _timer = null;
_msgBuffer.clear(); _msgBuffer.clear();
di<ILogRepository>().deleteAll(); await di<ILogRepository>().deleteAll();
} }
static void setGlobalErrorCallbacks() { static void setGlobalErrorCallbacks() {

View File

@ -35,10 +35,10 @@ dynamic upgradeDto(dynamic value, String targetType) {
} }
} }
addDefault(dynamic value, String keys, dynamic defaultValue) { addDefault(Map value, String keys, dynamic defaultValue) {
// Loop through the keys and assign the default value if the key is not present // Loop through the keys and assign the default value if the key is not present
List<String> keyList = keys.split('.'); List<String> keyList = keys.split('.');
dynamic current = value; Map current = value;
for (int i = 0; i < keyList.length - 1; i++) { for (int i = 0; i < keyList.length - 1; i++) {
if (current[keyList[i]] == null) { if (current[keyList[i]] == null) {

View File

@ -1,7 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:immich_mobile/utils/constants/globals.dart'; import 'package:immich_mobile/utils/constants/globals.dart';
class SnackbarManager { abstract final class SnackbarManager {
const SnackbarManager(); const SnackbarManager();
static ScaffoldMessengerState? get _s => kScafMessengerKey.currentState; static ScaffoldMessengerState? get _s => kScafMessengerKey.currentState;

View File

@ -976,6 +976,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.0.0" version: "2.0.0"
skeletonizer:
dependency: "direct main"
description:
name: skeletonizer
sha256: "3b202e4fa9c49b017d368fb0e570d4952bcd19972b67b2face071bdd68abbfae"
url: "https://pub.dev"
source: hosted
version: "1.4.2"
sky_engine: sky_engine:
dependency: transitive dependency: transitive
description: flutter description: flutter

View File

@ -54,6 +54,7 @@ dependencies:
flutter_list_view: ^1.1.28 flutter_list_view: ^1.1.28
cached_network_image: ^3.4.1 cached_network_image: ^3.4.1
flutter_cache_manager: ^3.4.1 flutter_cache_manager: ^3.4.1
skeletonizer: ^1.4.2
openapi: openapi:
path: openapi path: openapi

View File

@ -2,10 +2,10 @@ import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/logger_message.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/entities/logger_message.entity.dart';
import 'package:immich_mobile/services/immich_logger.service.dart'; import 'package:immich_mobile/services/immich_logger.service.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';