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 '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;

View File

@ -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 {

View File

@ -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 {

View File

@ -6,6 +6,7 @@ enum Setting<T> {
showStorageIndicator<bool>(StoreKey.storageIndicator, true),
loadOriginal<bool>(StoreKey.loadOriginal, false),
preferRemoteImage<bool>(StoreKey.preferRemoteImage, false),
advancedTroubleshooting<bool>(StoreKey.advancedTroubleshooting, false),
;
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/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<int>(0, (acc, bucket) => acc + bucket.assetCount);
unawaited(reloadBucket());
unawaited(_reloadBucket());
});
}
@ -79,8 +80,9 @@ class TimelineService {
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);
EventStream.shared.emit(const TimelineReloadEvent());
});
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 = [
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<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/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(

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/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(

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/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(

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/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(

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/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(

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/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(

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/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(

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/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(

View File

@ -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<AssetViewer> {
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<AssetViewer> {
WidgetsBinding.instance.addPostFrameCallback((_) {
_onAssetChanged(widget.initialIndex);
});
_reloadSubscription =
EventStream.shared.listen<TimelineReloadEvent>(_onTimelineReload);
}
@override
@ -95,9 +99,31 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
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<AssetViewer> {
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<AssetViewer> {
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<AssetViewer> {
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<AssetViewer> {
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<AssetViewer> {
);
}
void _onPop<T>(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,
),
),
);
}

View File

@ -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 = <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()],
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(

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),
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),
],
],
),
),

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_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';

View File

@ -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<TimelineReloadEvent>((_) => 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(),
),
],
),
);
},
);

View File

@ -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<void> {
final Set<BaseAsset> assets = switch (source) {
ActionSource.timeline =>
ref.read(multiSelectProvider.select((s) => s.selectedAssets)),
ActionSource.viewer => {},
ActionSource.viewer => {ref.read(currentAssetNotifier)},
};
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';
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
BaseAsset build() {
throw UnimplementedError(
@ -14,11 +18,17 @@ class CurrentAssetNotifier extends Notifier<BaseAsset> {
}
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);

View File

@ -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,
);
},
);

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/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(),
),
],
],
),
);
}
}

View File

@ -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<MapLibreMapController?>(null);
final styleLoaded = useState(false);
final position = useValueNotifier<Point<num>?>(null);
Future<void> 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<void> 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(

View File

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

View File

@ -271,7 +271,7 @@ class _PhotoViewGalleryState extends State<PhotoViewGallery> {
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<PhotoViewGallery> {
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<PhotoViewGallery> {
///
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;

View File

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