feat: new upload (cont) (#20029)

* new upload button

* wip

* pr feedback

* fix: updateAll override album selection value

* feat: status box

* feat: handle upload resume

* re-enable websocket event

* fix: update state condition and upload status

* Better backup detail page

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
This commit is contained in:
Alex 2025-07-21 15:30:51 -05:00 committed by GitHub
parent 1dc62fce5f
commit 4d27f187ea
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 1558 additions and 413 deletions

View File

@ -406,6 +406,7 @@
"album_options": "Album options", "album_options": "Album options",
"album_remove_user": "Remove user?", "album_remove_user": "Remove user?",
"album_remove_user_confirmation": "Are you sure you want to 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_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": "Album updated",
"album_updated_setting_description": "Receive an email notification when a shared album has new assets", "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": "Default album sort order",
"albums_default_sort_order_description": "Initial asset sort order when creating new albums.", "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_feature_description": "Collections of assets that can be shared with other users.",
"albums_on_device_count": "Albums on device ({count})",
"all": "All", "all": "All",
"all_albums": "All albums", "all_albums": "All albums",
"all_people": "All people", "all_people": "All people",
@ -605,6 +607,7 @@
"cancel": "Cancel", "cancel": "Cancel",
"cancel_search": "Cancel search", "cancel_search": "Cancel search",
"canceled": "Canceled", "canceled": "Canceled",
"canceling": "Canceling",
"cannot_merge_people": "Cannot merge people", "cannot_merge_people": "Cannot merge people",
"cannot_undo_this_action": "You cannot undo this action!", "cannot_undo_this_action": "You cannot undo this action!",
"cannot_update_the_description": "Cannot update the description", "cannot_update_the_description": "Cannot update the description",
@ -765,6 +768,7 @@
"description": "Description", "description": "Description",
"description_input_hint_text": "Add description...", "description_input_hint_text": "Add description...",
"description_input_submit_error": "Error updating description, check the log for more details", "description_input_submit_error": "Error updating description, check the log for more details",
"deselect_all": "Deselect All",
"details": "Details", "details": "Details",
"direction": "Direction", "direction": "Direction",
"disabled": "Disabled", "disabled": "Disabled",
@ -839,6 +843,7 @@
"empty_trash": "Empty trash", "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!", "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": "Enable",
"enable_backup": "Enable Backup",
"enable_biometric_auth_description": "Enter your PIN code to enable biometric authentication", "enable_biometric_auth_description": "Enter your PIN code to enable biometric authentication",
"enabled": "Enabled", "enabled": "Enabled",
"end_date": "End date", "end_date": "End date",
@ -1485,6 +1490,7 @@
"purchase_server_description_2": "Supporter status", "purchase_server_description_2": "Supporter status",
"purchase_server_title": "Server", "purchase_server_title": "Server",
"purchase_settings_server_activated": "The server product key is managed by the admin", "purchase_settings_server_activated": "The server product key is managed by the admin",
"queue_status": "Queuing {count}/{total}",
"rating": "Star rating", "rating": "Star rating",
"rating_clear": "Clear rating", "rating_clear": "Clear rating",
"rating_count": "{count, plural, one {# star} other {# stars}}", "rating_count": "{count, plural, one {# star} other {# stars}}",
@ -1915,6 +1921,7 @@
"updated_password": "Updated password", "updated_password": "Updated password",
"upload": "Upload", "upload": "Upload",
"upload_concurrency": "Upload concurrency", "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_info": "Do you want to backup the selected Asset(s) to the server?",
"upload_dialog_title": "Upload Asset", "upload_dialog_title": "Upload Asset",
"upload_errors": "Upload completed with {count, plural, one {# error} other {# errors}}, refresh the page to see new upload assets.", "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_album": "View Album",
"view_all": "View All", "view_all": "View All",
"view_all_users": "View all users", "view_all_users": "View all users",
"view_details": "View Details",
"view_in_timeline": "View in timeline", "view_in_timeline": "View in timeline",
"view_link": "View link", "view_link": "View link",
"view_links": "View links", "view_links": "View links",

View File

@ -83,6 +83,7 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
flutterLoader.ensureInitializationCompleteAsync(ctx, null, Handler(Looper.getMainLooper())) { flutterLoader.ensureInitializationCompleteAsync(ctx, null, Handler(Looper.getMainLooper())) {
runDart() runDart()
} }
return resolvableFuture return resolvableFuture

View File

@ -38,3 +38,6 @@ const List<(String, String)> kWidgetNames = [
('com.immich.widget.random', 'app.alextran.immich.widget.RandomReceiver'), ('com.immich.widget.random', 'app.alextran.immich.widget.RandomReceiver'),
('com.immich.widget.memory', 'app.alextran.immich.widget.MemoryReceiver'), ('com.immich.widget.memory', 'app.alextran.immich.widget.MemoryReceiver'),
]; ];
const double kUploadStatusFailed = -1.0;
const double kUploadStatusCanceled = -2.0;

View File

@ -70,7 +70,8 @@ enum StoreKey<T> {
// Experimental stuff // Experimental stuff
photoManagerCustomFilter<bool>._(1000), photoManagerCustomFilter<bool>._(1000),
betaPromptShown<bool>._(1001), betaPromptShown<bool>._(1001),
betaTimeline<bool>._(1002); betaTimeline<bool>._(1002),
enableBackup<bool>._(1003);
const StoreKey._(this.id); const StoreKey._(this.id);
final int id; final int id;

View File

@ -7,8 +7,8 @@ class LocalAlbumService {
const LocalAlbumService(this._repository); const LocalAlbumService(this._repository);
Future<List<LocalAlbum>> getAll() { Future<List<LocalAlbum>> getAll({Set<SortLocalAlbumsBy> sortBy = const {}}) {
return _repository.getAll(); return _repository.getAll(sortBy: sortBy);
} }
Future<LocalAsset?> getThumbnail(String albumId) { Future<LocalAsset?> getThumbnail(String albumId) {

View File

@ -101,14 +101,18 @@ class BackgroundSyncManager {
if (_syncWebsocketTask != null) { if (_syncWebsocketTask != null) {
return _syncWebsocketTask!.future; return _syncWebsocketTask!.future;
} }
_syncWebsocketTask = _handleWsAssetUploadReadyV1Batch(batchData);
_syncWebsocketTask = runInIsolateGentle(
computation: (ref) => ref
.read(syncStreamServiceProvider)
.handleWsAssetUploadReadyV1Batch(batchData),
);
return _syncWebsocketTask!.whenComplete(() { return _syncWebsocketTask!.whenComplete(() {
_syncWebsocketTask = null; _syncWebsocketTask = null;
}); });
} }
} }
Cancelable<void> _handleWsAssetUploadReadyV1Batch(
List<dynamic> batchData,
) =>
runInIsolateGentle(
computation: (ref) => ref
.read(syncStreamServiceProvider)
.handleWsAssetUploadReadyV1Batch(batchData),
);

View File

@ -33,6 +33,10 @@ extension ContextHelper on BuildContext {
// Returns the current Primary color of the Theme // Returns the current Primary color of the Theme
Color get primaryColor => themeData.colorScheme.primary; 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 // Returns the Scaffold background color of the Theme
Color get scaffoldBackgroundColor => colorScheme.surface; Color get scaffoldBackgroundColor => colorScheme.surface;

View File

@ -52,9 +52,7 @@ class DriftBackupRepository extends DriftDatabaseRepository {
Future<int> getRemainderCount() async { Future<int> getRemainderCount() async {
final query = _db.localAlbumAssetEntity.selectOnly(distinct: true) final query = _db.localAlbumAssetEntity.selectOnly(distinct: true)
..addColumns( ..addColumns([_db.localAlbumAssetEntity.assetId])
[_db.localAlbumAssetEntity.assetId],
)
..join([ ..join([
innerJoin( innerJoin(
_db.localAlbumEntity, _db.localAlbumEntity,
@ -147,6 +145,11 @@ class DriftBackupRepository extends DriftDatabaseRepository {
), ),
) & ) &
lae.id.isNotInQuery(_getExcludedSubquery()), lae.id.isNotInQuery(_getExcludedSubquery()),
)
..orderBy(
[
(localAsset) => OrderingTerm.desc(localAsset.createdAt),
],
); );
return query.map((localAsset) => localAsset.toDto()).get(); return query.map((localAsset) => localAsset.toDto()).get();

View File

@ -8,7 +8,13 @@ import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/utils/database.utils.dart'; import 'package:immich_mobile/utils/database.utils.dart';
import 'package:platform/platform.dart'; import 'package:platform/platform.dart';
enum SortLocalAlbumsBy { id, backupSelection, isIosSharedAlbum } enum SortLocalAlbumsBy {
id,
backupSelection,
isIosSharedAlbum,
name,
assetCount
}
class DriftLocalAlbumRepository extends DriftDatabaseRepository { class DriftLocalAlbumRepository extends DriftDatabaseRepository {
final Drift _db; final Drift _db;
@ -41,6 +47,9 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository {
OrderingTerm.asc(_db.localAlbumEntity.backupSelection), OrderingTerm.asc(_db.localAlbumEntity.backupSelection),
SortLocalAlbumsBy.isIosSharedAlbum => SortLocalAlbumsBy.isIosSharedAlbum =>
OrderingTerm.asc(_db.localAlbumEntity.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( batch.insert(
_db.localAlbumEntity, _db.localAlbumEntity,
companion, companion,
onConflict: DoUpdate((_) => companion), onConflict: DoUpdate(
(old) => LocalAlbumEntityCompanion(
id: companion.id,
name: companion.name,
updatedAt: companion.updatedAt,
isIosSharedAlbum: companion.isIosSharedAlbum,
marker_: companion.marker_,
),
),
); );
} }
}); });

View File

@ -96,8 +96,8 @@ Future<void> initApp() async {
// Initialize the file downloader // Initialize the file downloader
await FileDownloader().configure( await FileDownloader().configure(
// maxConcurrent: 5, maxConcurrentByHost: 2, maxConcurrentByGroup: 3 // maxConcurrent: 6, maxConcurrentByHost(server):6, maxConcurrentByGroup: 3
globalConfig: (Config.holdingQueue, (5, 2, 3)), globalConfig: (Config.holdingQueue, (6, 6, 3)),
); );
await FileDownloader().trackTasksInGroup( await FileDownloader().trackTasksInGroup(

View File

@ -1,83 +1,61 @@
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.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/domain/models/album/local_album.model.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/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_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/backup_album.provider.dart';
import 'package:immich_mobile/providers/backup/drift_backup.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/routing/router.dart';
import 'package:immich_mobile/widgets/backup/backup_info_card.dart'; import 'package:immich_mobile/widgets/backup/backup_info_card.dart';
@RoutePage() @RoutePage()
class DriftBackupPage extends HookConsumerWidget { class DriftBackupPage extends ConsumerStatefulWidget {
const DriftBackupPage({super.key}); const DriftBackupPage({super.key});
@override @override
Widget build(BuildContext context, WidgetRef ref) { ConsumerState<DriftBackupPage> createState() => _DriftBackupPageState();
useEffect(
() {
ref.read(driftBackupProvider.notifier).getBackupStatus();
return null;
},
[],
);
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<DriftBackupPage> {
@override
void initState() {
super.initState();
ref.read(driftBackupProvider.notifier).getBackupStatus();
}
Future<void> startBackup() async {
await ref.read(driftBackupProvider.notifier).getBackupStatus();
await ref.read(driftBackupProvider.notifier).backup();
}
Future<void> 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( return Scaffold(
appBar: AppBar( appBar: AppBar(
elevation: 0, elevation: 0,
title: const Text( title: Text(
"Backup (Experimental)", "backup_controller_page_backup".t(),
), ),
leading: IconButton( leading: IconButton(
onPressed: () { onPressed: () {
ref.watch(websocketProvider.notifier).listenUploadEvent();
context.maybePop(true); context.maybePop(true);
}, },
splashRadius: 24, splashRadius: 24,
@ -85,18 +63,6 @@ class DriftBackupPage extends HookConsumerWidget {
Icons.arrow_back_ios_rounded, 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( body: Stack(
children: [ children: [
@ -110,11 +76,24 @@ class DriftBackupPage extends HookConsumerWidget {
children: [ children: [
const SizedBox(height: 8), const SizedBox(height: 8),
const _BackupAlbumSelectionCard(), const _BackupAlbumSelectionCard(),
if (selectedAlbum.isNotEmpty) ...[
const _TotalCard(), const _TotalCard(),
const _BackupCard(), const _BackupCard(),
const _RemainderCard(), const _RemainderCard(),
const Divider(), const Divider(),
buildControlButtons(), 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)),
),
],
], ],
), ),
), ),

View File

@ -1,25 +1,67 @@
import 'dart:io';
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.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/domain/models/album/local_album.model.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/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/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/providers/backup/backup_album.provider.dart';
import 'package:immich_mobile/services/app_settings.service.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/backup/drift_album_info_list_tile.dart';
import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart'; import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart';
import 'package:immich_mobile/widgets/common/search_field.dart';
@RoutePage() @RoutePage()
class DriftBackupAlbumSelectionPage extends HookConsumerWidget { class DriftBackupAlbumSelectionPage extends ConsumerStatefulWidget {
const DriftBackupAlbumSelectionPage({super.key}); const DriftBackupAlbumSelectionPage({super.key});
@override @override
Widget build(BuildContext context, WidgetRef ref) { ConsumerState<DriftBackupAlbumSelectionPage> createState() =>
_DriftBackupAlbumSelectionPageState();
}
class _DriftBackupAlbumSelectionPageState
extends ConsumerState<DriftBackupAlbumSelectionPage> {
String _searchQuery = '';
bool _isSearchMode = false;
late ValueNotifier<bool> _enableSyncUploadAlbum;
late TextEditingController _searchController;
late FocusNode _searchFocusNode;
@override
void initState() {
super.initState();
_enableSyncUploadAlbum = ValueNotifier<bool>(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 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 final selectedBackupAlbums = albums
.where((album) => album.backupSelection == BackupSelection.selected) .where((album) => album.backupSelection == BackupSelection.selected)
@ -27,133 +69,6 @@ class DriftBackupAlbumSelectionPage extends HookConsumerWidget {
final excludedBackupAlbums = albums final excludedBackupAlbums = albums
.where((album) => album.backupSelection == BackupSelection.excluded) .where((album) => album.backupSelection == BackupSelection.excluded)
.toList(); .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 { handleSyncAlbumToggle(bool isEnable) async {
if (isEnable) { if (isEnable) {
@ -170,13 +85,40 @@ class DriftBackupAlbumSelectionPage extends HookConsumerWidget {
onPressed: () => context.maybePop(), onPressed: () => context.maybePop(),
icon: const Icon(Icons.arrow_back_ios_rounded), icon: const Icon(Icons.arrow_back_ios_rounded),
), ),
title: const Text( 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", "backup_album_selection_page_select_albums",
).tr(), ).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, elevation: 0,
), ),
body: SafeArea( body: CustomScrollView(
child: CustomScrollView(
physics: const ClampingScrollPhysics(), physics: const ClampingScrollPhysics(),
slivers: [ slivers: [
SliverToBoxAdapter( SliverToBoxAdapter(
@ -191,7 +133,7 @@ class DriftBackupAlbumSelectionPage extends HookConsumerWidget {
child: Text( child: Text(
"backup_album_selection_page_selection_info", "backup_album_selection_page_selection_info",
style: context.textTheme.titleSmall, style: context.textTheme.titleSmall,
).tr(), ).t(context: context),
), ),
// Selected Album Chips // Selected Album Chips
@ -199,16 +141,21 @@ class DriftBackupAlbumSelectionPage extends HookConsumerWidget {
padding: const EdgeInsets.symmetric(horizontal: 16.0), padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Wrap( child: Wrap(
children: [ children: [
...buildSelectedAlbumNameChip(), _SelectedAlbumNameChips(
...buildExcludedAlbumNameChip(), selectedBackupAlbums: selectedBackupAlbums,
),
_ExcludedAlbumNameChips(
excludedBackupAlbums: excludedBackupAlbums,
),
], ],
), ),
), ),
SettingsSwitchListTile( SettingsSwitchListTile(
valueNotifier: enableSyncUploadAlbum, valueNotifier: _enableSyncUploadAlbum,
title: "sync_albums".tr(), title: "sync_albums".t(context: context),
subtitle: "sync_upload_album_setting_subtitle".tr(), subtitle:
"sync_upload_album_setting_subtitle".t(context: context),
contentPadding: const EdgeInsets.symmetric(horizontal: 16), contentPadding: const EdgeInsets.symmetric(horizontal: 16),
titleStyle: context.textTheme.bodyLarge?.copyWith( titleStyle: context.textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
@ -221,14 +168,9 @@ class DriftBackupAlbumSelectionPage extends HookConsumerWidget {
ListTile( ListTile(
title: Text( title: Text(
"backup_album_selection_page_albums_device".tr( "albums_on_device_count".t(
namedArgs: { context: context,
'count': ref args: {'count': albumCount.toString()},
.watch(backupProvider)
.availableAlbums
.length
.toString(),
},
), ),
style: context.textTheme.titleSmall, style: context.textTheme.titleSmall,
), ),
@ -239,7 +181,7 @@ class DriftBackupAlbumSelectionPage extends HookConsumerWidget {
style: context.textTheme.labelLarge?.copyWith( style: context.textTheme.labelLarge?.copyWith(
color: context.primaryColor, color: context.primaryColor,
), ),
).tr(), ).t(context: context),
), ),
trailing: IconButton( trailing: IconButton(
splashRadius: 16, splashRadius: 16,
@ -266,7 +208,7 @@ class DriftBackupAlbumSelectionPage extends HookConsumerWidget {
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: context.primaryColor, color: context.primaryColor,
), ),
).tr(), ).t(context: context),
content: SingleChildScrollView( content: SingleChildScrollView(
child: ListBody( child: ListBody(
children: [ children: [
@ -275,7 +217,7 @@ class DriftBackupAlbumSelectionPage extends HookConsumerWidget {
style: TextStyle( style: TextStyle(
fontSize: 14, fontSize: 14,
), ),
).tr(), ).t(context: context),
], ],
), ),
), ),
@ -286,21 +228,295 @@ class DriftBackupAlbumSelectionPage extends HookConsumerWidget {
), ),
), ),
// buildSearchBar(), if (Platform.isAndroid)
_SelectAllButton(
filteredAlbums: filteredAlbums,
selectedBackupAlbums: selectedBackupAlbums,
),
], ],
), ),
), ),
SliverLayoutBuilder( SliverLayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
if (constraints.crossAxisExtent > 600) { if (constraints.crossAxisExtent > 600) {
return buildAlbumSelectionGrid(); return _AlbumSelectionGrid(
filteredAlbums: filteredAlbums,
searchQuery: _searchQuery,
);
} else { } else {
return buildAlbumSelectionList(); return _AlbumSelectionList(
filteredAlbums: filteredAlbums,
searchQuery: _searchQuery,
);
} }
}, },
), ),
], ],
), ),
);
}
}
class _AlbumSelectionList extends StatelessWidget {
final List<LocalAlbum> 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<LocalAlbum> 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<LocalAlbum> 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<LocalAlbum> 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<LocalAlbum> filteredAlbums;
final List<LocalAlbum> 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),
),
),
),
],
), ),
); );
} }

View File

@ -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<String, DriftUploadStatus> 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<double>(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<void> _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<LocalAsset?>(
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<Widget> 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<LocalAsset?> _getAssetDetails(
WidgetRef ref,
String localAssetId,
) async {
try {
final repository = ref.read(localAssetRepository);
return await repository.getById(localAssetId);
} catch (e) {
return null;
}
}
}

View File

@ -3,7 +3,9 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.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/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/haptic_feedback.provider.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/search/search_input_focus.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/timeline/multiselect.provider.dart';
import 'package:immich_mobile/providers/websocket.provider.dart'; import 'package:immich_mobile/providers/websocket.provider.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/utils/migration.dart'; import 'package:immich_mobile/utils/migration.dart';
@RoutePage() @RoutePage()
@ -25,9 +28,19 @@ class _TabShellPageState extends ConsumerState<TabShellPage> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
WidgetsBinding.instance.addPostFrameCallback((_) async {
ref.read(websocketProvider.notifier).connect(); 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();
}
});
}); });
} }

View File

@ -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<BackupToggleButton> createState() => BackupToggleButtonState();
}
class BackupToggleButtonState extends ConsumerState<BackupToggleButton>
with SingleTickerProviderStateMixin {
late AnimationController _animationController;
late Animation<double> _gradientAnimation;
bool _isEnabled = false;
@override
void initState() {
super.initState();
_animationController = AnimationController(
duration: const Duration(seconds: 8),
vsync: this,
);
_gradientAnimation = Tween<double>(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<void> _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),
),
],
),
),
),
),
),
);
},
);
}
}

View File

@ -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/entities/store.entity.dart';
import 'package:immich_mobile/models/backup/backup_state.model.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/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/asset.provider.dart';
import 'package:immich_mobile/providers/auth.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/backup/backup.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/ios_background_settings.provider.dart';
import 'package:immich_mobile/providers/backup/manual_upload.provider.dart'; import 'package:immich_mobile/providers/backup/manual_upload.provider.dart';
import 'package:immich_mobile/providers/gallery_permission.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/server_info.provider.dart';
import 'package:immich_mobile/providers/tab.provider.dart'; import 'package:immich_mobile/providers/tab.provider.dart';
import 'package:immich_mobile/providers/websocket.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:immich_mobile/services/background.service.dart';
import 'package:isar/isar.dart'; import 'package:isar/isar.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
@ -69,25 +72,18 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
} }
await _ref.read(serverInfoProvider.notifier).getServerVersion(); 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) { if (!Store.isBetaTimelineEnabled) {
switch (_ref.read(tabProvider)) { switch (_ref.read(tabProvider)) {
case TabEnum.home: case TabEnum.home:
await _ref.read(assetProvider.notifier).getAllAsset(); await _ref.read(assetProvider.notifier).getAllAsset();
break;
case TabEnum.search:
// nothing to do
break;
case TabEnum.albums: case TabEnum.albums:
await _ref.read(albumProvider.notifier).refreshRemoteAlbums(); await _ref.read(albumProvider.notifier).refreshRemoteAlbums();
break;
case TabEnum.library: case TabEnum.library:
// nothing to do case TabEnum.search:
break; break;
} }
} else { } else {
@ -108,7 +104,15 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
}, },
), ),
backgroundManager.syncRemote(), backgroundManager.syncRemote(),
]); ]).then((_) async {
final isEnableBackup = _ref
.read(appSettingsServiceProvider)
.getSetting(AppSettingsEnum.enableBackup);
if (isEnableBackup) {
await _ref.read(driftBackupProvider.notifier).handleBackupResume();
}
});
} catch (e, stackTrace) { } catch (e, stackTrace) {
Logger("AppLifeCycleNotifier").severe( Logger("AppLifeCycleNotifier").severe(
"Error during background sync", "Error during background sync",

View File

@ -1,6 +1,7 @@
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.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/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'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
final backupAlbumProvider = final backupAlbumProvider =
@ -18,7 +19,8 @@ class BackupAlbumNotifier extends StateNotifier<List<LocalAlbum>> {
final LocalAlbumService _localAlbumService; final LocalAlbumService _localAlbumService;
Future<void> getAll() async { Future<void> getAll() async {
state = await _localAlbumService.getAll(); state =
await _localAlbumService.getAll(sortBy: {SortLocalAlbumsBy.assetCount});
} }
Future<void> selectAlbum(LocalAlbum album) async { Future<void> selectAlbum(LocalAlbum album) async {

View File

@ -1,39 +1,75 @@
// ignore_for_file: public_member_api_docs, sort_constructors_first
import 'dart:async'; import 'dart:async';
import 'dart:convert';
import 'package:background_downloader/background_downloader.dart'; import 'package:background_downloader/background_downloader.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/services/drift_backup.service.dart'; import 'package:immich_mobile/services/drift_backup.service.dart';
import 'package:immich_mobile/services/upload.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 { class DriftUploadStatus {
final String taskId; final String taskId;
final String filename; final String filename;
final double progress; final double progress;
final int fileSize;
final String networkSpeedAsString;
const DriftUploadStatus({ const DriftUploadStatus({
required this.taskId, required this.taskId,
required this.filename, required this.filename,
required this.progress, required this.progress,
required this.fileSize,
required this.networkSpeedAsString,
}); });
DriftUploadStatus copyWith({ DriftUploadStatus copyWith({
String? taskId, String? taskId,
String? filename, String? filename,
double? progress, double? progress,
int? fileSize,
String? networkSpeedAsString,
}) { }) {
return DriftUploadStatus( return DriftUploadStatus(
taskId: taskId ?? this.taskId, taskId: taskId ?? this.taskId,
filename: filename ?? this.filename, filename: filename ?? this.filename,
progress: progress ?? this.progress, progress: progress ?? this.progress,
fileSize: fileSize ?? this.fileSize,
networkSpeedAsString: networkSpeedAsString ?? this.networkSpeedAsString,
); );
} }
@override @override
String toString() => String toString() {
'ExpUploadStatus(taskId: $taskId, filename: $filename, progress: $progress)'; return 'DriftUploadStatus(taskId: $taskId, filename: $filename, progress: $progress, fileSize: $fileSize, networkSpeedAsString: $networkSpeedAsString)';
}
@override @override
bool operator ==(covariant DriftUploadStatus other) { bool operator ==(covariant DriftUploadStatus other) {
@ -41,23 +77,65 @@ class DriftUploadStatus {
return other.taskId == taskId && return other.taskId == taskId &&
other.filename == filename && other.filename == filename &&
other.progress == progress; other.progress == progress &&
other.fileSize == fileSize &&
other.networkSpeedAsString == networkSpeedAsString;
} }
@override @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<String, dynamic> toMap() {
return <String, dynamic>{
'taskId': taskId,
'filename': filename,
'progress': progress,
'fileSize': fileSize,
'networkSpeedAsString': networkSpeedAsString,
};
}
factory DriftUploadStatus.fromMap(Map<String, dynamic> 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<String, dynamic>);
} }
class DriftBackupState { class DriftBackupState {
final int totalCount; final int totalCount;
final int backupCount; final int backupCount;
final int remainderCount; final int remainderCount;
final int enqueueCount;
final int enqueueTotalCount;
final bool isCanceling;
final Map<String, DriftUploadStatus> uploadItems; final Map<String, DriftUploadStatus> uploadItems;
const DriftBackupState({ const DriftBackupState({
required this.totalCount, required this.totalCount,
required this.backupCount, required this.backupCount,
required this.remainderCount, required this.remainderCount,
required this.enqueueCount,
required this.enqueueTotalCount,
required this.isCanceling,
required this.uploadItems, required this.uploadItems,
}); });
@ -65,19 +143,25 @@ class DriftBackupState {
int? totalCount, int? totalCount,
int? backupCount, int? backupCount,
int? remainderCount, int? remainderCount,
int? enqueueCount,
int? enqueueTotalCount,
bool? isCanceling,
Map<String, DriftUploadStatus>? uploadItems, Map<String, DriftUploadStatus>? uploadItems,
}) { }) {
return DriftBackupState( return DriftBackupState(
totalCount: totalCount ?? this.totalCount, totalCount: totalCount ?? this.totalCount,
backupCount: backupCount ?? this.backupCount, backupCount: backupCount ?? this.backupCount,
remainderCount: remainderCount ?? this.remainderCount, remainderCount: remainderCount ?? this.remainderCount,
enqueueCount: enqueueCount ?? this.enqueueCount,
enqueueTotalCount: enqueueTotalCount ?? this.enqueueTotalCount,
isCanceling: isCanceling ?? this.isCanceling,
uploadItems: uploadItems ?? this.uploadItems, uploadItems: uploadItems ?? this.uploadItems,
); );
} }
@override @override
String toString() { 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 @override
@ -88,6 +172,9 @@ class DriftBackupState {
return other.totalCount == totalCount && return other.totalCount == totalCount &&
other.backupCount == backupCount && other.backupCount == backupCount &&
other.remainderCount == remainderCount && other.remainderCount == remainderCount &&
other.enqueueCount == enqueueCount &&
other.enqueueTotalCount == enqueueTotalCount &&
other.isCanceling == isCanceling &&
mapEquals(other.uploadItems, uploadItems); mapEquals(other.uploadItems, uploadItems);
} }
@ -96,6 +183,9 @@ class DriftBackupState {
return totalCount.hashCode ^ return totalCount.hashCode ^
backupCount.hashCode ^ backupCount.hashCode ^
remainderCount.hashCode ^ remainderCount.hashCode ^
enqueueCount.hashCode ^
enqueueTotalCount.hashCode ^
isCanceling.hashCode ^
uploadItems.hashCode; uploadItems.hashCode;
} }
} }
@ -117,6 +207,9 @@ class ExpBackupNotifier extends StateNotifier<DriftBackupState> {
totalCount: 0, totalCount: 0,
backupCount: 0, backupCount: 0,
remainderCount: 0, remainderCount: 0,
enqueueCount: 0,
enqueueTotalCount: 0,
isCanceling: false,
uploadItems: {}, uploadItems: {},
), ),
) { ) {
@ -131,13 +224,39 @@ class ExpBackupNotifier extends StateNotifier<DriftBackupState> {
StreamSubscription<TaskStatusUpdate>? _statusSubscription; StreamSubscription<TaskStatusUpdate>? _statusSubscription;
StreamSubscription<TaskProgressUpdate>? _progressSubscription; StreamSubscription<TaskProgressUpdate>? _progressSubscription;
/// Remove upload item from state
void _removeUploadItem(String taskId) {
if (state.uploadItems.containsKey(taskId)) {
final updatedItems =
Map<String, DriftUploadStatus>.from(state.uploadItems);
updatedItems.remove(taskId);
state = state.copyWith(uploadItems: updatedItems);
}
}
void _handleTaskStatusUpdate(TaskStatusUpdate update) { void _handleTaskStatusUpdate(TaskStatusUpdate update) {
switch (update.status) { switch (update.status) {
case TaskStatus.complete: case TaskStatus.complete:
if (update.task.group == kBackupGroup) {
state = state.copyWith( state = state.copyWith(
backupCount: state.backupCount + 1, backupCount: state.backupCount + 1,
remainderCount: state.remainderCount - 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; break;
default: default:
@ -145,7 +264,48 @@ class ExpBackupNotifier extends StateNotifier<DriftBackupState> {
} }
} }
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<void> getBackupStatus() async { Future<void> getBackupStatus() async {
final [totalCount, backupCount, remainderCount] = await Future.wait([ final [totalCount, backupCount, remainderCount] = await Future.wait([
@ -162,27 +322,50 @@ class ExpBackupNotifier extends StateNotifier<DriftBackupState> {
} }
Future<void> backup() { Future<void> backup() {
return _backupService.backup(); return _backupService.backup(_updateEnqueueCount);
}
void _updateEnqueueCount(EnqueueStatus status) {
state = state.copyWith(
enqueueCount: status.enqueueCount,
enqueueTotalCount: status.totalCount,
);
} }
Future<void> cancel() async { Future<void> cancel() async {
state = state.copyWith(
enqueueCount: 0,
enqueueTotalCount: 0,
isCanceling: true,
);
await _backupService.cancel(); 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<void> getDataInfo() async { Future<void> handleBackupResume() async {
final a = await FileDownloader().database.allRecordsWithStatus( final tasks = await FileDownloader().allTasks(group: kBackupGroup);
TaskStatus.enqueued, if (tasks.isEmpty) {
group: kBackupGroup, // Start a new backup queue
); await backup();
}
final b = await FileDownloader().allTasks( debugPrint("Tasks to resume: ${tasks.length}");
group: kBackupGroup, await FileDownloader().start();
);
debugPrint(
"Enqueued tasks: ${a.length}, All tasks: ${b.length}",
);
} }
@override @override

View File

@ -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/asset.provider.dart';
import 'package:immich_mobile/providers/auth.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/background_sync.provider.dart';
import 'package:immich_mobile/providers/db.provider.dart'; import 'package:immich_mobile/providers/db.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/api.service.dart';
@ -177,9 +178,15 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
}); });
if (!Store.isBetaTimelineEnabled) { 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 { } else {
startListeningToBetaEvents(); socket.on('AssetUploadReadyV1', _handleSyncAssetUploadReady);
} }
socket.on('on_config_update', _handleOnConfigUpdate); socket.on('on_config_update', _handleOnConfigUpdate);
@ -207,7 +214,6 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
} }
void stopListenToEvent(String eventName) { void stopListenToEvent(String eventName) {
debugPrint("Stop listening to event $eventName");
state.socket?.off(eventName); state.socket?.off(eventName);
} }

View File

@ -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_album_selection.page.dart';
import 'package:immich_mobile/pages/backup/backup_controller.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/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/backup/failed_backup_status.page.dart';
import 'package:immich_mobile/pages/common/activities.page.dart'; import 'package:immich_mobile/pages/common/activities.page.dart';
import 'package:immich_mobile/pages/common/app_log.page.dart'; import 'package:immich_mobile/pages/common/app_log.page.dart';
@ -484,6 +485,10 @@ class AppRouter extends RootStackRouter {
page: ChangeExperienceRoute.page, page: ChangeExperienceRoute.page,
guards: [_authGuard, _duplicateGuard], guards: [_authGuard, _duplicateGuard],
), ),
AutoRoute(
page: DriftUploadDetailRoute.page,
guards: [_authGuard, _duplicateGuard],
),
// required to handle all deeplinks in deep_link.service.dart // required to handle all deeplinks in deep_link.service.dart
// auto_route_library#1722 // auto_route_library#1722
RedirectRoute(path: '*', redirectTo: '/'), RedirectRoute(path: '*', redirectTo: '/'),

View File

@ -1078,6 +1078,22 @@ class DriftTrashRoute extends PageRouteInfo<void> {
); );
} }
/// generated route for
/// [DriftUploadDetailPage]
class DriftUploadDetailRoute extends PageRouteInfo<void> {
const DriftUploadDetailRoute({List<PageRouteInfo>? children})
: super(DriftUploadDetailRoute.name, initialChildren: children);
static const String name = 'DriftUploadDetailRoute';
static PageInfo page = PageInfo(
name,
builder: (data) {
return const DriftUploadDetailPage();
},
);
}
/// generated route for /// generated route for
/// [DriftUserSelectionPage] /// [DriftUserSelectionPage]
class DriftUserSelectionRoute class DriftUserSelectionRoute

View File

@ -91,6 +91,7 @@ enum AppSettingsEnum<T> {
true, true,
), ),
betaTimeline<bool>(StoreKey.betaTimeline, null, false), betaTimeline<bool>(StoreKey.betaTimeline, null, false),
enableBackup<bool>(StoreKey.enableBackup, null, false),
; ;
const AppSettingsEnum(this.storeKey, this.hiveKey, this.defaultValue); const AppSettingsEnum(this.storeKey, this.hiveKey, this.defaultValue);

View File

@ -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/backup.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.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/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/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/storage.provider.dart'; import 'package:immich_mobile/providers/infrastructure/storage.provider.dart';
import 'package:immich_mobile/services/upload.service.dart'; import 'package:immich_mobile/services/upload.service.dart';
@ -55,7 +56,9 @@ class DriftBackupService {
return _backupRepository.getBackupCount(); return _backupRepository.getBackupCount();
} }
Future<void> backup() async { Future<void> backup(
void Function(EnqueueStatus status) onEnqueueTasks,
) async {
shouldCancel = false; shouldCancel = false;
final candidates = await _backupRepository.getCandidates(); final candidates = await _backupRepository.getCandidates();
@ -83,8 +86,12 @@ class DriftBackupService {
if (tasks.isNotEmpty && !shouldCancel) { if (tasks.isNotEmpty && !shouldCancel) {
count += tasks.length; count += tasks.length;
_uploadService.enqueueTasks(tasks); _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, deviceAssetId: asset.id,
fields: fields, fields: fields,
group: kBackupLivePhotoGroup, group: kBackupLivePhotoGroup,
priority: 0, priority: 0, // Highest priority to get upload immediately
); );
} }

View File

@ -134,6 +134,7 @@ class UploadService {
group: group, group: group,
priority: priority ?? 5, priority: priority ?? 5,
updates: Updates.statusAndProgress, updates: Updates.statusAndProgress,
retries: 3,
); );
} }
} }

View File

@ -1,5 +1,6 @@
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_svg/svg.dart'; import 'package:flutter_svg/svg.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
@ -83,6 +84,11 @@ class ImmichSliverAppBar extends ConsumerWidget {
child: action, child: action,
), ),
), ),
if (kDebugMode || kProfileMode)
IconButton(
icon: const Icon(Icons.science_rounded),
onPressed: () => context.pushRoute(const FeatInDevRoute()),
),
if (showUploadButton) if (showUploadButton)
const Padding( const Padding(
padding: EdgeInsets.only(right: 20), padding: EdgeInsets.only(right: 20),

View File

@ -90,6 +90,19 @@ class _BetaTimelineListTileState extends ConsumerState<BetaTimelineListTile>
), ),
actions: [ actions: [
TextButton( TextButton(
onPressed: () {
context.pop();
},
child: Text(
"cancel".t(context: context),
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
color: context.colorScheme.outline,
),
),
),
ElevatedButton(
onPressed: () async { onPressed: () async {
Navigator.of(context).pop(); Navigator.of(context).pop();
await ref.read(appSettingsServiceProvider).setSetting( await ref.read(appSettingsServiceProvider).setSetting(
@ -101,25 +114,7 @@ class _BetaTimelineListTileState extends ConsumerState<BetaTimelineListTile>
); );
}, },
child: Text( child: Text(
"YES", "ok".t(context: context),
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,
),
), ),
), ),
], ],
@ -135,13 +130,13 @@ class _BetaTimelineListTileState extends ConsumerState<BetaTimelineListTile>
_gradientAnimation.value, _gradientAnimation.value,
)!, )!,
Color.lerp( Color.lerp(
context.primaryColor.withValues(alpha: 0.4), context.logoPink.withValues(alpha: 0.2),
context.primaryColor.withValues(alpha: 0.6), context.logoPink.withValues(alpha: 0.4),
_gradientAnimation.value, _gradientAnimation.value,
)!, )!,
Color.lerp( Color.lerp(
context.primaryColor.withValues(alpha: 0.3), context.logoRed.withValues(alpha: 0.3),
context.primaryColor.withValues(alpha: 0.5), context.logoRed.withValues(alpha: 0.5),
_gradientAnimation.value, _gradientAnimation.value,
)!, )!,
]; ];
@ -155,7 +150,7 @@ class _BetaTimelineListTileState extends ConsumerState<BetaTimelineListTile>
stops: const [0.0, 0.5, 1.0], stops: const [0.0, 0.5, 1.0],
begin: Alignment.topLeft, begin: Alignment.topLeft,
end: Alignment.bottomRight, end: Alignment.bottomRight,
transform: GradientRotation(_rotationAnimation.value * 0.1), transform: GradientRotation(_rotationAnimation.value * 0.5),
), ),
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
@ -166,13 +161,12 @@ class _BetaTimelineListTileState extends ConsumerState<BetaTimelineListTile>
], ],
), ),
child: Container( child: Container(
margin: const EdgeInsets.all(1.5), margin: const EdgeInsets.all(2),
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(10.5)), borderRadius: const BorderRadius.all(Radius.circular(10.5)),
color: context.scaffoldBackgroundColor, color: context.scaffoldBackgroundColor,
), ),
child: Material( child: Material(
color: Colors.transparent,
borderRadius: const BorderRadius.all(Radius.circular(10.5)), borderRadius: const BorderRadius.all(Radius.circular(10.5)),
child: InkWell( child: InkWell(
borderRadius: const BorderRadius.all(Radius.circular(10.5)), borderRadius: const BorderRadius.all(Radius.circular(10.5)),
@ -205,7 +199,7 @@ class _BetaTimelineListTileState extends ConsumerState<BetaTimelineListTile>
), ),
), ),
), ),
const SizedBox(width: 16), const SizedBox(width: 28),
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@ -259,8 +253,9 @@ class _BetaTimelineListTileState extends ConsumerState<BetaTimelineListTile>
.t(context: context), .t(context: context),
style: context.textTheme.labelLarge?.copyWith( style: context.textTheme.labelLarge?.copyWith(
color: context.textTheme.labelLarge?.color color: context.textTheme.labelLarge?.color
?.withValues(alpha: 0.7), ?.withValues(alpha: 0.9),
), ),
maxLines: 2,
), ),
], ],
), ),