diff --git a/i18n/en.json b/i18n/en.json index 77dc2e235c..8961943678 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -406,6 +406,7 @@ "album_options": "Album options", "album_remove_user": "Remove user?", "album_remove_user_confirmation": "Are you sure you want to remove {user}?", + "album_search_not_found": "No albums found matching your search", "album_share_no_users": "Looks like you have shared this album with all users or you don't have any user to share with.", "album_updated": "Album updated", "album_updated_setting_description": "Receive an email notification when a shared album has new assets", @@ -425,6 +426,7 @@ "albums_default_sort_order": "Default album sort order", "albums_default_sort_order_description": "Initial asset sort order when creating new albums.", "albums_feature_description": "Collections of assets that can be shared with other users.", + "albums_on_device_count": "Albums on device ({count})", "all": "All", "all_albums": "All albums", "all_people": "All people", @@ -605,6 +607,7 @@ "cancel": "Cancel", "cancel_search": "Cancel search", "canceled": "Canceled", + "canceling": "Canceling", "cannot_merge_people": "Cannot merge people", "cannot_undo_this_action": "You cannot undo this action!", "cannot_update_the_description": "Cannot update the description", @@ -765,6 +768,7 @@ "description": "Description", "description_input_hint_text": "Add description...", "description_input_submit_error": "Error updating description, check the log for more details", + "deselect_all": "Deselect All", "details": "Details", "direction": "Direction", "disabled": "Disabled", @@ -839,6 +843,7 @@ "empty_trash": "Empty trash", "empty_trash_confirmation": "Are you sure you want to empty the trash? This will remove all the assets in trash permanently from Immich.\nYou cannot undo this action!", "enable": "Enable", + "enable_backup": "Enable Backup", "enable_biometric_auth_description": "Enter your PIN code to enable biometric authentication", "enabled": "Enabled", "end_date": "End date", @@ -1485,6 +1490,7 @@ "purchase_server_description_2": "Supporter status", "purchase_server_title": "Server", "purchase_settings_server_activated": "The server product key is managed by the admin", + "queue_status": "Queuing {count}/{total}", "rating": "Star rating", "rating_clear": "Clear rating", "rating_count": "{count, plural, one {# star} other {# stars}}", @@ -1915,6 +1921,7 @@ "updated_password": "Updated password", "upload": "Upload", "upload_concurrency": "Upload concurrency", + "upload_details": "Upload Details", "upload_dialog_info": "Do you want to backup the selected Asset(s) to the server?", "upload_dialog_title": "Upload Asset", "upload_errors": "Upload completed with {count, plural, one {# error} other {# errors}}, refresh the page to see new upload assets.", @@ -1965,6 +1972,7 @@ "view_album": "View Album", "view_all": "View All", "view_all_users": "View all users", + "view_details": "View Details", "view_in_timeline": "View in timeline", "view_link": "View link", "view_links": "View links", diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/BackupWorker.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/BackupWorker.kt index 0fb75b002c..9c90528dc9 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/BackupWorker.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/BackupWorker.kt @@ -83,6 +83,7 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct flutterLoader.ensureInitializationCompleteAsync(ctx, null, Handler(Looper.getMainLooper())) { runDart() + } return resolvableFuture diff --git a/mobile/lib/constants/constants.dart b/mobile/lib/constants/constants.dart index 37a3eec073..206fbbb28f 100644 --- a/mobile/lib/constants/constants.dart +++ b/mobile/lib/constants/constants.dart @@ -38,3 +38,6 @@ const List<(String, String)> kWidgetNames = [ ('com.immich.widget.random', 'app.alextran.immich.widget.RandomReceiver'), ('com.immich.widget.memory', 'app.alextran.immich.widget.MemoryReceiver'), ]; + +const double kUploadStatusFailed = -1.0; +const double kUploadStatusCanceled = -2.0; diff --git a/mobile/lib/domain/models/store.model.dart b/mobile/lib/domain/models/store.model.dart index 61c5807ba8..305b3f3387 100644 --- a/mobile/lib/domain/models/store.model.dart +++ b/mobile/lib/domain/models/store.model.dart @@ -70,7 +70,8 @@ enum StoreKey { // Experimental stuff photoManagerCustomFilter._(1000), betaPromptShown._(1001), - betaTimeline._(1002); + betaTimeline._(1002), + enableBackup._(1003); const StoreKey._(this.id); final int id; diff --git a/mobile/lib/domain/services/local_album.service.dart b/mobile/lib/domain/services/local_album.service.dart index 7ec9231196..79cc58f3e0 100644 --- a/mobile/lib/domain/services/local_album.service.dart +++ b/mobile/lib/domain/services/local_album.service.dart @@ -7,8 +7,8 @@ class LocalAlbumService { const LocalAlbumService(this._repository); - Future> getAll() { - return _repository.getAll(); + Future> getAll({Set sortBy = const {}}) { + return _repository.getAll(sortBy: sortBy); } Future getThumbnail(String albumId) { diff --git a/mobile/lib/domain/utils/background_sync.dart b/mobile/lib/domain/utils/background_sync.dart index 7ab2989354..4a44c4d8f2 100644 --- a/mobile/lib/domain/utils/background_sync.dart +++ b/mobile/lib/domain/utils/background_sync.dart @@ -101,14 +101,18 @@ class BackgroundSyncManager { if (_syncWebsocketTask != null) { return _syncWebsocketTask!.future; } - - _syncWebsocketTask = runInIsolateGentle( - computation: (ref) => ref - .read(syncStreamServiceProvider) - .handleWsAssetUploadReadyV1Batch(batchData), - ); + _syncWebsocketTask = _handleWsAssetUploadReadyV1Batch(batchData); return _syncWebsocketTask!.whenComplete(() { _syncWebsocketTask = null; }); } } + +Cancelable _handleWsAssetUploadReadyV1Batch( + List batchData, +) => + runInIsolateGentle( + computation: (ref) => ref + .read(syncStreamServiceProvider) + .handleWsAssetUploadReadyV1Batch(batchData), + ); diff --git a/mobile/lib/extensions/build_context_extensions.dart b/mobile/lib/extensions/build_context_extensions.dart index 69a9c3b347..7bb194cdae 100644 --- a/mobile/lib/extensions/build_context_extensions.dart +++ b/mobile/lib/extensions/build_context_extensions.dart @@ -33,6 +33,10 @@ extension ContextHelper on BuildContext { // Returns the current Primary color of the Theme Color get primaryColor => themeData.colorScheme.primary; + Color get logoYellow => const Color.fromARGB(255, 255, 184, 0); + Color get logoRed => const Color.fromARGB(255, 230, 65, 30); + Color get logoPink => const Color.fromARGB(255, 222, 127, 179); + Color get logoGreen => const Color.fromARGB(255, 49, 164, 82); // Returns the Scaffold background color of the Theme Color get scaffoldBackgroundColor => colorScheme.surface; diff --git a/mobile/lib/infrastructure/repositories/backup.repository.dart b/mobile/lib/infrastructure/repositories/backup.repository.dart index 4ce3b07e8b..99df206db5 100644 --- a/mobile/lib/infrastructure/repositories/backup.repository.dart +++ b/mobile/lib/infrastructure/repositories/backup.repository.dart @@ -52,9 +52,7 @@ class DriftBackupRepository extends DriftDatabaseRepository { Future getRemainderCount() async { final query = _db.localAlbumAssetEntity.selectOnly(distinct: true) - ..addColumns( - [_db.localAlbumAssetEntity.assetId], - ) + ..addColumns([_db.localAlbumAssetEntity.assetId]) ..join([ innerJoin( _db.localAlbumEntity, @@ -147,6 +145,11 @@ class DriftBackupRepository extends DriftDatabaseRepository { ), ) & lae.id.isNotInQuery(_getExcludedSubquery()), + ) + ..orderBy( + [ + (localAsset) => OrderingTerm.desc(localAsset.createdAt), + ], ); return query.map((localAsset) => localAsset.toDto()).get(); diff --git a/mobile/lib/infrastructure/repositories/local_album.repository.dart b/mobile/lib/infrastructure/repositories/local_album.repository.dart index ba9dfd979d..5f192a20cf 100644 --- a/mobile/lib/infrastructure/repositories/local_album.repository.dart +++ b/mobile/lib/infrastructure/repositories/local_album.repository.dart @@ -8,7 +8,13 @@ import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:immich_mobile/utils/database.utils.dart'; import 'package:platform/platform.dart'; -enum SortLocalAlbumsBy { id, backupSelection, isIosSharedAlbum } +enum SortLocalAlbumsBy { + id, + backupSelection, + isIosSharedAlbum, + name, + assetCount +} class DriftLocalAlbumRepository extends DriftDatabaseRepository { final Drift _db; @@ -41,6 +47,9 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository { OrderingTerm.asc(_db.localAlbumEntity.backupSelection), SortLocalAlbumsBy.isIosSharedAlbum => OrderingTerm.asc(_db.localAlbumEntity.isIosSharedAlbum), + SortLocalAlbumsBy.name => + OrderingTerm.asc(_db.localAlbumEntity.name), + SortLocalAlbumsBy.assetCount => OrderingTerm.desc(assetCount), }, ); } @@ -151,7 +160,15 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository { batch.insert( _db.localAlbumEntity, companion, - onConflict: DoUpdate((_) => companion), + onConflict: DoUpdate( + (old) => LocalAlbumEntityCompanion( + id: companion.id, + name: companion.name, + updatedAt: companion.updatedAt, + isIosSharedAlbum: companion.isIosSharedAlbum, + marker_: companion.marker_, + ), + ), ); } }); diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index acadf4c887..f036fd9bc3 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -96,8 +96,8 @@ Future initApp() async { // Initialize the file downloader await FileDownloader().configure( - // maxConcurrent: 5, maxConcurrentByHost: 2, maxConcurrentByGroup: 3 - globalConfig: (Config.holdingQueue, (5, 2, 3)), + // maxConcurrent: 6, maxConcurrentByHost(server):6, maxConcurrentByGroup: 3 + globalConfig: (Config.holdingQueue, (6, 6, 3)), ); await FileDownloader().trackTasksInGroup( diff --git a/mobile/lib/pages/backup/drift_backup.page.dart b/mobile/lib/pages/backup/drift_backup.page.dart index 34649ca42c..1b9ec8ad07 100644 --- a/mobile/lib/pages/backup/drift_backup.page.dart +++ b/mobile/lib/pages/backup/drift_backup.page.dart @@ -1,83 +1,61 @@ import 'package:auto_route/auto_route.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/album/local_album.model.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; +import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/presentation/widgets/backup/backup_toggle_button.widget.dart'; import 'package:immich_mobile/providers/backup/backup_album.provider.dart'; import 'package:immich_mobile/providers/backup/drift_backup.provider.dart'; -import 'package:immich_mobile/providers/websocket.provider.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/widgets/backup/backup_info_card.dart'; @RoutePage() -class DriftBackupPage extends HookConsumerWidget { +class DriftBackupPage extends ConsumerStatefulWidget { const DriftBackupPage({super.key}); @override - Widget build(BuildContext context, WidgetRef ref) { - useEffect( - () { - ref.read(driftBackupProvider.notifier).getBackupStatus(); - return null; - }, - [], - ); + ConsumerState createState() => _DriftBackupPageState(); +} - Widget buildControlButtons() { - return Padding( - padding: const EdgeInsets.only( - top: 24, - ), - child: Column( - children: [ - ElevatedButton( - onPressed: () => ref.read(driftBackupProvider.notifier).backup(), - child: const Text( - "backup_controller_page_start_backup", - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - ), - ).tr(), - ), - OutlinedButton( - onPressed: () => ref.read(driftBackupProvider.notifier).cancel(), - child: const Text( - "cancel", - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - ), - ).tr(), - ), - OutlinedButton( - onPressed: () => - ref.read(driftBackupProvider.notifier).getDataInfo(), - child: const Text( - "Get database info", - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - ), - ).tr(), - ), - ], - ), - ); - } +class _DriftBackupPageState extends ConsumerState { + @override + void initState() { + super.initState(); + ref.read(driftBackupProvider.notifier).getBackupStatus(); + } + + Future startBackup() async { + await ref.read(driftBackupProvider.notifier).getBackupStatus(); + await ref.read(driftBackupProvider.notifier).backup(); + } + + Future stopBackup() async { + await ref.read(driftBackupProvider.notifier).cancel(); + } + + @override + Widget build(BuildContext context) { + final selectedAlbum = ref + .watch(backupAlbumProvider) + .where( + (album) => album.backupSelection == BackupSelection.selected, + ) + .toList(); + final uploadItems = ref.watch( + driftBackupProvider.select((state) => state.uploadItems), + ); return Scaffold( appBar: AppBar( elevation: 0, - title: const Text( - "Backup (Experimental)", + title: Text( + "backup_controller_page_backup".t(), ), leading: IconButton( onPressed: () { - ref.watch(websocketProvider.notifier).listenUploadEvent(); context.maybePop(true); }, splashRadius: 24, @@ -85,18 +63,6 @@ class DriftBackupPage extends HookConsumerWidget { Icons.arrow_back_ios_rounded, ), ), - actions: [ - Padding( - padding: const EdgeInsets.only(right: 8.0), - child: IconButton( - onPressed: () => context.pushRoute(const BackupOptionsRoute()), - splashRadius: 24, - icon: const Icon( - Icons.settings_outlined, - ), - ), - ), - ], ), body: Stack( children: [ @@ -110,11 +76,24 @@ class DriftBackupPage extends HookConsumerWidget { children: [ const SizedBox(height: 8), const _BackupAlbumSelectionCard(), - const _TotalCard(), - const _BackupCard(), - const _RemainderCard(), - const Divider(), - buildControlButtons(), + if (selectedAlbum.isNotEmpty) ...[ + const _TotalCard(), + const _BackupCard(), + const _RemainderCard(), + const Divider(), + BackupToggleButton( + onStart: () async => await startBackup(), + onStop: () async => await stopBackup(), + ), + if (uploadItems.isNotEmpty) + TextButton.icon( + icon: const Icon(Icons.info_outline_rounded), + onPressed: () => context.pushRoute( + const DriftUploadDetailRoute(), + ), + label: Text("view_details".t(context: context)), + ), + ], ], ), ), diff --git a/mobile/lib/pages/backup/drift_backup_album_selection.page.dart b/mobile/lib/pages/backup/drift_backup_album_selection.page.dart index 2e7a2d4c2d..fd39f0a579 100644 --- a/mobile/lib/pages/backup/drift_backup_album_selection.page.dart +++ b/mobile/lib/pages/backup/drift_backup_album_selection.page.dart @@ -1,25 +1,67 @@ +import 'dart:io'; + import 'package:auto_route/auto_route.dart'; -import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/album/local_album.model.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/providers/album/album.provider.dart'; -import 'package:immich_mobile/providers/backup/backup.provider.dart'; import 'package:immich_mobile/providers/backup/backup_album.provider.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; -import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart'; +import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/widgets/backup/drift_album_info_list_tile.dart'; import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart'; +import 'package:immich_mobile/widgets/common/search_field.dart'; @RoutePage() -class DriftBackupAlbumSelectionPage extends HookConsumerWidget { +class DriftBackupAlbumSelectionPage extends ConsumerStatefulWidget { const DriftBackupAlbumSelectionPage({super.key}); + @override - Widget build(BuildContext context, WidgetRef ref) { + ConsumerState createState() => + _DriftBackupAlbumSelectionPageState(); +} + +class _DriftBackupAlbumSelectionPageState + extends ConsumerState { + String _searchQuery = ''; + bool _isSearchMode = false; + late ValueNotifier _enableSyncUploadAlbum; + late TextEditingController _searchController; + late FocusNode _searchFocusNode; + + @override + void initState() { + super.initState(); + _enableSyncUploadAlbum = ValueNotifier(false); + _searchController = TextEditingController(); + _searchFocusNode = FocusNode(); + + _enableSyncUploadAlbum.value = ref + .read(appSettingsServiceProvider) + .getSetting(AppSettingsEnum.syncAlbums); + ref.read(backupAlbumProvider.notifier).getAll(); + } + + @override + void dispose() { + _enableSyncUploadAlbum.dispose(); + _searchController.dispose(); + _searchFocusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { final albums = ref.watch(backupAlbumProvider); + final albumCount = albums.length; + // Filter albums based on search query + final filteredAlbums = albums.where((album) { + if (_searchQuery.isEmpty) return true; + return album.name.toLowerCase().contains(_searchQuery.toLowerCase()); + }).toList(); final selectedBackupAlbums = albums .where((album) => album.backupSelection == BackupSelection.selected) @@ -27,133 +69,6 @@ class DriftBackupAlbumSelectionPage extends HookConsumerWidget { final excludedBackupAlbums = albums .where((album) => album.backupSelection == BackupSelection.excluded) .toList(); - final enableSyncUploadAlbum = - useAppSettingsState(AppSettingsEnum.syncAlbums); - final isDarkTheme = context.isDarkTheme; - - useEffect( - () { - ref.watch(backupProvider.notifier).getBackupInfo(); - ref.watch(backupAlbumProvider.notifier).getAll(); - return null; - }, - [], - ); - - buildAlbumSelectionList() { - if (albums.isEmpty) { - return const SliverToBoxAdapter( - child: Center( - child: CircularProgressIndicator(), - ), - ); - } - - return SliverPadding( - padding: const EdgeInsets.symmetric(vertical: 12.0), - sliver: SliverList( - delegate: SliverChildBuilderDelegate( - ((context, index) { - return DriftAlbumInfoListTile( - album: albums[index], - ); - }), - childCount: albums.length, - ), - ), - ); - } - - buildAlbumSelectionGrid() { - if (albums.isEmpty) { - return const SliverToBoxAdapter( - child: Center( - child: CircularProgressIndicator(), - ), - ); - } - - return SliverPadding( - padding: const EdgeInsets.all(12.0), - sliver: SliverGrid.builder( - gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: 300, - mainAxisSpacing: 12, - crossAxisSpacing: 12, - ), - itemCount: albums.length, - itemBuilder: ((context, index) { - return DriftAlbumInfoListTile( - album: albums[index], - ); - }), - ), - ); - } - - buildSelectedAlbumNameChip() { - return selectedBackupAlbums.map((album) { - void removeSelection() { - ref.read(backupAlbumProvider.notifier).deselectAlbum(album); - } - - return Padding( - padding: const EdgeInsets.only(right: 8.0), - child: GestureDetector( - onTap: removeSelection, - child: Chip( - label: Text( - album.name, - style: TextStyle( - fontSize: 12, - color: isDarkTheme ? Colors.black : Colors.white, - fontWeight: FontWeight.bold, - ), - ), - backgroundColor: context.primaryColor, - deleteIconColor: isDarkTheme ? Colors.black : Colors.white, - deleteIcon: const Icon( - Icons.cancel_rounded, - size: 15, - ), - onDeleted: removeSelection, - ), - ), - ); - }).toSet(); - } - - buildExcludedAlbumNameChip() { - return excludedBackupAlbums.map((album) { - void removeSelection() { - ref.read(backupAlbumProvider.notifier).deselectAlbum(album); - } - - return GestureDetector( - onTap: removeSelection, - child: Padding( - padding: const EdgeInsets.only(right: 8.0), - child: Chip( - label: Text( - album.name, - style: TextStyle( - fontSize: 12, - color: context.scaffoldBackgroundColor, - fontWeight: FontWeight.bold, - ), - ), - backgroundColor: Colors.red[300], - deleteIconColor: context.scaffoldBackgroundColor, - deleteIcon: const Icon( - Icons.cancel_rounded, - size: 15, - ), - onDeleted: removeSelection, - ), - ), - ); - }).toSet(); - } handleSyncAlbumToggle(bool isEnable) async { if (isEnable) { @@ -170,138 +85,439 @@ class DriftBackupAlbumSelectionPage extends HookConsumerWidget { onPressed: () => context.maybePop(), icon: const Icon(Icons.arrow_back_ios_rounded), ), - title: const Text( - "backup_album_selection_page_select_albums", - ).tr(), + title: _isSearchMode + ? SearchField( + hintText: 'search_albums'.t(context: context), + autofocus: true, + controller: _searchController, + focusNode: _searchFocusNode, + onChanged: (value) => + setState(() => _searchQuery = value.trim()), + ) + : const Text( + "backup_album_selection_page_select_albums", + ).t(context: context), + actions: [ + if (!_isSearchMode) + IconButton( + icon: const Icon(Icons.search), + onPressed: () => setState(() { + _isSearchMode = true; + _searchQuery = ''; + }), + ) + else + IconButton( + icon: const Icon(Icons.close), + onPressed: () => setState(() { + _isSearchMode = false; + _searchQuery = ''; + _searchController.clear(); + }), + ), + ], elevation: 0, ), - body: SafeArea( - child: CustomScrollView( - physics: const ClampingScrollPhysics(), - slivers: [ - SliverToBoxAdapter( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.symmetric( - vertical: 8.0, - horizontal: 16.0, - ), - child: Text( - "backup_album_selection_page_selection_info", - style: context.textTheme.titleSmall, - ).tr(), + body: CustomScrollView( + physics: const ClampingScrollPhysics(), + slivers: [ + SliverToBoxAdapter( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric( + vertical: 8.0, + horizontal: 16.0, ), - // Selected Album Chips + child: Text( + "backup_album_selection_page_selection_info", + style: context.textTheme.titleSmall, + ).t(context: context), + ), + // Selected Album Chips - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: Wrap( - children: [ - ...buildSelectedAlbumNameChip(), - ...buildExcludedAlbumNameChip(), - ], - ), - ), - - SettingsSwitchListTile( - valueNotifier: enableSyncUploadAlbum, - title: "sync_albums".tr(), - subtitle: "sync_upload_album_setting_subtitle".tr(), - contentPadding: const EdgeInsets.symmetric(horizontal: 16), - titleStyle: context.textTheme.bodyLarge?.copyWith( - fontWeight: FontWeight.bold, - ), - subtitleStyle: context.textTheme.labelLarge?.copyWith( - color: context.colorScheme.primary, - ), - onChanged: handleSyncAlbumToggle, - ), - - ListTile( - title: Text( - "backup_album_selection_page_albums_device".tr( - namedArgs: { - 'count': ref - .watch(backupProvider) - .availableAlbums - .length - .toString(), - }, + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Wrap( + children: [ + _SelectedAlbumNameChips( + selectedBackupAlbums: selectedBackupAlbums, ), - style: context.textTheme.titleSmall, + _ExcludedAlbumNameChips( + excludedBackupAlbums: excludedBackupAlbums, + ), + ], + ), + ), + + SettingsSwitchListTile( + valueNotifier: _enableSyncUploadAlbum, + title: "sync_albums".t(context: context), + subtitle: + "sync_upload_album_setting_subtitle".t(context: context), + contentPadding: const EdgeInsets.symmetric(horizontal: 16), + titleStyle: context.textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + subtitleStyle: context.textTheme.labelLarge?.copyWith( + color: context.colorScheme.primary, + ), + onChanged: handleSyncAlbumToggle, + ), + + ListTile( + title: Text( + "albums_on_device_count".t( + context: context, + args: {'count': albumCount.toString()}, ), - subtitle: Padding( - padding: const EdgeInsets.symmetric(vertical: 8.0), - child: Text( - "backup_album_selection_page_albums_tap", - style: context.textTheme.labelLarge?.copyWith( - color: context.primaryColor, - ), - ).tr(), - ), - trailing: IconButton( - splashRadius: 16, - icon: Icon( - Icons.info, - size: 20, + style: context.textTheme.titleSmall, + ), + subtitle: Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: Text( + "backup_album_selection_page_albums_tap", + style: context.textTheme.labelLarge?.copyWith( color: context.primaryColor, ), - onPressed: () { - // show the dialog - showDialog( - context: context, - builder: (BuildContext context) { - return AlertDialog( - shape: const RoundedRectangleBorder( - borderRadius: - BorderRadius.all(Radius.circular(10)), - ), - elevation: 5, - title: Text( - 'backup_album_selection_page_selection_info', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - color: context.primaryColor, - ), - ).tr(), - content: SingleChildScrollView( - child: ListBody( - children: [ - const Text( - 'backup_album_selection_page_assets_scatter', - style: TextStyle( - fontSize: 14, - ), - ).tr(), - ], - ), - ), - ); - }, - ); - }, - ), + ).t(context: context), ), + trailing: IconButton( + splashRadius: 16, + icon: Icon( + Icons.info, + size: 20, + color: context.primaryColor, + ), + onPressed: () { + // show the dialog + showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + shape: const RoundedRectangleBorder( + borderRadius: + BorderRadius.all(Radius.circular(10)), + ), + elevation: 5, + title: Text( + 'backup_album_selection_page_selection_info', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: context.primaryColor, + ), + ).t(context: context), + content: SingleChildScrollView( + child: ListBody( + children: [ + const Text( + 'backup_album_selection_page_assets_scatter', + style: TextStyle( + fontSize: 14, + ), + ).t(context: context), + ], + ), + ), + ); + }, + ); + }, + ), + ), - // buildSearchBar(), - ], - ), + if (Platform.isAndroid) + _SelectAllButton( + filteredAlbums: filteredAlbums, + selectedBackupAlbums: selectedBackupAlbums, + ), + ], ), - SliverLayoutBuilder( - builder: (context, constraints) { - if (constraints.crossAxisExtent > 600) { - return buildAlbumSelectionGrid(); - } else { - return buildAlbumSelectionList(); - } - }, - ), - ], + ), + SliverLayoutBuilder( + builder: (context, constraints) { + if (constraints.crossAxisExtent > 600) { + return _AlbumSelectionGrid( + filteredAlbums: filteredAlbums, + searchQuery: _searchQuery, + ); + } else { + return _AlbumSelectionList( + filteredAlbums: filteredAlbums, + searchQuery: _searchQuery, + ); + } + }, + ), + ], + ), + ); + } +} + +class _AlbumSelectionList extends StatelessWidget { + final List filteredAlbums; + final String searchQuery; + + const _AlbumSelectionList({ + required this.filteredAlbums, + required this.searchQuery, + }); + + @override + Widget build(BuildContext context) { + if (filteredAlbums.isEmpty && searchQuery.isNotEmpty) { + return SliverToBoxAdapter( + child: Center( + child: Padding( + padding: const EdgeInsets.all(24.0), + child: Text('album_search_not_found'.t(context: context)), + ), + ), + ); + } + + if (filteredAlbums.isEmpty) { + return const SliverToBoxAdapter( + child: Center( + child: CircularProgressIndicator(), + ), + ); + } + + return SliverPadding( + padding: const EdgeInsets.symmetric(vertical: 12.0), + sliver: SliverList( + delegate: SliverChildBuilderDelegate( + ((context, index) { + return DriftAlbumInfoListTile( + album: filteredAlbums[index], + ); + }), + childCount: filteredAlbums.length, ), ), ); } } + +class _AlbumSelectionGrid extends StatelessWidget { + final List filteredAlbums; + final String searchQuery; + + const _AlbumSelectionGrid({ + required this.filteredAlbums, + required this.searchQuery, + }); + + @override + Widget build(BuildContext context) { + if (filteredAlbums.isEmpty && searchQuery.isNotEmpty) { + return SliverToBoxAdapter( + child: Center( + child: Padding( + padding: const EdgeInsets.all(24.0), + child: Text('album_search_not_found'.t(context: context)), + ), + ), + ); + } + + if (filteredAlbums.isEmpty) { + return const SliverToBoxAdapter( + child: Center( + child: CircularProgressIndicator(), + ), + ); + } + + return SliverPadding( + padding: const EdgeInsets.all(12.0), + sliver: SliverGrid.builder( + gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 300, + mainAxisSpacing: 12, + crossAxisSpacing: 12, + ), + itemCount: filteredAlbums.length, + itemBuilder: ((context, index) { + return DriftAlbumInfoListTile( + album: filteredAlbums[index], + ); + }), + ), + ); + } +} + +class _SelectedAlbumNameChips extends ConsumerWidget { + final List selectedBackupAlbums; + + const _SelectedAlbumNameChips({ + required this.selectedBackupAlbums, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return Wrap( + children: selectedBackupAlbums.asMap().entries.map((entry) { + final album = entry.value; + + void removeSelection() { + ref.read(backupAlbumProvider.notifier).deselectAlbum(album); + } + + return Padding( + padding: const EdgeInsets.only(right: 8.0), + child: GestureDetector( + onTap: removeSelection, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + child: Chip( + label: Text( + album.name, + style: TextStyle( + fontSize: 12, + color: context.isDarkTheme ? Colors.black : Colors.white, + fontWeight: FontWeight.bold, + ), + ), + backgroundColor: context.primaryColor, + deleteIconColor: + context.isDarkTheme ? Colors.black : Colors.white, + deleteIcon: const Icon( + Icons.cancel_rounded, + size: 15, + ), + onDeleted: removeSelection, + ), + ), + ), + ); + }).toList(), + ); + } +} + +class _ExcludedAlbumNameChips extends ConsumerWidget { + final List excludedBackupAlbums; + + const _ExcludedAlbumNameChips({ + required this.excludedBackupAlbums, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return Wrap( + children: excludedBackupAlbums.asMap().entries.map((entry) { + final album = entry.value; + + void removeSelection() { + ref.read(backupAlbumProvider.notifier).deselectAlbum(album); + } + + return GestureDetector( + onTap: removeSelection, + child: Padding( + padding: const EdgeInsets.only(right: 8.0), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + child: Chip( + label: Text( + album.name, + style: TextStyle( + fontSize: 12, + color: context.scaffoldBackgroundColor, + fontWeight: FontWeight.bold, + ), + ), + backgroundColor: Colors.red[300], + deleteIconColor: context.scaffoldBackgroundColor, + deleteIcon: const Icon( + Icons.cancel_rounded, + size: 15, + ), + onDeleted: removeSelection, + ), + ), + ), + ); + }).toList(), + ); + } +} + +class _SelectAllButton extends ConsumerWidget { + final List filteredAlbums; + final List selectedBackupAlbums; + + const _SelectAllButton({ + required this.filteredAlbums, + required this.selectedBackupAlbums, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final canSelectAll = filteredAlbums + .where((album) => album.backupSelection != BackupSelection.selected) + .isNotEmpty; + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), + child: Row( + children: [ + Expanded( + child: ElevatedButton.icon( + onPressed: canSelectAll + ? () { + for (final album in filteredAlbums) { + if (album.backupSelection != BackupSelection.selected) { + ref + .read(backupAlbumProvider.notifier) + .selectAlbum(album); + } + } + } + : null, + icon: const Icon(Icons.select_all), + label: AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + child: Text( + "select_all".t(context: context), + ), + ), + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 12.0), + ), + ), + ), + const SizedBox(width: 8.0), + Expanded( + child: OutlinedButton.icon( + onPressed: selectedBackupAlbums.isNotEmpty + ? () { + for (final album in filteredAlbums) { + if (album.backupSelection == BackupSelection.selected) { + ref + .read(backupAlbumProvider.notifier) + .deselectAlbum(album); + } + } + } + : null, + icon: const Icon(Icons.deselect), + label: Text('deselect_all'.t(context: context)), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 12.0), + ), + ), + ), + ], + ), + ); + } +} diff --git a/mobile/lib/pages/backup/drift_upload_detail.page.dart b/mobile/lib/pages/backup/drift_upload_detail.page.dart new file mode 100644 index 0000000000..66803265e6 --- /dev/null +++ b/mobile/lib/pages/backup/drift_upload_detail.page.dart @@ -0,0 +1,428 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/providers/backup/drift_backup.provider.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; +import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart'; +import 'package:immich_mobile/utils/bytes_units.dart'; +import 'package:path/path.dart' as path; + +@RoutePage() +class DriftUploadDetailPage extends ConsumerWidget { + const DriftUploadDetailPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final uploadItems = ref.watch( + driftBackupProvider.select((state) => state.uploadItems), + ); + + return Scaffold( + appBar: AppBar( + title: Text("upload_details".t(context: context)), + backgroundColor: context.colorScheme.surface, + elevation: 0, + scrolledUnderElevation: 1, + ), + body: uploadItems.isEmpty + ? _buildEmptyState(context) + : _buildUploadList(uploadItems), + ); + } + + Widget _buildEmptyState(BuildContext context) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.cloud_upload_outlined, + size: 80, + color: context.colorScheme.onSurface.withValues(alpha: 0.3), + ), + const SizedBox(height: 16), + Text( + "no_uploads_in_progress".t(context: context), + style: context.textTheme.titleMedium?.copyWith( + color: context.colorScheme.onSurface.withValues(alpha: 0.6), + ), + ), + ], + ), + ); + } + + Widget _buildUploadList( + Map uploadItems, + ) { + return ListView.separated( + addAutomaticKeepAlives: true, + padding: const EdgeInsets.all(16), + itemCount: uploadItems.length, + separatorBuilder: (context, index) => const SizedBox(height: 4), + itemBuilder: (context, index) { + final item = uploadItems.values.elementAt(index); + return _buildUploadCard(context, item); + }, + ); + } + + Widget _buildUploadCard( + BuildContext context, + DriftUploadStatus item, + ) { + final isCompleted = item.progress >= 1.0; + final double progressPercentage = (item.progress * 100).clamp(0, 100); + + return Card( + elevation: 0, + color: context.colorScheme.surfaceContainer, + shape: RoundedRectangleBorder( + borderRadius: const BorderRadius.all( + Radius.circular(16), + ), + side: BorderSide( + color: context.colorScheme.outline.withValues(alpha: 0.1), + width: 1, + ), + ), + child: InkWell( + onTap: () => _showFileDetailDialog(context, item), + borderRadius: const BorderRadius.all( + Radius.circular(16), + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + path.basename(item.filename), + style: context.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Text( + 'Tap for more details', + style: context.textTheme.bodySmall?.copyWith( + color: context.colorScheme.onSurface + .withValues(alpha: 0.6), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + _buildProgressIndicator( + context, + item.progress, + progressPercentage, + isCompleted, + item.networkSpeedAsString, + ), + ], + ), + ], + ), + ), + ), + ); + } + + Widget _buildProgressIndicator( + BuildContext context, + double progress, + double percentage, + bool isCompleted, + String networkSpeedAsString, + ) { + return Column( + children: [ + Stack( + alignment: AlignmentDirectional.center, + children: [ + SizedBox( + width: 36, + height: 36, + child: TweenAnimationBuilder( + tween: Tween(begin: 0.0, end: progress), + duration: const Duration(milliseconds: 300), + builder: (context, value, _) => CircularProgressIndicator( + backgroundColor: + context.colorScheme.outline.withValues(alpha: 0.2), + strokeWidth: 3, + value: value, + color: isCompleted + ? context.colorScheme.primary + : context.colorScheme.secondary, + ), + ), + ), + if (isCompleted) + Icon( + Icons.check_circle_rounded, + size: 28, + color: context.colorScheme.primary, + ) + else + Text( + percentage.toStringAsFixed(0), + style: context.textTheme.labelSmall?.copyWith( + fontWeight: FontWeight.bold, + fontSize: 10, + ), + ), + ], + ), + Text( + networkSpeedAsString, + style: context.textTheme.labelSmall?.copyWith( + color: context.colorScheme.onSurface.withValues(alpha: 0.6), + fontSize: 10, + ), + ), + ], + ); + } + + Future _showFileDetailDialog( + BuildContext context, + DriftUploadStatus item, + ) async { + showDialog( + context: context, + builder: (context) => FileDetailDialog(uploadStatus: item), + ); + } +} + +class FileDetailDialog extends ConsumerWidget { + final DriftUploadStatus uploadStatus; + + const FileDetailDialog({ + super.key, + required this.uploadStatus, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return AlertDialog( + insetPadding: const EdgeInsets.all(20), + backgroundColor: context.colorScheme.surfaceContainerLow, + shape: RoundedRectangleBorder( + borderRadius: const BorderRadius.all( + Radius.circular(16), + ), + side: BorderSide( + color: context.colorScheme.outline.withValues(alpha: 0.2), + width: 1, + ), + ), + title: Row( + children: [ + Icon( + Icons.info_outline, + color: context.primaryColor, + size: 24, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + "details".t(context: context), + style: context.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + color: context.primaryColor, + ), + ), + ), + ], + ), + content: SizedBox( + width: double.maxFinite, + child: FutureBuilder( + future: _getAssetDetails(ref, uploadStatus.taskId), + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const SizedBox( + height: 200, + child: Center(child: CircularProgressIndicator()), + ); + } + + final asset = snapshot.data; + return SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + // Thumbnail at the top center + Center( + child: ClipRRect( + borderRadius: const BorderRadius.all(Radius.circular(12)), + child: Container( + width: 128, + height: 128, + decoration: BoxDecoration( + border: Border.all( + color: context.colorScheme.outline + .withValues(alpha: 0.2), + width: 1, + ), + borderRadius: + const BorderRadius.all(Radius.circular(12)), + ), + child: asset != null + ? Thumbnail( + asset: asset, + size: const Size(512, 512), + fit: BoxFit.cover, + ) + : null, + ), + ), + ), + const SizedBox(height: 24), + if (asset != null) ...[ + _buildInfoSection(context, [ + _buildInfoRow( + context, + "Filename", + path.basename(uploadStatus.filename), + ), + _buildInfoRow( + context, + "Local ID", + asset.id, + ), + _buildInfoRow( + context, + "File Size", + formatHumanReadableBytes(uploadStatus.fileSize, 2), + ), + if (asset.width != null) + _buildInfoRow(context, "Width", "${asset.width}px"), + if (asset.height != null) + _buildInfoRow( + context, + "Height", + "${asset.height}px", + ), + _buildInfoRow( + context, + "Created At", + asset.createdAt.toString(), + ), + _buildInfoRow( + context, + "Updated At", + asset.updatedAt.toString(), + ), + if (asset.checksum != null) + _buildInfoRow( + context, + "Checksum", + asset.checksum!, + ), + ]), + ], + ], + ), + ); + }, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text( + "close".t(), + style: TextStyle( + fontWeight: FontWeight.w600, + color: context.primaryColor, + ), + ), + ), + ], + ); + } + + Widget _buildInfoSection( + BuildContext context, + List children, + ) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: context.colorScheme.surfaceContainer, + borderRadius: const BorderRadius.all( + Radius.circular(12), + ), + border: Border.all( + color: context.colorScheme.outline.withValues(alpha: 0.1), + width: 1, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ...children, + ], + ), + ); + } + + Widget _buildInfoRow(BuildContext context, String label, String value) { + return Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 100, + child: Text( + "$label:", + style: context.textTheme.labelMedium?.copyWith( + fontWeight: FontWeight.w500, + color: context.colorScheme.onSurface.withValues(alpha: 0.7), + ), + ), + ), + Expanded( + child: Text( + value, + style: context.textTheme.labelMedium?.copyWith(), + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ); + } + + Future _getAssetDetails( + WidgetRef ref, + String localAssetId, + ) async { + try { + final repository = ref.read(localAssetRepository); + return await repository.getById(localAssetId); + } catch (e) { + return null; + } + } +} diff --git a/mobile/lib/pages/common/tab_shell.page.dart b/mobile/lib/pages/common/tab_shell.page.dart index 671c1a6156..b0be136a15 100644 --- a/mobile/lib/pages/common/tab_shell.page.dart +++ b/mobile/lib/pages/common/tab_shell.page.dart @@ -3,7 +3,9 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/scroll_notifier.provider.dart'; +import 'package:immich_mobile/providers/backup/drift_backup.provider.dart'; import 'package:immich_mobile/providers/haptic_feedback.provider.dart'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; import 'package:immich_mobile/providers/search/search_input_focus.provider.dart'; @@ -11,6 +13,7 @@ import 'package:immich_mobile/providers/tab.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/providers/websocket.provider.dart'; import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/utils/migration.dart'; @RoutePage() @@ -25,9 +28,19 @@ class _TabShellPageState extends ConsumerState { @override void initState() { super.initState(); - WidgetsBinding.instance.addPostFrameCallback((_) { + + WidgetsBinding.instance.addPostFrameCallback((_) async { ref.read(websocketProvider.notifier).connect(); - runNewSync(ref, full: true); + + final isEnableBackup = ref + .read(appSettingsServiceProvider) + .getSetting(AppSettingsEnum.enableBackup); + + await runNewSync(ref, full: true).then((_) async { + if (isEnableBackup) { + await ref.read(driftBackupProvider.notifier).handleBackupResume(); + } + }); }); } diff --git a/mobile/lib/presentation/widgets/backup/backup_toggle_button.widget.dart b/mobile/lib/presentation/widgets/backup/backup_toggle_button.widget.dart new file mode 100644 index 0000000000..c817b9b4b6 --- /dev/null +++ b/mobile/lib/presentation/widgets/backup/backup_toggle_button.widget.dart @@ -0,0 +1,242 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/theme_extensions.dart'; +import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/providers/app_settings.provider.dart'; +import 'package:immich_mobile/providers/backup/drift_backup.provider.dart'; +import 'package:immich_mobile/services/app_settings.service.dart'; + +class BackupToggleButton extends ConsumerStatefulWidget { + final VoidCallback onStart; + final VoidCallback onStop; + + const BackupToggleButton({ + super.key, + required this.onStart, + required this.onStop, + }); + + @override + ConsumerState createState() => BackupToggleButtonState(); +} + +class BackupToggleButtonState extends ConsumerState + with SingleTickerProviderStateMixin { + late AnimationController _animationController; + late Animation _gradientAnimation; + bool _isEnabled = false; + + @override + void initState() { + super.initState(); + _animationController = AnimationController( + duration: const Duration(seconds: 8), + vsync: this, + ); + + _gradientAnimation = Tween(begin: 0, end: 1).animate( + CurvedAnimation( + parent: _animationController, + curve: Curves.easeInOut, + ), + ); + + _isEnabled = ref + .read(appSettingsServiceProvider) + .getSetting(AppSettingsEnum.enableBackup); + } + + @override + void dispose() { + _animationController.dispose(); + super.dispose(); + } + + Future _onToggle(bool value) async { + await ref + .read(appSettingsServiceProvider) + .setSetting(AppSettingsEnum.enableBackup, value); + + setState(() { + _isEnabled = value; + }); + + if (value) { + widget.onStart.call(); + } else { + widget.onStop.call(); + } + } + + @override + Widget build(BuildContext context) { + final enqueueCount = ref.watch( + driftBackupProvider.select((state) => state.enqueueCount), + ); + + final enqueueTotalCount = ref.watch( + driftBackupProvider.select((state) => state.enqueueTotalCount), + ); + + final isCanceling = ref.watch( + driftBackupProvider.select((state) => state.isCanceling), + ); + + final uploadTasks = ref.watch( + driftBackupProvider.select((state) => state.uploadItems), + ); + + final isUploading = uploadTasks.isNotEmpty; + + return AnimatedBuilder( + animation: _animationController, + builder: (context, child) { + final gradientColors = [ + Color.lerp( + context.primaryColor.withValues(alpha: 0.5), + context.primaryColor.withValues(alpha: 0.3), + _gradientAnimation.value, + )!, + Color.lerp( + context.primaryColor.withValues(alpha: 0.2), + context.primaryColor.withValues(alpha: 0.4), + _gradientAnimation.value, + )!, + Color.lerp( + context.primaryColor.withValues(alpha: 0.3), + context.primaryColor.withValues(alpha: 0.5), + _gradientAnimation.value, + )!, + ]; + + return Container( + margin: const EdgeInsets.symmetric(horizontal: 4, vertical: 4), + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(20)), + gradient: LinearGradient( + colors: gradientColors, + stops: const [0.0, 0.5, 1.0], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + boxShadow: [ + BoxShadow( + color: context.primaryColor.withValues(alpha: 0.1), + blurRadius: 12, + offset: const Offset(0, 2), + ), + ], + ), + child: Container( + margin: const EdgeInsets.all(1.5), + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(18.5)), + color: context.colorScheme.surfaceContainerLow, + ), + child: Material( + color: context.colorScheme.surfaceContainerLow, + borderRadius: const BorderRadius.all(Radius.circular(20.5)), + child: InkWell( + borderRadius: const BorderRadius.all(Radius.circular(20.5)), + onTap: () => isCanceling ? null : _onToggle(!_isEnabled), + child: Padding( + padding: + const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: LinearGradient( + colors: [ + context.primaryColor.withValues(alpha: 0.2), + context.primaryColor.withValues(alpha: 0.1), + ], + ), + ), + child: isUploading + ? const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ) + : Icon( + Icons.cloud_upload_outlined, + color: context.primaryColor, + size: 24, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + "enable_backup".t(context: context), + style: + context.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + color: context.primaryColor, + ), + ), + ], + ), + if (enqueueCount != enqueueTotalCount) + Text( + "queue_status".t( + context: context, + args: { + 'count': enqueueCount.toString(), + 'total': enqueueTotalCount.toString(), + }, + ), + style: context.textTheme.labelLarge?.copyWith( + color: context.colorScheme.onSurfaceSecondary, + ), + ), + if (isCanceling) + Row( + children: [ + Text( + "canceling".t(), + style: context.textTheme.labelLarge, + ), + const SizedBox(width: 4), + SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + backgroundColor: context + .colorScheme.onSurface + .withValues(alpha: 0.2), + ), + ), + ], + ), + ], + ), + ), + Switch.adaptive( + value: _isEnabled, + onChanged: (value) => + isCanceling ? null : _onToggle(value), + ), + ], + ), + ), + ), + ), + ), + ); + }, + ); + } +} diff --git a/mobile/lib/providers/app_life_cycle.provider.dart b/mobile/lib/providers/app_life_cycle.provider.dart index 5984160241..3be46d2fbd 100644 --- a/mobile/lib/providers/app_life_cycle.provider.dart +++ b/mobile/lib/providers/app_life_cycle.provider.dart @@ -6,10 +6,12 @@ import 'package:immich_mobile/domain/services/log.service.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/models/backup/backup_state.model.dart'; import 'package:immich_mobile/providers/album/album.provider.dart'; +import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/asset.provider.dart'; import 'package:immich_mobile/providers/auth.provider.dart'; import 'package:immich_mobile/providers/background_sync.provider.dart'; import 'package:immich_mobile/providers/backup/backup.provider.dart'; +import 'package:immich_mobile/providers/backup/drift_backup.provider.dart'; import 'package:immich_mobile/providers/backup/ios_background_settings.provider.dart'; import 'package:immich_mobile/providers/backup/manual_upload.provider.dart'; import 'package:immich_mobile/providers/gallery_permission.provider.dart'; @@ -18,6 +20,7 @@ import 'package:immich_mobile/providers/notification_permission.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/providers/tab.provider.dart'; import 'package:immich_mobile/providers/websocket.provider.dart'; +import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/services/background.service.dart'; import 'package:isar/isar.dart'; import 'package:logging/logging.dart'; @@ -69,25 +72,18 @@ class AppLifeCycleNotifier extends StateNotifier { } await _ref.read(serverInfoProvider.notifier).getServerVersion(); - - // TODO: Need to decide on how we want to handle uploads once the app is resumed - // await FileDownloader().start(); } if (!Store.isBetaTimelineEnabled) { switch (_ref.read(tabProvider)) { case TabEnum.home: await _ref.read(assetProvider.notifier).getAllAsset(); - break; - case TabEnum.search: - // nothing to do - break; case TabEnum.albums: await _ref.read(albumProvider.notifier).refreshRemoteAlbums(); - break; + case TabEnum.library: - // nothing to do + case TabEnum.search: break; } } else { @@ -108,7 +104,15 @@ class AppLifeCycleNotifier extends StateNotifier { }, ), backgroundManager.syncRemote(), - ]); + ]).then((_) async { + final isEnableBackup = _ref + .read(appSettingsServiceProvider) + .getSetting(AppSettingsEnum.enableBackup); + + if (isEnableBackup) { + await _ref.read(driftBackupProvider.notifier).handleBackupResume(); + } + }); } catch (e, stackTrace) { Logger("AppLifeCycleNotifier").severe( "Error during background sync", diff --git a/mobile/lib/providers/backup/backup_album.provider.dart b/mobile/lib/providers/backup/backup_album.provider.dart index b36d3ac57e..2915c7c216 100644 --- a/mobile/lib/providers/backup/backup_album.provider.dart +++ b/mobile/lib/providers/backup/backup_album.provider.dart @@ -1,6 +1,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/album/local_album.model.dart'; import 'package:immich_mobile/domain/services/local_album.service.dart'; +import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; final backupAlbumProvider = @@ -18,7 +19,8 @@ class BackupAlbumNotifier extends StateNotifier> { final LocalAlbumService _localAlbumService; Future getAll() async { - state = await _localAlbumService.getAll(); + state = + await _localAlbumService.getAll(sortBy: {SortLocalAlbumsBy.assetCount}); } Future selectAlbum(LocalAlbum album) async { diff --git a/mobile/lib/providers/backup/drift_backup.provider.dart b/mobile/lib/providers/backup/drift_backup.provider.dart index 2544d208c4..c51c40775e 100644 --- a/mobile/lib/providers/backup/drift_backup.provider.dart +++ b/mobile/lib/providers/backup/drift_backup.provider.dart @@ -1,39 +1,75 @@ +// ignore_for_file: public_member_api_docs, sort_constructors_first import 'dart:async'; +import 'dart:convert'; import 'package:background_downloader/background_downloader.dart'; import 'package:collection/collection.dart'; import 'package:flutter/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; + import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/services/drift_backup.service.dart'; import 'package:immich_mobile/services/upload.service.dart'; +class EnqueueStatus { + final int enqueueCount; + final int totalCount; + + const EnqueueStatus({ + required this.enqueueCount, + required this.totalCount, + }); + + EnqueueStatus copyWith({ + int? enqueueCount, + int? totalCount, + }) { + return EnqueueStatus( + enqueueCount: enqueueCount ?? this.enqueueCount, + totalCount: totalCount ?? this.totalCount, + ); + } + + @override + String toString() => + 'EnqueueStatus(enqueueCount: $enqueueCount, totalCount: $totalCount)'; +} + class DriftUploadStatus { final String taskId; final String filename; final double progress; + final int fileSize; + final String networkSpeedAsString; const DriftUploadStatus({ required this.taskId, required this.filename, required this.progress, + required this.fileSize, + required this.networkSpeedAsString, }); DriftUploadStatus copyWith({ String? taskId, String? filename, double? progress, + int? fileSize, + String? networkSpeedAsString, }) { return DriftUploadStatus( taskId: taskId ?? this.taskId, filename: filename ?? this.filename, progress: progress ?? this.progress, + fileSize: fileSize ?? this.fileSize, + networkSpeedAsString: networkSpeedAsString ?? this.networkSpeedAsString, ); } @override - String toString() => - 'ExpUploadStatus(taskId: $taskId, filename: $filename, progress: $progress)'; + String toString() { + return 'DriftUploadStatus(taskId: $taskId, filename: $filename, progress: $progress, fileSize: $fileSize, networkSpeedAsString: $networkSpeedAsString)'; + } @override bool operator ==(covariant DriftUploadStatus other) { @@ -41,23 +77,65 @@ class DriftUploadStatus { return other.taskId == taskId && other.filename == filename && - other.progress == progress; + other.progress == progress && + other.fileSize == fileSize && + other.networkSpeedAsString == networkSpeedAsString; } @override - int get hashCode => taskId.hashCode ^ filename.hashCode ^ progress.hashCode; + int get hashCode { + return taskId.hashCode ^ + filename.hashCode ^ + progress.hashCode ^ + fileSize.hashCode ^ + networkSpeedAsString.hashCode; + } + + Map toMap() { + return { + 'taskId': taskId, + 'filename': filename, + 'progress': progress, + 'fileSize': fileSize, + 'networkSpeedAsString': networkSpeedAsString, + }; + } + + factory DriftUploadStatus.fromMap(Map map) { + return DriftUploadStatus( + taskId: map['taskId'] as String, + filename: map['filename'] as String, + progress: map['progress'] as double, + fileSize: map['fileSize'] as int, + networkSpeedAsString: map['networkSpeedAsString'] as String, + ); + } + + String toJson() => json.encode(toMap()); + + factory DriftUploadStatus.fromJson(String source) => + DriftUploadStatus.fromMap(json.decode(source) as Map); } class DriftBackupState { final int totalCount; final int backupCount; final int remainderCount; + + final int enqueueCount; + final int enqueueTotalCount; + + final bool isCanceling; + final Map uploadItems; const DriftBackupState({ required this.totalCount, required this.backupCount, required this.remainderCount, + required this.enqueueCount, + required this.enqueueTotalCount, + required this.isCanceling, required this.uploadItems, }); @@ -65,19 +143,25 @@ class DriftBackupState { int? totalCount, int? backupCount, int? remainderCount, + int? enqueueCount, + int? enqueueTotalCount, + bool? isCanceling, Map? uploadItems, }) { return DriftBackupState( totalCount: totalCount ?? this.totalCount, backupCount: backupCount ?? this.backupCount, remainderCount: remainderCount ?? this.remainderCount, + enqueueCount: enqueueCount ?? this.enqueueCount, + enqueueTotalCount: enqueueTotalCount ?? this.enqueueTotalCount, + isCanceling: isCanceling ?? this.isCanceling, uploadItems: uploadItems ?? this.uploadItems, ); } @override String toString() { - return 'ExpBackupState(totalCount: $totalCount, backupCount: $backupCount, remainderCount: $remainderCount, uploadItems: $uploadItems)'; + return 'DriftBackupState(totalCount: $totalCount, backupCount: $backupCount, remainderCount: $remainderCount, enqueueCount: $enqueueCount, enqueueTotalCount: $enqueueTotalCount, isCanceling: $isCanceling, uploadItems: $uploadItems)'; } @override @@ -88,6 +172,9 @@ class DriftBackupState { return other.totalCount == totalCount && other.backupCount == backupCount && other.remainderCount == remainderCount && + other.enqueueCount == enqueueCount && + other.enqueueTotalCount == enqueueTotalCount && + other.isCanceling == isCanceling && mapEquals(other.uploadItems, uploadItems); } @@ -96,6 +183,9 @@ class DriftBackupState { return totalCount.hashCode ^ backupCount.hashCode ^ remainderCount.hashCode ^ + enqueueCount.hashCode ^ + enqueueTotalCount.hashCode ^ + isCanceling.hashCode ^ uploadItems.hashCode; } } @@ -117,6 +207,9 @@ class ExpBackupNotifier extends StateNotifier { totalCount: 0, backupCount: 0, remainderCount: 0, + enqueueCount: 0, + enqueueTotalCount: 0, + isCanceling: false, uploadItems: {}, ), ) { @@ -131,13 +224,39 @@ class ExpBackupNotifier extends StateNotifier { StreamSubscription? _statusSubscription; StreamSubscription? _progressSubscription; + /// Remove upload item from state + void _removeUploadItem(String taskId) { + if (state.uploadItems.containsKey(taskId)) { + final updatedItems = + Map.from(state.uploadItems); + updatedItems.remove(taskId); + state = state.copyWith(uploadItems: updatedItems); + } + } + void _handleTaskStatusUpdate(TaskStatusUpdate update) { switch (update.status) { case TaskStatus.complete: - state = state.copyWith( - backupCount: state.backupCount + 1, - remainderCount: state.remainderCount - 1, - ); + if (update.task.group == kBackupGroup) { + state = state.copyWith( + backupCount: state.backupCount + 1, + remainderCount: state.remainderCount - 1, + ); + } + + // Remove the completed task from the upload items + final taskId = update.task.taskId; + if (state.uploadItems.containsKey(taskId)) { + Future.delayed(const Duration(milliseconds: 500), () { + _removeUploadItem(taskId); + }); + } + + case TaskStatus.failed: + break; + + case TaskStatus.canceled: + _removeUploadItem(update.task.taskId); break; default: @@ -145,7 +264,48 @@ class ExpBackupNotifier extends StateNotifier { } } - void _handleTaskProgressUpdate(TaskProgressUpdate update) {} + void _handleTaskProgressUpdate(TaskProgressUpdate update) { + final taskId = update.task.taskId; + final filename = update.task.displayName; + final progress = update.progress; + final currentItem = state.uploadItems[taskId]; + if (currentItem != null) { + if (progress == kUploadStatusCanceled) { + _removeUploadItem(update.task.taskId); + return; + } + + state = state.copyWith( + uploadItems: { + ...state.uploadItems, + taskId: update.hasExpectedFileSize + ? currentItem.copyWith( + progress: progress, + fileSize: update.expectedFileSize, + networkSpeedAsString: update.networkSpeedAsString, + ) + : currentItem.copyWith( + progress: progress, + ), + }, + ); + + return; + } + + state = state.copyWith( + uploadItems: { + ...state.uploadItems, + taskId: DriftUploadStatus( + taskId: taskId, + filename: filename, + progress: progress, + fileSize: update.expectedFileSize, + networkSpeedAsString: update.networkSpeedAsString, + ), + }, + ); + } Future getBackupStatus() async { final [totalCount, backupCount, remainderCount] = await Future.wait([ @@ -162,27 +322,50 @@ class ExpBackupNotifier extends StateNotifier { } Future backup() { - return _backupService.backup(); + return _backupService.backup(_updateEnqueueCount); + } + + void _updateEnqueueCount(EnqueueStatus status) { + state = state.copyWith( + enqueueCount: status.enqueueCount, + enqueueTotalCount: status.totalCount, + ); } Future cancel() async { + state = state.copyWith( + enqueueCount: 0, + enqueueTotalCount: 0, + isCanceling: true, + ); + await _backupService.cancel(); - await getDataInfo(); + + // Check if there are any tasks left in the queue + final tasks = await FileDownloader().allTasks(group: kBackupGroup); + + debugPrint("Tasks left to cancel: ${tasks.length}"); + + if (tasks.isNotEmpty) { + await cancel(); + } else { + // Clear all upload items when cancellation is complete + state = state.copyWith( + isCanceling: false, + uploadItems: {}, + ); + } } - Future getDataInfo() async { - final a = await FileDownloader().database.allRecordsWithStatus( - TaskStatus.enqueued, - group: kBackupGroup, - ); + Future handleBackupResume() async { + final tasks = await FileDownloader().allTasks(group: kBackupGroup); + if (tasks.isEmpty) { + // Start a new backup queue + await backup(); + } - final b = await FileDownloader().allTasks( - group: kBackupGroup, - ); - - debugPrint( - "Enqueued tasks: ${a.length}, All tasks: ${b.length}", - ); + debugPrint("Tasks to resume: ${tasks.length}"); + await FileDownloader().start(); } @override diff --git a/mobile/lib/providers/websocket.provider.dart b/mobile/lib/providers/websocket.provider.dart index 05fe5a087c..2718738286 100644 --- a/mobile/lib/providers/websocket.provider.dart +++ b/mobile/lib/providers/websocket.provider.dart @@ -12,6 +12,7 @@ import 'package:immich_mobile/models/server_info/server_version.model.dart'; import 'package:immich_mobile/providers/asset.provider.dart'; import 'package:immich_mobile/providers/auth.provider.dart'; import 'package:immich_mobile/providers/background_sync.provider.dart'; +// import 'package:immich_mobile/providers/background_sync.provider.dart'; import 'package:immich_mobile/providers/db.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/services/api.service.dart'; @@ -177,9 +178,15 @@ class WebsocketNotifier extends StateNotifier { }); if (!Store.isBetaTimelineEnabled) { - startListeningToOldEvents(); + socket.on('on_upload_success', _handleOnUploadSuccess); + socket.on('on_asset_delete', _handleOnAssetDelete); + socket.on('on_asset_trash', _handleOnAssetTrash); + socket.on('on_asset_restore', _handleServerUpdates); + socket.on('on_asset_update', _handleServerUpdates); + socket.on('on_asset_stack_update', _handleServerUpdates); + socket.on('on_asset_hidden', _handleOnAssetHidden); } else { - startListeningToBetaEvents(); + socket.on('AssetUploadReadyV1', _handleSyncAssetUploadReady); } socket.on('on_config_update', _handleOnConfigUpdate); @@ -207,7 +214,6 @@ class WebsocketNotifier extends StateNotifier { } void stopListenToEvent(String eventName) { - debugPrint("Stop listening to event $eventName"); state.socket?.off(eventName); } diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart index d7dd45dbd9..d0f8852dc3 100644 --- a/mobile/lib/routing/router.dart +++ b/mobile/lib/routing/router.dart @@ -27,6 +27,7 @@ import 'package:immich_mobile/pages/backup/drift_backup.page.dart'; import 'package:immich_mobile/pages/backup/backup_album_selection.page.dart'; import 'package:immich_mobile/pages/backup/backup_controller.page.dart'; import 'package:immich_mobile/pages/backup/backup_options.page.dart'; +import 'package:immich_mobile/pages/backup/drift_upload_detail.page.dart'; import 'package:immich_mobile/pages/backup/failed_backup_status.page.dart'; import 'package:immich_mobile/pages/common/activities.page.dart'; import 'package:immich_mobile/pages/common/app_log.page.dart'; @@ -484,6 +485,10 @@ class AppRouter extends RootStackRouter { page: ChangeExperienceRoute.page, guards: [_authGuard, _duplicateGuard], ), + AutoRoute( + page: DriftUploadDetailRoute.page, + guards: [_authGuard, _duplicateGuard], + ), // required to handle all deeplinks in deep_link.service.dart // auto_route_library#1722 RedirectRoute(path: '*', redirectTo: '/'), diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index c72dc62765..c9ed8a40a3 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -1078,6 +1078,22 @@ class DriftTrashRoute extends PageRouteInfo { ); } +/// generated route for +/// [DriftUploadDetailPage] +class DriftUploadDetailRoute extends PageRouteInfo { + const DriftUploadDetailRoute({List? children}) + : super(DriftUploadDetailRoute.name, initialChildren: children); + + static const String name = 'DriftUploadDetailRoute'; + + static PageInfo page = PageInfo( + name, + builder: (data) { + return const DriftUploadDetailPage(); + }, + ); +} + /// generated route for /// [DriftUserSelectionPage] class DriftUserSelectionRoute diff --git a/mobile/lib/services/app_settings.service.dart b/mobile/lib/services/app_settings.service.dart index 982dab1cae..cefc52385a 100644 --- a/mobile/lib/services/app_settings.service.dart +++ b/mobile/lib/services/app_settings.service.dart @@ -91,6 +91,7 @@ enum AppSettingsEnum { true, ), betaTimeline(StoreKey.betaTimeline, null, false), + enableBackup(StoreKey.enableBackup, null, false), ; const AppSettingsEnum(this.storeKey, this.hiveKey, this.defaultValue); diff --git a/mobile/lib/services/drift_backup.service.dart b/mobile/lib/services/drift_backup.service.dart index 5966aad304..2f51c261fb 100644 --- a/mobile/lib/services/drift_backup.service.dart +++ b/mobile/lib/services/drift_backup.service.dart @@ -9,6 +9,7 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/infrastructure/repositories/backup.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart'; +import 'package:immich_mobile/providers/backup/drift_backup.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/storage.provider.dart'; import 'package:immich_mobile/services/upload.service.dart'; @@ -55,7 +56,9 @@ class DriftBackupService { return _backupRepository.getBackupCount(); } - Future backup() async { + Future backup( + void Function(EnqueueStatus status) onEnqueueTasks, + ) async { shouldCancel = false; final candidates = await _backupRepository.getCandidates(); @@ -83,8 +86,12 @@ class DriftBackupService { if (tasks.isNotEmpty && !shouldCancel) { count += tasks.length; _uploadService.enqueueTasks(tasks); - debugPrint( - "Enqueued $count/${candidates.length} tasks for backup", + + onEnqueueTasks( + EnqueueStatus( + enqueueCount: count, + totalCount: candidates.length, + ), ); } } @@ -213,7 +220,7 @@ class DriftBackupService { deviceAssetId: asset.id, fields: fields, group: kBackupLivePhotoGroup, - priority: 0, + priority: 0, // Highest priority to get upload immediately ); } diff --git a/mobile/lib/services/upload.service.dart b/mobile/lib/services/upload.service.dart index 60aab4a16c..b869624e52 100644 --- a/mobile/lib/services/upload.service.dart +++ b/mobile/lib/services/upload.service.dart @@ -134,6 +134,7 @@ class UploadService { group: group, priority: priority ?? 5, updates: Updates.statusAndProgress, + retries: 3, ); } } diff --git a/mobile/lib/widgets/common/immich_sliver_app_bar.dart b/mobile/lib/widgets/common/immich_sliver_app_bar.dart index 2b020d20ee..c7ddeca6e0 100644 --- a/mobile/lib/widgets/common/immich_sliver_app_bar.dart +++ b/mobile/lib/widgets/common/immich_sliver_app_bar.dart @@ -1,5 +1,6 @@ import 'package:auto_route/auto_route.dart'; import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_svg/svg.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -83,6 +84,11 @@ class ImmichSliverAppBar extends ConsumerWidget { child: action, ), ), + if (kDebugMode || kProfileMode) + IconButton( + icon: const Icon(Icons.science_rounded), + onPressed: () => context.pushRoute(const FeatInDevRoute()), + ), if (showUploadButton) const Padding( padding: EdgeInsets.only(right: 20), diff --git a/mobile/lib/widgets/settings/beta_timeline_list_tile.dart b/mobile/lib/widgets/settings/beta_timeline_list_tile.dart index 1de3a6e7ab..a9c873cb67 100644 --- a/mobile/lib/widgets/settings/beta_timeline_list_tile.dart +++ b/mobile/lib/widgets/settings/beta_timeline_list_tile.dart @@ -90,6 +90,19 @@ class _BetaTimelineListTileState extends ConsumerState ), actions: [ TextButton( + onPressed: () { + context.pop(); + }, + child: Text( + "cancel".t(context: context), + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + color: context.colorScheme.outline, + ), + ), + ), + ElevatedButton( onPressed: () async { Navigator.of(context).pop(); await ref.read(appSettingsServiceProvider).setSetting( @@ -101,25 +114,7 @@ class _BetaTimelineListTileState extends ConsumerState ); }, child: Text( - "YES", - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w500, - color: context.primaryColor, - ), - ), - ), - TextButton( - onPressed: () { - context.pop(); - }, - child: Text( - "NO", - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w500, - color: context.colorScheme.outline, - ), + "ok".t(context: context), ), ), ], @@ -135,13 +130,13 @@ class _BetaTimelineListTileState extends ConsumerState _gradientAnimation.value, )!, Color.lerp( - context.primaryColor.withValues(alpha: 0.4), - context.primaryColor.withValues(alpha: 0.6), + context.logoPink.withValues(alpha: 0.2), + context.logoPink.withValues(alpha: 0.4), _gradientAnimation.value, )!, Color.lerp( - context.primaryColor.withValues(alpha: 0.3), - context.primaryColor.withValues(alpha: 0.5), + context.logoRed.withValues(alpha: 0.3), + context.logoRed.withValues(alpha: 0.5), _gradientAnimation.value, )!, ]; @@ -155,7 +150,7 @@ class _BetaTimelineListTileState extends ConsumerState stops: const [0.0, 0.5, 1.0], begin: Alignment.topLeft, end: Alignment.bottomRight, - transform: GradientRotation(_rotationAnimation.value * 0.1), + transform: GradientRotation(_rotationAnimation.value * 0.5), ), boxShadow: [ BoxShadow( @@ -166,13 +161,12 @@ class _BetaTimelineListTileState extends ConsumerState ], ), child: Container( - margin: const EdgeInsets.all(1.5), + margin: const EdgeInsets.all(2), decoration: BoxDecoration( borderRadius: const BorderRadius.all(Radius.circular(10.5)), color: context.scaffoldBackgroundColor, ), child: Material( - color: Colors.transparent, borderRadius: const BorderRadius.all(Radius.circular(10.5)), child: InkWell( borderRadius: const BorderRadius.all(Radius.circular(10.5)), @@ -205,7 +199,7 @@ class _BetaTimelineListTileState extends ConsumerState ), ), ), - const SizedBox(width: 16), + const SizedBox(width: 28), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -259,8 +253,9 @@ class _BetaTimelineListTileState extends ConsumerState .t(context: context), style: context.textTheme.labelLarge?.copyWith( color: context.textTheme.labelLarge?.color - ?.withValues(alpha: 0.7), + ?.withValues(alpha: 0.9), ), + maxLines: 2, ), ], ),