diff --git a/mobile/lib/domain/models/asset/base_asset.model.dart b/mobile/lib/domain/models/asset/base_asset.model.dart index dd57456c76..1226d1730f 100644 --- a/mobile/lib/domain/models/asset/base_asset.model.dart +++ b/mobile/lib/domain/models/asset/base_asset.model.dart @@ -1,5 +1,5 @@ -part 'remote_asset.model.dart'; part 'local_asset.model.dart'; +part 'remote_asset.model.dart'; enum AssetType { // do not change this order! @@ -48,6 +48,13 @@ sealed class BaseAsset { return null; } + bool get hasRemote => + storage == AssetState.remote || storage == AssetState.merged; + bool get hasLocal => + storage == AssetState.local || storage == AssetState.merged; + bool get isLocalOnly => storage == AssetState.local; + bool get isRemoteOnly => storage == AssetState.remote; + // Overridden in subclasses AssetState get storage; String get heroTag; diff --git a/mobile/lib/domain/models/asset/local_asset.model.dart b/mobile/lib/domain/models/asset/local_asset.model.dart index 8aab1e3431..30a4955fa8 100644 --- a/mobile/lib/domain/models/asset/local_asset.model.dart +++ b/mobile/lib/domain/models/asset/local_asset.model.dart @@ -25,8 +25,6 @@ class LocalAsset extends BaseAsset { @override String get heroTag => '${id}_${remoteId ?? checksum}'; - bool get hasRemote => remoteId != null; - @override String toString() { return '''LocalAsset { diff --git a/mobile/lib/domain/models/asset/remote_asset.model.dart b/mobile/lib/domain/models/asset/remote_asset.model.dart index 052e1d5eff..e5c1f8345a 100644 --- a/mobile/lib/domain/models/asset/remote_asset.model.dart +++ b/mobile/lib/domain/models/asset/remote_asset.model.dart @@ -39,8 +39,6 @@ class RemoteAsset extends BaseAsset { @override String get heroTag => '${localId ?? checksum}_$id'; - bool get hasLocal => localId != null; - @override String toString() { return '''Asset { diff --git a/mobile/lib/domain/models/setting.model.dart b/mobile/lib/domain/models/setting.model.dart index fe341dc028..a256ee3589 100644 --- a/mobile/lib/domain/models/setting.model.dart +++ b/mobile/lib/domain/models/setting.model.dart @@ -6,6 +6,7 @@ enum Setting { showStorageIndicator(StoreKey.storageIndicator, true), loadOriginal(StoreKey.loadOriginal, false), preferRemoteImage(StoreKey.preferRemoteImage, false), + advancedTroubleshooting(StoreKey.advancedTroubleshooting, false), ; const Setting(this.storeKey, this.defaultValue); diff --git a/mobile/lib/domain/services/timeline.service.dart b/mobile/lib/domain/services/timeline.service.dart index 54a9a3a142..618ccd250f 100644 --- a/mobile/lib/domain/services/timeline.service.dart +++ b/mobile/lib/domain/services/timeline.service.dart @@ -7,6 +7,7 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/setting.model.dart'; import 'package:immich_mobile/domain/models/timeline.model.dart'; import 'package:immich_mobile/domain/services/setting.service.dart'; +import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/infrastructure/repositories/timeline.repository.dart'; import 'package:immich_mobile/utils/async_mutex.dart'; @@ -68,7 +69,7 @@ class TimelineService { _bucketSubscription = _bucketSource().listen((buckets) { _totalAssets = buckets.fold(0, (acc, bucket) => acc + bucket.assetCount); - unawaited(reloadBucket()); + unawaited(_reloadBucket()); }); } @@ -79,8 +80,9 @@ class TimelineService { Stream> Function() get watchBuckets => _bucketSource; - Future reloadBucket() => _mutex.run(() async { + Future _reloadBucket() => _mutex.run(() async { _buffer = await _assetSource(_bufferOffset, _buffer.length); + EventStream.shared.emit(const TimelineReloadEvent()); }); Future> loadAssets(int index, int count) => diff --git a/mobile/lib/domain/utils/event_stream.dart b/mobile/lib/domain/utils/event_stream.dart new file mode 100644 index 0000000000..9acb3b4e39 --- /dev/null +++ b/mobile/lib/domain/utils/event_stream.dart @@ -0,0 +1,48 @@ +import 'dart:async'; + +sealed class Event { + const Event(); +} + +class TimelineReloadEvent extends Event { + const TimelineReloadEvent(); +} + +class EventStream { + EventStream._(); + + static final EventStream shared = EventStream._(); + + final StreamController _controller = + StreamController.broadcast(); + + void emit(Event event) { + _controller.add(event); + } + + Stream where() { + if (T == Event) { + return _controller.stream as Stream; + } + return _controller.stream.where((event) => event is T).cast(); + } + + StreamSubscription listen( + void Function(T event)? onData, { + Function? onError, + void Function()? onDone, + bool? cancelOnError, + }) { + return where().listen( + onData, + onError: onError, + onDone: onDone, + cancelOnError: cancelOnError, + ); + } + + /// Closes the stream controller + void dispose() { + _controller.close(); + } +} diff --git a/mobile/lib/pages/common/tab_shell.page.dart b/mobile/lib/pages/common/tab_shell.page.dart index 31ccb12392..452c153342 100644 --- a/mobile/lib/pages/common/tab_shell.page.dart +++ b/mobile/lib/pages/common/tab_shell.page.dart @@ -42,22 +42,6 @@ class TabShellPage extends ConsumerWidget { ); } - void onNavigationSelected(TabsRouter router, int index) { - // On Photos page menu tapped - if (router.activeIndex == 0 && index == 0) { - scrollToTopNotifierProvider.scrollToTop(); - } - - // On Search page tapped - if (router.activeIndex == 1 && index == 1) { - ref.read(searchInputFocusProvider).requestFocus(); - } - - ref.read(hapticFeedbackProvider.notifier).selectionClick(); - router.setActiveIndex(index); - ref.read(tabProvider.notifier).state = TabEnum.values[index]; - } - final navigationDestinations = [ NavigationDestination( label: 'photos'.tr(), @@ -110,15 +94,6 @@ class TabShellPage extends ConsumerWidget { ), ]; - Widget bottomNavigationBar(TabsRouter tabsRouter) { - return NavigationBar( - selectedIndex: tabsRouter.activeIndex, - onDestinationSelected: (index) => - onNavigationSelected(tabsRouter, index), - destinations: navigationDestinations, - ); - } - Widget navigationRail(TabsRouter tabsRouter) { return NavigationRail( destinations: navigationDestinations @@ -131,15 +106,13 @@ class TabShellPage extends ConsumerWidget { ) .toList(), onDestinationSelected: (index) => - onNavigationSelected(tabsRouter, index), + _onNavigationSelected(tabsRouter, index, ref), selectedIndex: tabsRouter.activeIndex, labelType: NavigationRailLabelType.all, groupAlignment: 0.0, ); } - final multiselectEnabled = - ref.watch(multiSelectProvider.select((s) => s.isEnabled)); return AutoTabsRouter( routes: [ const MainTimelineRoute(), @@ -173,12 +146,57 @@ class TabShellPage extends ConsumerWidget { ], ) : heroedChild, - bottomNavigationBar: multiselectEnabled || isScreenLandscape - ? null - : bottomNavigationBar(tabsRouter), + bottomNavigationBar: _BottomNavigationBar( + tabsRouter: tabsRouter, + destinations: navigationDestinations, + ), ), ); }, ); } } + +void _onNavigationSelected(TabsRouter router, int index, WidgetRef ref) { + // On Photos page menu tapped + if (router.activeIndex == 0 && index == 0) { + scrollToTopNotifierProvider.scrollToTop(); + } + + // On Search page tapped + if (router.activeIndex == 1 && index == 1) { + ref.read(searchInputFocusProvider).requestFocus(); + } + + ref.read(hapticFeedbackProvider.notifier).selectionClick(); + router.setActiveIndex(index); + ref.read(tabProvider.notifier).state = TabEnum.values[index]; +} + +class _BottomNavigationBar extends ConsumerWidget { + const _BottomNavigationBar({ + required this.tabsRouter, + required this.destinations, + }); + + final List destinations; + final TabsRouter tabsRouter; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isScreenLandscape = context.orientation == Orientation.landscape; + final isMultiselectEnabled = + ref.watch(multiSelectProvider.select((s) => s.isEnabled)); + + if (isScreenLandscape || isMultiselectEnabled) { + return const SizedBox.shrink(); + } + + return NavigationBar( + selectedIndex: tabsRouter.activeIndex, + onDestinationSelected: (index) => + _onNavigationSelected(tabsRouter, index, ref), + destinations: destinations, + ); + } +} diff --git a/mobile/lib/presentation/widgets/action_buttons/archive_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/archive_action_button.widget.dart index d1d0695a99..86537816e3 100644 --- a/mobile/lib/presentation/widgets/action_buttons/archive_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/archive_action_button.widget.dart @@ -5,7 +5,6 @@ import 'package:immich_mobile/constants/enums.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/infrastructure/action.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; @@ -20,7 +19,6 @@ class ArchiveActionButton extends ConsumerWidget { } final result = await ref.read(actionProvider.notifier).archive(source); - await ref.read(timelineServiceProvider).reloadBucket(); ref.read(multiSelectProvider.notifier).reset(); final successMessage = 'archive_action_prompt'.t( diff --git a/mobile/lib/presentation/widgets/action_buttons/delete_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/delete_action_button.widget.dart index 6f8c0f5227..d81f998a7b 100644 --- a/mobile/lib/presentation/widgets/action_buttons/delete_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/delete_action_button.widget.dart @@ -5,7 +5,6 @@ import 'package:immich_mobile/constants/enums.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/infrastructure/action.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; @@ -20,7 +19,6 @@ class DeletePermanentActionButton extends ConsumerWidget { } final result = await ref.read(actionProvider.notifier).delete(source); - await ref.read(timelineServiceProvider).reloadBucket(); ref.read(multiSelectProvider.notifier).reset(); final successMessage = 'delete_action_prompt'.t( diff --git a/mobile/lib/presentation/widgets/action_buttons/favorite_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/favorite_action_button.widget.dart index 50d13e6b4e..4b717149d9 100644 --- a/mobile/lib/presentation/widgets/action_buttons/favorite_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/favorite_action_button.widget.dart @@ -5,7 +5,6 @@ import 'package:immich_mobile/constants/enums.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/infrastructure/action.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; @@ -20,7 +19,6 @@ class FavoriteActionButton extends ConsumerWidget { } final result = await ref.read(actionProvider.notifier).favorite(source); - await ref.read(timelineServiceProvider).reloadBucket(); ref.read(multiSelectProvider.notifier).reset(); final successMessage = 'favorite_action_prompt'.t( diff --git a/mobile/lib/presentation/widgets/action_buttons/move_to_lock_folder_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/move_to_lock_folder_action_button.widget.dart index 503dd34403..7546f07961 100644 --- a/mobile/lib/presentation/widgets/action_buttons/move_to_lock_folder_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/move_to_lock_folder_action_button.widget.dart @@ -5,7 +5,6 @@ import 'package:immich_mobile/constants/enums.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/infrastructure/action.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; @@ -21,7 +20,6 @@ class MoveToLockFolderActionButton extends ConsumerWidget { final result = await ref.read(actionProvider.notifier).moveToLockFolder(source); - await ref.read(timelineServiceProvider).reloadBucket(); ref.read(multiSelectProvider.notifier).reset(); final successMessage = 'move_to_lock_folder_action_prompt'.t( diff --git a/mobile/lib/presentation/widgets/action_buttons/remove_from_lock_folder_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/remove_from_lock_folder_action_button.widget.dart index 32857f300e..20fb62013f 100644 --- a/mobile/lib/presentation/widgets/action_buttons/remove_from_lock_folder_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/remove_from_lock_folder_action_button.widget.dart @@ -5,7 +5,6 @@ import 'package:immich_mobile/constants/enums.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/infrastructure/action.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; @@ -21,7 +20,6 @@ class RemoveFromLockFolderActionButton extends ConsumerWidget { final result = await ref.read(actionProvider.notifier).removeFromLockFolder(source); - await ref.read(timelineServiceProvider).reloadBucket(); ref.read(multiSelectProvider.notifier).reset(); final successMessage = 'remove_from_lock_folder_action_prompt'.t( diff --git a/mobile/lib/presentation/widgets/action_buttons/trash_action_buton.widget.dart b/mobile/lib/presentation/widgets/action_buttons/trash_action_button.widget.dart similarity index 92% rename from mobile/lib/presentation/widgets/action_buttons/trash_action_buton.widget.dart rename to mobile/lib/presentation/widgets/action_buttons/trash_action_button.widget.dart index 1d287e34e2..449b688550 100644 --- a/mobile/lib/presentation/widgets/action_buttons/trash_action_buton.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/trash_action_button.widget.dart @@ -5,7 +5,6 @@ import 'package:immich_mobile/constants/enums.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/infrastructure/action.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; @@ -20,7 +19,6 @@ class TrashActionButton extends ConsumerWidget { } final result = await ref.read(actionProvider.notifier).trash(source); - await ref.read(timelineServiceProvider).reloadBucket(); ref.read(multiSelectProvider.notifier).reset(); final successMessage = 'trash_action_prompt'.t( diff --git a/mobile/lib/presentation/widgets/action_buttons/unarchive_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/unarchive_action_button.widget.dart index b5e210eb3b..a58d9f1ee1 100644 --- a/mobile/lib/presentation/widgets/action_buttons/unarchive_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/unarchive_action_button.widget.dart @@ -5,7 +5,6 @@ import 'package:immich_mobile/constants/enums.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/infrastructure/action.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; @@ -20,7 +19,6 @@ class UnarchiveActionButton extends ConsumerWidget { } final result = await ref.read(actionProvider.notifier).unArchive(source); - await ref.read(timelineServiceProvider).reloadBucket(); ref.read(multiSelectProvider.notifier).reset(); final successMessage = 'unarchive_action_prompt'.t( diff --git a/mobile/lib/presentation/widgets/action_buttons/unfavorite_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/unfavorite_action_button.widget.dart index 2d485f3418..cf014c356c 100644 --- a/mobile/lib/presentation/widgets/action_buttons/unfavorite_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/unfavorite_action_button.widget.dart @@ -5,7 +5,6 @@ import 'package:immich_mobile/constants/enums.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/infrastructure/action.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; @@ -20,7 +19,6 @@ class UnFavoriteActionButton extends ConsumerWidget { } final result = await ref.read(actionProvider.notifier).unFavorite(source); - await ref.read(timelineServiceProvider).reloadBucket(); ref.read(multiSelectProvider.notifier).reset(); final successMessage = 'unfavorite_action_prompt'.t( 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 6f5ceb6da1..d221347ce1 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart @@ -4,9 +4,10 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/services/timeline.service.dart'; +import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/scroll_extensions.dart'; -import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_sheet.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_sheet.widget.dart'; import 'package:immich_mobile/presentation/widgets/images/image_provider.dart'; import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart'; import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; @@ -60,6 +61,7 @@ class _AssetViewerState extends ConsumerState { PersistentBottomSheetController? sheetCloseNotifier; // PhotoViewGallery takes care of disposing it's controllers PhotoViewControllerBase? viewController; + StreamSubscription? _reloadSubscription; late Platform platform; late PhotoViewControllerValue initialPhotoViewState; @@ -88,6 +90,8 @@ class _AssetViewerState extends ConsumerState { WidgetsBinding.instance.addPostFrameCallback((_) { _onAssetChanged(widget.initialIndex); }); + _reloadSubscription = + EventStream.shared.listen(_onTimelineReload); } @override @@ -95,9 +99,31 @@ class _AssetViewerState extends ConsumerState { pageController.dispose(); bottomSheetController.dispose(); _cancelTimers(); + _reloadSubscription?.cancel(); super.dispose(); } + void _onTimelineReload(_) { + setState(() { + totalAssets = ref.read(timelineServiceProvider).totalAssets; + if (totalAssets == 0) { + context.maybePop(); + return; + } + + final index = pageController.page?.round() ?? 0; + final newAsset = ref.read(timelineServiceProvider).getAsset(index); + final currentAsset = ref.read(currentAssetNotifier); + // Do not reload / close the bottom sheet if the asset has not changed + if (newAsset.heroTag == currentAsset.heroTag) { + return; + } + + _onAssetChanged(pageController.page!.round()); + sheetCloseNotifier?.close(); + }); + } + Color get backgroundColor { if (showingBottomSheet && !context.isDarkTheme) { return Colors.white; @@ -186,11 +212,12 @@ class _AssetViewerState extends ConsumerState { void _onDragStart( _, DragStartDetails details, - PhotoViewControllerValue value, + PhotoViewControllerBase controller, PhotoViewScaleStateController scaleStateController, ) { + viewController = controller; dragDownPosition = details.localPosition; - initialPhotoViewState = value; + initialPhotoViewState = controller.value; final isZoomed = scaleStateController.scaleState == PhotoViewScaleState.zoomedIn || scaleStateController.scaleState == PhotoViewScaleState.covering; @@ -277,7 +304,7 @@ class _AssetViewerState extends ConsumerState { void _openBottomSheet(BuildContext ctx) { setState(() { initialScale = viewController?.scale; - viewController?.animateMultiple(scale: _getScaleForBottomSheet); + viewController?.updateMultiple(scale: _getScaleForBottomSheet); showingBottomSheet = true; previousExtent = _kBottomSheetMinimumExtent; sheetCloseNotifier = showBottomSheet( @@ -308,10 +335,8 @@ class _AssetViewerState extends ConsumerState { setState(() { showingBottomSheet = false; sheetCloseNotifier = null; - viewController?.animateMultiple( - position: Offset.zero, - scale: initialScale, - ); + viewController?.animateMultiple(position: Offset.zero); + viewController?.updateMultiple(scale: initialScale); shouldPopOnDrag = false; hasDraggedDown = null; }); @@ -420,10 +445,10 @@ class _AssetViewerState extends ConsumerState { PhotoViewGalleryPageOptions _assetBuilder(BuildContext ctx, int index) { final asset = ref.read(timelineServiceProvider).getAsset(index); final size = Size(ctx.width, ctx.height); - final imageProvider = getFullImageProvider(asset, size: size); return PhotoViewGalleryPageOptions( - imageProvider: imageProvider, + key: ValueKey(asset.heroTag), + imageProvider: getFullImageProvider(asset, size: size), heroAttributes: PhotoViewHeroAttributes(tag: asset.heroTag), filterQuality: FilterQuality.high, tightMode: true, @@ -446,27 +471,34 @@ class _AssetViewerState extends ConsumerState { ); } + void _onPop(bool didPop, T? result) { + ref.read(currentAssetNotifier.notifier).dispose(); + } + @override Widget build(BuildContext context) { // Currently it is not possible to scroll the asset when the bottom sheet is open all the way. // Issue: https://github.com/flutter/flutter/issues/109037 // TODO: Add a custom scrum builder once the fix lands on stable - return Scaffold( - backgroundColor: Colors.black.withAlpha(backgroundOpacity), - body: PhotoViewGallery.builder( - gaplessPlayback: true, - loadingBuilder: _placeholderBuilder, - pageController: pageController, - scrollPhysics: platform.isIOS - ? const FastScrollPhysics() // Use bouncing physics for iOS - : const FastClampingScrollPhysics() // Use heavy physics for Android - , - itemCount: totalAssets, - onPageChanged: _onPageChanged, - onPageBuild: _onPageBuild, - builder: _assetBuilder, - backgroundDecoration: BoxDecoration(color: backgroundColor), - enablePanAlways: true, + return PopScope( + onPopInvokedWithResult: _onPop, + child: Scaffold( + backgroundColor: Colors.black.withAlpha(backgroundOpacity), + body: PhotoViewGallery.builder( + gaplessPlayback: true, + loadingBuilder: _placeholderBuilder, + pageController: pageController, + scrollPhysics: platform.isIOS + ? const FastScrollPhysics() // Use bouncing physics for iOS + : const FastClampingScrollPhysics() // Use heavy physics for Android + , + itemCount: totalAssets, + onPageChanged: _onPageChanged, + onPageBuild: _onPageBuild, + builder: _assetBuilder, + backgroundDecoration: BoxDecoration(color: backgroundColor), + enablePanAlways: true, + ), ), ); } diff --git a/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.dart b/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart similarity index 66% rename from mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.dart rename to mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart index 39f28d2f60..d8afe81564 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart @@ -1,30 +1,78 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/exif.model.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/archive_action_button.widget.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/delete_action_button.widget.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_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/move_to_lock_folder_action_button.widget.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_action_button.widget.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_button.widget.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_sheet/location_details.widget.dart'; import 'package:immich_mobile/presentation/widgets/bottom_app_bar/base_bottom_sheet.widget.dart'; import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; +import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/utils/bytes_units.dart'; const _kSeparator = ' • '; -class AssetDetailBottomSheet extends BaseBottomSheet { +class AssetDetailBottomSheet extends ConsumerWidget { + final DraggableScrollableController? controller; + final double initialChildSize; + const AssetDetailBottomSheet({ - super.controller, - super.initialChildSize, + this.controller, + this.initialChildSize = 0.35, super.key, - }) : super( - actions: const [], - slivers: const [_AssetDetailBottomSheet()], - minChildSize: 0.1, - maxChildSize: 1.0, - expand: false, - shouldCloseOnMinExtent: false, - resizeOnScroll: false, - ); + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final asset = ref.watch(currentAssetNotifier); + final isTrashEnable = ref.watch( + serverInfoProvider.select((state) => state.serverFeatures.trash), + ); + + final actions = [ + const ShareActionButton(), + if (asset.hasRemote) ...[ + const ShareLinkActionButton(source: ActionSource.viewer), + const ArchiveActionButton(source: ActionSource.viewer), + const FavoriteActionButton(source: ActionSource.viewer), + if (!asset.hasLocal) const DownloadActionButton(), + isTrashEnable + ? const TrashActionButton(source: ActionSource.viewer) + : const DeletePermanentActionButton(source: ActionSource.viewer), + const MoveToLockFolderActionButton( + source: ActionSource.viewer, + ), + ], + if (asset.storage == AssetState.local) ...[ + const DeleteLocalActionButton(), + const UploadActionButton(), + ], + ]; + + return BaseBottomSheet( + actions: actions, + slivers: const [_AssetDetailBottomSheet()], + controller: controller, + initialChildSize: initialChildSize, + minChildSize: 0.1, + maxChildSize: 1.0, + expand: false, + shouldCloseOnMinExtent: false, + resizeOnScroll: false, + ); + } } class _AssetDetailBottomSheet extends ConsumerWidget { @@ -96,16 +144,16 @@ class _AssetDetailBottomSheet extends ConsumerWidget { // Asset Date and Time _SheetTile( title: _getDateTime(context, asset), - titleStyle: context.textTheme.bodyLarge - ?.copyWith(fontWeight: FontWeight.w600), + titleStyle: context.textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w500, + fontSize: 16, + ), ), + const SheetLocationDetails(), // Details header _SheetTile( title: 'exif_bottom_sheet_details'.t(context: context), - titleStyle: context.textTheme.labelLarge?.copyWith( - color: context.textTheme.labelLarge?.color, - fontWeight: FontWeight.w600, - ), + titleStyle: context.textTheme.labelLarge, ), // File info _SheetTile( diff --git a/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet/location_details.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet/location_details.widget.dart new file mode 100644 index 0000000000..4b0294253c --- /dev/null +++ b/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet/location_details.widget.dart @@ -0,0 +1,124 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/models/exif.model.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; +import 'package:immich_mobile/widgets/asset_viewer/detail_panel/exif_map.dart'; +import 'package:maplibre_gl/maplibre_gl.dart'; + +class SheetLocationDetails extends ConsumerStatefulWidget { + const SheetLocationDetails({super.key}); + + @override + ConsumerState createState() => _SheetLocationDetailsState(); +} + +class _SheetLocationDetailsState extends ConsumerState { + late BaseAsset asset; + ExifInfo? exifInfo; + MapLibreMapController? _mapController; + + String? _getLocationName(ExifInfo? exifInfo) { + if (exifInfo == null) { + return null; + } + + final cityName = exifInfo.city; + final stateName = exifInfo.state; + + if (cityName != null && stateName != null) { + return "$cityName, $stateName"; + } + return null; + } + + void _onMapCreated(MapLibreMapController controller) { + _mapController = controller; + } + + void _onExifChanged( + AsyncValue? previous, + AsyncValue current, + ) { + asset = ref.read(currentAssetNotifier); + setState(() { + exifInfo = current.valueOrNull; + final hasCoordinates = exifInfo?.hasCoordinates ?? false; + if (exifInfo != null && hasCoordinates) { + _mapController?.moveCamera( + CameraUpdate.newLatLng( + LatLng(exifInfo!.latitude!, exifInfo!.longitude!), + ), + ); + } + }); + } + + @override + void initState() { + super.initState(); + ref.listenManual( + currentAssetExifProvider, + _onExifChanged, + fireImmediately: true, + ); + } + + @override + Widget build(BuildContext context) { + final hasCoordinates = exifInfo?.hasCoordinates ?? false; + + // Guard no lat/lng + if (!hasCoordinates || + (asset is LocalAsset && !(asset as LocalAsset).hasRemote)) { + return const SizedBox.shrink(); + } + + final remoteId = asset is LocalAsset + ? (asset as LocalAsset).remoteId + : (asset as RemoteAsset).id; + final locationName = _getLocationName(exifInfo); + final coordinates = + "${exifInfo!.latitude!.toStringAsFixed(4)}, ${exifInfo!.longitude!.toStringAsFixed(4)}"; + + return Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Text( + "exif_bottom_sheet_location".t(context: context), + style: context.textTheme.labelLarge, + ), + ), + ExifMap( + exifInfo: exifInfo!, + markerId: remoteId, + onMapCreated: _onMapCreated, + ), + const SizedBox(height: 15), + if (locationName != null) + Padding( + padding: const EdgeInsets.only(bottom: 4.0), + child: Text( + locationName, + style: context.textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w500, + ), + ), + ), + Text( + coordinates, + style: context.textTheme.labelLarge?.copyWith( + color: context.textTheme.labelLarge?.color?.withAlpha(150), + ), + ), + ], + ), + ); + } +} diff --git a/mobile/lib/presentation/widgets/bottom_app_bar/base_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/bottom_app_bar/base_bottom_sheet.widget.dart index 1fb98a0032..2db8ae2b4c 100644 --- a/mobile/lib/presentation/widgets/bottom_app_bar/base_bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/bottom_app_bar/base_bottom_sheet.widget.dart @@ -89,17 +89,17 @@ class _BaseDraggableScrollableSheetState const SizedBox(height: 14), if (widget.actions.isNotEmpty) SizedBox( - height: 80, + height: 115, child: ListView( shrinkWrap: true, scrollDirection: Axis.horizontal, children: widget.actions, ), ), - if (widget.actions.isNotEmpty) const SizedBox(height: 14), - if (widget.actions.isNotEmpty) - const Divider(indent: 20, endIndent: 20), - if (widget.actions.isNotEmpty) const SizedBox(height: 14), + if (widget.actions.isNotEmpty) ...[ + const Divider(indent: 16, endIndent: 16), + const SizedBox(height: 16), + ], ], ), ), diff --git a/mobile/lib/presentation/widgets/bottom_app_bar/home_bottom_app_bar.widget.dart b/mobile/lib/presentation/widgets/bottom_app_bar/home_bottom_app_bar.widget.dart index 7f870bfdff..f5ba759130 100644 --- a/mobile/lib/presentation/widgets/bottom_app_bar/home_bottom_app_bar.widget.dart +++ b/mobile/lib/presentation/widgets/bottom_app_bar/home_bottom_app_bar.widget.dart @@ -12,7 +12,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/move_to_lock_f import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/stack_action_button.widget.dart'; -import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_buton.widget.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/bottom_app_bar/base_bottom_sheet.widget.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; diff --git a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart index fd0806cff0..2ef01e5faf 100644 --- a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart +++ b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:math' as math; import 'package:collection/collection.dart'; @@ -6,6 +7,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/setting.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/bottom_app_bar/home_bottom_app_bar.widget.dart'; @@ -43,96 +45,120 @@ class Timeline extends StatelessWidget { } } -class _SliverTimeline extends StatefulWidget { +class _SliverTimeline extends ConsumerStatefulWidget { const _SliverTimeline(); @override - State createState() => _SliverTimelineState(); + ConsumerState createState() => _SliverTimelineState(); } -class _SliverTimelineState extends State<_SliverTimeline> { +class _SliverTimelineState extends ConsumerState<_SliverTimeline> { final _scrollController = ScrollController(); + StreamSubscription? _reloadSubscription; + + @override + void initState() { + super.initState(); + _reloadSubscription = + EventStream.shared.listen((_) => setState(() {})); + } @override void dispose() { _scrollController.dispose(); + _reloadSubscription?.cancel(); super.dispose(); } @override Widget build(BuildContext _) { - return Consumer( - builder: (context, ref, child) { - final asyncSegments = ref.watch(timelineSegmentProvider); - final maxHeight = - ref.watch(timelineArgsProvider.select((args) => args.maxHeight)); - final isMultiSelectEnabled = - ref.watch(multiSelectProvider.select((s) => s.isEnabled)); - return asyncSegments.widgetWhen( - onData: (segments) { - final childCount = (segments.lastOrNull?.lastIndex ?? -1) + 1; - final statusBarHeight = context.padding.top; - final totalAppBarHeight = statusBarHeight + kToolbarHeight; - const scrubberBottomPadding = 100.0; + final asyncSegments = ref.watch(timelineSegmentProvider); + final maxHeight = + ref.watch(timelineArgsProvider.select((args) => args.maxHeight)); + return asyncSegments.widgetWhen( + onData: (segments) { + final childCount = (segments.lastOrNull?.lastIndex ?? -1) + 1; + final statusBarHeight = context.padding.top; + final totalAppBarHeight = statusBarHeight + kToolbarHeight; + const scrubberBottomPadding = 100.0; - return PrimaryScrollController( - controller: _scrollController, - child: Stack( - children: [ - Scrubber( - layoutSegments: segments, - timelineHeight: maxHeight, - topPadding: totalAppBarHeight + 10, - bottomPadding: - context.padding.bottom + scrubberBottomPadding, - child: CustomScrollView( - primary: true, - cacheExtent: maxHeight * 2, - slivers: [ - SliverAnimatedOpacity( - duration: Durations.medium1, - opacity: isMultiSelectEnabled ? 0 : 1, - sliver: const ImmichSliverAppBar( - floating: true, - pinned: false, - snap: false, - ), - ), - _SliverSegmentedList( - segments: segments, - delegate: SliverChildBuilderDelegate( - (ctx, index) { - if (index >= childCount) return null; - final segment = segments.findByIndex(index); - return segment?.builder(ctx, index) ?? - const SizedBox.shrink(); - }, - childCount: childCount, - addAutomaticKeepAlives: false, - // We add repaint boundary around tiles, so skip the auto boundaries - addRepaintBoundaries: false, - ), - ), - const SliverPadding( - padding: EdgeInsets.only( - bottom: scrubberBottomPadding, - ), - ), - ], + return PrimaryScrollController( + controller: _scrollController, + child: Stack( + children: [ + Scrubber( + layoutSegments: segments, + timelineHeight: maxHeight, + topPadding: totalAppBarHeight + 10, + bottomPadding: context.padding.bottom + scrubberBottomPadding, + child: CustomScrollView( + primary: true, + cacheExtent: maxHeight * 2, + slivers: [ + const ImmichSliverAppBar( + floating: true, + pinned: false, + snap: false, ), - ), - if (isMultiSelectEnabled) ...[ - const Positioned( - top: 60, - left: 25, - child: _MultiSelectStatusButton(), + _SliverSegmentedList( + segments: segments, + delegate: SliverChildBuilderDelegate( + (ctx, index) { + if (index >= childCount) return null; + final segment = segments.findByIndex(index); + return segment?.builder(ctx, index) ?? + const SizedBox.shrink(); + }, + childCount: childCount, + addAutomaticKeepAlives: false, + // We add repaint boundary around tiles, so skip the auto boundaries + addRepaintBoundaries: false, + ), + ), + const SliverPadding( + padding: EdgeInsets.only( + bottom: scrubberBottomPadding, + ), ), - const HomeBottomAppBar(), ], - ], + ), ), - ); - }, + Consumer( + builder: (_, consumerRef, child) { + final isMultiSelectEnabled = consumerRef.watch( + multiSelectProvider.select( + (s) => s.isEnabled, + ), + ); + + if (isMultiSelectEnabled) { + return child!; + } + return const SizedBox.shrink(); + }, + child: const Positioned( + top: 60, + left: 25, + child: _MultiSelectStatusButton(), + ), + ), + Consumer( + builder: (_, consumerRef, child) { + final isMultiSelectEnabled = consumerRef.watch( + multiSelectProvider.select( + (s) => s.isEnabled, + ), + ); + + if (isMultiSelectEnabled) { + return child!; + } + return const SizedBox.shrink(); + }, + child: const HomeBottomAppBar(), + ), + ], + ), ); }, ); diff --git a/mobile/lib/providers/infrastructure/action.provider.dart b/mobile/lib/providers/infrastructure/action.provider.dart index 4f92d4e325..947cc7df5b 100644 --- a/mobile/lib/providers/infrastructure/action.provider.dart +++ b/mobile/lib/providers/infrastructure/action.provider.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/services/action.service.dart'; @@ -55,7 +56,7 @@ class ActionNotifier extends Notifier { final Set assets = switch (source) { ActionSource.timeline => ref.read(multiSelectProvider.select((s) => s.selectedAssets)), - ActionSource.viewer => {}, + ActionSource.viewer => {ref.read(currentAssetNotifier)}, }; return switch (T) { diff --git a/mobile/lib/providers/infrastructure/asset_viewer/current_asset.provider.dart b/mobile/lib/providers/infrastructure/asset_viewer/current_asset.provider.dart index 48cf190bbc..1ce6c740f9 100644 --- a/mobile/lib/providers/infrastructure/asset_viewer/current_asset.provider.dart +++ b/mobile/lib/providers/infrastructure/asset_viewer/current_asset.provider.dart @@ -3,9 +3,13 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; final currentAssetNotifier = - NotifierProvider(CurrentAssetNotifier.new); + AutoDisposeNotifierProvider( + CurrentAssetNotifier.new, +); + +class CurrentAssetNotifier extends AutoDisposeNotifier { + KeepAliveLink? _keepAliveLink; -class CurrentAssetNotifier extends Notifier { @override BaseAsset build() { throw UnimplementedError( @@ -14,11 +18,17 @@ class CurrentAssetNotifier extends Notifier { } void setAsset(BaseAsset asset) { + _keepAliveLink?.close(); state = asset; + _keepAliveLink = ref.keepAlive(); + } + + void dispose() { + _keepAliveLink?.close(); } } -final currentAssetExifProvider = FutureProvider( +final currentAssetExifProvider = FutureProvider.autoDispose( (ref) { final currentAsset = ref.watch(currentAssetNotifier); return ref.watch(assetServiceProvider).getExif(currentAsset); diff --git a/mobile/lib/widgets/asset_viewer/detail_panel/exif_map.dart b/mobile/lib/widgets/asset_viewer/detail_panel/exif_map.dart index f3f72dfd87..7b6325cf2c 100644 --- a/mobile/lib/widgets/asset_viewer/detail_panel/exif_map.dart +++ b/mobile/lib/widgets/asset_viewer/detail_panel/exif_map.dart @@ -9,11 +9,13 @@ import 'package:url_launcher/url_launcher.dart'; class ExifMap extends StatelessWidget { final ExifInfo exifInfo; final String? markerId; + final MapCreatedCallback? onMapCreated; const ExifMap({ super.key, required this.exifInfo, this.markerId = 'marker', + this.onMapCreated, }); @override @@ -82,6 +84,7 @@ class ExifMap extends StatelessWidget { debugPrint('Opening Map Uri: $uri'); launchUrl(uri); }, + onCreated: onMapCreated, ); }, ); diff --git a/mobile/lib/widgets/common/immich_sliver_app_bar.dart b/mobile/lib/widgets/common/immich_sliver_app_bar.dart index 51a3a136b9..ff0e88e5d7 100644 --- a/mobile/lib/widgets/common/immich_sliver_app_bar.dart +++ b/mobile/lib/widgets/common/immich_sliver_app_bar.dart @@ -10,6 +10,7 @@ import 'package:immich_mobile/providers/background_sync.provider.dart'; import 'package:immich_mobile/providers/backup/backup.provider.dart'; import 'package:immich_mobile/providers/cast.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; +import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/widgets/asset_viewer/cast_dialog.dart'; @@ -39,64 +40,70 @@ class ImmichSliverAppBar extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final isCasting = ref.watch(castProvider.select((c) => c.isCasting)); + final isMultiSelectEnabled = + ref.watch(multiSelectProvider.select((s) => s.isEnabled)); - return SliverAppBar( - floating: floating, - pinned: pinned, - snap: snap, - expandedHeight: expandedHeight, - backgroundColor: context.colorScheme.surfaceContainer, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.all( - Radius.circular(5), - ), - ), - automaticallyImplyLeading: false, - centerTitle: false, - title: title ?? const _ImmichLogoWithText(), - actions: [ - if (actions != null) - ...actions!.map( - (action) => Padding( - padding: const EdgeInsets.only(right: 16), - child: action, - ), - ), - IconButton( - icon: const Icon(Icons.swipe_left_alt_rounded), - onPressed: () => context.pop(), - ), - IconButton( - onPressed: () => ref.read(backgroundSyncProvider).syncRemote(), - icon: const Icon( - Icons.sync, + return SliverAnimatedOpacity( + duration: Durations.medium1, + opacity: isMultiSelectEnabled ? 0 : 1, + sliver: SliverAppBar( + floating: floating, + pinned: pinned, + snap: snap, + expandedHeight: expandedHeight, + backgroundColor: context.colorScheme.surfaceContainer, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all( + Radius.circular(5), ), ), - if (isCasting) - Padding( - padding: const EdgeInsets.only(right: 12), - child: IconButton( - onPressed: () { - showDialog( - context: context, - builder: (context) => const CastDialog(), - ); - }, - icon: Icon( - isCasting ? Icons.cast_connected_rounded : Icons.cast_rounded, + automaticallyImplyLeading: false, + centerTitle: false, + title: title ?? const _ImmichLogoWithText(), + actions: [ + if (actions != null) + ...actions!.map( + (action) => Padding( + padding: const EdgeInsets.only(right: 16), + child: action, ), ), + IconButton( + icon: const Icon(Icons.swipe_left_alt_rounded), + onPressed: () => context.pop(), ), - if (showUploadButton) + IconButton( + onPressed: () => ref.read(backgroundSyncProvider).syncRemote(), + icon: const Icon( + Icons.sync, + ), + ), + if (isCasting) + Padding( + padding: const EdgeInsets.only(right: 12), + child: IconButton( + onPressed: () { + showDialog( + context: context, + builder: (context) => const CastDialog(), + ); + }, + icon: Icon( + isCasting ? Icons.cast_connected_rounded : Icons.cast_rounded, + ), + ), + ), + if (showUploadButton) + const Padding( + padding: EdgeInsets.only(right: 20), + child: _BackupIndicator(), + ), const Padding( padding: EdgeInsets.only(right: 20), - child: _BackupIndicator(), + child: _ProfileIndicator(), ), - const Padding( - padding: EdgeInsets.only(right: 20), - child: _ProfileIndicator(), - ), - ], + ], + ), ); } } diff --git a/mobile/lib/widgets/map/map_thumbnail.dart b/mobile/lib/widgets/map/map_thumbnail.dart index b225a2edcb..06935cd4b5 100644 --- a/mobile/lib/widgets/map/map_thumbnail.dart +++ b/mobile/lib/widgets/map/map_thumbnail.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/asyncvalue_extensions.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/maplibrecontroller_extensions.dart'; import 'package:immich_mobile/widgets/map/map_theme_override.dart'; import 'package:immich_mobile/widgets/map/positioned_asset_marker_icon.dart'; @@ -24,6 +25,7 @@ class MapThumbnail extends HookConsumerWidget { final double width; final ThemeMode? themeMode; final bool showAttribution; + final MapCreatedCallback? onCreated; const MapThumbnail({ super.key, @@ -36,16 +38,19 @@ class MapThumbnail extends HookConsumerWidget { this.showMarkerPin = false, this.themeMode, this.showAttribution = true, + this.onCreated, }); @override Widget build(BuildContext context, WidgetRef ref) { final offsettedCentre = LatLng(centre.latitude + 0.002, centre.longitude); final controller = useRef(null); + final styleLoaded = useState(false); final position = useValueNotifier?>(null); Future onMapCreated(MapLibreMapController mapController) async { controller.value = mapController; + styleLoaded.value = false; if (assetMarkerRemoteId != null) { // The iOS impl returns wrong toScreenLocation without the delay Future.delayed( @@ -54,17 +59,26 @@ class MapThumbnail extends HookConsumerWidget { position.value = await mapController.toScreenLocation(centre), ); } + onCreated?.call(mapController); } Future onStyleLoaded() async { if (showMarkerPin && controller.value != null) { await controller.value?.addMarkerAtLatLng(centre); } + styleLoaded.value = true; } return MapThemeOverride( themeMode: themeMode, - mapBuilder: (style) => SizedBox( + mapBuilder: (style) => AnimatedContainer( + duration: Durations.medium2, + curve: Curves.easeOut, + foregroundDecoration: BoxDecoration( + color: context.colorScheme.inverseSurface + .withAlpha(styleLoaded.value ? 0 : 200), + borderRadius: const BorderRadius.all(Radius.circular(15)), + ), height: height, width: width, child: ClipRRect( diff --git a/mobile/lib/widgets/photo_view/photo_view.dart b/mobile/lib/widgets/photo_view/photo_view.dart index 0c1a4f4855..30e08748b8 100644 --- a/mobile/lib/widgets/photo_view/photo_view.dart +++ b/mobile/lib/widgets/photo_view/photo_view.dart @@ -660,7 +660,7 @@ typedef PhotoViewImageTapDownCallback = Function( typedef PhotoViewImageDragStartCallback = Function( BuildContext context, DragStartDetails details, - PhotoViewControllerValue controllerValue, + PhotoViewControllerBase controllerValue, PhotoViewScaleStateController scaleStateController, ); diff --git a/mobile/lib/widgets/photo_view/photo_view_gallery.dart b/mobile/lib/widgets/photo_view/photo_view_gallery.dart index cf026288fb..1cd4d4b217 100644 --- a/mobile/lib/widgets/photo_view/photo_view_gallery.dart +++ b/mobile/lib/widgets/photo_view/photo_view_gallery.dart @@ -271,7 +271,7 @@ class _PhotoViewGalleryState extends State { final PhotoView photoView = isCustomChild ? PhotoView.customChild( - key: ObjectKey(index), + key: pageOption.key ?? ObjectKey(index), childSize: pageOption.childSize, backgroundDecoration: widget.backgroundDecoration, wantKeepAlive: widget.wantKeepAlive, @@ -304,7 +304,7 @@ class _PhotoViewGalleryState extends State { child: pageOption.child, ) : PhotoView( - key: ObjectKey(index), + key: pageOption.key ?? ObjectKey(index), index: index, imageProvider: pageOption.imageProvider, loadingBuilder: widget.loadingBuilder, @@ -363,7 +363,7 @@ class _PhotoViewGalleryState extends State { /// class PhotoViewGalleryPageOptions { PhotoViewGalleryPageOptions({ - Key? key, + this.key, required this.imageProvider, this.heroAttributes, this.semanticLabel, @@ -392,6 +392,7 @@ class PhotoViewGalleryPageOptions { assert(imageProvider != null); const PhotoViewGalleryPageOptions.customChild({ + this.key, required this.child, this.childSize, this.semanticLabel, @@ -418,6 +419,8 @@ class PhotoViewGalleryPageOptions { }) : errorBuilder = null, imageProvider = null; + final Key? key; + /// Mirror to [PhotoView.imageProvider] final ImageProvider? imageProvider; diff --git a/mobile/lib/widgets/photo_view/src/core/photo_view_core.dart b/mobile/lib/widgets/photo_view/src/core/photo_view_core.dart index bc97da1e06..6b6e5067c5 100644 --- a/mobile/lib/widgets/photo_view/src/core/photo_view_core.dart +++ b/mobile/lib/widgets/photo_view/src/core/photo_view_core.dart @@ -416,7 +416,7 @@ class PhotoViewCoreState extends State ? (details) => widget.onDragStart!( context, details, - widget.controller.value, + widget.controller, widget.scaleStateController, ) : null,