mirror of
https://github.com/immich-app/immich
synced 2025-10-17 18:19:27 +00:00
fix: download feedback (#22178)
* fix: download feedback * chore: use FAB for asset viewer as well
This commit is contained in:
parent
642065f506
commit
33d76fb386
10 changed files with 185 additions and 42 deletions
57
mobile/lib/presentation/pages/download_info.page.dart
Normal file
57
mobile/lib/presentation/pages/download_info.page.dart
Normal file
|
|
@ -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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,54 +1,45 @@
|
||||||
import 'package:fluttertoast/fluttertoast.dart';
|
|
||||||
import 'package:immich_mobile/constants/enums.dart';
|
import 'package:immich_mobile/constants/enums.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/domain/utils/background_sync.dart';
|
||||||
import 'package:immich_mobile/extensions/translate_extensions.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/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/infrastructure/action.provider.dart';
|
||||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
|
||||||
|
|
||||||
class DownloadActionButton extends ConsumerWidget {
|
class DownloadActionButton extends ConsumerWidget {
|
||||||
final ActionSource source;
|
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, BackgroundSyncManager backgroundSyncManager) async {
|
||||||
|
|
||||||
void _onTap(BuildContext context, WidgetRef ref) async {
|
|
||||||
if (!context.mounted) {
|
if (!context.mounted) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final result = await ref.read(actionProvider.notifier).downloadAll(source);
|
try {
|
||||||
ref.read(multiSelectProvider.notifier).reset();
|
await ref.read(actionProvider.notifier).downloadAll(source);
|
||||||
|
|
||||||
if (!context.mounted) {
|
Future.delayed(const Duration(seconds: 1), () async {
|
||||||
return;
|
await backgroundSyncManager.syncLocal();
|
||||||
}
|
await backgroundSyncManager.hashAssets();
|
||||||
|
});
|
||||||
if (!result.success) {
|
} finally {
|
||||||
ImmichToast.show(
|
ref.read(multiSelectProvider.notifier).reset();
|
||||||
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,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final backgroundManager = ref.watch(backgroundSyncProvider);
|
||||||
|
|
||||||
return BaseActionButton(
|
return BaseActionButton(
|
||||||
iconData: Icons.download,
|
iconData: Icons.download,
|
||||||
maxWidth: 95,
|
maxWidth: 95,
|
||||||
label: "download".t(context: context),
|
label: "download".t(context: context),
|
||||||
onPressed: () => _onTap(context, ref),
|
menuItem: menuItem,
|
||||||
|
onPressed: () => _onTap(context, ref, backgroundManager),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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/build_context_extensions.dart';
|
||||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||||
import 'package:immich_mobile/extensions/scroll_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.provider.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.widget.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';
|
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
||||||
|
|
@ -649,20 +650,25 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||||
appBar: const ViewerTopAppBar(),
|
appBar: const ViewerTopAppBar(),
|
||||||
extendBody: true,
|
extendBody: true,
|
||||||
extendBodyBehindAppBar: true,
|
extendBodyBehindAppBar: true,
|
||||||
body: PhotoViewGallery.builder(
|
floatingActionButton: const DownloadStatusFloatingButton(),
|
||||||
gaplessPlayback: true,
|
body: Stack(
|
||||||
loadingBuilder: _placeholderBuilder,
|
children: [
|
||||||
pageController: pageController,
|
PhotoViewGallery.builder(
|
||||||
scrollPhysics: CurrentPlatform.isIOS
|
gaplessPlayback: true,
|
||||||
? const FastScrollPhysics() // Use bouncing physics for iOS
|
loadingBuilder: _placeholderBuilder,
|
||||||
: const FastClampingScrollPhysics(), // Use heavy physics for Android
|
pageController: pageController,
|
||||||
itemCount: totalAssets,
|
scrollPhysics: CurrentPlatform.isIOS
|
||||||
onPageChanged: _onPageChanged,
|
? const FastScrollPhysics() // Use bouncing physics for iOS
|
||||||
onPageBuild: _onPageBuild,
|
: const FastClampingScrollPhysics(), // Use heavy physics for Android
|
||||||
scaleStateChangedCallback: _onScaleStateChanged,
|
itemCount: totalAssets,
|
||||||
builder: _assetBuilder,
|
onPageChanged: _onPageChanged,
|
||||||
backgroundDecoration: BoxDecoration(color: backgroundColor),
|
onPageBuild: _onPageBuild,
|
||||||
enablePanAlways: true,
|
scaleStateChangedCallback: _onScaleStateChanged,
|
||||||
|
builder: _assetBuilder,
|
||||||
|
backgroundDecoration: BoxDecoration(color: backgroundColor),
|
||||||
|
enablePanAlways: true,
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
bottomNavigationBar: showingBottomSheet
|
bottomNavigationBar: showingBottomSheet
|
||||||
? const SizedBox.shrink()
|
? const SizedBox.shrink()
|
||||||
|
|
|
||||||
|
|
@ -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/build_context_extensions.dart';
|
||||||
import 'package:immich_mobile/extensions/translate_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/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/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/motion_photo_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/unfavorite_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 isCasting = ref.watch(castProvider.select((c) => c.isCasting));
|
||||||
|
|
||||||
final actions = <Widget>[
|
final actions = <Widget>[
|
||||||
|
if (asset.hasRemote) const DownloadActionButton(source: ActionSource.viewer, menuItem: true),
|
||||||
if (isCasting || (asset.hasRemote)) const CastActionButton(menuItem: true),
|
if (isCasting || (asset.hasRemote)) const CastActionButton(menuItem: true),
|
||||||
if (album != null && album.isActivityEnabled && album.isShared)
|
if (album != null && album.isActivityEnabled && album.isShared)
|
||||||
IconButton(
|
IconButton(
|
||||||
|
|
|
||||||
|
|
@ -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/domain/utils/event_stream.dart';
|
||||||
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
|
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
|
||||||
import 'package:immich_mobile/extensions/build_context_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/bottom_sheet/general_bottom_sheet.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/timeline/scrubber.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/timeline/scrubber.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart';
|
import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart';
|
||||||
|
|
@ -55,6 +56,7 @@ class Timeline extends StatelessWidget {
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
resizeToAvoidBottomInset: false,
|
resizeToAvoidBottomInset: false,
|
||||||
|
floatingActionButton: const DownloadStatusFloatingButton(),
|
||||||
body: LayoutBuilder(
|
body: LayoutBuilder(
|
||||||
builder: (_, constraints) => ProviderScope(
|
builder: (_, constraints) => ProviderScope(
|
||||||
overrides: [
|
overrides: [
|
||||||
|
|
|
||||||
|
|
@ -356,7 +356,6 @@ class ActionNotifier extends Notifier<void> {
|
||||||
|
|
||||||
Future<ActionResult> downloadAll(ActionSource source) async {
|
Future<ActionResult> downloadAll(ActionSource source) async {
|
||||||
final assets = _getAssets(source).whereType<RemoteAsset>().toList(growable: false);
|
final assets = _getAssets(source).whereType<RemoteAsset>().toList(growable: false);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final didEnqueue = await _service.downloadAll(assets);
|
final didEnqueue = await _service.downloadAll(assets);
|
||||||
final enqueueCount = didEnqueue.where((e) => e).length;
|
final enqueueCount = didEnqueue.where((e) => e).length;
|
||||||
|
|
|
||||||
|
|
@ -90,7 +90,11 @@ class DownloadRepository {
|
||||||
final isVideo = asset.isVideo;
|
final isVideo = asset.isVideo;
|
||||||
final url = getOriginalUrlForRemoteId(id);
|
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(
|
tasks[taskIndex++] = DownloadTask(
|
||||||
taskId: id,
|
taskId: id,
|
||||||
url: url,
|
url: url,
|
||||||
|
|
|
||||||
|
|
@ -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/feat_in_development.page.dart';
|
||||||
import 'package:immich_mobile/presentation/pages/dev/main_timeline.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/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_activities.page.dart';
|
||||||
import 'package:immich_mobile/presentation/pages/drift_album.page.dart';
|
import 'package:immich_mobile/presentation/pages/drift_album.page.dart';
|
||||||
import 'package:immich_mobile/presentation/pages/drift_album_options.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: DriftActivitiesRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||||
AutoRoute(page: DriftBackupAssetDetailRoute.page, guards: [_authGuard, _duplicateGuard]),
|
AutoRoute(page: DriftBackupAssetDetailRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||||
AutoRoute(page: AssetTroubleshootRoute.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
|
// required to handle all deeplinks in deep_link.service.dart
|
||||||
// auto_route_library#1722
|
// auto_route_library#1722
|
||||||
RedirectRoute(path: '*', redirectTo: '/'),
|
RedirectRoute(path: '*', redirectTo: '/'),
|
||||||
|
|
|
||||||
|
|
@ -688,6 +688,22 @@ class CropImageRouteArgs {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// generated route for
|
||||||
|
/// [DownloadInfoPage]
|
||||||
|
class DownloadInfoRoute extends PageRouteInfo<void> {
|
||||||
|
const DownloadInfoRoute({List<PageRouteInfo>? children})
|
||||||
|
: super(DownloadInfoRoute.name, initialChildren: children);
|
||||||
|
|
||||||
|
static const String name = 'DownloadInfoRoute';
|
||||||
|
|
||||||
|
static PageInfo page = PageInfo(
|
||||||
|
name,
|
||||||
|
builder: (data) {
|
||||||
|
return const DownloadInfoPage();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// generated route for
|
/// generated route for
|
||||||
/// [DriftActivitiesPage]
|
/// [DriftActivitiesPage]
|
||||||
class DriftActivitiesRoute extends PageRouteInfo<void> {
|
class DriftActivitiesRoute extends PageRouteInfo<void> {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue