diff --git a/mobile/lib/presentation/pages/download_info.page.dart b/mobile/lib/presentation/pages/download_info.page.dart new file mode 100644 index 0000000000..e805458e76 --- /dev/null +++ b/mobile/lib/presentation/pages/download_info.page.dart @@ -0,0 +1,57 @@ +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/pages/common/download_panel.dart'; +import 'package:immich_mobile/providers/asset_viewer/download.provider.dart'; + +@RoutePage() +class DownloadInfoPage extends ConsumerWidget { + const DownloadInfoPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final tasks = ref.watch(downloadStateProvider.select((state) => state.taskProgress)).entries.toList(); + + onCancelDownload(String id) { + ref.watch(downloadStateProvider.notifier).cancelDownload(id); + } + + return Scaffold( + appBar: AppBar( + title: Text("download".t(context: context)), + actions: [], + ), + body: ListView.builder( + physics: const ClampingScrollPhysics(), + shrinkWrap: true, + itemCount: tasks.length, + itemBuilder: (context, index) { + final task = tasks[index]; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0), + child: DownloadTaskTile( + progress: task.value.progress, + fileName: task.value.fileName, + status: task.value.status, + onCancelDownload: () => onCancelDownload(task.key), + ), + ); + }, + ), + persistentFooterButtons: [ + OutlinedButton( + onPressed: () { + tasks.map((e) => e.key).forEach(onCancelDownload); + }, + style: OutlinedButton.styleFrom(side: BorderSide(color: context.colorScheme.primary)), + child: Text( + 'clear_all'.t(context: context), + style: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.primary), + ), + ), + ], + ); + } +} diff --git a/mobile/lib/presentation/widgets/action_buttons/download_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/download_action_button.widget.dart index 7c0db5ed9a..cb898f069a 100644 --- a/mobile/lib/presentation/widgets/action_buttons/download_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/download_action_button.widget.dart @@ -1,54 +1,45 @@ -import 'package:fluttertoast/fluttertoast.dart'; import 'package:immich_mobile/constants/enums.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/utils/background_sync.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; +import 'package:immich_mobile/providers/background_sync.provider.dart'; import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; -import 'package:immich_mobile/widgets/common/immich_toast.dart'; class DownloadActionButton extends ConsumerWidget { final ActionSource source; + final bool menuItem; + const DownloadActionButton({super.key, required this.source, this.menuItem = false}); - const DownloadActionButton({super.key, required this.source}); - - void _onTap(BuildContext context, WidgetRef ref) async { + void _onTap(BuildContext context, WidgetRef ref, BackgroundSyncManager backgroundSyncManager) async { if (!context.mounted) { return; } - final result = await ref.read(actionProvider.notifier).downloadAll(source); - ref.read(multiSelectProvider.notifier).reset(); + try { + await ref.read(actionProvider.notifier).downloadAll(source); - if (!context.mounted) { - return; - } - - if (!result.success) { - ImmichToast.show( - context: context, - msg: 'scaffold_body_error_occurred'.t(context: context), - gravity: ToastGravity.BOTTOM, - toastType: ToastType.error, - ); - } else if (result.count > 0) { - ImmichToast.show( - context: context, - msg: 'download_action_prompt'.t(context: context, args: {'count': result.count.toString()}), - gravity: ToastGravity.BOTTOM, - toastType: ToastType.success, - ); + Future.delayed(const Duration(seconds: 1), () async { + await backgroundSyncManager.syncLocal(); + await backgroundSyncManager.hashAssets(); + }); + } finally { + ref.read(multiSelectProvider.notifier).reset(); } } @override Widget build(BuildContext context, WidgetRef ref) { + final backgroundManager = ref.watch(backgroundSyncProvider); + return BaseActionButton( iconData: Icons.download, maxWidth: 95, label: "download".t(context: context), - onPressed: () => _onTap(context, ref), + menuItem: menuItem, + onPressed: () => _onTap(context, ref, backgroundManager), ); } } diff --git a/mobile/lib/presentation/widgets/action_buttons/download_status_floating_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/download_status_floating_button.widget.dart new file mode 100644 index 0000000000..efa7f5c6d0 --- /dev/null +++ b/mobile/lib/presentation/widgets/action_buttons/download_status_floating_button.widget.dart @@ -0,0 +1,64 @@ +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/providers/asset_viewer/download.provider.dart'; +import 'package:immich_mobile/routing/router.dart'; + +class DownloadStatusFloatingButton extends ConsumerWidget { + const DownloadStatusFloatingButton({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final shouldShow = ref.watch(downloadStateProvider.select((state) => state.showProgress)); + final itemCount = ref.watch(downloadStateProvider.select((state) => state.taskProgress.length)); + final isDownloading = ref + .watch(downloadStateProvider.select((state) => state.taskProgress)) + .values + .where((element) => element.progress != 1) + .isNotEmpty; + + return shouldShow + ? Badge.count( + count: itemCount, + textColor: context.colorScheme.onPrimary, + backgroundColor: context.colorScheme.primary, + child: FloatingActionButton( + shape: RoundedRectangleBorder( + borderRadius: const BorderRadius.all(Radius.circular(20)), + side: BorderSide(color: context.colorScheme.outlineVariant, width: 1), + ), + backgroundColor: context.isDarkTheme + ? context.colorScheme.surfaceContainer + : context.colorScheme.surfaceBright, + elevation: 2, + onPressed: () { + context.pushRoute(const DownloadInfoRoute()); + }, + child: Stack( + alignment: AlignmentDirectional.center, + children: [ + isDownloading + ? Icon(Icons.downloading_rounded, color: context.colorScheme.primary, size: 28) + : Icon( + Icons.download_done, + color: context.isDarkTheme ? Colors.green[200] : Colors.green[400], + size: 28, + ), + if (isDownloading) + const SizedBox( + height: 31, + width: 31, + child: CircularProgressIndicator( + strokeWidth: 2, + backgroundColor: Colors.transparent, + value: null, // Indeterminate progress + ), + ), + ], + ), + ), + ) + : const SizedBox.shrink(); + } +} diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart index 170cb3fdd9..7ac8cf34d5 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart @@ -12,6 +12,7 @@ import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/platform_extensions.dart'; import 'package:immich_mobile/extensions/scroll_extensions.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/download_status_floating_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.provider.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; @@ -649,20 +650,25 @@ class _AssetViewerState extends ConsumerState { appBar: const ViewerTopAppBar(), extendBody: true, extendBodyBehindAppBar: true, - body: PhotoViewGallery.builder( - gaplessPlayback: true, - loadingBuilder: _placeholderBuilder, - pageController: pageController, - scrollPhysics: CurrentPlatform.isIOS - ? const FastScrollPhysics() // Use bouncing physics for iOS - : const FastClampingScrollPhysics(), // Use heavy physics for Android - itemCount: totalAssets, - onPageChanged: _onPageChanged, - onPageBuild: _onPageBuild, - scaleStateChangedCallback: _onScaleStateChanged, - builder: _assetBuilder, - backgroundDecoration: BoxDecoration(color: backgroundColor), - enablePanAlways: true, + floatingActionButton: const DownloadStatusFloatingButton(), + body: Stack( + children: [ + PhotoViewGallery.builder( + gaplessPlayback: true, + loadingBuilder: _placeholderBuilder, + pageController: pageController, + scrollPhysics: CurrentPlatform.isIOS + ? const FastScrollPhysics() // Use bouncing physics for iOS + : const FastClampingScrollPhysics(), // Use heavy physics for Android + itemCount: totalAssets, + onPageChanged: _onPageChanged, + onPageBuild: _onPageBuild, + scaleStateChangedCallback: _onScaleStateChanged, + builder: _assetBuilder, + backgroundDecoration: BoxDecoration(color: backgroundColor), + enablePanAlways: true, + ), + ], ), bottomNavigationBar: showingBottomSheet ? const SizedBox.shrink() diff --git a/mobile/lib/presentation/widgets/asset_viewer/top_app_bar.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/top_app_bar.widget.dart index 05492f17e4..0159e04c4e 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/top_app_bar.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/top_app_bar.widget.dart @@ -8,6 +8,7 @@ import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/cast_action_button.widget.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/favorite_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/motion_photo_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/unfavorite_action_button.widget.dart'; @@ -56,6 +57,7 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget { final isCasting = ref.watch(castProvider.select((c) => c.isCasting)); final actions = [ + if (asset.hasRemote) const DownloadActionButton(source: ActionSource.viewer, menuItem: true), if (isCasting || (asset.hasRemote)) const CastActionButton(menuItem: true), if (album != null && album.isActivityEnabled && album.isShared) IconButton( diff --git a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart index 7ac9a4aaee..98831403f6 100644 --- a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart +++ b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart @@ -14,6 +14,7 @@ import 'package:immich_mobile/domain/models/timeline.model.dart'; import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/extensions/asyncvalue_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/download_status_floating_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart'; import 'package:immich_mobile/presentation/widgets/timeline/scrubber.widget.dart'; import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart'; @@ -55,6 +56,7 @@ class Timeline extends StatelessWidget { Widget build(BuildContext context) { return Scaffold( resizeToAvoidBottomInset: false, + floatingActionButton: const DownloadStatusFloatingButton(), body: LayoutBuilder( builder: (_, constraints) => ProviderScope( overrides: [ diff --git a/mobile/lib/providers/infrastructure/action.provider.dart b/mobile/lib/providers/infrastructure/action.provider.dart index 03e2dfc6d5..f62791fb57 100644 --- a/mobile/lib/providers/infrastructure/action.provider.dart +++ b/mobile/lib/providers/infrastructure/action.provider.dart @@ -356,7 +356,6 @@ class ActionNotifier extends Notifier { Future downloadAll(ActionSource source) async { final assets = _getAssets(source).whereType().toList(growable: false); - try { final didEnqueue = await _service.downloadAll(assets); final enqueueCount = didEnqueue.where((e) => e).length; diff --git a/mobile/lib/repositories/download.repository.dart b/mobile/lib/repositories/download.repository.dart index c4b31e9d93..1ac9410fc6 100644 --- a/mobile/lib/repositories/download.repository.dart +++ b/mobile/lib/repositories/download.repository.dart @@ -90,7 +90,11 @@ class DownloadRepository { final isVideo = asset.isVideo; final url = getOriginalUrlForRemoteId(id); - if (Platform.isAndroid || livePhotoVideoId == null || isVideo) { + // on iOS it cannot link the image, check if the filename has .MP extension + // to avoid downloading the video part + final isAndroidMotionPhoto = asset.name.contains(".MP"); + + if (Platform.isAndroid || livePhotoVideoId == null || isVideo || isAndroidMotionPhoto) { tasks[taskIndex++] = DownloadTask( taskId: id, url: url, diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart index cdf384fcf8..7554c7b1cf 100644 --- a/mobile/lib/routing/router.dart +++ b/mobile/lib/routing/router.dart @@ -81,6 +81,7 @@ import 'package:immich_mobile/pages/share_intent/share_intent.page.dart'; import 'package:immich_mobile/presentation/pages/dev/feat_in_development.page.dart'; import 'package:immich_mobile/presentation/pages/dev/main_timeline.page.dart'; import 'package:immich_mobile/presentation/pages/dev/media_stat.page.dart'; +import 'package:immich_mobile/presentation/pages/download_info.page.dart'; import 'package:immich_mobile/presentation/pages/drift_activities.page.dart'; import 'package:immich_mobile/presentation/pages/drift_album.page.dart'; import 'package:immich_mobile/presentation/pages/drift_album_options.page.dart'; @@ -345,6 +346,7 @@ class AppRouter extends RootStackRouter { AutoRoute(page: DriftActivitiesRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: DriftBackupAssetDetailRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: AssetTroubleshootRoute.page, guards: [_authGuard, _duplicateGuard]), + AutoRoute(page: DownloadInfoRoute.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 981828acf1..4e488a30c7 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -688,6 +688,22 @@ class CropImageRouteArgs { } } +/// generated route for +/// [DownloadInfoPage] +class DownloadInfoRoute extends PageRouteInfo { + const DownloadInfoRoute({List? children}) + : super(DownloadInfoRoute.name, initialChildren: children); + + static const String name = 'DownloadInfoRoute'; + + static PageInfo page = PageInfo( + name, + builder: (data) { + return const DownloadInfoPage(); + }, + ); +} + /// generated route for /// [DriftActivitiesPage] class DriftActivitiesRoute extends PageRouteInfo {