diff --git a/mobile/flutter_01.png b/mobile/flutter_01.png new file mode 100644 index 0000000000..3dbcf323fc Binary files /dev/null and b/mobile/flutter_01.png differ diff --git a/mobile/lib/presentation/pages/dev/ui_showcase.page.dart b/mobile/lib/presentation/pages/dev/ui_showcase.page.dart index dc9f734232..0fafd7a38f 100644 --- a/mobile/lib/presentation/pages/dev/ui_showcase.page.dart +++ b/mobile/lib/presentation/pages/dev/ui_showcase.page.dart @@ -4,37 +4,239 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_ui/immich_ui.dart'; @RoutePage() -class ImmichUIShowcasePage extends StatelessWidget { +class ImmichUIShowcasePage extends StatefulWidget { const ImmichUIShowcasePage({super.key}); + @override + State 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); + } +}