diff --git a/mobile/lib/domain/models/asset/remote_asset.model.dart b/mobile/lib/domain/models/asset/remote_asset.model.dart index e5c1f8345a..d04f3340ac 100644 --- a/mobile/lib/domain/models/asset/remote_asset.model.dart +++ b/mobile/lib/domain/models/asset/remote_asset.model.dart @@ -78,4 +78,38 @@ class RemoteAsset extends BaseAsset { localId.hashCode ^ thumbHash.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, + ); + } } diff --git a/mobile/lib/domain/services/asset.service.dart b/mobile/lib/domain/services/asset.service.dart index ee39220554..c4a0766601 100644 --- a/mobile/lib/domain/services/asset.service.dart +++ b/mobile/lib/domain/services/asset.service.dart @@ -1,13 +1,24 @@ import 'package:immich_mobile/domain/models/asset/base_asset.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'; class AssetService { final RemoteAssetRepository _remoteAssetRepository; + final DriftLocalAssetRepository _localAssetRepository; const AssetService({ required RemoteAssetRepository remoteAssetRepository, - }) : _remoteAssetRepository = remoteAssetRepository; + required DriftLocalAssetRepository localAssetRepository, + }) : _remoteAssetRepository = remoteAssetRepository, + _localAssetRepository = localAssetRepository; + + Stream 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 getExif(BaseAsset asset) async { if (asset is LocalAsset || asset is! RemoteAsset) { diff --git a/mobile/lib/domain/utils/event_stream.dart b/mobile/lib/domain/utils/event_stream.dart index 9acb3b4e39..65ee17e12b 100644 --- a/mobile/lib/domain/utils/event_stream.dart +++ b/mobile/lib/domain/utils/event_stream.dart @@ -8,6 +8,10 @@ class TimelineReloadEvent extends Event { const TimelineReloadEvent(); } +class ViewerOpenBottomSheetEvent extends Event { + const ViewerOpenBottomSheetEvent(); +} + class EventStream { EventStream._(); diff --git a/mobile/lib/infrastructure/entities/local_asset.entity.dart b/mobile/lib/infrastructure/entities/local_asset.entity.dart index 39c3822b04..62f91ae458 100644 --- a/mobile/lib/infrastructure/entities/local_asset.entity.dart +++ b/mobile/lib/infrastructure/entities/local_asset.entity.dart @@ -28,5 +28,8 @@ extension LocalAssetEntityDataDomainEx on LocalAssetEntityData { updatedAt: updatedAt, durationInSeconds: durationInSeconds, isFavorite: isFavorite, + height: height, + width: width, + remoteId: null, ); } diff --git a/mobile/lib/infrastructure/repositories/local_asset.repository.dart b/mobile/lib/infrastructure/repositories/local_asset.repository.dart index 2efa04cc1b..28ca600f61 100644 --- a/mobile/lib/infrastructure/repositories/local_asset.repository.dart +++ b/mobile/lib/infrastructure/repositories/local_asset.repository.dart @@ -1,5 +1,6 @@ import 'package:drift/drift.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/repositories/db.repository.dart'; @@ -7,6 +8,26 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository { final Drift _db; const DriftLocalAssetRepository(this._db) : super(_db); + Stream 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 updateHashes(Iterable hashes) { if (hashes.isEmpty) { return Future.value(); diff --git a/mobile/lib/infrastructure/repositories/remote_asset.repository.dart b/mobile/lib/infrastructure/repositories/remote_asset.repository.dart index 9c036e4ea1..fc341bc91e 100644 --- a/mobile/lib/infrastructure/repositories/remote_asset.repository.dart +++ b/mobile/lib/infrastructure/repositories/remote_asset.repository.dart @@ -4,6 +4,7 @@ import 'package:immich_mobile/domain/models/exif.model.dart'; import 'package:immich_mobile/infrastructure/entities/exif.entity.dart' hide ExifInfo; 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/repositories/db.repository.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; @@ -12,6 +13,26 @@ class RemoteAssetRepository extends DriftDatabaseRepository { final Drift _db; const RemoteAssetRepository(this._db) : super(_db); + Stream 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 getExif(String id) { return _db.managers.remoteExifEntity .filter((row) => row.assetId.id.equals(id)) diff --git a/mobile/lib/presentation/widgets/action_buttons/base_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/base_action_button.widget.dart index 6c60b47535..94e3610a57 100644 --- a/mobile/lib/presentation/widgets/action_buttons/base_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/base_action_button.widget.dart @@ -9,17 +9,33 @@ class BaseActionButton extends StatelessWidget { this.onPressed, this.onLongPressed, this.maxWidth = 90.0, + this.minWidth, + this.menuItem = false, }); final String label; final IconData iconData; final double maxWidth; + final double? minWidth; + final bool menuItem; final void Function()? onPressed; final void Function()? onLongPressed; @override 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( constraints: BoxConstraints( @@ -30,19 +46,22 @@ class BaseActionButton extends StatelessWidget { shape: const RoundedRectangleBorder( borderRadius: BorderRadius.all(Radius.circular(20)), ), + textColor: textColor, onPressed: onPressed, onLongPress: onLongPressed, - minWidth: minWidth, + minWidth: miniWidth, child: Column( mainAxisAlignment: MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.center, children: [ - Icon(iconData, size: 24), + Icon(iconData, size: iconSize, color: iconColor), const SizedBox(height: 8), Text( label, - style: - const TextStyle(fontSize: 14.0, fontWeight: FontWeight.w400), + style: const TextStyle( + fontSize: 14.0, + fontWeight: FontWeight.w400, + ), maxLines: 3, textAlign: TextAlign.center, ), diff --git a/mobile/lib/presentation/widgets/action_buttons/favorite_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/favorite_action_button.widget.dart index 4b717149d9..39d059c2d1 100644 --- a/mobile/lib/presentation/widgets/action_buttons/favorite_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/favorite_action_button.widget.dart @@ -10,8 +10,13 @@ import 'package:immich_mobile/widgets/common/immich_toast.dart'; class FavoriteActionButton extends ConsumerWidget { 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 { if (!context.mounted) { @@ -19,6 +24,11 @@ class FavoriteActionButton extends ConsumerWidget { } final result = await ref.read(actionProvider.notifier).favorite(source); + + if (source == ActionSource.viewer) { + return; + } + ref.read(multiSelectProvider.notifier).reset(); final successMessage = 'favorite_action_prompt'.t( @@ -43,6 +53,7 @@ class FavoriteActionButton extends ConsumerWidget { return BaseActionButton( iconData: Icons.favorite_border_rounded, label: "favorite".t(context: context), + menuItem: menuItem, onPressed: () => _onTap(context, ref), ); } diff --git a/mobile/lib/presentation/widgets/action_buttons/unfavorite_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/unfavorite_action_button.widget.dart index cf014c356c..b465643796 100644 --- a/mobile/lib/presentation/widgets/action_buttons/unfavorite_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/unfavorite_action_button.widget.dart @@ -10,8 +10,13 @@ import 'package:immich_mobile/widgets/common/immich_toast.dart'; class UnFavoriteActionButton extends ConsumerWidget { 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 { if (!context.mounted) { @@ -19,6 +24,11 @@ class UnFavoriteActionButton extends ConsumerWidget { } final result = await ref.read(actionProvider.notifier).unFavorite(source); + + if (source == ActionSource.viewer) { + return; + } + ref.read(multiSelectProvider.notifier).reset(); final successMessage = 'unfavorite_action_prompt'.t( @@ -44,6 +54,7 @@ class UnFavoriteActionButton extends ConsumerWidget { iconData: Icons.favorite_rounded, label: "unfavorite".t(context: context), onPressed: () => _onTap(context, ref), + menuItem: menuItem, ); } } diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart index d221347ce1..c91b71319c 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart @@ -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/extensions/build_context_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/top_app_bar.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'; @@ -58,10 +61,10 @@ const double _kBottomSheetSnapExtent = 0.7; class _AssetViewerState extends ConsumerState { late PageController pageController; late DraggableScrollableController bottomSheetController; - PersistentBottomSheetController? sheetCloseNotifier; + PersistentBottomSheetController? sheetCloseController; // PhotoViewGallery takes care of disposing it's controllers PhotoViewControllerBase? viewController; - StreamSubscription? _reloadSubscription; + StreamSubscription? reloadSubscription; late Platform platform; late PhotoViewControllerValue initialPhotoViewState; @@ -70,12 +73,11 @@ class _AssetViewerState extends ConsumerState { bool blockGestures = false; bool dragInProgress = false; bool shouldPopOnDrag = false; - bool showingBottomSheet = false; double? initialScale; double previousExtent = _kBottomSheetMinimumExtent; Offset dragDownPosition = Offset.zero; int totalAssets = 0; - int backgroundOpacity = 255; + BuildContext? scaffoldContext; // Delayed operations that should be cancelled on disposal final List _delayedOperations = []; @@ -90,8 +92,7 @@ class _AssetViewerState extends ConsumerState { WidgetsBinding.instance.addPostFrameCallback((_) { _onAssetChanged(widget.initialIndex); }); - _reloadSubscription = - EventStream.shared.listen(_onTimelineReload); + reloadSubscription = EventStream.shared.listen(_onEvent); } @override @@ -99,36 +100,17 @@ class _AssetViewerState extends ConsumerState { pageController.dispose(); bottomSheetController.dispose(); _cancelTimers(); - _reloadSubscription?.cancel(); + 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(); - }); - } + bool get showingBottomSheet => + ref.read(assetViewerProvider.select((s) => s.showingBottomSheet)); Color get backgroundColor { - if (showingBottomSheet && !context.isDarkTheme) { - return Colors.white; - } - return Colors.black.withAlpha(backgroundOpacity); + final opacity = + ref.read(assetViewerProvider.select((s) => s.backgroundOpacity)); + return Colors.black.withAlpha(opacity); } void _cancelTimers() { @@ -145,6 +127,9 @@ class _AssetViewerState extends ConsumerState { (viewController?.prevValue.scale ?? viewController?.value.scale ?? 1.0) + 0.01; + double _getVerticalOffsetForBottomSheet(double extent) => + (context.height * extent) - (context.height * _kBottomSheetMinimumExtent); + Future _precacheImage(int index) async { if (!mounted || index < 0 || index >= totalAssets) { return; @@ -247,16 +232,14 @@ class _AssetViewerState extends ConsumerState { return; } - setState(() { - shouldPopOnDrag = false; - hasDraggedDown = null; - backgroundOpacity = 255; - viewController?.animateMultiple( - position: initialPhotoViewState.position, - scale: initialPhotoViewState.scale, - rotation: initialPhotoViewState.rotation, - ); - }); + shouldPopOnDrag = false; + hasDraggedDown = null; + viewController?.animateMultiple( + position: initialPhotoViewState.position, + scale: initialPhotoViewState.scale, + rotation: initialPhotoViewState.rotation, + ); + ref.read(assetViewerProvider.notifier).setOpacity(255); } void _onDragUpdate(BuildContext ctx, DragUpdateDetails details, _) { @@ -277,18 +260,10 @@ class _AssetViewerState extends ConsumerState { void _handleDragUp(BuildContext ctx, Offset delta) { const double openThreshold = 50; - const double closeThreshold = 25; final position = initialPhotoViewState.position + Offset(0, delta.dy); 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); // Moves the bottom sheet when the asset is being dragged up if (showingBottomSheet && bottomSheetController.isAttached) { @@ -301,66 +276,37 @@ class _AssetViewerState extends ConsumerState { } } - void _openBottomSheet(BuildContext ctx) { - setState(() { - initialScale = viewController?.scale; - 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( - onNotification: _onNotification, - child: AssetDetailBottomSheet( - controller: bottomSheetController, - initialChildSize: _kBottomSheetMinimumExtent, - ), - ); - }, - ); - sheetCloseNotifier?.closed.then((_) => _handleSheetClose()); - }); - } + void _handleDragDown(BuildContext ctx, Offset delta) { + const double dragRatio = 0.2; + const double popThreshold = 75; - void _handleSheetClose() { - setState(() { - showingBottomSheet = false; - sheetCloseNotifier = null; - viewController?.animateMultiple(position: Offset.zero); - viewController?.updateMultiple(scale: initialScale); - shouldPopOnDrag = false; - hasDraggedDown = null; - }); - } + final distance = delta.distance; + shouldPopOnDrag = delta.dy > 0 && distance > popThreshold; - void _snapBottomSheet() { - if (bottomSheetController.size > _kBottomSheetSnapExtent || - bottomSheetController.size < 0.4) { - return; + final maxScaleDistance = ctx.height * 0.5; + final scaleReduction = (distance / maxScaleDistance).clamp(0.0, dragRatio); + double? updatedScale; + if (initialPhotoViewState.scale != null) { + updatedScale = initialPhotoViewState.scale! * (1.0 - scaleReduction); } - isSnapping = true; - bottomSheetController.animateTo( - _kBottomSheetSnapExtent, - duration: Durations.short3, - curve: Curves.easeOut, + + final backgroundOpacity = + (255 * (1.0 - (scaleReduction / dragRatio))).round(); + + 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) { - // Ignore notifications when user dragging the asset - if (dragInProgress) { - return false; - } - if (delta is DraggableScrollableNotification) { _handleDraggableNotification(delta); } @@ -375,50 +321,117 @@ class _AssetViewerState extends ConsumerState { } void _handleDraggableNotification(DraggableScrollableNotification delta) { - final verticalOffset = (context.height * delta.extent) - - (context.height * _kBottomSheetMinimumExtent); + final currentExtent = delta.extent; + 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 if (verticalOffset > 0) { viewController?.position = Offset(0, -verticalOffset); } + } - final currentExtent = delta.extent; - final isDraggingDown = currentExtent < previousExtent; - previousExtent = currentExtent; - // Closes the bottom sheet if the user is dragging down and the extent is less than the snap extent - if (isDraggingDown && delta.extent < _kBottomSheetSnapExtent - 0.1) { - sheetCloseNotifier?.close(); + void _onEvent(Event event) { + if (event is TimelineReloadEvent) { + _onTimelineReload(event); + return; + } + + 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) { - const double dragRatio = 0.2; - const double popThreshold = 75; + void _onTimelineReload(_) { + setState(() { + totalAssets = ref.read(timelineServiceProvider).totalAssets; + if (totalAssets == 0) { + context.maybePop(); + return; + } - final distance = delta.distance; - final newShouldPopOnDrag = delta.dy > 0 && distance > popThreshold; + 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; + } - final maxScaleDistance = ctx.height * 0.5; - final scaleReduction = (distance / maxScaleDistance).clamp(0.0, dragRatio); - double? updatedScale; - if (initialPhotoViewState.scale != null) { - updatedScale = initialPhotoViewState.scale! * (1.0 - scaleReduction); - } + _onAssetChanged(pageController.page!.round()); + sheetCloseController?.close(); + }); + } - final newBackgroundOpacity = - (255 * (1.0 - (scaleReduction / dragRatio))).round(); - - viewController?.updateMultiple( - position: initialPhotoViewState.position + delta, - scale: updatedScale, + void _openBottomSheet( + BuildContext ctx, { + double extent = _kBottomSheetMinimumExtent, + }) { + ref.read(assetViewerProvider.notifier).setBottomSheet(true); + 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( + onNotification: _onNotification, + child: AssetDetailBottomSheet( + controller: bottomSheetController, + initialChildSize: extent, + ), + ); + }, ); - if (shouldPopOnDrag != newShouldPopOnDrag || - backgroundOpacity != newBackgroundOpacity) { - setState(() { - shouldPopOnDrag = newShouldPopOnDrag; - backgroundOpacity = newBackgroundOpacity; - }); + sheetCloseController?.closed.then((_) => _handleSheetClose()); + } + + void _handleSheetClose() { + 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( @@ -443,6 +456,7 @@ class _AssetViewerState extends ConsumerState { } PhotoViewGalleryPageOptions _assetBuilder(BuildContext ctx, int index) { + scaffoldContext ??= ctx; final asset = ref.read(timelineServiceProvider).getAsset(index); final size = Size(ctx.width, ctx.height); @@ -458,6 +472,7 @@ class _AssetViewerState extends ConsumerState { onDragStart: _onDragStart, onDragUpdate: _onDragUpdate, onDragEnd: _onDragEnd, + onTapDown: _onTapDown, errorBuilder: (_, __, ___) => Container( width: ctx.width, height: ctx.height, @@ -477,13 +492,21 @@ class _AssetViewerState extends ConsumerState { @override 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. // Issue: https://github.com/flutter/flutter/issues/109037 // TODO: Add a custom scrum builder once the fix lands on stable return PopScope( onPopInvokedWithResult: _onPop, child: Scaffold( - backgroundColor: Colors.black.withAlpha(backgroundOpacity), + backgroundColor: backgroundColor, + appBar: const ViewerTopAppBar(), + extendBody: true, + extendBodyBehindAppBar: true, body: PhotoViewGallery.builder( gaplessPlayback: true, loadingBuilder: _placeholderBuilder, @@ -499,6 +522,7 @@ class _AssetViewerState extends ConsumerState { backgroundDecoration: BoxDecoration(color: backgroundColor), enablePanAlways: true, ), + bottomNavigationBar: const ViewerBottomBar(), ), ); } diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.state.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.state.dart new file mode 100644 index 0000000000..231d40c9ca --- /dev/null +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.state.dart @@ -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 { + @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.new, +); diff --git a/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart new file mode 100644 index 0000000000..6269bef6be --- /dev/null +++ b/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart @@ -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 = [ + 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), + ); + } +} diff --git a/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart index d8afe81564..d0bdc28d10 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart @@ -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_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'; @@ -37,6 +36,10 @@ class AssetDetailBottomSheet extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final asset = ref.watch(currentAssetNotifier); + if (asset == null) { + return const SizedBox.shrink(); + } + final isTrashEnable = ref.watch( serverInfoProvider.select((state) => state.serverFeatures.trash), ); @@ -46,7 +49,6 @@ class AssetDetailBottomSheet extends ConsumerWidget { 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) @@ -67,7 +69,7 @@ class AssetDetailBottomSheet extends ConsumerWidget { controller: controller, initialChildSize: initialChildSize, minChildSize: 0.1, - maxChildSize: 1.0, + maxChildSize: 0.88, expand: false, shouldCloseOnMinExtent: false, resizeOnScroll: false, @@ -136,6 +138,10 @@ class _AssetDetailBottomSheet extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final asset = ref.watch(currentAssetNotifier); + if (asset == null) { + return const SliverToBoxAdapter(child: SizedBox.shrink()); + } + final exifInfo = ref.watch(currentAssetExifProvider).valueOrNull; final cameraTitle = _getCameraInfoTitle(exifInfo); diff --git a/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet/location_details.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet/location_details.widget.dart index 4b0294253c..2d22d063bd 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet/location_details.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet/location_details.widget.dart @@ -16,7 +16,7 @@ class SheetLocationDetails extends ConsumerStatefulWidget { } class _SheetLocationDetailsState extends ConsumerState { - late BaseAsset asset; + BaseAsset? asset; ExifInfo? exifInfo; MapLibreMapController? _mapController; @@ -84,7 +84,10 @@ class _SheetLocationDetailsState extends ConsumerState { "${exifInfo!.latitude!.toStringAsFixed(4)}, ${exifInfo!.longitude!.toStringAsFixed(4)}"; return Padding( - padding: const EdgeInsets.all(16.0), + padding: EdgeInsets.symmetric( + vertical: 16.0, + horizontal: context.isMobile ? 16.0 : 56.0, + ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ diff --git a/mobile/lib/presentation/widgets/asset_viewer/top_app_bar.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/top_app_bar.widget.dart new file mode 100644 index 0000000000..b7e8477073 --- /dev/null +++ b/mobile/lib/presentation/widgets/asset_viewer/top_app_bar.widget.dart @@ -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 = [ + 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), + ), + ); + } +} diff --git a/mobile/lib/providers/infrastructure/action.provider.dart b/mobile/lib/providers/infrastructure/action.provider.dart index 947cc7df5b..0c197ca683 100644 --- a/mobile/lib/providers/infrastructure/action.provider.dart +++ b/mobile/lib/providers/infrastructure/action.provider.dart @@ -56,7 +56,10 @@ class ActionNotifier extends Notifier { final Set assets = switch (source) { ActionSource.timeline => 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) { diff --git a/mobile/lib/providers/infrastructure/asset.provider.dart b/mobile/lib/providers/infrastructure/asset.provider.dart index 860af134ae..0015986243 100644 --- a/mobile/lib/providers/infrastructure/asset.provider.dart +++ b/mobile/lib/providers/infrastructure/asset.provider.dart @@ -15,5 +15,6 @@ final remoteAssetRepositoryProvider = Provider( final assetServiceProvider = Provider( (ref) => AssetService( remoteAssetRepository: ref.watch(remoteAssetRepositoryProvider), + localAssetRepository: ref.watch(localAssetRepository), ), ); diff --git a/mobile/lib/providers/infrastructure/asset_viewer/current_asset.provider.dart b/mobile/lib/providers/infrastructure/asset_viewer/current_asset.provider.dart index 1ce6c740f9..996d5d816f 100644 --- a/mobile/lib/providers/infrastructure/asset_viewer/current_asset.provider.dart +++ b/mobile/lib/providers/infrastructure/asset_viewer/current_asset.provider.dart @@ -1,36 +1,48 @@ +import 'dart:async'; + import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; final currentAssetNotifier = - AutoDisposeNotifierProvider( + AutoDisposeNotifierProvider( CurrentAssetNotifier.new, ); -class CurrentAssetNotifier extends AutoDisposeNotifier { +class CurrentAssetNotifier extends AutoDisposeNotifier { KeepAliveLink? _keepAliveLink; + StreamSubscription? _assetSubscription; @override - BaseAsset build() { - throw UnimplementedError( - 'An asset must be set before using the currentAssetProvider.', - ); - } + BaseAsset? build() => null; void setAsset(BaseAsset asset) { _keepAliveLink?.close(); + _assetSubscription?.cancel(); state = asset; + _assetSubscription = ref + .watch(assetServiceProvider) + .watchAsset(asset) + .listen((updatedAsset) { + if (updatedAsset != null) { + state = updatedAsset; + } + }); _keepAliveLink = ref.keepAlive(); } void dispose() { _keepAliveLink?.close(); + _assetSubscription?.cancel(); } } final currentAssetExifProvider = FutureProvider.autoDispose( (ref) { final currentAsset = ref.watch(currentAssetNotifier); + if (currentAsset == null) { + return null; + } return ref.watch(assetServiceProvider).getExif(currentAsset); }, );