diff --git a/mobile/lib/domain/models/events.model.dart b/mobile/lib/domain/models/events.model.dart index fc9cebc80f..d7fb6768cf 100644 --- a/mobile/lib/domain/models/events.model.dart +++ b/mobile/lib/domain/models/events.model.dart @@ -16,9 +16,9 @@ class ScrollToDateEvent extends Event { } // Asset Viewer Events -class ViewerOpenBottomSheetEvent extends Event { +class ViewerShowDetailsEvent extends Event { final bool activitiesMode; - const ViewerOpenBottomSheetEvent({this.activitiesMode = false}); + const ViewerShowDetailsEvent({this.activitiesMode = false}); } class ViewerReloadAssetEvent extends Event { diff --git a/mobile/lib/presentation/pages/dev/ui_showcase.page.dart b/mobile/lib/presentation/pages/dev/ui_showcase.page.dart index 876d726cb5..37c412a0e9 100644 --- a/mobile/lib/presentation/pages/dev/ui_showcase.page.dart +++ b/mobile/lib/presentation/pages/dev/ui_showcase.page.dart @@ -3,279 +3,98 @@ import 'package:flutter/material.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_ui/immich_ui.dart'; -@RoutePage() -class ImmichUIShowcasePage extends StatefulWidget { - const ImmichUIShowcasePage({super.key}); +List _showcaseBuilder(Function(ImmichVariant variant, ImmichColor color) builder) { + final children = []; - @override - State createState() => _ImmichUIShowcasePageState(); + final items = [ + (variant: ImmichVariant.filled, title: "Filled Variant"), + (variant: ImmichVariant.ghost, title: "Ghost Variant"), + ]; + + for (final (:variant, :title) in items) { + children.add(Text(title)); + children.add(Row(spacing: 10, children: [for (var color in ImmichColor.values) builder(variant, color)])); + } + + return children; } -class _ImmichUIShowcasePageState extends State { - final ScrollController _scrollController = ScrollController(); - double _opacity = 0.0; +class _ComponentTitle extends StatelessWidget { + final String title; + + const _ComponentTitle(this.title); @override - void initState() { - super.initState(); - _scrollController.addListener(_onScroll); + Widget build(BuildContext context) { + return Text(title, style: context.textTheme.titleLarge); } +} - void _onScroll() { - print('Scroll offset: ${_scrollController.offset}'); - setState(() { - _opacity = (_scrollController.offset / 50).clamp(0.0, 1.0); - }); - } - - @override - void dispose() { - _scrollController.dispose(); - super.dispose(); - } +@RoutePage() +class ImmichUIShowcasePage extends StatelessWidget { + const ImmichUIShowcasePage({super.key}); @override Widget build(BuildContext context) { return Scaffold( - body: StackedScrollView( - bottomPeekHeight: 100, - topChild: Container( - color: Colors.blue, - child: const Center( - child: Text('Top', style: TextStyle(color: Colors.white, fontSize: 32)), + appBar: AppBar(title: const Text('Immich UI Showcase')), + body: Padding( + padding: const EdgeInsets.all(20), + child: SingleChildScrollView( + child: Column( + spacing: 10, + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const _ComponentTitle("IconButton"), + ..._showcaseBuilder( + (variant, color) => + ImmichIconButton(icon: Icons.favorite, color: color, variant: variant, onPressed: () {}), + ), + const _ComponentTitle("CloseButton"), + ..._showcaseBuilder( + (variant, color) => ImmichCloseButton(color: color, variant: variant, onPressed: () {}), + ), + const _ComponentTitle("TextButton"), + + ImmichTextButton( + labelText: "Text Button", + onPressed: () {}, + variant: ImmichVariant.filled, + color: ImmichColor.primary, + ), + ImmichTextButton( + labelText: "Text Button", + onPressed: () {}, + variant: ImmichVariant.filled, + color: ImmichColor.primary, + loading: true, + ), + ImmichTextButton( + labelText: "Text Button", + onPressed: () {}, + variant: ImmichVariant.ghost, + color: ImmichColor.primary, + ), + ImmichTextButton( + labelText: "Text Button", + onPressed: () {}, + variant: ImmichVariant.ghost, + color: ImmichColor.primary, + loading: true, + ), + const _ComponentTitle("Form"), + ImmichForm( + onSubmit: () {}, + child: const Column( + spacing: 10, + children: [ImmichTextInput(label: "Title", hintText: "Enter a title")], + ), + ), + ], ), ), - 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 StackedScrollView extends StatefulWidget { - final Widget topChild; - final Widget bottomChild; - final double bottomPeekHeight; - - const StackedScrollView({super.key, required this.topChild, required this.bottomChild, this.bottomPeekHeight = 80}); - - @override - 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; - -// @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_viewer.page.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart index b53b63be78..97d5bbd003 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart @@ -107,6 +107,7 @@ class _AssetViewerState extends ConsumerState with TickerProviderSt // PhotoViewGallery takes care of disposing it's controllers PhotoViewControllerBase? viewController; StreamSubscription? reloadSubscription; + StreamSubscription? _scaleBoundarySub; late final int heroOffset; late PhotoViewControllerValue initialPhotoViewState; @@ -171,8 +172,14 @@ class _AssetViewerState extends ConsumerState with TickerProviderSt } } - static const _scrollTolerance = Tolerance(distance: 0.5, velocity: 10.0); - static final _snapSpring = SpringDescription.withDampingRatio(mass: 0.5, stiffness: 100.0, ratio: 1.0); + // Match Flutter's ScrollPhysics defaults + static final _snapSpring = SpringDescription.withDampingRatio(mass: 0.5, stiffness: 100.0, ratio: 1.1); + static const _minFlingVelocity = 50.0; // px/s, matches ScrollPhysics.minFlingVelocity + + Tolerance get _scrollTolerance { + final dpr = MediaQuery.devicePixelRatioOf(context); + return Tolerance(velocity: 1.0 / (0.050 * dpr), distance: 1.0 / dpr); + } /// Drive the scroll controller by [dy] pixels (positive = scroll down). void _scrollBy(double dy) { @@ -184,16 +191,33 @@ class _AssetViewerState extends ConsumerState with TickerProviderSt /// Animate the scroll position to [target] using a spring simulation. void _animateScrollTo(double target, double velocity) { final offset = _scrollController.offset; - if ((offset - target).abs() < 0.5) { + final tolerance = _scrollTolerance; + if ((offset - target).abs() < tolerance.distance) { _scrollController.jumpTo(target); return; } _ballisticAnimController.value = offset; _ballisticAnimController.animateWith( - ScrollSpringSimulation(_snapSpring, offset, target, velocity, tolerance: _scrollTolerance), + ScrollSpringSimulation(_snapSpring, offset, target, velocity, tolerance: tolerance), ); } + /// Create a platform-appropriate fling simulation (clamping on Android, bouncing on iOS). + Simulation _createFlingSimulation(double offset, double velocity) { + final tolerance = _scrollTolerance; + if (CurrentPlatform.isIOS) { + return BouncingScrollSimulation( + position: offset, + velocity: velocity, + leadingExtent: _currentSnapOffset, + trailingExtent: _scrollController.position.maxScrollExtent, + spring: _snapSpring, + tolerance: tolerance, + ); + } + return ClampingScrollSimulation(position: offset, velocity: velocity, tolerance: tolerance); + } + void _onBallisticTick() { if (!_scrollController.hasClients) return; final raw = _ballisticAnimController.value; @@ -228,22 +252,20 @@ class _AssetViewerState extends ConsumerState with TickerProviderSt // Above snap offset: free scroll or spring back to snap if (offset >= snap) { - if (velocity.abs() < 10) return; - if (velocity < -50) { + if (velocity.abs() < _minFlingVelocity) return; + if (velocity < -_minFlingVelocity) { _animateScrollTo(snap, velocity); return; } - // Scrolling up: decelerate naturally + // Scrolling up: decelerate with platform-native physics _ballisticAnimController.value = offset; - _ballisticAnimController.animateWith( - ClampingScrollSimulation(position: offset, velocity: velocity, tolerance: _scrollTolerance), - ); + _ballisticAnimController.animateWith(_createFlingSimulation(offset, velocity)); return; } // In snap zone (0 → snapOffset): snap to nearest target final double target; - if (velocity.abs() > 50) { + if (velocity.abs() > _minFlingVelocity) { target = velocity > 0 ? snap : 0; } else { target = (offset < snap / 2) ? 0 : snap; @@ -258,6 +280,7 @@ class _AssetViewerState extends ConsumerState with TickerProviderSt pageController.dispose(); _cancelTimers(); reloadSubscription?.cancel(); + _scaleBoundarySub?.cancel(); _prevPreCacheStream?.removeListener(_dummyListener); _nextPreCacheStream?.removeListener(_dummyListener); SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); @@ -265,7 +288,7 @@ class _AssetViewerState extends ConsumerState with TickerProviderSt super.dispose(); } - bool get showingBottomSheet => _scrollController.offset > 0; + bool get showingDetails => _scrollController.offset > 0; Color get backgroundColor { final opacity = ref.read(assetViewerProvider.select((s) => s.backgroundOpacity)); @@ -361,6 +384,20 @@ class _AssetViewerState extends ConsumerState with TickerProviderSt void _onPageChanged(int index, PhotoViewControllerBase? controller) { _onAssetChanged(index); viewController = controller; + _listenForScaleBoundaries(controller); + } + + void _listenForScaleBoundaries(PhotoViewControllerBase? controller) { + _scaleBoundarySub?.cancel(); + _scaleBoundarySub = null; + if (controller == null || controller.scaleBoundaries != null) return; + _scaleBoundarySub = controller.outputStateStream.listen((_) { + if (controller.scaleBoundaries != null) { + _scaleBoundarySub?.cancel(); + _scaleBoundarySub = null; + if (mounted) setState(() {}); + } + }); } void _onDragStart( @@ -374,11 +411,11 @@ class _AssetViewerState extends ConsumerState with TickerProviderSt dragDownPosition = details.localPosition; _dragStartGlobalPosition = details.globalPosition; initialPhotoViewState = controller.value; - _dragMode = showingBottomSheet ? _DragMode.scroll : _DragMode.undecided; + _dragMode = showingDetails ? _DragMode.scroll : _DragMode.undecided; final isZoomed = scaleStateController.scaleState == PhotoViewScaleState.zoomedIn || scaleStateController.scaleState == PhotoViewScaleState.covering; - if (!showingBottomSheet && isZoomed) { + if (!showingDetails && isZoomed) { blockGestures = true; } } @@ -460,8 +497,8 @@ class _AssetViewerState extends ConsumerState with TickerProviderSt ref.read(assetViewerProvider.notifier).setOpacity(backgroundOpacity); } - void _onTapDown(_, __, ___) { - if (!showingBottomSheet) { + void _onTapUp(_, __, ___) { + if (!showingDetails) { ref.read(assetViewerProvider.notifier).toggleControls(); } } @@ -477,13 +514,13 @@ class _AssetViewerState extends ConsumerState with TickerProviderSt return; } - if (event is ViewerOpenBottomSheetEvent) { - _openDetails(); + if (event is ViewerShowDetailsEvent) { + _showDetails(); return; } } - void _openDetails() { + void _showDetails() { if (!_scrollController.hasClients || _currentSnapOffset <= 0) return; _ballisticAnimController.stop(); _animateScrollTo(_currentSnapOffset, 0); @@ -513,7 +550,7 @@ class _AssetViewerState extends ConsumerState with TickerProviderSt } final currentAsset = ref.read(currentAssetNotifier); - // Do not reload / close the bottom sheet if the asset has not changed + // Do not reload if the asset has not changed if (newAsset.heroTag == currentAsset?.heroTag) { return; } @@ -536,7 +573,7 @@ class _AssetViewerState extends ConsumerState with TickerProviderSt return; } - if (!showingBottomSheet) { + if (!showingDetails) { ref.read(assetViewerProvider.notifier).setControls(true); } } @@ -585,11 +622,11 @@ class _AssetViewerState extends ConsumerState with TickerProviderSt heroAttributes: PhotoViewHeroAttributes(tag: '${asset.heroTag}_$heroOffset'), filterQuality: FilterQuality.high, tightMode: true, - disableScaleGestures: showingBottomSheet, + disableScaleGestures: showingDetails, onDragStart: _onDragStart, onDragUpdate: _onDragUpdate, onDragEnd: _onDragEnd, - onTapDown: _onTapDown, + onTapUp: _onTapUp, onLongPressStart: asset.isMotionPhoto ? _onLongPress : null, errorBuilder: (_, __, ___) => Container( width: size.width, @@ -610,7 +647,7 @@ class _AssetViewerState extends ConsumerState with TickerProviderSt onDragStart: _onDragStart, onDragUpdate: _onDragUpdate, onDragEnd: _onDragEnd, - onTapDown: _onTapDown, + onTapUp: _onTapUp, heroAttributes: PhotoViewHeroAttributes(tag: '${asset.heroTag}_$heroOffset'), filterQuality: FilterQuality.high, maxScale: 1.0, @@ -689,23 +726,26 @@ class _AssetViewerState extends ConsumerState with TickerProviderSt final viewportWidth = constraints.maxWidth; final viewportHeight = constraints.maxHeight; - // Calculate image display height from asset dimensions - final asset = ref.read(currentAssetNotifier); - final assetWidth = asset?.width; - final assetHeight = asset?.height; - - // should probably get this value from the actual size of the image - // rather than calculating it. It could lead to very slight - // misalignments. - double imageHeight = viewportHeight; - if (assetWidth != null && assetHeight != null && assetWidth > 0 && assetHeight > 0) { - final aspectRatio = assetWidth / assetHeight; - imageHeight = math.min(viewportWidth / aspectRatio, viewportHeight); + // Use the actual rendered image size from PhotoView when available, + // falling back to a calculation from asset metadata. + final sb = viewController?.scaleBoundaries; + double imageHeight; + if (sb != null) { + imageHeight = sb.childSize.height * sb.initialScale; + } else { + final asset = ref.read(currentAssetNotifier); + final assetWidth = asset?.width; + final assetHeight = asset?.height; + imageHeight = viewportHeight; + if (assetWidth != null && assetHeight != null && assetWidth > 0 && assetHeight > 0) { + final aspectRatio = assetWidth / assetHeight; + imageHeight = math.min(viewportWidth / aspectRatio, viewportHeight); + } } // Calculate padding to center the image in the viewport final topPadding = math.max((viewportHeight - imageHeight) / 2, 0.0); - final snapOffset = (topPadding + (imageHeight / 2)).clamp(viewportHeight / 2, viewportHeight / 3 * 2); + final snapOffset = math.max(topPadding + (imageHeight / 2), viewportHeight / 4 * 3); _currentSnapOffset = snapOffset; return Stack( 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 index 193cf60220..69fd9249ad 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/top_app_bar.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/top_app_bar.widget.dart @@ -55,7 +55,7 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget { IconButton( icon: const Icon(Icons.chat_outlined), onPressed: () { - EventStream.shared.emit(const ViewerOpenBottomSheetEvent(activitiesMode: true)); + EventStream.shared.emit(const ViewerShowDetailsEvent(activitiesMode: true)); }, ), diff --git a/mobile/lib/utils/action_button.utils.dart b/mobile/lib/utils/action_button.utils.dart index 1a2883bee7..dccb765760 100644 --- a/mobile/lib/utils/action_button.utils.dart +++ b/mobile/lib/utils/action_button.utils.dart @@ -225,7 +225,7 @@ enum ActionButtonType { iconData: Icons.info_outline, iconColor: context.originalTheme?.iconTheme.color, menuItem: true, - onPressed: () => EventStream.shared.emit(const ViewerOpenBottomSheetEvent()), + onPressed: () => EventStream.shared.emit(const ViewerShowDetailsEvent()), ), ActionButtonType.viewInTimeline => BaseActionButton( label: 'view_in_timeline'.tr(),