diff --git a/mobile/lib/presentation/pages/dev/ui_showcase.page.dart b/mobile/lib/presentation/pages/dev/ui_showcase.page.dart index 0fafd7a38f..876d726cb5 100644 --- a/mobile/lib/presentation/pages/dev/ui_showcase.page.dart +++ b/mobile/lib/presentation/pages/dev/ui_showcase.page.dart @@ -37,77 +37,116 @@ class _ImmichUIShowcasePageState extends State { @override Widget build(BuildContext context) { return Scaffold( - 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 = topPadding + (imageHeight * 2 / 3); - - 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'), - ), - ), - ], - ), - ); - }, + body: StackedScrollView( + bottomPeekHeight: 100, + topChild: Container( + color: Colors.blue, + child: const Center( + child: Text('Top', style: TextStyle(color: Colors.white, fontSize: 32)), + ), + ), + bottomChild: Container( + height: 800, + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + boxShadow: [BoxShadow(color: Colors.black26, blurRadius: 10, offset: Offset(0, -2))], + ), + padding: const EdgeInsets.all(24), + child: const Text('Bottom sheet content'), + ), ), ); } } -class SnapToPartialPhysics extends ScrollPhysics { - final double snapOffset; +class StackedScrollView extends StatefulWidget { + final Widget topChild; + final Widget bottomChild; + final double bottomPeekHeight; - const SnapToPartialPhysics({super.parent, required this.snapOffset}); + const StackedScrollView({super.key, required this.topChild, required this.bottomChild, this.bottomPeekHeight = 80}); @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); - } + State createState() => _StackedScrollViewState(); } +class _StackedScrollViewState extends State with SingleTickerProviderStateMixin { + double _offset = 0; + late double _maxOffset; + late AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController.unbounded(vsync: this)..addListener(_onAnimate); + } + + void _onAnimate() { + final clamped = _controller.value.clamp(0.0, _maxOffset); + if (clamped != _offset) { + setState(() => _offset = clamped); + } + // Stop the controller if we've hit a boundary + if (_controller.value <= 0 || _controller.value >= _maxOffset) { + _controller.stop(); + } + } + + void _onDragUpdate(DragUpdateDetails details) { + _controller.stop(); + setState(() { + _offset = (_offset - details.delta.dy).clamp(0.0, _maxOffset); + }); + } + + void _onDragEnd(DragEndDetails details) { + final velocity = -(details.primaryVelocity ?? 0); + final simulation = ClampingScrollSimulation(position: _offset, velocity: velocity); + _controller.animateWith(simulation); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final viewportHeight = constraints.maxHeight; + _maxOffset = viewportHeight - widget.bottomPeekHeight; + + return GestureDetector( + behavior: HitTestBehavior.opaque, + onVerticalDragUpdate: _onDragUpdate, + onVerticalDragEnd: _onDragEnd, + child: ClipRect( + child: SizedBox( + height: viewportHeight, + child: Stack( + clipBehavior: Clip.none, + children: [ + // Top child — fills the screen, scrolls up + Positioned(top: -_offset, left: 0, right: 0, height: viewportHeight, child: widget.topChild), + // Bottom child — overlaps, peeks from bottom + Positioned( + top: viewportHeight - widget.bottomPeekHeight - _offset, + left: 0, + right: 0, + child: widget.bottomChild, + ), + ], + ), + ), + ), + ); + }, + ); + } +} // class _ImmichUIShowcasePageState extends State { // final ScrollController _scrollController = ScrollController(); // double _opacity = 0.0; diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_details.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_details.widget.dart index 69a5c4e499..73c68e42c1 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_details.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_details.widget.dart @@ -224,86 +224,85 @@ class AssetDetails extends ConsumerWidget { return Container( constraints: BoxConstraints(minHeight: minHeight), - decoration: BoxDecoration(color: context.isDarkTheme ? context.colorScheme.surface : Colors.white), - child: Stack( + decoration: BoxDecoration( + color: context.isDarkTheme ? context.colorScheme.surface : Colors.white, + borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), + ), + child: Column( children: [ const _DragHandle(), - Column( - children: [ - // 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), - ], + // 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), ], ), ); @@ -408,14 +407,9 @@ class _DragHandle extends StatelessWidget { label: MaterialLocalizations.of(context).modalBarrierDismissLabel, container: true, button: true, - child: Container( - decoration: BoxDecoration( - color: context.isDarkTheme ? context.colorScheme.surface : Colors.white, - borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), - ), + child: SizedBox( width: double.infinity, height: math.max(handleSize.height, kMinInteractiveDimension), - transform: Matrix4.translationValues(0, -kMinInteractiveDimension, 0), child: Center( child: Container( height: handleSize.height, 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 d7573eef9e..19669e0307 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart @@ -111,7 +111,6 @@ class _AssetViewerState extends ConsumerState { late final int heroOffset; late PhotoViewControllerValue initialPhotoViewState; bool? hasDraggedDown; - bool isSnapping = false; bool blockGestures = false; bool dragInProgress = false; bool shouldPopOnDrag = false; @@ -133,7 +132,7 @@ class _AssetViewerState extends ConsumerState { final ScrollController _scrollController = ScrollController(); double _assetDetailsOpacity = 0.0; - final _assetDetailsSnap = 5; + // final _assetDetailsSnap = 5; @override void initState() { @@ -278,12 +277,29 @@ class _AssetViewerState extends ConsumerState { viewController = controller; } + bool onScrollNotification(ScrollNotification notification) { + if (notification is ScrollStartNotification) { + // Drag started + print('Scroll/drag started'); + // final dragDetails = notification.dragDetails; + // _onDragStart(_, notification.dragDetails, controller, scaleStateController) + } else if (notification is ScrollUpdateNotification) { + // Drag is ongoing + print('Scroll offset: ${notification.metrics.pixels}'); + } else if (notification is ScrollEndNotification) { + // Drag ended + print('Scroll/drag ended'); + } + return false; // return false to allow the notification to continue propagating + } + void _onDragStart( _, DragStartDetails details, PhotoViewControllerBase controller, PhotoViewScaleStateController scaleStateController, ) { + print("photoview drag start"); viewController = controller; dragDownPosition = details.localPosition; initialPhotoViewState = controller.value; @@ -560,6 +576,8 @@ class _AssetViewerState extends ConsumerState { } }); + // debugPaintSizeEnabled = true; + // 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 @@ -599,41 +617,54 @@ class _AssetViewerState extends ConsumerState { // Calculate padding to center the image in the viewport final topPadding = math.max((viewportHeight - imageHeight) / 2, 0.0); - final snapOffset = math.min(topPadding + (imageHeight / 2), (viewportHeight / 3) * 2); + final snapOffset = math.max(topPadding + (imageHeight / 2), viewportHeight / 3 * 2); return Stack( clipBehavior: Clip.none, children: [ - SingleChildScrollView( - controller: _scrollController, - physics: VariableHeightSnappingPhysics(snapStart: 0, snapEnd: snapOffset, snapOffset: snapOffset), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - SizedBox(height: topPadding), - SizedBox( - height: imageHeight, - 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, - scaleStateChangedCallback: _onScaleStateChanged, - builder: _assetBuilder, - backgroundDecoration: BoxDecoration(color: backgroundColor), - enablePanAlways: true, + NotificationListener( + onNotification: onScrollNotification, + child: SingleChildScrollView( + controller: _scrollController, + physics: VariableHeightSnappingPhysics(snapStart: 0, snapEnd: snapOffset, snapOffset: snapOffset), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Container( + // height: topPadding, + // decoration: const BoxDecoration(color: Colors.green), + // ), + SizedOverflowBox( + size: Size(double.infinity, topPadding + imageHeight - (kMinInteractiveDimension / 2)), + alignment: Alignment.topCenter, + child: 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, + scaleStateChangedCallback: _onScaleStateChanged, + builder: _assetBuilder, + backgroundDecoration: BoxDecoration(color: backgroundColor), + enablePanAlways: true, + ), + ), ), - ), - AnimatedOpacity( - opacity: _assetDetailsOpacity, - duration: kThemeAnimationDuration, - child: AssetDetails(minHeight: viewportHeight / 3 * 2), - ), - ], + + // TODO: if zooming, this should be hidden, and we should + // probably disable the scroll physics + AnimatedOpacity( + opacity: _assetDetailsOpacity, + duration: kThemeAnimationDuration, + child: AssetDetails(minHeight: snapOffset), + ), + ], + ), ), ), Positioned( @@ -679,6 +710,14 @@ class VariableHeightSnappingPhysics extends ScrollPhysics { ); } + @override + double applyBoundaryConditions(ScrollMetrics position, double value) { + if (value < position.pixels && position.pixels <= position.minScrollExtent) { + return 0.0; + } + return super.applyBoundaryConditions(position, value); + } + @override Simulation? createBallisticSimulation(ScrollMetrics position, double velocity) { final tolerance = toleranceFor(position);