refactor: reduce timeline rebuilds (#19704)

* reduce timeline rebuilds

* feat: adds bottom sheet map and actions (#19692)

* adds bottom sheet map and actions

* PR feedbacks

* only reload the asset viewer if asset is changed

* styling tweak

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
Co-authored-by: Alex <alex.tran1502@gmail.com>

* rename singleton and remove event prefix

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
Co-authored-by: Alex <alex.tran1502@gmail.com>
This commit is contained in:
shenlong 2025-07-04 21:00:34 +05:30 committed by GitHub
parent b00d44a00c
commit 181efb9010
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
29 changed files with 557 additions and 233 deletions

View File

@ -1,5 +1,5 @@
part 'remote_asset.model.dart';
part 'local_asset.model.dart'; part 'local_asset.model.dart';
part 'remote_asset.model.dart';
enum AssetType { enum AssetType {
// do not change this order! // do not change this order!
@ -48,6 +48,13 @@ sealed class BaseAsset {
return null; 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 // Overridden in subclasses
AssetState get storage; AssetState get storage;
String get heroTag; String get heroTag;

View File

@ -25,8 +25,6 @@ class LocalAsset extends BaseAsset {
@override @override
String get heroTag => '${id}_${remoteId ?? checksum}'; String get heroTag => '${id}_${remoteId ?? checksum}';
bool get hasRemote => remoteId != null;
@override @override
String toString() { String toString() {
return '''LocalAsset { return '''LocalAsset {

View File

@ -39,8 +39,6 @@ class RemoteAsset extends BaseAsset {
@override @override
String get heroTag => '${localId ?? checksum}_$id'; String get heroTag => '${localId ?? checksum}_$id';
bool get hasLocal => localId != null;
@override @override
String toString() { String toString() {
return '''Asset { return '''Asset {

View File

@ -6,6 +6,7 @@ enum Setting<T> {
showStorageIndicator<bool>(StoreKey.storageIndicator, true), showStorageIndicator<bool>(StoreKey.storageIndicator, true),
loadOriginal<bool>(StoreKey.loadOriginal, false), loadOriginal<bool>(StoreKey.loadOriginal, false),
preferRemoteImage<bool>(StoreKey.preferRemoteImage, false), preferRemoteImage<bool>(StoreKey.preferRemoteImage, false),
advancedTroubleshooting<bool>(StoreKey.advancedTroubleshooting, false),
; ;
const Setting(this.storeKey, this.defaultValue); const Setting(this.storeKey, this.defaultValue);

View File

@ -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/setting.model.dart';
import 'package:immich_mobile/domain/models/timeline.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/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/infrastructure/repositories/timeline.repository.dart';
import 'package:immich_mobile/utils/async_mutex.dart'; import 'package:immich_mobile/utils/async_mutex.dart';
@ -68,7 +69,7 @@ class TimelineService {
_bucketSubscription = _bucketSource().listen((buckets) { _bucketSubscription = _bucketSource().listen((buckets) {
_totalAssets = _totalAssets =
buckets.fold<int>(0, (acc, bucket) => acc + bucket.assetCount); buckets.fold<int>(0, (acc, bucket) => acc + bucket.assetCount);
unawaited(reloadBucket()); unawaited(_reloadBucket());
}); });
} }
@ -79,8 +80,9 @@ class TimelineService {
Stream<List<Bucket>> Function() get watchBuckets => _bucketSource; Stream<List<Bucket>> Function() get watchBuckets => _bucketSource;
Future<void> reloadBucket() => _mutex.run(() async { Future<void> _reloadBucket() => _mutex.run(() async {
_buffer = await _assetSource(_bufferOffset, _buffer.length); _buffer = await _assetSource(_bufferOffset, _buffer.length);
EventStream.shared.emit(const TimelineReloadEvent());
}); });
Future<List<BaseAsset>> loadAssets(int index, int count) => Future<List<BaseAsset>> loadAssets(int index, int count) =>

View File

@ -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<Event> _controller =
StreamController<Event>.broadcast();
void emit(Event event) {
_controller.add(event);
}
Stream<T> where<T extends Event>() {
if (T == Event) {
return _controller.stream as Stream<T>;
}
return _controller.stream.where((event) => event is T).cast<T>();
}
StreamSubscription<T> listen<T extends Event>(
void Function(T event)? onData, {
Function? onError,
void Function()? onDone,
bool? cancelOnError,
}) {
return where<T>().listen(
onData,
onError: onError,
onDone: onDone,
cancelOnError: cancelOnError,
);
}
/// Closes the stream controller
void dispose() {
_controller.close();
}
}

View File

@ -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 = [ final navigationDestinations = [
NavigationDestination( NavigationDestination(
label: 'photos'.tr(), 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) { Widget navigationRail(TabsRouter tabsRouter) {
return NavigationRail( return NavigationRail(
destinations: navigationDestinations destinations: navigationDestinations
@ -131,15 +106,13 @@ class TabShellPage extends ConsumerWidget {
) )
.toList(), .toList(),
onDestinationSelected: (index) => onDestinationSelected: (index) =>
onNavigationSelected(tabsRouter, index), _onNavigationSelected(tabsRouter, index, ref),
selectedIndex: tabsRouter.activeIndex, selectedIndex: tabsRouter.activeIndex,
labelType: NavigationRailLabelType.all, labelType: NavigationRailLabelType.all,
groupAlignment: 0.0, groupAlignment: 0.0,
); );
} }
final multiselectEnabled =
ref.watch(multiSelectProvider.select((s) => s.isEnabled));
return AutoTabsRouter( return AutoTabsRouter(
routes: [ routes: [
const MainTimelineRoute(), const MainTimelineRoute(),
@ -173,12 +146,57 @@ class TabShellPage extends ConsumerWidget {
], ],
) )
: heroedChild, : heroedChild,
bottomNavigationBar: multiselectEnabled || isScreenLandscape bottomNavigationBar: _BottomNavigationBar(
? null tabsRouter: tabsRouter,
: bottomNavigationBar(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<Widget> 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,
);
}
}

View File

@ -5,7 +5,6 @@ import 'package:immich_mobile/constants/enums.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/infrastructure/action.provider.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/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.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); final result = await ref.read(actionProvider.notifier).archive(source);
await ref.read(timelineServiceProvider).reloadBucket();
ref.read(multiSelectProvider.notifier).reset(); ref.read(multiSelectProvider.notifier).reset();
final successMessage = 'archive_action_prompt'.t( final successMessage = 'archive_action_prompt'.t(

View File

@ -5,7 +5,6 @@ import 'package:immich_mobile/constants/enums.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/infrastructure/action.provider.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/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.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); final result = await ref.read(actionProvider.notifier).delete(source);
await ref.read(timelineServiceProvider).reloadBucket();
ref.read(multiSelectProvider.notifier).reset(); ref.read(multiSelectProvider.notifier).reset();
final successMessage = 'delete_action_prompt'.t( final successMessage = 'delete_action_prompt'.t(

View File

@ -5,7 +5,6 @@ import 'package:immich_mobile/constants/enums.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/infrastructure/action.provider.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/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.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); final result = await ref.read(actionProvider.notifier).favorite(source);
await ref.read(timelineServiceProvider).reloadBucket();
ref.read(multiSelectProvider.notifier).reset(); ref.read(multiSelectProvider.notifier).reset();
final successMessage = 'favorite_action_prompt'.t( final successMessage = 'favorite_action_prompt'.t(

View File

@ -5,7 +5,6 @@ import 'package:immich_mobile/constants/enums.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/infrastructure/action.provider.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/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart';
@ -21,7 +20,6 @@ class MoveToLockFolderActionButton extends ConsumerWidget {
final result = final result =
await ref.read(actionProvider.notifier).moveToLockFolder(source); await ref.read(actionProvider.notifier).moveToLockFolder(source);
await ref.read(timelineServiceProvider).reloadBucket();
ref.read(multiSelectProvider.notifier).reset(); ref.read(multiSelectProvider.notifier).reset();
final successMessage = 'move_to_lock_folder_action_prompt'.t( final successMessage = 'move_to_lock_folder_action_prompt'.t(

View File

@ -5,7 +5,6 @@ import 'package:immich_mobile/constants/enums.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/infrastructure/action.provider.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/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart';
@ -21,7 +20,6 @@ class RemoveFromLockFolderActionButton extends ConsumerWidget {
final result = final result =
await ref.read(actionProvider.notifier).removeFromLockFolder(source); await ref.read(actionProvider.notifier).removeFromLockFolder(source);
await ref.read(timelineServiceProvider).reloadBucket();
ref.read(multiSelectProvider.notifier).reset(); ref.read(multiSelectProvider.notifier).reset();
final successMessage = 'remove_from_lock_folder_action_prompt'.t( final successMessage = 'remove_from_lock_folder_action_prompt'.t(

View File

@ -5,7 +5,6 @@ import 'package:immich_mobile/constants/enums.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/infrastructure/action.provider.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/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.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); final result = await ref.read(actionProvider.notifier).trash(source);
await ref.read(timelineServiceProvider).reloadBucket();
ref.read(multiSelectProvider.notifier).reset(); ref.read(multiSelectProvider.notifier).reset();
final successMessage = 'trash_action_prompt'.t( final successMessage = 'trash_action_prompt'.t(

View File

@ -5,7 +5,6 @@ import 'package:immich_mobile/constants/enums.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/infrastructure/action.provider.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/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.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); final result = await ref.read(actionProvider.notifier).unArchive(source);
await ref.read(timelineServiceProvider).reloadBucket();
ref.read(multiSelectProvider.notifier).reset(); ref.read(multiSelectProvider.notifier).reset();
final successMessage = 'unarchive_action_prompt'.t( final successMessage = 'unarchive_action_prompt'.t(

View File

@ -5,7 +5,6 @@ import 'package:immich_mobile/constants/enums.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/infrastructure/action.provider.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/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.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); final result = await ref.read(actionProvider.notifier).unFavorite(source);
await ref.read(timelineServiceProvider).reloadBucket();
ref.read(multiSelectProvider.notifier).reset(); ref.read(multiSelectProvider.notifier).reset();
final successMessage = 'unfavorite_action_prompt'.t( final successMessage = 'unfavorite_action_prompt'.t(

View File

@ -4,9 +4,10 @@ import 'package:auto_route/auto_route.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/services/timeline.service.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/build_context_extensions.dart';
import 'package:immich_mobile/extensions/scroll_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/image_provider.dart';
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart'; import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
@ -60,6 +61,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
PersistentBottomSheetController? sheetCloseNotifier; PersistentBottomSheetController? sheetCloseNotifier;
// PhotoViewGallery takes care of disposing it's controllers // PhotoViewGallery takes care of disposing it's controllers
PhotoViewControllerBase? viewController; PhotoViewControllerBase? viewController;
StreamSubscription? _reloadSubscription;
late Platform platform; late Platform platform;
late PhotoViewControllerValue initialPhotoViewState; late PhotoViewControllerValue initialPhotoViewState;
@ -88,6 +90,8 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
_onAssetChanged(widget.initialIndex); _onAssetChanged(widget.initialIndex);
}); });
_reloadSubscription =
EventStream.shared.listen<TimelineReloadEvent>(_onTimelineReload);
} }
@override @override
@ -95,9 +99,31 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
pageController.dispose(); pageController.dispose();
bottomSheetController.dispose(); bottomSheetController.dispose();
_cancelTimers(); _cancelTimers();
_reloadSubscription?.cancel();
super.dispose(); 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 { Color get backgroundColor {
if (showingBottomSheet && !context.isDarkTheme) { if (showingBottomSheet && !context.isDarkTheme) {
return Colors.white; return Colors.white;
@ -186,11 +212,12 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
void _onDragStart( void _onDragStart(
_, _,
DragStartDetails details, DragStartDetails details,
PhotoViewControllerValue value, PhotoViewControllerBase controller,
PhotoViewScaleStateController scaleStateController, PhotoViewScaleStateController scaleStateController,
) { ) {
viewController = controller;
dragDownPosition = details.localPosition; dragDownPosition = details.localPosition;
initialPhotoViewState = value; initialPhotoViewState = controller.value;
final isZoomed = final isZoomed =
scaleStateController.scaleState == PhotoViewScaleState.zoomedIn || scaleStateController.scaleState == PhotoViewScaleState.zoomedIn ||
scaleStateController.scaleState == PhotoViewScaleState.covering; scaleStateController.scaleState == PhotoViewScaleState.covering;
@ -277,7 +304,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
void _openBottomSheet(BuildContext ctx) { void _openBottomSheet(BuildContext ctx) {
setState(() { setState(() {
initialScale = viewController?.scale; initialScale = viewController?.scale;
viewController?.animateMultiple(scale: _getScaleForBottomSheet); viewController?.updateMultiple(scale: _getScaleForBottomSheet);
showingBottomSheet = true; showingBottomSheet = true;
previousExtent = _kBottomSheetMinimumExtent; previousExtent = _kBottomSheetMinimumExtent;
sheetCloseNotifier = showBottomSheet( sheetCloseNotifier = showBottomSheet(
@ -308,10 +335,8 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
setState(() { setState(() {
showingBottomSheet = false; showingBottomSheet = false;
sheetCloseNotifier = null; sheetCloseNotifier = null;
viewController?.animateMultiple( viewController?.animateMultiple(position: Offset.zero);
position: Offset.zero, viewController?.updateMultiple(scale: initialScale);
scale: initialScale,
);
shouldPopOnDrag = false; shouldPopOnDrag = false;
hasDraggedDown = null; hasDraggedDown = null;
}); });
@ -420,10 +445,10 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
PhotoViewGalleryPageOptions _assetBuilder(BuildContext ctx, int index) { PhotoViewGalleryPageOptions _assetBuilder(BuildContext ctx, int index) {
final asset = ref.read(timelineServiceProvider).getAsset(index); final asset = ref.read(timelineServiceProvider).getAsset(index);
final size = Size(ctx.width, ctx.height); final size = Size(ctx.width, ctx.height);
final imageProvider = getFullImageProvider(asset, size: size);
return PhotoViewGalleryPageOptions( return PhotoViewGalleryPageOptions(
imageProvider: imageProvider, key: ValueKey(asset.heroTag),
imageProvider: getFullImageProvider(asset, size: size),
heroAttributes: PhotoViewHeroAttributes(tag: asset.heroTag), heroAttributes: PhotoViewHeroAttributes(tag: asset.heroTag),
filterQuality: FilterQuality.high, filterQuality: FilterQuality.high,
tightMode: true, tightMode: true,
@ -446,12 +471,18 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
); );
} }
void _onPop<T>(bool didPop, T? result) {
ref.read(currentAssetNotifier.notifier).dispose();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// Currently it is not possible to scroll the asset when the bottom sheet is open all the way. // 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 // Issue: https://github.com/flutter/flutter/issues/109037
// TODO: Add a custom scrum builder once the fix lands on stable // TODO: Add a custom scrum builder once the fix lands on stable
return Scaffold( return PopScope(
onPopInvokedWithResult: _onPop,
child: Scaffold(
backgroundColor: Colors.black.withAlpha(backgroundOpacity), backgroundColor: Colors.black.withAlpha(backgroundOpacity),
body: PhotoViewGallery.builder( body: PhotoViewGallery.builder(
gaplessPlayback: true, gaplessPlayback: true,
@ -468,6 +499,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
backgroundDecoration: BoxDecoration(color: backgroundColor), backgroundDecoration: BoxDecoration(color: backgroundColor),
enablePanAlways: true, enablePanAlways: true,
), ),
),
); );
} }
} }

View File

@ -1,30 +1,78 @@
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:hooks_riverpod/hooks_riverpod.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/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/exif.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/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/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/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/infrastructure/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/utils/bytes_units.dart'; import 'package:immich_mobile/utils/bytes_units.dart';
const _kSeparator = ''; const _kSeparator = '';
class AssetDetailBottomSheet extends BaseBottomSheet { class AssetDetailBottomSheet extends ConsumerWidget {
final DraggableScrollableController? controller;
final double initialChildSize;
const AssetDetailBottomSheet({ const AssetDetailBottomSheet({
super.controller, this.controller,
super.initialChildSize, this.initialChildSize = 0.35,
super.key, super.key,
}) : super( });
actions: const [],
@override
Widget build(BuildContext context, WidgetRef ref) {
final asset = ref.watch(currentAssetNotifier);
final isTrashEnable = ref.watch(
serverInfoProvider.select((state) => state.serverFeatures.trash),
);
final actions = <Widget>[
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()], slivers: const [_AssetDetailBottomSheet()],
controller: controller,
initialChildSize: initialChildSize,
minChildSize: 0.1, minChildSize: 0.1,
maxChildSize: 1.0, maxChildSize: 1.0,
expand: false, expand: false,
shouldCloseOnMinExtent: false, shouldCloseOnMinExtent: false,
resizeOnScroll: false, resizeOnScroll: false,
); );
}
} }
class _AssetDetailBottomSheet extends ConsumerWidget { class _AssetDetailBottomSheet extends ConsumerWidget {
@ -96,16 +144,16 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
// Asset Date and Time // Asset Date and Time
_SheetTile( _SheetTile(
title: _getDateTime(context, asset), title: _getDateTime(context, asset),
titleStyle: context.textTheme.bodyLarge titleStyle: context.textTheme.bodyLarge?.copyWith(
?.copyWith(fontWeight: FontWeight.w600), fontWeight: FontWeight.w500,
fontSize: 16,
), ),
),
const SheetLocationDetails(),
// Details header // Details header
_SheetTile( _SheetTile(
title: 'exif_bottom_sheet_details'.t(context: context), title: 'exif_bottom_sheet_details'.t(context: context),
titleStyle: context.textTheme.labelLarge?.copyWith( titleStyle: context.textTheme.labelLarge,
color: context.textTheme.labelLarge?.color,
fontWeight: FontWeight.w600,
),
), ),
// File info // File info
_SheetTile( _SheetTile(

View File

@ -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<SheetLocationDetails> {
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<ExifInfo?>? previous,
AsyncValue<ExifInfo?> 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),
),
),
],
),
);
}
}

View File

@ -89,17 +89,17 @@ class _BaseDraggableScrollableSheetState
const SizedBox(height: 14), const SizedBox(height: 14),
if (widget.actions.isNotEmpty) if (widget.actions.isNotEmpty)
SizedBox( SizedBox(
height: 80, height: 115,
child: ListView( child: ListView(
shrinkWrap: true, shrinkWrap: true,
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
children: widget.actions, children: widget.actions,
), ),
), ),
if (widget.actions.isNotEmpty) const SizedBox(height: 14), if (widget.actions.isNotEmpty) ...[
if (widget.actions.isNotEmpty) const Divider(indent: 16, endIndent: 16),
const Divider(indent: 20, endIndent: 20), const SizedBox(height: 16),
if (widget.actions.isNotEmpty) const SizedBox(height: 14), ],
], ],
), ),
), ),

View File

@ -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_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/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/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/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/presentation/widgets/bottom_app_bar/base_bottom_sheet.widget.dart';
import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart';

View File

@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:math' as math; import 'dart:math' as math;
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
@ -6,6 +7,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart'; import 'package:flutter/rendering.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/setting.model.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/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/bottom_app_bar/home_bottom_app_bar.widget.dart'; import 'package:immich_mobile/presentation/widgets/bottom_app_bar/home_bottom_app_bar.widget.dart';
@ -43,31 +45,36 @@ class Timeline extends StatelessWidget {
} }
} }
class _SliverTimeline extends StatefulWidget { class _SliverTimeline extends ConsumerStatefulWidget {
const _SliverTimeline(); const _SliverTimeline();
@override @override
State createState() => _SliverTimelineState(); ConsumerState createState() => _SliverTimelineState();
} }
class _SliverTimelineState extends State<_SliverTimeline> { class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
final _scrollController = ScrollController(); final _scrollController = ScrollController();
StreamSubscription? _reloadSubscription;
@override
void initState() {
super.initState();
_reloadSubscription =
EventStream.shared.listen<TimelineReloadEvent>((_) => setState(() {}));
}
@override @override
void dispose() { void dispose() {
_scrollController.dispose(); _scrollController.dispose();
_reloadSubscription?.cancel();
super.dispose(); super.dispose();
} }
@override @override
Widget build(BuildContext _) { Widget build(BuildContext _) {
return Consumer(
builder: (context, ref, child) {
final asyncSegments = ref.watch(timelineSegmentProvider); final asyncSegments = ref.watch(timelineSegmentProvider);
final maxHeight = final maxHeight =
ref.watch(timelineArgsProvider.select((args) => args.maxHeight)); ref.watch(timelineArgsProvider.select((args) => args.maxHeight));
final isMultiSelectEnabled =
ref.watch(multiSelectProvider.select((s) => s.isEnabled));
return asyncSegments.widgetWhen( return asyncSegments.widgetWhen(
onData: (segments) { onData: (segments) {
final childCount = (segments.lastOrNull?.lastIndex ?? -1) + 1; final childCount = (segments.lastOrNull?.lastIndex ?? -1) + 1;
@ -83,21 +90,16 @@ class _SliverTimelineState extends State<_SliverTimeline> {
layoutSegments: segments, layoutSegments: segments,
timelineHeight: maxHeight, timelineHeight: maxHeight,
topPadding: totalAppBarHeight + 10, topPadding: totalAppBarHeight + 10,
bottomPadding: bottomPadding: context.padding.bottom + scrubberBottomPadding,
context.padding.bottom + scrubberBottomPadding,
child: CustomScrollView( child: CustomScrollView(
primary: true, primary: true,
cacheExtent: maxHeight * 2, cacheExtent: maxHeight * 2,
slivers: [ slivers: [
SliverAnimatedOpacity( const ImmichSliverAppBar(
duration: Durations.medium1,
opacity: isMultiSelectEnabled ? 0 : 1,
sliver: const ImmichSliverAppBar(
floating: true, floating: true,
pinned: false, pinned: false,
snap: false, snap: false,
), ),
),
_SliverSegmentedList( _SliverSegmentedList(
segments: segments, segments: segments,
delegate: SliverChildBuilderDelegate( delegate: SliverChildBuilderDelegate(
@ -121,18 +123,42 @@ class _SliverTimelineState extends State<_SliverTimeline> {
], ],
), ),
), ),
if (isMultiSelectEnabled) ...[ Consumer(
const Positioned( 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, top: 60,
left: 25, left: 25,
child: _MultiSelectStatusButton(), child: _MultiSelectStatusButton(),
), ),
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 HomeBottomAppBar(),
),
],
),
); );
}, },
); );

View File

@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:immich_mobile/constants/enums.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/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/timeline/multiselect.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/services/action.service.dart'; import 'package:immich_mobile/services/action.service.dart';
@ -55,7 +56,7 @@ class ActionNotifier extends Notifier<void> {
final Set<BaseAsset> assets = switch (source) { final Set<BaseAsset> assets = switch (source) {
ActionSource.timeline => ActionSource.timeline =>
ref.read(multiSelectProvider.select((s) => s.selectedAssets)), ref.read(multiSelectProvider.select((s) => s.selectedAssets)),
ActionSource.viewer => {}, ActionSource.viewer => {ref.read(currentAssetNotifier)},
}; };
return switch (T) { return switch (T) {

View File

@ -3,9 +3,13 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
final currentAssetNotifier = final currentAssetNotifier =
NotifierProvider<CurrentAssetNotifier, BaseAsset>(CurrentAssetNotifier.new); AutoDisposeNotifierProvider<CurrentAssetNotifier, BaseAsset>(
CurrentAssetNotifier.new,
);
class CurrentAssetNotifier extends AutoDisposeNotifier<BaseAsset> {
KeepAliveLink? _keepAliveLink;
class CurrentAssetNotifier extends Notifier<BaseAsset> {
@override @override
BaseAsset build() { BaseAsset build() {
throw UnimplementedError( throw UnimplementedError(
@ -14,11 +18,17 @@ class CurrentAssetNotifier extends Notifier<BaseAsset> {
} }
void setAsset(BaseAsset asset) { void setAsset(BaseAsset asset) {
_keepAliveLink?.close();
state = asset; state = asset;
_keepAliveLink = ref.keepAlive();
}
void dispose() {
_keepAliveLink?.close();
} }
} }
final currentAssetExifProvider = FutureProvider( final currentAssetExifProvider = FutureProvider.autoDispose(
(ref) { (ref) {
final currentAsset = ref.watch(currentAssetNotifier); final currentAsset = ref.watch(currentAssetNotifier);
return ref.watch(assetServiceProvider).getExif(currentAsset); return ref.watch(assetServiceProvider).getExif(currentAsset);

View File

@ -9,11 +9,13 @@ import 'package:url_launcher/url_launcher.dart';
class ExifMap extends StatelessWidget { class ExifMap extends StatelessWidget {
final ExifInfo exifInfo; final ExifInfo exifInfo;
final String? markerId; final String? markerId;
final MapCreatedCallback? onMapCreated;
const ExifMap({ const ExifMap({
super.key, super.key,
required this.exifInfo, required this.exifInfo,
this.markerId = 'marker', this.markerId = 'marker',
this.onMapCreated,
}); });
@override @override
@ -82,6 +84,7 @@ class ExifMap extends StatelessWidget {
debugPrint('Opening Map Uri: $uri'); debugPrint('Opening Map Uri: $uri');
launchUrl(uri); launchUrl(uri);
}, },
onCreated: onMapCreated,
); );
}, },
); );

View File

@ -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/backup/backup.provider.dart';
import 'package:immich_mobile/providers/cast.provider.dart'; import 'package:immich_mobile/providers/cast.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/timeline/multiselect.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/widgets/asset_viewer/cast_dialog.dart'; import 'package:immich_mobile/widgets/asset_viewer/cast_dialog.dart';
@ -39,8 +40,13 @@ class ImmichSliverAppBar extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final isCasting = ref.watch(castProvider.select((c) => c.isCasting)); final isCasting = ref.watch(castProvider.select((c) => c.isCasting));
final isMultiSelectEnabled =
ref.watch(multiSelectProvider.select((s) => s.isEnabled));
return SliverAppBar( return SliverAnimatedOpacity(
duration: Durations.medium1,
opacity: isMultiSelectEnabled ? 0 : 1,
sliver: SliverAppBar(
floating: floating, floating: floating,
pinned: pinned, pinned: pinned,
snap: snap, snap: snap,
@ -97,6 +103,7 @@ class ImmichSliverAppBar extends ConsumerWidget {
child: _ProfileIndicator(), child: _ProfileIndicator(),
), ),
], ],
),
); );
} }
} }

View File

@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.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/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/maplibrecontroller_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/map_theme_override.dart';
import 'package:immich_mobile/widgets/map/positioned_asset_marker_icon.dart'; import 'package:immich_mobile/widgets/map/positioned_asset_marker_icon.dart';
@ -24,6 +25,7 @@ class MapThumbnail extends HookConsumerWidget {
final double width; final double width;
final ThemeMode? themeMode; final ThemeMode? themeMode;
final bool showAttribution; final bool showAttribution;
final MapCreatedCallback? onCreated;
const MapThumbnail({ const MapThumbnail({
super.key, super.key,
@ -36,16 +38,19 @@ class MapThumbnail extends HookConsumerWidget {
this.showMarkerPin = false, this.showMarkerPin = false,
this.themeMode, this.themeMode,
this.showAttribution = true, this.showAttribution = true,
this.onCreated,
}); });
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final offsettedCentre = LatLng(centre.latitude + 0.002, centre.longitude); final offsettedCentre = LatLng(centre.latitude + 0.002, centre.longitude);
final controller = useRef<MapLibreMapController?>(null); final controller = useRef<MapLibreMapController?>(null);
final styleLoaded = useState(false);
final position = useValueNotifier<Point<num>?>(null); final position = useValueNotifier<Point<num>?>(null);
Future<void> onMapCreated(MapLibreMapController mapController) async { Future<void> onMapCreated(MapLibreMapController mapController) async {
controller.value = mapController; controller.value = mapController;
styleLoaded.value = false;
if (assetMarkerRemoteId != null) { if (assetMarkerRemoteId != null) {
// The iOS impl returns wrong toScreenLocation without the delay // The iOS impl returns wrong toScreenLocation without the delay
Future.delayed( Future.delayed(
@ -54,17 +59,26 @@ class MapThumbnail extends HookConsumerWidget {
position.value = await mapController.toScreenLocation(centre), position.value = await mapController.toScreenLocation(centre),
); );
} }
onCreated?.call(mapController);
} }
Future<void> onStyleLoaded() async { Future<void> onStyleLoaded() async {
if (showMarkerPin && controller.value != null) { if (showMarkerPin && controller.value != null) {
await controller.value?.addMarkerAtLatLng(centre); await controller.value?.addMarkerAtLatLng(centre);
} }
styleLoaded.value = true;
} }
return MapThemeOverride( return MapThemeOverride(
themeMode: themeMode, 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, height: height,
width: width, width: width,
child: ClipRRect( child: ClipRRect(

View File

@ -660,7 +660,7 @@ typedef PhotoViewImageTapDownCallback = Function(
typedef PhotoViewImageDragStartCallback = Function( typedef PhotoViewImageDragStartCallback = Function(
BuildContext context, BuildContext context,
DragStartDetails details, DragStartDetails details,
PhotoViewControllerValue controllerValue, PhotoViewControllerBase controllerValue,
PhotoViewScaleStateController scaleStateController, PhotoViewScaleStateController scaleStateController,
); );

View File

@ -271,7 +271,7 @@ class _PhotoViewGalleryState extends State<PhotoViewGallery> {
final PhotoView photoView = isCustomChild final PhotoView photoView = isCustomChild
? PhotoView.customChild( ? PhotoView.customChild(
key: ObjectKey(index), key: pageOption.key ?? ObjectKey(index),
childSize: pageOption.childSize, childSize: pageOption.childSize,
backgroundDecoration: widget.backgroundDecoration, backgroundDecoration: widget.backgroundDecoration,
wantKeepAlive: widget.wantKeepAlive, wantKeepAlive: widget.wantKeepAlive,
@ -304,7 +304,7 @@ class _PhotoViewGalleryState extends State<PhotoViewGallery> {
child: pageOption.child, child: pageOption.child,
) )
: PhotoView( : PhotoView(
key: ObjectKey(index), key: pageOption.key ?? ObjectKey(index),
index: index, index: index,
imageProvider: pageOption.imageProvider, imageProvider: pageOption.imageProvider,
loadingBuilder: widget.loadingBuilder, loadingBuilder: widget.loadingBuilder,
@ -363,7 +363,7 @@ class _PhotoViewGalleryState extends State<PhotoViewGallery> {
/// ///
class PhotoViewGalleryPageOptions { class PhotoViewGalleryPageOptions {
PhotoViewGalleryPageOptions({ PhotoViewGalleryPageOptions({
Key? key, this.key,
required this.imageProvider, required this.imageProvider,
this.heroAttributes, this.heroAttributes,
this.semanticLabel, this.semanticLabel,
@ -392,6 +392,7 @@ class PhotoViewGalleryPageOptions {
assert(imageProvider != null); assert(imageProvider != null);
const PhotoViewGalleryPageOptions.customChild({ const PhotoViewGalleryPageOptions.customChild({
this.key,
required this.child, required this.child,
this.childSize, this.childSize,
this.semanticLabel, this.semanticLabel,
@ -418,6 +419,8 @@ class PhotoViewGalleryPageOptions {
}) : errorBuilder = null, }) : errorBuilder = null,
imageProvider = null; imageProvider = null;
final Key? key;
/// Mirror to [PhotoView.imageProvider] /// Mirror to [PhotoView.imageProvider]
final ImageProvider? imageProvider; final ImageProvider? imageProvider;

View File

@ -416,7 +416,7 @@ class PhotoViewCoreState extends State<PhotoViewCore>
? (details) => widget.onDragStart!( ? (details) => widget.onDragStart!(
context, context,
details, details,
widget.controller.value, widget.controller,
widget.scaleStateController, widget.scaleStateController,
) )
: null, : null,