You've already forked immich
mirror of
https://github.com/immich-app/immich.git
synced 2025-08-07 23:03:36 +02:00
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:
@ -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(
|
||||
|
@ -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(
|
||||
|
@ -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(
|
||||
|
@ -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(
|
||||
|
@ -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(
|
||||
|
@ -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(
|
@ -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(
|
||||
|
@ -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(
|
||||
|
@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
@ -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(
|
@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -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),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
@ -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';
|
||||
|
@ -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(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
Reference in New Issue
Block a user