feat: adds bottom sheet map and actions (#19726)

* 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

* adds bottom sheet map and actions

* PR feedbacks

* refactor: use provider for viewer state

* feat: adds top and bottom app bar

* add safe area to bottom app bar

* change app and bottom bar color

* viewer - always have black background

* use the full width for the bottom sheet on landscape as well

* constraint the bottom sheet to not expand all the way

* add padding for location details in landscape

---------

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-05 00:38:06 +05:30 committed by GitHub
parent 4a2cf28882
commit 73733370a2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 622 additions and 155 deletions

View File

@ -78,4 +78,38 @@ class RemoteAsset extends BaseAsset {
localId.hashCode ^ localId.hashCode ^
thumbHash.hashCode ^ thumbHash.hashCode ^
visibility.hashCode; visibility.hashCode;
RemoteAsset copyWith({
String? id,
String? localId,
String? name,
String? ownerId,
String? checksum,
AssetType? type,
DateTime? createdAt,
DateTime? updatedAt,
int? width,
int? height,
int? durationInSeconds,
bool? isFavorite,
String? thumbHash,
AssetVisibility? visibility,
}) {
return RemoteAsset(
id: id ?? this.id,
localId: localId ?? this.localId,
name: name ?? this.name,
ownerId: ownerId ?? this.ownerId,
checksum: checksum ?? this.checksum,
type: type ?? this.type,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
width: width ?? this.width,
height: height ?? this.height,
durationInSeconds: durationInSeconds ?? this.durationInSeconds,
isFavorite: isFavorite ?? this.isFavorite,
thumbHash: thumbHash ?? this.thumbHash,
visibility: visibility ?? this.visibility,
);
}
} }

View File

@ -1,13 +1,24 @@
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/infrastructure/repositories/local_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart';
class AssetService { class AssetService {
final RemoteAssetRepository _remoteAssetRepository; final RemoteAssetRepository _remoteAssetRepository;
final DriftLocalAssetRepository _localAssetRepository;
const AssetService({ const AssetService({
required RemoteAssetRepository remoteAssetRepository, required RemoteAssetRepository remoteAssetRepository,
}) : _remoteAssetRepository = remoteAssetRepository; required DriftLocalAssetRepository localAssetRepository,
}) : _remoteAssetRepository = remoteAssetRepository,
_localAssetRepository = localAssetRepository;
Stream<BaseAsset?> watchAsset(BaseAsset asset) {
final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).id;
return asset is LocalAsset
? _localAssetRepository.watchAsset(id)
: _remoteAssetRepository.watchAsset(id);
}
Future<ExifInfo?> getExif(BaseAsset asset) async { Future<ExifInfo?> getExif(BaseAsset asset) async {
if (asset is LocalAsset || asset is! RemoteAsset) { if (asset is LocalAsset || asset is! RemoteAsset) {

View File

@ -8,6 +8,10 @@ class TimelineReloadEvent extends Event {
const TimelineReloadEvent(); const TimelineReloadEvent();
} }
class ViewerOpenBottomSheetEvent extends Event {
const ViewerOpenBottomSheetEvent();
}
class EventStream { class EventStream {
EventStream._(); EventStream._();

View File

@ -28,5 +28,8 @@ extension LocalAssetEntityDataDomainEx on LocalAssetEntityData {
updatedAt: updatedAt, updatedAt: updatedAt,
durationInSeconds: durationInSeconds, durationInSeconds: durationInSeconds,
isFavorite: isFavorite, isFavorite: isFavorite,
height: height,
width: width,
remoteId: null,
); );
} }

View File

@ -1,5 +1,6 @@
import 'package:drift/drift.dart'; import 'package:drift/drift.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/infrastructure/entities/local_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
@ -7,6 +8,26 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository {
final Drift _db; final Drift _db;
const DriftLocalAssetRepository(this._db) : super(_db); const DriftLocalAssetRepository(this._db) : super(_db);
Stream<LocalAsset?> watchAsset(String id) {
final query = _db.localAssetEntity
.select()
.addColumns([_db.localAssetEntity.id]).join([
leftOuterJoin(
_db.remoteAssetEntity,
_db.localAssetEntity.checksum.equalsExp(_db.remoteAssetEntity.checksum),
useColumns: false,
),
])
..where(_db.localAssetEntity.id.equals(id));
return query.map((row) {
final asset = row.readTable(_db.localAssetEntity).toDto();
return asset.copyWith(
remoteId: row.read(_db.remoteAssetEntity.id),
);
}).watchSingleOrNull();
}
Future<void> updateHashes(Iterable<LocalAsset> hashes) { Future<void> updateHashes(Iterable<LocalAsset> hashes) {
if (hashes.isEmpty) { if (hashes.isEmpty) {
return Future.value(); return Future.value();

View File

@ -4,6 +4,7 @@ import 'package:immich_mobile/domain/models/exif.model.dart';
import 'package:immich_mobile/infrastructure/entities/exif.entity.dart' import 'package:immich_mobile/infrastructure/entities/exif.entity.dart'
hide ExifInfo; hide ExifInfo;
import 'package:immich_mobile/infrastructure/entities/exif.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/exif.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:maplibre_gl/maplibre_gl.dart'; import 'package:maplibre_gl/maplibre_gl.dart';
@ -12,6 +13,26 @@ class RemoteAssetRepository extends DriftDatabaseRepository {
final Drift _db; final Drift _db;
const RemoteAssetRepository(this._db) : super(_db); const RemoteAssetRepository(this._db) : super(_db);
Stream<RemoteAsset?> watchAsset(String id) {
final query = _db.remoteAssetEntity
.select()
.addColumns([_db.localAssetEntity.id]).join([
leftOuterJoin(
_db.localAssetEntity,
_db.remoteAssetEntity.checksum.equalsExp(_db.localAssetEntity.checksum),
useColumns: false,
),
])
..where(_db.remoteAssetEntity.id.equals(id));
return query.map((row) {
final asset = row.readTable(_db.remoteAssetEntity).toDto();
return asset.copyWith(
localId: row.read(_db.localAssetEntity.id),
);
}).watchSingleOrNull();
}
Future<ExifInfo?> getExif(String id) { Future<ExifInfo?> getExif(String id) {
return _db.managers.remoteExifEntity return _db.managers.remoteExifEntity
.filter((row) => row.assetId.id.equals(id)) .filter((row) => row.assetId.id.equals(id))

View File

@ -9,17 +9,33 @@ class BaseActionButton extends StatelessWidget {
this.onPressed, this.onPressed,
this.onLongPressed, this.onLongPressed,
this.maxWidth = 90.0, this.maxWidth = 90.0,
this.minWidth,
this.menuItem = false,
}); });
final String label; final String label;
final IconData iconData; final IconData iconData;
final double maxWidth; final double maxWidth;
final double? minWidth;
final bool menuItem;
final void Function()? onPressed; final void Function()? onPressed;
final void Function()? onLongPressed; final void Function()? onLongPressed;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final minWidth = context.isMobile ? context.width / 4.5 : 75.0; final miniWidth =
minWidth ?? (context.isMobile ? context.width / 4.5 : 75.0);
final iconTheme = IconTheme.of(context);
final iconSize = iconTheme.size ?? 24.0;
final iconColor = iconTheme.color ?? context.themeData.iconTheme.color;
final textColor = context.themeData.textTheme.labelLarge?.color;
if (menuItem) {
return IconButton(
onPressed: onPressed,
icon: Icon(iconData, size: iconSize, color: iconColor),
);
}
return ConstrainedBox( return ConstrainedBox(
constraints: BoxConstraints( constraints: BoxConstraints(
@ -30,19 +46,22 @@ class BaseActionButton extends StatelessWidget {
shape: const RoundedRectangleBorder( shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(20)), borderRadius: BorderRadius.all(Radius.circular(20)),
), ),
textColor: textColor,
onPressed: onPressed, onPressed: onPressed,
onLongPress: onLongPressed, onLongPress: onLongPressed,
minWidth: minWidth, minWidth: miniWidth,
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
Icon(iconData, size: 24), Icon(iconData, size: iconSize, color: iconColor),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
label, label,
style: style: const TextStyle(
const TextStyle(fontSize: 14.0, fontWeight: FontWeight.w400), fontSize: 14.0,
fontWeight: FontWeight.w400,
),
maxLines: 3, maxLines: 3,
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),

View File

@ -10,8 +10,13 @@ import 'package:immich_mobile/widgets/common/immich_toast.dart';
class FavoriteActionButton extends ConsumerWidget { class FavoriteActionButton extends ConsumerWidget {
final ActionSource source; final ActionSource source;
final bool menuItem;
const FavoriteActionButton({super.key, required this.source}); const FavoriteActionButton({
super.key,
required this.source,
this.menuItem = false,
});
void _onTap(BuildContext context, WidgetRef ref) async { void _onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) { if (!context.mounted) {
@ -19,6 +24,11 @@ class FavoriteActionButton extends ConsumerWidget {
} }
final result = await ref.read(actionProvider.notifier).favorite(source); final result = await ref.read(actionProvider.notifier).favorite(source);
if (source == ActionSource.viewer) {
return;
}
ref.read(multiSelectProvider.notifier).reset(); ref.read(multiSelectProvider.notifier).reset();
final successMessage = 'favorite_action_prompt'.t( final successMessage = 'favorite_action_prompt'.t(
@ -43,6 +53,7 @@ class FavoriteActionButton extends ConsumerWidget {
return BaseActionButton( return BaseActionButton(
iconData: Icons.favorite_border_rounded, iconData: Icons.favorite_border_rounded,
label: "favorite".t(context: context), label: "favorite".t(context: context),
menuItem: menuItem,
onPressed: () => _onTap(context, ref), onPressed: () => _onTap(context, ref),
); );
} }

View File

@ -10,8 +10,13 @@ import 'package:immich_mobile/widgets/common/immich_toast.dart';
class UnFavoriteActionButton extends ConsumerWidget { class UnFavoriteActionButton extends ConsumerWidget {
final ActionSource source; final ActionSource source;
final bool menuItem;
const UnFavoriteActionButton({super.key, required this.source}); const UnFavoriteActionButton({
super.key,
required this.source,
this.menuItem = false,
});
void _onTap(BuildContext context, WidgetRef ref) async { void _onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) { if (!context.mounted) {
@ -19,6 +24,11 @@ class UnFavoriteActionButton extends ConsumerWidget {
} }
final result = await ref.read(actionProvider.notifier).unFavorite(source); final result = await ref.read(actionProvider.notifier).unFavorite(source);
if (source == ActionSource.viewer) {
return;
}
ref.read(multiSelectProvider.notifier).reset(); ref.read(multiSelectProvider.notifier).reset();
final successMessage = 'unfavorite_action_prompt'.t( final successMessage = 'unfavorite_action_prompt'.t(
@ -44,6 +54,7 @@ class UnFavoriteActionButton extends ConsumerWidget {
iconData: Icons.favorite_rounded, iconData: Icons.favorite_rounded,
label: "unfavorite".t(context: context), label: "unfavorite".t(context: context),
onPressed: () => _onTap(context, ref), onPressed: () => _onTap(context, ref),
menuItem: menuItem,
); );
} }
} }

View File

@ -7,7 +7,10 @@ import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/domain/utils/event_stream.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/asset_viewer.state.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_bar.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_sheet.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_sheet.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/top_app_bar.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';
@ -58,10 +61,10 @@ const double _kBottomSheetSnapExtent = 0.7;
class _AssetViewerState extends ConsumerState<AssetViewer> { class _AssetViewerState extends ConsumerState<AssetViewer> {
late PageController pageController; late PageController pageController;
late DraggableScrollableController bottomSheetController; late DraggableScrollableController bottomSheetController;
PersistentBottomSheetController? sheetCloseNotifier; PersistentBottomSheetController? sheetCloseController;
// PhotoViewGallery takes care of disposing it's controllers // PhotoViewGallery takes care of disposing it's controllers
PhotoViewControllerBase? viewController; PhotoViewControllerBase? viewController;
StreamSubscription? _reloadSubscription; StreamSubscription? reloadSubscription;
late Platform platform; late Platform platform;
late PhotoViewControllerValue initialPhotoViewState; late PhotoViewControllerValue initialPhotoViewState;
@ -70,12 +73,11 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
bool blockGestures = false; bool blockGestures = false;
bool dragInProgress = false; bool dragInProgress = false;
bool shouldPopOnDrag = false; bool shouldPopOnDrag = false;
bool showingBottomSheet = false;
double? initialScale; double? initialScale;
double previousExtent = _kBottomSheetMinimumExtent; double previousExtent = _kBottomSheetMinimumExtent;
Offset dragDownPosition = Offset.zero; Offset dragDownPosition = Offset.zero;
int totalAssets = 0; int totalAssets = 0;
int backgroundOpacity = 255; BuildContext? scaffoldContext;
// Delayed operations that should be cancelled on disposal // Delayed operations that should be cancelled on disposal
final List<Timer> _delayedOperations = []; final List<Timer> _delayedOperations = [];
@ -90,8 +92,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
_onAssetChanged(widget.initialIndex); _onAssetChanged(widget.initialIndex);
}); });
_reloadSubscription = reloadSubscription = EventStream.shared.listen(_onEvent);
EventStream.shared.listen<TimelineReloadEvent>(_onTimelineReload);
} }
@override @override
@ -99,36 +100,17 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
pageController.dispose(); pageController.dispose();
bottomSheetController.dispose(); bottomSheetController.dispose();
_cancelTimers(); _cancelTimers();
_reloadSubscription?.cancel(); reloadSubscription?.cancel();
super.dispose(); super.dispose();
} }
void _onTimelineReload(_) { bool get showingBottomSheet =>
setState(() { ref.read(assetViewerProvider.select((s) => s.showingBottomSheet));
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) { final opacity =
return Colors.white; ref.read(assetViewerProvider.select((s) => s.backgroundOpacity));
} return Colors.black.withAlpha(opacity);
return Colors.black.withAlpha(backgroundOpacity);
} }
void _cancelTimers() { void _cancelTimers() {
@ -145,6 +127,9 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
(viewController?.prevValue.scale ?? viewController?.value.scale ?? 1.0) + (viewController?.prevValue.scale ?? viewController?.value.scale ?? 1.0) +
0.01; 0.01;
double _getVerticalOffsetForBottomSheet(double extent) =>
(context.height * extent) - (context.height * _kBottomSheetMinimumExtent);
Future<void> _precacheImage(int index) async { Future<void> _precacheImage(int index) async {
if (!mounted || index < 0 || index >= totalAssets) { if (!mounted || index < 0 || index >= totalAssets) {
return; return;
@ -247,16 +232,14 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
return; return;
} }
setState(() { shouldPopOnDrag = false;
shouldPopOnDrag = false; hasDraggedDown = null;
hasDraggedDown = null; viewController?.animateMultiple(
backgroundOpacity = 255; position: initialPhotoViewState.position,
viewController?.animateMultiple( scale: initialPhotoViewState.scale,
position: initialPhotoViewState.position, rotation: initialPhotoViewState.rotation,
scale: initialPhotoViewState.scale, );
rotation: initialPhotoViewState.rotation, ref.read(assetViewerProvider.notifier).setOpacity(255);
);
});
} }
void _onDragUpdate(BuildContext ctx, DragUpdateDetails details, _) { void _onDragUpdate(BuildContext ctx, DragUpdateDetails details, _) {
@ -277,18 +260,10 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
void _handleDragUp(BuildContext ctx, Offset delta) { void _handleDragUp(BuildContext ctx, Offset delta) {
const double openThreshold = 50; const double openThreshold = 50;
const double closeThreshold = 25;
final position = initialPhotoViewState.position + Offset(0, delta.dy); final position = initialPhotoViewState.position + Offset(0, delta.dy);
final distanceToOrigin = position.distance; final distanceToOrigin = position.distance;
if (showingBottomSheet && distanceToOrigin < closeThreshold) {
// Prevents the user from dragging the bottom sheet further down
blockGestures = true;
sheetCloseNotifier?.close();
return;
}
viewController?.updateMultiple(position: position); viewController?.updateMultiple(position: position);
// Moves the bottom sheet when the asset is being dragged up // Moves the bottom sheet when the asset is being dragged up
if (showingBottomSheet && bottomSheetController.isAttached) { if (showingBottomSheet && bottomSheetController.isAttached) {
@ -301,66 +276,37 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
} }
} }
void _openBottomSheet(BuildContext ctx) { void _handleDragDown(BuildContext ctx, Offset delta) {
setState(() { const double dragRatio = 0.2;
initialScale = viewController?.scale; const double popThreshold = 75;
viewController?.updateMultiple(scale: _getScaleForBottomSheet);
showingBottomSheet = true;
previousExtent = _kBottomSheetMinimumExtent;
sheetCloseNotifier = showBottomSheet(
context: ctx,
sheetAnimationStyle: AnimationStyle(
duration: Duration.zero,
reverseDuration: Duration.zero,
),
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20.0)),
),
backgroundColor: ctx.colorScheme.surfaceContainerLowest,
builder: (_) {
return NotificationListener<Notification>(
onNotification: _onNotification,
child: AssetDetailBottomSheet(
controller: bottomSheetController,
initialChildSize: _kBottomSheetMinimumExtent,
),
);
},
);
sheetCloseNotifier?.closed.then((_) => _handleSheetClose());
});
}
void _handleSheetClose() { final distance = delta.distance;
setState(() { shouldPopOnDrag = delta.dy > 0 && distance > popThreshold;
showingBottomSheet = false;
sheetCloseNotifier = null;
viewController?.animateMultiple(position: Offset.zero);
viewController?.updateMultiple(scale: initialScale);
shouldPopOnDrag = false;
hasDraggedDown = null;
});
}
void _snapBottomSheet() { final maxScaleDistance = ctx.height * 0.5;
if (bottomSheetController.size > _kBottomSheetSnapExtent || final scaleReduction = (distance / maxScaleDistance).clamp(0.0, dragRatio);
bottomSheetController.size < 0.4) { double? updatedScale;
return; if (initialPhotoViewState.scale != null) {
updatedScale = initialPhotoViewState.scale! * (1.0 - scaleReduction);
} }
isSnapping = true;
bottomSheetController.animateTo( final backgroundOpacity =
_kBottomSheetSnapExtent, (255 * (1.0 - (scaleReduction / dragRatio))).round();
duration: Durations.short3,
curve: Curves.easeOut, viewController?.updateMultiple(
position: initialPhotoViewState.position + delta,
scale: updatedScale,
); );
ref.read(assetViewerProvider.notifier).setOpacity(backgroundOpacity);
}
void _onTapDown(_, __, ___) {
if (!showingBottomSheet) {
ref.read(assetViewerProvider.notifier).toggleControls();
}
} }
bool _onNotification(Notification delta) { bool _onNotification(Notification delta) {
// Ignore notifications when user dragging the asset
if (dragInProgress) {
return false;
}
if (delta is DraggableScrollableNotification) { if (delta is DraggableScrollableNotification) {
_handleDraggableNotification(delta); _handleDraggableNotification(delta);
} }
@ -375,50 +321,117 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
} }
void _handleDraggableNotification(DraggableScrollableNotification delta) { void _handleDraggableNotification(DraggableScrollableNotification delta) {
final verticalOffset = (context.height * delta.extent) - final currentExtent = delta.extent;
(context.height * _kBottomSheetMinimumExtent); final isDraggingDown = currentExtent < previousExtent;
previousExtent = currentExtent;
// Closes the bottom sheet if the user is dragging down
if (isDraggingDown && delta.extent < 0.5) {
if (dragInProgress) {
blockGestures = true;
}
sheetCloseController?.close();
}
// If the asset is being dragged down, we do not want to update the asset position again
if (dragInProgress) {
return;
}
final verticalOffset = _getVerticalOffsetForBottomSheet(delta.extent);
// Moves the asset when the bottom sheet is being dragged // Moves the asset when the bottom sheet is being dragged
if (verticalOffset > 0) { if (verticalOffset > 0) {
viewController?.position = Offset(0, -verticalOffset); viewController?.position = Offset(0, -verticalOffset);
} }
}
final currentExtent = delta.extent; void _onEvent(Event event) {
final isDraggingDown = currentExtent < previousExtent; if (event is TimelineReloadEvent) {
previousExtent = currentExtent; _onTimelineReload(event);
// Closes the bottom sheet if the user is dragging down and the extent is less than the snap extent return;
if (isDraggingDown && delta.extent < _kBottomSheetSnapExtent - 0.1) { }
sheetCloseNotifier?.close();
if (event is ViewerOpenBottomSheetEvent) {
final extent = _kBottomSheetMinimumExtent + 0.3;
_openBottomSheet(scaffoldContext!, extent: extent);
final offset = _getVerticalOffsetForBottomSheet(extent);
viewController?.position = Offset(0, -offset);
return;
} }
} }
void _handleDragDown(BuildContext ctx, Offset delta) { void _onTimelineReload(_) {
const double dragRatio = 0.2; setState(() {
const double popThreshold = 75; totalAssets = ref.read(timelineServiceProvider).totalAssets;
if (totalAssets == 0) {
context.maybePop();
return;
}
final distance = delta.distance; final index = pageController.page?.round() ?? 0;
final newShouldPopOnDrag = delta.dy > 0 && distance > popThreshold; 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;
}
final maxScaleDistance = ctx.height * 0.5; _onAssetChanged(pageController.page!.round());
final scaleReduction = (distance / maxScaleDistance).clamp(0.0, dragRatio); sheetCloseController?.close();
double? updatedScale; });
if (initialPhotoViewState.scale != null) { }
updatedScale = initialPhotoViewState.scale! * (1.0 - scaleReduction);
}
final newBackgroundOpacity = void _openBottomSheet(
(255 * (1.0 - (scaleReduction / dragRatio))).round(); BuildContext ctx, {
double extent = _kBottomSheetMinimumExtent,
viewController?.updateMultiple( }) {
position: initialPhotoViewState.position + delta, ref.read(assetViewerProvider.notifier).setBottomSheet(true);
scale: updatedScale, initialScale = viewController?.scale;
viewController?.updateMultiple(scale: _getScaleForBottomSheet);
previousExtent = _kBottomSheetMinimumExtent;
sheetCloseController = showBottomSheet(
context: ctx,
sheetAnimationStyle: AnimationStyle(
duration: Durations.short4,
reverseDuration: Durations.short2,
),
constraints: const BoxConstraints(maxWidth: double.infinity),
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20.0)),
),
backgroundColor: ctx.colorScheme.surfaceContainerLowest,
builder: (_) {
return NotificationListener<Notification>(
onNotification: _onNotification,
child: AssetDetailBottomSheet(
controller: bottomSheetController,
initialChildSize: extent,
),
);
},
); );
if (shouldPopOnDrag != newShouldPopOnDrag || sheetCloseController?.closed.then((_) => _handleSheetClose());
backgroundOpacity != newBackgroundOpacity) { }
setState(() {
shouldPopOnDrag = newShouldPopOnDrag; void _handleSheetClose() {
backgroundOpacity = newBackgroundOpacity; viewController?.animateMultiple(position: Offset.zero);
}); viewController?.updateMultiple(scale: initialScale);
ref.read(assetViewerProvider.notifier).setBottomSheet(false);
sheetCloseController = null;
shouldPopOnDrag = false;
hasDraggedDown = null;
}
void _snapBottomSheet() {
if (bottomSheetController.size > _kBottomSheetSnapExtent ||
bottomSheetController.size < 0.4) {
return;
} }
isSnapping = true;
bottomSheetController.animateTo(
_kBottomSheetSnapExtent,
duration: Durations.short3,
curve: Curves.easeOut,
);
} }
Widget _placeholderBuilder( Widget _placeholderBuilder(
@ -443,6 +456,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
} }
PhotoViewGalleryPageOptions _assetBuilder(BuildContext ctx, int index) { PhotoViewGalleryPageOptions _assetBuilder(BuildContext ctx, int index) {
scaffoldContext ??= ctx;
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);
@ -458,6 +472,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
onDragStart: _onDragStart, onDragStart: _onDragStart,
onDragUpdate: _onDragUpdate, onDragUpdate: _onDragUpdate,
onDragEnd: _onDragEnd, onDragEnd: _onDragEnd,
onTapDown: _onTapDown,
errorBuilder: (_, __, ___) => Container( errorBuilder: (_, __, ___) => Container(
width: ctx.width, width: ctx.width,
height: ctx.height, height: ctx.height,
@ -477,13 +492,21 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// Rebuild the widget when the asset viewer state changes
// Using multiple selectors to avoid unnecessary rebuilds for other state changes
ref.watch(assetViewerProvider.select((s) => s.showingBottomSheet));
ref.watch(assetViewerProvider.select((s) => s.backgroundOpacity));
// 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 PopScope( return PopScope(
onPopInvokedWithResult: _onPop, onPopInvokedWithResult: _onPop,
child: Scaffold( child: Scaffold(
backgroundColor: Colors.black.withAlpha(backgroundOpacity), backgroundColor: backgroundColor,
appBar: const ViewerTopAppBar(),
extendBody: true,
extendBodyBehindAppBar: true,
body: PhotoViewGallery.builder( body: PhotoViewGallery.builder(
gaplessPlayback: true, gaplessPlayback: true,
loadingBuilder: _placeholderBuilder, loadingBuilder: _placeholderBuilder,
@ -499,6 +522,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
backgroundDecoration: BoxDecoration(color: backgroundColor), backgroundDecoration: BoxDecoration(color: backgroundColor),
enablePanAlways: true, enablePanAlways: true,
), ),
bottomNavigationBar: const ViewerBottomBar(),
), ),
); );
} }

View File

@ -0,0 +1,76 @@
import 'package:riverpod_annotation/riverpod_annotation.dart';
class AssetViewerState {
final int backgroundOpacity;
final bool showingBottomSheet;
final bool showingControls;
const AssetViewerState({
this.backgroundOpacity = 255,
this.showingBottomSheet = false,
this.showingControls = true,
});
AssetViewerState copyWith({
int? backgroundOpacity,
bool? showingBottomSheet,
bool? showingControls,
}) {
return AssetViewerState(
backgroundOpacity: backgroundOpacity ?? this.backgroundOpacity,
showingBottomSheet: showingBottomSheet ?? this.showingBottomSheet,
showingControls: showingControls ?? this.showingControls,
);
}
@override
String toString() {
return 'AssetViewerState(opacity: $backgroundOpacity, bottomSheet: $showingBottomSheet, controls: $showingControls)';
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
if (other.runtimeType != runtimeType) return false;
return other is AssetViewerState &&
other.backgroundOpacity == backgroundOpacity &&
other.showingBottomSheet == showingBottomSheet &&
other.showingControls == showingControls;
}
@override
int get hashCode =>
backgroundOpacity.hashCode ^
showingBottomSheet.hashCode ^
showingControls.hashCode;
}
class AssetViewerStateNotifier extends AutoDisposeNotifier<AssetViewerState> {
@override
AssetViewerState build() {
return const AssetViewerState();
}
void setOpacity(int opacity) {
state = state.copyWith(
backgroundOpacity: opacity,
showingControls: opacity == 255 ? true : state.showingControls,
);
}
void setBottomSheet(bool showing) {
state = state.copyWith(
showingBottomSheet: showing,
showingControls: showing ? true : state.showingControls,
);
}
void toggleControls() {
state = state.copyWith(showingControls: !state.showingControls);
}
}
final assetViewerProvider =
AutoDisposeNotifierProvider<AssetViewerStateNotifier, AssetViewerState>(
AssetViewerStateNotifier.new,
);

View File

@ -0,0 +1,93 @@
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/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/base_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
class ViewerBottomBar extends ConsumerWidget {
const ViewerBottomBar({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final asset = ref.watch(currentAssetNotifier);
if (asset == null) {
return const SizedBox.shrink();
}
final user = ref.watch(currentUserProvider);
final isOwner = asset is RemoteAsset && asset.ownerId == user?.id;
final isSheetOpen = ref.watch(
assetViewerProvider.select((s) => s.showingBottomSheet),
);
int opacity = ref.watch(
assetViewerProvider.select((state) => state.backgroundOpacity),
);
final showControls =
ref.watch(assetViewerProvider.select((s) => s.showingControls));
if (!showControls) {
opacity = 0;
}
final actions = <Widget>[
const ShareActionButton(),
const _EditActionButton(),
if (asset.hasRemote && isOwner)
const ArchiveActionButton(source: ActionSource.viewer),
];
return IgnorePointer(
ignoring: opacity < 255,
child: AnimatedOpacity(
opacity: opacity / 255,
duration: Durations.short2,
child: AnimatedSwitcher(
duration: Durations.short4,
child: isSheetOpen
? const SizedBox.shrink()
: SafeArea(
child: Theme(
data: context.themeData.copyWith(
iconTheme:
const IconThemeData(size: 22, color: Colors.white),
textTheme: context.themeData.textTheme.copyWith(
labelLarge:
context.themeData.textTheme.labelLarge?.copyWith(
color: Colors.white,
),
),
),
child: Container(
height: 80,
color: Colors.black.withAlpha(125),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: actions,
),
),
),
),
),
),
);
}
}
class _EditActionButton extends ConsumerWidget {
const _EditActionButton();
@override
Widget build(BuildContext context, WidgetRef ref) {
return BaseActionButton(
iconData: Icons.tune_outlined,
label: 'edit'.t(context: context),
);
}
}

View File

@ -10,7 +10,6 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_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/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/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/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_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';
@ -37,6 +36,10 @@ class AssetDetailBottomSheet extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final asset = ref.watch(currentAssetNotifier); final asset = ref.watch(currentAssetNotifier);
if (asset == null) {
return const SizedBox.shrink();
}
final isTrashEnable = ref.watch( final isTrashEnable = ref.watch(
serverInfoProvider.select((state) => state.serverFeatures.trash), serverInfoProvider.select((state) => state.serverFeatures.trash),
); );
@ -46,7 +49,6 @@ class AssetDetailBottomSheet extends ConsumerWidget {
if (asset.hasRemote) ...[ if (asset.hasRemote) ...[
const ShareLinkActionButton(source: ActionSource.viewer), const ShareLinkActionButton(source: ActionSource.viewer),
const ArchiveActionButton(source: ActionSource.viewer), const ArchiveActionButton(source: ActionSource.viewer),
const FavoriteActionButton(source: ActionSource.viewer),
if (!asset.hasLocal) const DownloadActionButton(), if (!asset.hasLocal) const DownloadActionButton(),
isTrashEnable isTrashEnable
? const TrashActionButton(source: ActionSource.viewer) ? const TrashActionButton(source: ActionSource.viewer)
@ -67,7 +69,7 @@ class AssetDetailBottomSheet extends ConsumerWidget {
controller: controller, controller: controller,
initialChildSize: initialChildSize, initialChildSize: initialChildSize,
minChildSize: 0.1, minChildSize: 0.1,
maxChildSize: 1.0, maxChildSize: 0.88,
expand: false, expand: false,
shouldCloseOnMinExtent: false, shouldCloseOnMinExtent: false,
resizeOnScroll: false, resizeOnScroll: false,
@ -136,6 +138,10 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final asset = ref.watch(currentAssetNotifier); final asset = ref.watch(currentAssetNotifier);
if (asset == null) {
return const SliverToBoxAdapter(child: SizedBox.shrink());
}
final exifInfo = ref.watch(currentAssetExifProvider).valueOrNull; final exifInfo = ref.watch(currentAssetExifProvider).valueOrNull;
final cameraTitle = _getCameraInfoTitle(exifInfo); final cameraTitle = _getCameraInfoTitle(exifInfo);

View File

@ -16,7 +16,7 @@ class SheetLocationDetails extends ConsumerStatefulWidget {
} }
class _SheetLocationDetailsState extends ConsumerState<SheetLocationDetails> { class _SheetLocationDetailsState extends ConsumerState<SheetLocationDetails> {
late BaseAsset asset; BaseAsset? asset;
ExifInfo? exifInfo; ExifInfo? exifInfo;
MapLibreMapController? _mapController; MapLibreMapController? _mapController;
@ -84,7 +84,10 @@ class _SheetLocationDetailsState extends ConsumerState<SheetLocationDetails> {
"${exifInfo!.latitude!.toStringAsFixed(4)}, ${exifInfo!.longitude!.toStringAsFixed(4)}"; "${exifInfo!.latitude!.toStringAsFixed(4)}, ${exifInfo!.longitude!.toStringAsFixed(4)}";
return Padding( return Padding(
padding: const EdgeInsets.all(16.0), padding: EdgeInsets.symmetric(
vertical: 16.0,
horizontal: context.isMobile ? 16.0 : 56.0,
),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [

View File

@ -0,0 +1,114 @@
import 'package:auto_route/auto_route.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/utils/event_stream.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/favorite_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/unfavorite_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
const ViewerTopAppBar({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final asset = ref.watch(currentAssetNotifier);
if (asset == null) {
return const SizedBox.shrink();
}
final user = ref.watch(currentUserProvider);
final isOwner = asset is RemoteAsset && asset.ownerId == user?.id;
final isShowingSheet = ref
.watch(assetViewerProvider.select((state) => state.showingBottomSheet));
int opacity = ref.watch(
assetViewerProvider.select((state) => state.backgroundOpacity),
);
final showControls =
ref.watch(assetViewerProvider.select((s) => s.showingControls));
if (!showControls) {
opacity = 0;
}
final actions = <Widget>[
if (asset.hasRemote && isOwner && !asset.isFavorite)
const FavoriteActionButton(source: ActionSource.viewer, menuItem: true),
if (asset.hasRemote && isOwner && asset.isFavorite)
const UnFavoriteActionButton(
source: ActionSource.viewer,
menuItem: true,
),
const _KebabMenu(),
];
return IgnorePointer(
ignoring: opacity < 255,
child: AnimatedOpacity(
opacity: opacity / 255,
duration: Durations.short2,
child: AppBar(
backgroundColor:
isShowingSheet ? Colors.transparent : Colors.black.withAlpha(125),
leading: const _AppBarBackButton(),
iconTheme: const IconThemeData(size: 22, color: Colors.white),
actionsIconTheme: const IconThemeData(size: 22, color: Colors.white),
shape: const Border(),
actions: isShowingSheet ? null : actions,
),
),
);
}
@override
Size get preferredSize => const Size.fromHeight(60.0);
}
class _KebabMenu extends ConsumerWidget {
const _KebabMenu();
@override
Widget build(BuildContext context, WidgetRef ref) {
return IconButton(
onPressed: () {
EventStream.shared.emit(const ViewerOpenBottomSheetEvent());
},
icon: const Icon(Icons.more_vert_rounded),
);
}
}
class _AppBarBackButton extends ConsumerWidget {
const _AppBarBackButton();
@override
Widget build(BuildContext context, WidgetRef ref) {
final isShowingSheet = ref
.watch(assetViewerProvider.select((state) => state.showingBottomSheet));
final backgroundColor =
isShowingSheet && !context.isDarkTheme ? Colors.white : Colors.black;
final foregroundColor =
isShowingSheet && !context.isDarkTheme ? Colors.black : Colors.white;
return Padding(
padding: const EdgeInsets.only(left: 12.0),
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: backgroundColor,
shape: const CircleBorder(),
iconSize: 22,
iconColor: foregroundColor,
padding: EdgeInsets.zero,
elevation: isShowingSheet ? 4 : 0,
),
onPressed: context.maybePop,
child: const Icon(Icons.arrow_back_rounded),
),
);
}
}

View File

@ -56,7 +56,10 @@ 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 => {ref.read(currentAssetNotifier)}, ActionSource.viewer => switch (ref.read(currentAssetNotifier)) {
BaseAsset asset => {asset},
null => {},
},
}; };
return switch (T) { return switch (T) {

View File

@ -15,5 +15,6 @@ final remoteAssetRepositoryProvider = Provider<RemoteAssetRepository>(
final assetServiceProvider = Provider( final assetServiceProvider = Provider(
(ref) => AssetService( (ref) => AssetService(
remoteAssetRepository: ref.watch(remoteAssetRepositoryProvider), remoteAssetRepository: ref.watch(remoteAssetRepositoryProvider),
localAssetRepository: ref.watch(localAssetRepository),
), ),
); );

View File

@ -1,36 +1,48 @@
import 'dart:async';
import 'package:hooks_riverpod/hooks_riverpod.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/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 =
AutoDisposeNotifierProvider<CurrentAssetNotifier, BaseAsset>( AutoDisposeNotifierProvider<CurrentAssetNotifier, BaseAsset?>(
CurrentAssetNotifier.new, CurrentAssetNotifier.new,
); );
class CurrentAssetNotifier extends AutoDisposeNotifier<BaseAsset> { class CurrentAssetNotifier extends AutoDisposeNotifier<BaseAsset?> {
KeepAliveLink? _keepAliveLink; KeepAliveLink? _keepAliveLink;
StreamSubscription<BaseAsset?>? _assetSubscription;
@override @override
BaseAsset build() { BaseAsset? build() => null;
throw UnimplementedError(
'An asset must be set before using the currentAssetProvider.',
);
}
void setAsset(BaseAsset asset) { void setAsset(BaseAsset asset) {
_keepAliveLink?.close(); _keepAliveLink?.close();
_assetSubscription?.cancel();
state = asset; state = asset;
_assetSubscription = ref
.watch(assetServiceProvider)
.watchAsset(asset)
.listen((updatedAsset) {
if (updatedAsset != null) {
state = updatedAsset;
}
});
_keepAliveLink = ref.keepAlive(); _keepAliveLink = ref.keepAlive();
} }
void dispose() { void dispose() {
_keepAliveLink?.close(); _keepAliveLink?.close();
_assetSubscription?.cancel();
} }
} }
final currentAssetExifProvider = FutureProvider.autoDispose( final currentAssetExifProvider = FutureProvider.autoDispose(
(ref) { (ref) {
final currentAsset = ref.watch(currentAssetNotifier); final currentAsset = ref.watch(currentAssetNotifier);
if (currentAsset == null) {
return null;
}
return ref.watch(assetServiceProvider).getExif(currentAsset); return ref.watch(assetServiceProvider).getExif(currentAsset);
}, },
); );