From 6e91e2e202e4533fe5f6a2ccdd442886390b5e0c Mon Sep 17 00:00:00 2001 From: Thomas Way Date: Thu, 5 Feb 2026 15:37:07 +0000 Subject: [PATCH] scroll physics, asset details --- mobile/flutter_01.png | Bin 0 -> 25779 bytes .../pages/dev/ui_showcase.page.dart | 238 ++++++++- .../asset_viewer/asset_details.widget.dart | 454 ++++++++++++++++++ .../asset_viewer/asset_viewer.page.dart | 163 +++++-- 4 files changed, 808 insertions(+), 47 deletions(-) create mode 100644 mobile/flutter_01.png create mode 100644 mobile/lib/presentation/widgets/asset_viewer/asset_details.widget.dart diff --git a/mobile/flutter_01.png b/mobile/flutter_01.png new file mode 100644 index 0000000000000000000000000000000000000000..3dbcf323fc8edb9b09bc762b55e92c215e283068 GIT binary patch literal 25779 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 createState() => _ImmichUIShowcasePageState(); +} + +class _ImmichUIShowcasePageState extends State { + final ScrollController _scrollController = ScrollController(); + double _opacity = 0.0; + + @override + void initState() { + super.initState(); + _scrollController.addListener(_onScroll); + } + + void _onScroll() { + print('Scroll offset: ${_scrollController.offset}'); + setState(() { + _opacity = (_scrollController.offset / 50).clamp(0.0, 1.0); + }); + } + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { return Scaffold( body: LayoutBuilder( builder: (context, constraints) { - final itemHeight = constraints.maxHeight * 0.5; // Each item takes 50% of screen + final imageHeight = 200.0; // Your image's height + final viewportHeight = constraints.maxHeight; - return PageView.builder( - scrollDirection: Axis.vertical, - controller: PageController( - viewportFraction: 0.5, // Shows 2 items at once - ), - itemCount: 2, - itemBuilder: (context, index) { - final colors = [Colors.blue, Colors.green]; - final labels = ['First Item', 'Second Item']; + // Calculate padding to center the image in the viewport + final topPadding = (viewportHeight - imageHeight) / 2; + final snapOffset = topPadding + (imageHeight * 2 / 3); - return Center( - child: Container( - padding: const EdgeInsets.all(24), - color: colors[index], - child: Text(labels[index], style: const TextStyle(color: Colors.white, fontSize: 24)), + return SingleChildScrollView( + controller: _scrollController, + physics: SnapToPartialPhysics(snapOffset: snapOffset), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SizedBox(height: topPadding), // Push image to center + Center(child: Image.asset('assets/immich-logo.png', height: imageHeight)), + Opacity( + opacity: _opacity, + child: Container( + constraints: BoxConstraints(minHeight: snapOffset + 100), + color: Colors.blue, + height: 100 + imageHeight * (1 / 3), + child: const Text('Some content'), + ), ), - ); - }, + ], + ), ); }, ), ); } } + +class SnapToPartialPhysics extends ScrollPhysics { + final double snapOffset; + + const SnapToPartialPhysics({super.parent, required this.snapOffset}); + + @override + SnapToPartialPhysics applyTo(ScrollPhysics? ancestor) { + return SnapToPartialPhysics(parent: buildParent(ancestor), snapOffset: snapOffset); + } + + @override + Simulation? createBallisticSimulation(ScrollMetrics position, double velocity) { + final tolerance = toleranceFor(position); + + // If already at a snap point, let it settle naturally + if ((position.pixels - 0).abs() < tolerance.distance || (position.pixels - snapOffset).abs() < tolerance.distance) { + return super.createBallisticSimulation(position, velocity); + } + + // Determine snap target based on position and velocity + double target; + if (velocity > 0) { + // Scrolling down + target = snapOffset; + } else if (velocity < 0) { + // Scrolling up + target = 0; + } else { + // No velocity, snap to nearest + target = position.pixels < snapOffset / 2 ? 0 : snapOffset; + } + + return ScrollSpringSimulation(spring, position.pixels, target, velocity, tolerance: tolerance); + } +} + +// class _ImmichUIShowcasePageState extends State { +// final ScrollController _scrollController = ScrollController(); +// double _opacity = 0.0; + +// @override +// void initState() { +// super.initState(); +// _scrollController.addListener(_onScroll); +// } + +// void _onScroll() { +// print('Scroll offset: ${_scrollController.offset}'); +// setState(() { +// _opacity = (_scrollController.offset / 50).clamp(0.0, 1.0); +// }); +// } + +// @override +// void dispose() { +// _scrollController.dispose(); +// super.dispose(); +// } + +// @override +// Widget build(BuildContext context) { +// return Scaffold( +// body: ListView( +// controller: _scrollController, +// physics: const PageScrollPhysics(), +// children: [ +// Container( +// constraints: BoxConstraints.expand(height: MediaQuery.sizeOf(context).height), +// decoration: const BoxDecoration(color: Colors.green), +// ), +// AnimatedOpacity( +// opacity: _opacity, +// duration: const Duration(milliseconds: 300), +// child: Container( +// constraints: const BoxConstraints.expand(height: 2000), +// decoration: const BoxDecoration(color: Colors.blue), +// ), +// ), +// ], +// ), +// ); +// } +// } + +// class _ImmichUIShowcasePageState extends State { +// final ScrollController _scrollController = ScrollController(); +// double _opacity = 0.0; + +// @override +// void initState() { +// super.initState(); +// _scrollController.addListener(_onScroll); +// } + +// void _onScroll() { +// print('Scroll offset: ${_scrollController.offset}'); +// setState(() { +// _opacity = (_scrollController.offset / 50).clamp(0.0, 1.0); +// }); +// } + +// @override +// void dispose() { +// _scrollController.dispose(); +// super.dispose(); +// } + +// @override +// Widget build(BuildContext context) { +// return Scaffold( +// body: ListView( +// controller: _scrollController, +// physics: const PageScrollPhysics(), +// children: [ +// Container( +// constraints: BoxConstraints.expand(height: MediaQuery.sizeOf(context).height), +// decoration: const BoxDecoration(color: Colors.green), +// ), +// AnimatedOpacity( +// opacity: _opacity, +// duration: const Duration(milliseconds: 300), +// child: Container( +// constraints: const BoxConstraints.expand(height: 2000), +// decoration: const BoxDecoration(color: Colors.blue), +// ), +// ), +// ], +// ), +// ); +// } +// } + +// @RoutePage() +// class ImmichUIShowcasePage extends StatelessWidget { +// const ImmichUIShowcasePage({super.key}); +// @override +// Widget build(BuildContext context) { +// return Scaffold( +// body: LayoutBuilder( +// builder: (context, constraints) { +// final itemHeight = constraints.maxHeight * 0.5; // Each item takes 50% of screen + +// return PageView.builder( +// scrollDirection: Axis.vertical, +// controller: PageController( +// viewportFraction: 1, // Shows 2 items at once +// ), +// itemCount: 2, +// itemBuilder: (context, index) { +// final colors = [Colors.blue, Colors.green]; +// final labels = ['First Item', 'Second Item']; + +// return Center( +// child: Container( +// height: index == 0 ? 100 : 30000, +// width: constraints.maxWidth, +// padding: const EdgeInsets.all(24), +// color: colors[index], +// child: Text(labels[index], style: const TextStyle(color: Colors.white, fontSize: 24)), +// ), +// ); +// }, +// ); +// }, +// ), +// ); +// } +// } diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_details.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_details.widget.dart new file mode 100644 index 0000000000..d23f6a5012 --- /dev/null +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_details.widget.dart @@ -0,0 +1,454 @@ +import 'dart:async'; +import 'dart:math' as math; + +import 'package:auto_route/auto_route.dart'; +import 'package:collection/collection.dart'; +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/duration_extensions.dart'; +import 'package:immich_mobile/extensions/theme_extensions.dart'; +import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/presentation/widgets/album/album_tile.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_sheet/sheet_location_details.widget.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_sheet/sheet_people_details.widget.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/rating_bar.widget.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/sheet_tile.widget.dart'; +import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/user_metadata.provider.dart'; +import 'package:immich_mobile/providers/user.provider.dart'; +import 'package:immich_mobile/repositories/asset_media.repository.dart'; +import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/utils/bytes_units.dart'; +import 'package:immich_mobile/utils/timezone.dart'; +import 'package:immich_mobile/widgets/common/immich_toast.dart'; + +const _kSeparator = ' • '; + +class AssetDetails extends ConsumerWidget { + final double minHeight; + + const AssetDetails({required this.minHeight, super.key}); + + String _getDateTime(BuildContext ctx, BaseAsset asset, ExifInfo? exifInfo) { + DateTime dateTime = asset.createdAt.toLocal(); + Duration timeZoneOffset = dateTime.timeZoneOffset; + + // Use EXIF timezone information if available (matching web app behavior) + if (exifInfo?.dateTimeOriginal != null) { + (dateTime, timeZoneOffset) = applyTimezoneOffset( + dateTime: exifInfo!.dateTimeOriginal!, + timeZone: exifInfo.timeZone, + ); + } + + final date = DateFormat.yMMMEd(ctx.locale.toLanguageTag()).format(dateTime); + final time = DateFormat.jm(ctx.locale.toLanguageTag()).format(dateTime); + final timezone = 'GMT${timeZoneOffset.formatAsOffset()}'; + return '$date$_kSeparator$time $timezone'; + } + + String _getFileInfo(BaseAsset asset, ExifInfo? exifInfo) { + final height = asset.height; + final width = asset.width; + final resolution = (width != null && height != null) ? "${width.toInt()} x ${height.toInt()}" : null; + final fileSize = exifInfo?.fileSize != null ? formatBytes(exifInfo!.fileSize!) : null; + + return switch ((fileSize, resolution)) { + (null, null) => '', + (String fileSize, null) => fileSize, + (null, String resolution) => resolution, + (String fileSize, String resolution) => '$fileSize$_kSeparator$resolution', + }; + } + + String? _getCameraInfoTitle(ExifInfo? exifInfo) { + if (exifInfo == null) { + return null; + } + + return switch ((exifInfo.make, exifInfo.model)) { + (null, null) => null, + (String make, null) => make, + (null, String model) => model, + (String make, String model) => '$make $model', + }; + } + + String? _getCameraInfoSubtitle(ExifInfo? exifInfo) { + if (exifInfo == null) { + return null; + } + final exposureTime = exifInfo.exposureTime.isNotEmpty ? exifInfo.exposureTime : null; + final iso = exifInfo.iso != null ? 'ISO ${exifInfo.iso}' : null; + return [exposureTime, iso].where((spec) => spec != null && spec.isNotEmpty).join(_kSeparator); + } + + String? _getLensInfoSubtitle(ExifInfo? exifInfo) { + if (exifInfo == null) { + return null; + } + final fNumber = exifInfo.fNumber.isNotEmpty ? 'ƒ/${exifInfo.fNumber}' : null; + final focalLength = exifInfo.focalLength.isNotEmpty ? '${exifInfo.focalLength} mm' : null; + return [fNumber, focalLength].where((spec) => spec != null && spec.isNotEmpty).join(_kSeparator); + } + + Future _editDateTime(BuildContext context, WidgetRef ref) async { + await ref.read(actionProvider.notifier).editDateTime(ActionSource.viewer, context); + } + + Widget _buildAppearsInList(WidgetRef ref, BuildContext context) { + final asset = ref.watch(currentAssetNotifier); + if (asset == null) { + return const SizedBox.shrink(); + } + + if (!asset.hasRemote) { + return const SizedBox.shrink(); + } + + String? remoteAssetId; + if (asset is RemoteAsset) { + remoteAssetId = asset.id; + } else if (asset is LocalAsset) { + remoteAssetId = asset.remoteAssetId; + } + + if (remoteAssetId == null) { + return const SizedBox.shrink(); + } + + final userId = ref.watch(currentUserProvider)?.id; + final assetAlbums = ref.watch(albumsContainingAssetProvider(remoteAssetId)); + + return assetAlbums.when( + data: (albums) { + if (albums.isEmpty) { + return const SizedBox.shrink(); + } + + albums.sortBy((a) => a.name); + + return Column( + spacing: 12, + children: [ + if (albums.isNotEmpty) + SheetTile( + title: 'appears_in'.t(context: context), + titleStyle: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurfaceSecondary), + ), + Padding( + padding: const EdgeInsets.only(left: 24), + child: Column( + spacing: 12, + children: albums.map((album) { + final isOwner = album.ownerId == userId; + return AlbumTile( + album: album, + isOwner: isOwner, + onAlbumSelected: (album) async { + ref.invalidate(assetViewerProvider); + unawaited(context.router.popAndPush(RemoteAlbumRoute(album: album))); + }, + ); + }).toList(), + ), + ), + ], + ); + }, + loading: () => const SizedBox.shrink(), + error: (_, __) => const SizedBox.shrink(), + ); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final asset = ref.watch(currentAssetNotifier); + if (asset == null) { + print("null asset"); + return const SliverToBoxAdapter(child: SizedBox.shrink()); + } + + final exifInfo = ref.watch(currentAssetExifProvider).valueOrNull; + final cameraTitle = _getCameraInfoTitle(exifInfo); + final lensTitle = exifInfo?.lens != null && exifInfo!.lens!.isNotEmpty ? exifInfo.lens : null; + final isOwner = ref.watch(currentUserProvider)?.id == (asset is RemoteAsset ? asset.ownerId : null); + final isRatingEnabled = ref + .watch(userMetadataPreferencesProvider) + .maybeWhen(data: (prefs) => prefs?.ratingsEnabled ?? false, orElse: () => false); + + // Build file info tile based on asset type + Widget buildFileInfoTile() { + if (asset is LocalAsset) { + final assetMediaRepository = ref.watch(assetMediaRepositoryProvider); + return FutureBuilder( + future: assetMediaRepository.getOriginalFilename(asset.id), + builder: (context, snapshot) { + final displayName = snapshot.data ?? asset.name; + return SheetTile( + title: displayName, + titleStyle: context.textTheme.labelLarge, + leading: Icon( + asset.isImage ? Icons.image_outlined : Icons.videocam_outlined, + size: 24, + color: context.textTheme.labelLarge?.color, + ), + subtitle: _getFileInfo(asset, exifInfo), + subtitleStyle: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary), + ); + }, + ); + } else { + // For remote assets, use the name directly + return SheetTile( + title: asset.name, + titleStyle: context.textTheme.labelLarge, + leading: Icon( + asset.isImage ? Icons.image_outlined : Icons.videocam_outlined, + size: 24, + color: context.textTheme.labelLarge?.color, + ), + subtitle: _getFileInfo(asset, exifInfo), + subtitleStyle: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary), + ); + } + } + + return Container( + constraints: BoxConstraints(minHeight: minHeight), + decoration: BoxDecoration(color: context.isDarkTheme ? context.colorScheme.surface : Colors.white), + child: Column( + children: [ + const _DragHandle(), + // Asset Date and Time + SheetTile( + title: _getDateTime(context, asset, exifInfo), + titleStyle: context.textTheme.labelLarge, + trailing: asset.hasRemote && isOwner ? const Icon(Icons.edit, size: 18) : null, + onTap: asset.hasRemote && isOwner ? () async => await _editDateTime(context, ref) : null, + ), + if (exifInfo != null) _SheetAssetDescription(exif: exifInfo, isEditable: isOwner), + const SheetPeopleDetails(), + const SheetLocationDetails(), + // Details header + SheetTile( + title: 'details'.t(context: context), + titleStyle: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurfaceSecondary), + ), + // File info + buildFileInfoTile(), + // Camera info + if (cameraTitle != null) ...[ + const SizedBox(height: 16), + SheetTile( + title: cameraTitle, + titleStyle: context.textTheme.labelLarge, + leading: Icon(Icons.camera_alt_outlined, size: 24, color: context.textTheme.labelLarge?.color), + subtitle: _getCameraInfoSubtitle(exifInfo), + subtitleStyle: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary), + ), + ], + // Lens info + if (lensTitle != null) ...[ + const SizedBox(height: 16), + SheetTile( + title: lensTitle, + titleStyle: context.textTheme.labelLarge, + leading: Icon(Icons.camera_outlined, size: 24, color: context.textTheme.labelLarge?.color), + subtitle: _getLensInfoSubtitle(exifInfo), + subtitleStyle: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary), + ), + ], + // Rating bar + if (isRatingEnabled) ...[ + Padding( + padding: const EdgeInsets.only(left: 16.0, top: 16.0), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 8, + children: [ + Text( + 'rating'.t(context: context), + style: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurfaceSecondary), + ), + RatingBar( + initialRating: exifInfo?.rating?.toDouble() ?? 0, + filledColor: context.themeData.colorScheme.primary, + unfilledColor: context.themeData.colorScheme.onSurface.withAlpha(100), + itemSize: 40, + onRatingUpdate: (rating) async { + await ref.read(actionProvider.notifier).updateRating(ActionSource.viewer, rating.round()); + }, + onClearRating: () async { + await ref.read(actionProvider.notifier).updateRating(ActionSource.viewer, 0); + }, + ), + ], + ), + ), + ], + // Appears in (Albums) + Padding(padding: const EdgeInsets.only(top: 16.0), child: _buildAppearsInList(ref, context)), + // padding at the bottom to avoid cut-off + const SizedBox(height: 60), + ], + ), + ); + } +} + +class _SheetAssetDescription extends ConsumerStatefulWidget { + final ExifInfo exif; + final bool isEditable; + + const _SheetAssetDescription({required this.exif, this.isEditable = true}); + + @override + ConsumerState<_SheetAssetDescription> createState() => _SheetAssetDescriptionState(); +} + +class _SheetAssetDescriptionState extends ConsumerState<_SheetAssetDescription> { + late TextEditingController _controller; + final _descriptionFocus = FocusNode(); + + @override + void initState() { + super.initState(); + _controller = TextEditingController(text: widget.exif.description ?? ''); + } + + Future saveDescription(String? previousDescription) async { + final newDescription = _controller.text.trim(); + + if (newDescription == previousDescription) { + _descriptionFocus.unfocus(); + return; + } + + final editAction = await ref.read(actionProvider.notifier).updateDescription(ActionSource.viewer, newDescription); + + if (!editAction.success) { + _controller.text = previousDescription ?? ''; + + ImmichToast.show( + context: context, + msg: 'exif_bottom_sheet_description_error'.t(context: context), + toastType: ToastType.error, + ); + } + + _descriptionFocus.unfocus(); + } + + @override + Widget build(BuildContext context) { + // Watch the current asset EXIF provider to get updates + final currentExifInfo = ref.watch(currentAssetExifProvider).valueOrNull; + + // Update controller text when EXIF data changes + final currentDescription = currentExifInfo?.description ?? ''; + final hintText = (widget.isEditable ? 'exif_bottom_sheet_description' : 'exif_bottom_sheet_no_description').t( + context: context, + ); + if (_controller.text != currentDescription && !_descriptionFocus.hasFocus) { + _controller.text = currentDescription; + } + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8), + child: IgnorePointer( + ignoring: !widget.isEditable, + child: TextField( + controller: _controller, + keyboardType: TextInputType.multiline, + focusNode: _descriptionFocus, + maxLines: null, // makes it grow as text is added + decoration: InputDecoration( + hintText: hintText, + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + disabledBorder: InputBorder.none, + errorBorder: InputBorder.none, + focusedErrorBorder: InputBorder.none, + ), + onTapOutside: (_) => saveDescription(currentExifInfo?.description), + ), + ), + ); + } +} + +class _DragHandle extends StatelessWidget { + const _DragHandle({this.dragHandleColor, this.dragHandleSize}); + + final Color? dragHandleColor; + final Size? dragHandleSize; + + @override + Widget build(BuildContext context) { + final BottomSheetThemeData bottomSheetTheme = Theme.of(context).bottomSheetTheme; + final BottomSheetThemeData m3Defaults = _BottomSheetDefaultsM3(context); + final Size handleSize = dragHandleSize ?? bottomSheetTheme.dragHandleSize ?? m3Defaults.dragHandleSize!; + + return Semantics( + label: MaterialLocalizations.of(context).modalBarrierDismissLabel, + container: true, + button: true, + child: SizedBox( + width: math.max(handleSize.width, kMinInteractiveDimension), + height: math.max(handleSize.height, kMinInteractiveDimension), + child: Center( + child: Container( + height: handleSize.height, + width: handleSize.width, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(handleSize.height / 2), + color: m3Defaults.dragHandleColor, + ), + ), + ), + ), + ); + } +} + +class _BottomSheetDefaultsM3 extends BottomSheetThemeData { + _BottomSheetDefaultsM3(this.context) + : super( + elevation: 1.0, + modalElevation: 1.0, + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(28.0))), + constraints: const BoxConstraints(maxWidth: 640), + ); + + final BuildContext context; + late final ColorScheme _colors = Theme.of(context).colorScheme; + + @override + Color? get backgroundColor => _colors.surfaceContainerLow; + + @override + Color? get surfaceTintColor => Colors.transparent; + + @override + Color? get shadowColor => Colors.transparent; + + @override + Color? get dragHandleColor => _colors.onSurfaceVariant; + + @override + Size? get dragHandleSize => const Size(32, 4); + + @override + BoxConstraints? get constraints => const BoxConstraints(maxWidth: 640.0); +} 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 8f58f8f294..2ee25a8259 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart @@ -1,4 +1,6 @@ import 'dart:async'; +import 'dart:math' as math; +import 'package:flutter/rendering.dart'; import 'package:auto_route/auto_route.dart'; import 'package:easy_localization/easy_localization.dart'; @@ -15,6 +17,7 @@ import 'package:immich_mobile/extensions/platform_extensions.dart'; import 'package:immich_mobile/extensions/scroll_extensions.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/download_status_floating_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/activities_bottom_sheet.widget.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_details.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.provider.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; @@ -135,11 +138,15 @@ class _AssetViewerState extends ConsumerState { KeepAliveLink? _stackChildrenKeepAlive; + final ScrollController _scrollController = ScrollController(); + double _assetDetailsOpacity = 0.0; + @override void initState() { super.initState(); assert(ref.read(currentAssetNotifier) != null, "Current asset should not be null when opening the AssetViewer"); pageController = PageController(initialPage: widget.initialIndex); + _scrollController.addListener(_onScroll); totalAssets = ref.read(timelineServiceProvider).totalAssets; bottomSheetController = DraggableScrollableController(); WidgetsBinding.instance.addPostFrameCallback(_onAssetInit); @@ -156,8 +163,15 @@ class _AssetViewerState extends ConsumerState { } } + void _onScroll() { + setState(() { + _assetDetailsOpacity = (_scrollController.offset / 50).clamp(0.0, 1.0); + }); + } + @override void dispose() { + _scrollController.dispose(); pageController.dispose(); bottomSheetController.dispose(); _cancelTimers(); @@ -690,38 +704,129 @@ class _AssetViewerState extends ConsumerState { child: const DownloadStatusFloatingButton(), ), ), - body: Stack( - children: [ - PhotoViewGallery.builder( - gaplessPlayback: true, - loadingBuilder: _placeholderBuilder, - pageController: pageController, - scrollPhysics: CurrentPlatform.isIOS - ? const FastScrollPhysics() // Use bouncing physics for iOS - : const FastClampingScrollPhysics(), // Use heavy physics for Android - itemCount: totalAssets, - onPageChanged: _onPageChanged, - onPageBuild: _onPageBuild, - scaleStateChangedCallback: _onScaleStateChanged, - builder: _assetBuilder, - backgroundDecoration: BoxDecoration(color: backgroundColor), - enablePanAlways: true, - ), - if (!showingBottomSheet) - const Positioned( - bottom: 0, - left: 0, - right: 0, - child: Column( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.end, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [AssetStackRow(), ViewerBottomBar()], - ), + body: LayoutBuilder( + builder: (context, constraints) { + final imageHeight = 200.0; // Your image's height + final viewportHeight = constraints.maxHeight; + + // Calculate padding to center the image in the viewport + final topPadding = (viewportHeight - imageHeight) / 2; + final snapOffset = math.min(topPadding + (imageHeight / 2), viewportHeight / 3); + + return SingleChildScrollView( + controller: _scrollController, + physics: VariableHeightSnappingPhysics(snapStart: 0, snapEnd: snapOffset, snapOffset: snapOffset), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SizedBox(height: topPadding), + // Center(child: Image.asset('assets/immich-logo.png', height: imageHeight)), + SizedBox( + height: viewportHeight, + child: PhotoViewGallery.builder( + gaplessPlayback: true, + loadingBuilder: _placeholderBuilder, + pageController: pageController, + scrollPhysics: CurrentPlatform.isIOS + ? const FastScrollPhysics() // Use bouncing physics for iOS + : const FastClampingScrollPhysics(), // Use heavy physics for Android + itemCount: totalAssets, + onPageChanged: _onPageChanged, + onPageBuild: _onPageBuild, + scaleStateChangedCallback: _onScaleStateChanged, + builder: _assetBuilder, + backgroundDecoration: BoxDecoration(color: backgroundColor), + enablePanAlways: true, + ), + ), + Opacity( + opacity: _assetDetailsOpacity, + child: AssetDetails(minHeight: viewportHeight / 3 * 2), + ), + + // Stack( + // children: [ + // PhotoViewGallery.builder( + // gaplessPlayback: true, + // loadingBuilder: _placeholderBuilder, + // pageController: pageController, + // scrollPhysics: CurrentPlatform.isIOS + // ? const FastScrollPhysics() // Use bouncing physics for iOS + // : const FastClampingScrollPhysics(), // Use heavy physics for Android + // itemCount: totalAssets, + // onPageChanged: _onPageChanged, + // onPageBuild: _onPageBuild, + // scaleStateChangedCallback: _onScaleStateChanged, + // builder: _assetBuilder, + // backgroundDecoration: BoxDecoration(color: backgroundColor), + // enablePanAlways: true, + // ), + // if (!showingBottomSheet) + // const Positioned( + // bottom: 0, + // left: 0, + // right: 0, + // child: Column( + // mainAxisSize: MainAxisSize.min, + // mainAxisAlignment: MainAxisAlignment.end, + // crossAxisAlignment: CrossAxisAlignment.stretch, + // children: [AssetStackRow(), ViewerBottomBar()], + // ), + // ), + // ], + // ), + ], ), - ], + ); + }, ), ), ); } } + +class VariableHeightSnappingPhysics extends ScrollPhysics { + final double snapStart; + final double snapEnd; + final double snapOffset; + + const VariableHeightSnappingPhysics({ + required this.snapStart, + required this.snapEnd, + required this.snapOffset, + super.parent, + }); + + @override + VariableHeightSnappingPhysics applyTo(ScrollPhysics? ancestor) { + return VariableHeightSnappingPhysics( + parent: buildParent(ancestor), + snapStart: snapStart, + snapEnd: snapEnd, + snapOffset: snapOffset, + ); + } + + @override + Simulation? createBallisticSimulation(ScrollMetrics position, double velocity) { + final tolerance = toleranceFor(position); + + if (position.pixels >= snapStart && position.pixels <= snapEnd) { + double targetPixels; + + if (velocity < -tolerance.velocity) { + targetPixels = 0; + } else if (velocity > tolerance.velocity) { + targetPixels = snapOffset; + } else { + targetPixels = (position.pixels < snapOffset / 2) ? 0 : snapOffset; + } + + if ((position.pixels - targetPixels).abs() > tolerance.distance) { + return ScrollSpringSimulation(spring, position.pixels, targetPixels, velocity, tolerance: tolerance); + } + } + + return super.createBallisticSimulation(position, velocity); + } +}