diff --git a/mobile/lib/presentation/pages/dev/main_timeline.page.dart b/mobile/lib/presentation/pages/dev/main_timeline.page.dart index b78bbc2979..8cd1667ff4 100644 --- a/mobile/lib/presentation/pages/dev/main_timeline.page.dart +++ b/mobile/lib/presentation/pages/dev/main_timeline.page.dart @@ -1,49 +1,17 @@ import 'package:auto_route/auto_route.dart'; -import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/presentation/widgets/memory/memory_lane.widget.dart'; import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart'; -import 'package:immich_mobile/presentation/widgets/feature_message/feature_message_dialog.widget.dart'; -import 'package:immich_mobile/providers/feature_message.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/memory.provider.dart'; +import 'package:immich_mobile/widgets/common/page_chrome/tab_page.dart'; +import 'package:immich_mobile/widgets/common/page_chrome/types.dart'; @RoutePage() -class MainTimelinePage extends ConsumerStatefulWidget { +class MainTimelinePage extends TabPage { const MainTimelinePage({super.key}); @override - ConsumerState createState() => _MainTimelinePageState(); -} - -class _MainTimelinePageState extends ConsumerState { - @override - void initState() { - super.initState(); - WidgetsBinding.instance.addPostFrameCallback((_) async { - if (!mounted) { - return; - } - final service = ref.read(featureMessageServiceProvider); - if (!service.shouldShow()) { - return; - } - - await service.markSeen(); - if (!mounted) { - return; - } - - await showFeatureMessageDialog(context); - }); - } + Widget scopeBuilder(BuildContext context, Widget child) => timelineChromeScope(context, child); @override - Widget build(BuildContext context) { - final hasMemories = ref.watch(driftMemoryFutureProvider.select((state) => state.value?.isNotEmpty ?? false)); - return Timeline( - topSliverWidget: const SliverToBoxAdapter(child: DriftMemoryLane()), - topSliverWidgetHeight: hasMemories ? 200 : 0, - showStorageIndicator: true, - ); - } + ProviderListenable get content => timelineChromeProvider; } diff --git a/mobile/lib/presentation/pages/drift_album.page.dart b/mobile/lib/presentation/pages/drift_album.page.dart index 47a4625f87..1cc4703dc8 100644 --- a/mobile/lib/presentation/pages/drift_album.page.dart +++ b/mobile/lib/presentation/pages/drift_album.page.dart @@ -1,5 +1,3 @@ -import 'dart:async'; - import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -7,64 +5,70 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; import 'package:immich_mobile/routing/router.dart'; -import 'package:immich_mobile/widgets/common/immich_sliver_app_bar.dart'; +import 'package:immich_mobile/widgets/common/page_chrome/tab_page.dart'; +import 'package:immich_mobile/widgets/common/page_chrome/types.dart'; +import 'package:immich_mobile/widgets/common/primary_app_bar.dart'; @RoutePage() -class DriftAlbumsPage extends ConsumerStatefulWidget { +class DriftAlbumsPage extends TabPage { const DriftAlbumsPage({super.key}); @override - ConsumerState createState() => _DriftAlbumsPageState(); -} - -class _DriftAlbumsPageState extends ConsumerState { - final ScrollController _scrollController = ScrollController(); - - Future onRefresh() async { - await ref.read(remoteAlbumProvider.notifier).refresh(); - } - - @override - void dispose() { - _scrollController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { + ProviderListenable get content => Provider((ref) { final albumCount = ref.watch(remoteAlbumProvider.select((state) => state.albums.length)); final showScrollbar = albumCount > 20; - final scrollView = CustomScrollView( - controller: _scrollController, + return PageChromeContent( physics: const AlwaysScrollableScrollPhysics(), - slivers: [ - ImmichSliverAppBar( - snap: false, - floating: false, - pinned: true, - actions: [ - IconButton( - onPressed: () => context.pushRoute(const DriftCreateAlbumRoute()), - icon: const Icon(Icons.add_rounded), - ), - ], - showUploadButton: false, - ), - AlbumSelector( - onAlbumSelected: (album) { - context.router.push(RemoteAlbumRoute(album: album)); - }, - ), - ], + appBar: const PrimaryAppBar( + snap: false, + floating: false, + pinned: true, + showUploadButton: false, + actions: [_CreateAlbumAction()], + ), + viewportBuilder: (scrollView) => _AlbumRefreshWrapper(showScrollbar: showScrollbar, scrollView: scrollView), + slivers: [const _AlbumSelectorSliver()], ); + }); +} +class _CreateAlbumAction extends StatelessWidget { + const _CreateAlbumAction(); + + @override + Widget build(BuildContext context) { + return IconButton( + onPressed: () => context.pushRoute(const DriftCreateAlbumRoute()), + icon: const Icon(Icons.add_rounded), + ); + } +} + +class _AlbumSelectorSliver extends StatelessWidget { + const _AlbumSelectorSliver(); + + @override + Widget build(BuildContext context) { + return AlbumSelector(onAlbumSelected: (album) => context.router.push(RemoteAlbumRoute(album: album))); + } +} + +class _AlbumRefreshWrapper extends ConsumerWidget { + final bool showScrollbar; + final CustomScrollView scrollView; + + const _AlbumRefreshWrapper({required this.showScrollbar, required this.scrollView}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final controller = PrimaryScrollController.of(context); return RefreshIndicator( - onRefresh: onRefresh, + onRefresh: () => ref.read(remoteAlbumProvider.notifier).refresh(), edgeOffset: 100, child: showScrollbar ? RawScrollbar( - controller: _scrollController, + controller: controller, interactive: true, thickness: 8, radius: const Radius.circular(4), diff --git a/mobile/lib/presentation/pages/drift_library.page.dart b/mobile/lib/presentation/pages/drift_library.page.dart index 673df089d5..a5f673de5d 100644 --- a/mobile/lib/presentation/pages/drift_library.page.dart +++ b/mobile/lib/presentation/pages/drift_library.page.dart @@ -16,27 +16,23 @@ import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/utils/image_url_builder.dart'; -import 'package:immich_mobile/widgets/common/immich_sliver_app_bar.dart'; +import 'package:immich_mobile/widgets/common/page_chrome/tab_page.dart'; +import 'package:immich_mobile/widgets/common/page_chrome/types.dart'; +import 'package:immich_mobile/widgets/common/primary_app_bar.dart'; import 'package:immich_mobile/widgets/map/map_thumbnail.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; @RoutePage() -class DriftLibraryPage extends ConsumerWidget { +class DriftLibraryPage extends TabPage { const DriftLibraryPage({super.key}); @override - Widget build(BuildContext context, WidgetRef ref) { - return const Scaffold( - body: CustomScrollView( - slivers: [ - ImmichSliverAppBar(snap: false, floating: false, pinned: true, showUploadButton: false), - _ActionButtonGrid(), - _CollectionCards(), - _QuickAccessButtonList(), - ], - ), + ProviderListenable get content => Provider((ref) { + return const PageChromeContent( + appBar: PrimaryAppBar(snap: false, floating: false, pinned: true, showUploadButton: false), + slivers: [_ActionButtonGrid(), _CollectionCards(), _QuickAccessButtonList()], ); - } + }); } class _ActionButtonGrid extends ConsumerWidget { diff --git a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart index 3f05edc2a4..3960f2c90b 100644 --- a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart +++ b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart @@ -7,6 +7,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/events.model.dart'; @@ -16,17 +17,23 @@ import 'package:immich_mobile/extensions/asyncvalue_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/download_status_floating_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart'; +import 'package:immich_mobile/presentation/widgets/feature_message/feature_message_dialog.widget.dart'; +import 'package:immich_mobile/presentation/widgets/memory/memory_lane.widget.dart'; import 'package:immich_mobile/presentation/widgets/timeline/constants.dart'; import 'package:immich_mobile/presentation/widgets/timeline/scrubber.widget.dart'; import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart'; import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart'; import 'package:immich_mobile/presentation/widgets/timeline/timeline_drag_region.dart'; +import 'package:immich_mobile/presentation/widgets/timeline/timeline_scroll.provider.dart'; +import 'package:immich_mobile/providers/feature_message.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/memory.provider.dart'; import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart'; import 'package:immich_mobile/providers/infrastructure/settings.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; -import 'package:immich_mobile/widgets/common/immich_sliver_app_bar.dart'; import 'package:immich_mobile/widgets/common/mesmerizing_sliver_app_bar.dart'; +import 'package:immich_mobile/widgets/common/page_chrome/types.dart'; +import 'package:immich_mobile/widgets/common/primary_app_bar.dart'; import 'package:immich_mobile/widgets/common/selection_sliver_app_bar.dart'; class Timeline extends ConsumerWidget { @@ -37,7 +44,7 @@ class Timeline extends ConsumerWidget { this.bottomSliverWidget, this.showStorageIndicator = false, this.withStack = false, - this.appBar = const ImmichSliverAppBar(floating: true, pinned: false, snap: false), + this.appBar = const PrimaryAppBar(floating: true, pinned: false, snap: false), this.bottomSheet = const GeneralBottomSheet(minChildSize: 0.23), this.groupBy, this.withScrubber = true, @@ -748,3 +755,225 @@ class CustomScaleGestureRecognizer extends ScaleGestureRecognizer { acceptGesture(pointer); } } + +// --------------------------------------------------------------------------- +// PageChrome (Photos) path +// +// The shared `PageChrome` owns the Scaffold + scroll view + app bar. The +// timeline contributes a [PageChromeContent] via [timelineChromeProvider], +// read inside [timelineChromeScope] (which overrides `timelineArgsProvider` +// from the real layout constraints). The controller/observer/scroll logic +// lives in [timelineScrollProvider] so the chrome can read the controller +// before it builds the scroll view (same frame). +// --------------------------------------------------------------------------- + +/// Wraps the chrome body in the timeline's `ProviderScope`, overriding +/// `timelineArgsProvider` from the actual scroll-view constraints via a +/// `LayoutBuilder` (built during layout, so the value is real, not a guess). +Widget timelineChromeScope(BuildContext context, Widget child) { + return LayoutBuilder( + builder: (context, constraints) { + return Consumer( + builder: (context, ref, _) { + final columnCount = ref.watch(appConfigProvider.select((config) => config.timeline.tilesPerRow)); + return ProviderScope( + overrides: [ + timelineArgsProvider.overrideWithValue( + TimelineArgs( + maxWidth: constraints.maxWidth, + maxHeight: constraints.maxHeight, + columnCount: columnCount, + showStorageIndicator: true, + ), + ), + ], + child: _TimelineChromePopScope(child: child), + ); + }, + ); + }, + ); +} + +/// Pops out of multiselect (instead of the route) while a selection is active. +class _TimelineChromePopScope extends ConsumerWidget { + final Widget child; + + const _TimelineChromePopScope({required this.child}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isMultiSelectEnabled = ref.watch(multiSelectProvider.select((s) => s.isEnabled)); + return PopScope( + canPop: !isMultiSelectEnabled, + onPopInvokedWithResult: (didPop, _) { + if (!didPop && isMultiSelectEnabled) { + ref.read(multiSelectProvider.notifier).reset(); + } + }, + child: child, + ); + } +} + +final timelineChromeProvider = Provider((ref) { + final scroll = ref.watch(timelineScrollProvider.notifier); + final physics = ref.watch(timelineScrollProvider.select((s) => s.physics)); + final maxHeight = ref.watch(timelineArgsProvider.select((args) => args.maxHeight)); + final isSelectionMode = ref.watch(multiSelectProvider.select((s) => s.forceEnable)); + + return PageChromeContent( + controller: scroll.controller, + physics: physics, + scrollCacheExtent: ScrollCacheExtent.pixels(maxHeight * 2), + appBar: isSelectionMode + ? const SelectionSliverAppBar() + : const PrimaryAppBar(floating: true, pinned: false, snap: false), + slivers: const [_TimelineChromeContent()], + viewportBuilder: (scrollView) => _TimelineChromeViewport(scrollView: scrollView), + floatingActionButton: const DownloadStatusFloatingButton(), + overlays: const [_TimelineChromeOverlays()], + ); +}, dependencies: [timelineServiceProvider, timelineArgsProvider, timelineScrollProvider, multiSelectProvider]); + +/// The timeline's content slivers (memory lane, the segmented grid, bottom +/// padding). Mounted by the chrome inside its scroll view, so the ambient +/// `PrimaryScrollController` is the chrome's controller. +class _TimelineChromeContent extends ConsumerWidget { + const _TimelineChromeContent(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + // A reload event forces the grid to rebuild. + ref.listen(timelineSegmentProvider, (_, __) {}); + + final asyncSegments = ref.watch(timelineSegmentProvider); + final isMultiSelectEnabled = ref.watch(multiSelectProvider.select((s) => s.isEnabled)); + final hasMemories = ref.watch(driftMemoryFutureProvider.select((state) => state.value?.isNotEmpty ?? false)); + + return asyncSegments.when( + loading: () => const SliverToBoxAdapter(child: SizedBox.shrink()), + error: (_, __) => const SliverToBoxAdapter(child: SizedBox.shrink()), + data: (segments) { + final childCount = (segments.lastOrNull?.lastIndex ?? -1) + 1; + const bottomSheetOpenModifier = 120.0; + final contentBottomPadding = context.padding.bottom + (isMultiSelectEnabled ? bottomSheetOpenModifier : 0); + + return SliverMainAxisGroup( + slivers: [ + if (hasMemories) const SliverToBoxAdapter(child: DriftMemoryLane()), + _SliverSegmentedList( + segments: segments, + delegate: SliverChildBuilderDelegate((ctx, index) { + if (index >= childCount) { + return null; + } + final segment = segments.findByIndex(index); + return segment?.builder(ctx, index) ?? const SizedBox.shrink(); + }, childCount: childCount, addAutomaticKeepAlives: false, addRepaintBoundaries: false), + ), + SliverPadding(padding: EdgeInsets.only(bottom: contentBottomPadding)), + ], + ); + }, + ); + } +} + +/// Wraps the shared scroll view in the pinch-to-scale gesture, drag selection +/// region and the scrubber. +class _TimelineChromeViewport extends ConsumerWidget { + final CustomScrollView scrollView; + + const _TimelineChromeViewport({required this.scrollView}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final scroll = ref.read(timelineScrollProvider.notifier); + final isReadonlyModeEnabled = ref.watch(readonlyModeProvider); + final maxHeight = ref.watch(timelineArgsProvider.select((args) => args.maxHeight)); + final isMultiSelectEnabled = ref.watch(multiSelectProvider.select((s) => s.isEnabled)); + final hasMemories = ref.watch(driftMemoryFutureProvider.select((state) => state.value?.isNotEmpty ?? false)); + final segments = ref.watch(timelineSegmentProvider).valueOrNull ?? const []; + + final topPadding = context.padding.top + kToolbarHeight + 10; + const bottomSheetOpenModifier = 120.0; + final contentBottomPadding = context.padding.bottom + (isMultiSelectEnabled ? bottomSheetOpenModifier : 0); + final scrubberBottomPadding = contentBottomPadding + kScrubberThumbHeight; + + final timeline = Scrubber( + layoutSegments: segments, + timelineHeight: maxHeight, + topPadding: topPadding, + bottomPadding: scrubberBottomPadding, + monthSegmentSnappingOffset: hasMemories ? 200 : 0, + hasAppBar: true, + child: scrollView, + ); + + return RawGestureDetector( + gestures: { + CustomScaleGestureRecognizer: GestureRecognizerFactoryWithHandlers( + () => CustomScaleGestureRecognizer(), + (CustomScaleGestureRecognizer recognizer) { + recognizer.onStart = (_) => scroll.onScaleStart(); + recognizer.onUpdate = (details) => scroll.onScaleUpdate(details.scale, segments); + }, + ), + }, + child: TimelineDragRegion( + onStart: !isReadonlyModeEnabled ? scroll.setDragStartIndex : null, + onAssetEnter: scroll.handleDragAssetEnter, + onEnd: !isReadonlyModeEnabled ? scroll.stopDrag : null, + onScroll: scroll.dragScroll, + onScrollStart: scroll.onScrollStart, + child: timeline, + ), + ); + } +} + +/// The multiselect status button + bottom sheet stacked on top of the timeline. +class _TimelineChromeOverlays extends HookConsumerWidget { + const _TimelineChromeOverlays(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + // Show the "what's new" feature message once, when the timeline mounts. + useEffect(() { + WidgetsBinding.instance.addPostFrameCallback((_) async { + if (!context.mounted) { + return; + } + final service = ref.read(featureMessageServiceProvider); + if (!service.shouldShow()) { + return; + } + await service.markSeen(); + if (!context.mounted) { + return; + } + await showFeatureMessageDialog(context); + }); + return null; + }, const []); + + final isSelectionMode = ref.watch(multiSelectProvider.select((s) => s.forceEnable)); + final isMultiSelectEnabled = ref.watch(multiSelectProvider.select((s) => s.isEnabled)); + final isVisible = !isSelectionMode && isMultiSelectEnabled; + if (!isVisible) { + return const SizedBox.shrink(); + } + return Stack( + clipBehavior: Clip.none, + children: [ + Positioned( + top: MediaQuery.paddingOf(context).top, + left: 25, + child: const SizedBox(height: kToolbarHeight, child: Center(child: _MultiSelectStatusButton())), + ), + const GeneralBottomSheet(minChildSize: 0.23), + ], + ); + } +} diff --git a/mobile/lib/presentation/widgets/timeline/timeline_scroll.provider.dart b/mobile/lib/presentation/widgets/timeline/timeline_scroll.provider.dart new file mode 100644 index 0000000000..f954f528d5 --- /dev/null +++ b/mobile/lib/presentation/widgets/timeline/timeline_scroll.provider.dart @@ -0,0 +1,282 @@ +import 'dart:async'; +import 'dart:collection'; +import 'dart:math' as math; + +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/models/events.model.dart'; +import 'package:immich_mobile/domain/models/settings_key.dart'; +import 'package:immich_mobile/domain/models/timeline.model.dart'; +import 'package:immich_mobile/domain/utils/event_stream.dart'; +import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart'; +import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart'; +import 'package:immich_mobile/presentation/widgets/timeline/timeline_drag_region.dart'; +import 'package:immich_mobile/providers/infrastructure/settings.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; +import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; + +/// Transient interaction state of the chrome timeline that drives rebuilds of +/// the content slivers + viewport (scale/drag/physics). +class TimelineScrollState { + final int perRow; + final double scaleFactor; + final bool dragging; + final ScrollPhysics? physics; + + const TimelineScrollState({this.perRow = 4, this.scaleFactor = 3.0, this.dragging = false, this.physics}); + + TimelineScrollState copyWith({int? perRow, double? scaleFactor, bool? dragging, ScrollPhysics? physics}) { + return TimelineScrollState( + perRow: perRow ?? this.perRow, + scaleFactor: scaleFactor ?? this.scaleFactor, + dragging: dragging ?? this.dragging, + // physics is nullable-by-design; pass explicitly to clear. + physics: physics, + ); + } +} + +/// Owns the chrome timeline's [ScrollController] and everything bound to it: +/// the iOS status-bar-tap observer, the scroll event subscription, position +/// restore, pinch-to-scale and drag-selection logic. +/// +/// This lives in a provider (not a widget [State]) so the shared `PageChrome` +/// can read the controller *before* it builds the scroll view, keeping the +/// timeline's chrome same-frame. Scoped under the timeline's `ProviderScope` +/// (via [timelineServiceProvider]/[timelineArgsProvider]) so each timeline gets +/// its own instance. +class TimelineScrollController extends Notifier with WidgetsBindingObserver { + late final ScrollController controller; + StreamSubscription? _eventSubscription; + + // Drag selection + TimelineAssetIndex? _dragAnchorIndex; + final Set _draggedAssets = HashSet(); + + double _baseScaleFactor = 3.0; + int? _restoreAssetIndex; + + @override + TimelineScrollState build() { + WidgetsBinding.instance.addObserver(this); + controller = ScrollController(onAttach: _restoreAssetPosition); + _eventSubscription = EventStream.shared.listen(_onEvent); + + final perRow = ref.read(appConfigProvider.select((config) => config.timeline.tilesPerRow)); + _baseScaleFactor = 7.0 - perRow; + + ref.listen(multiSelectProvider.select((s) => s.isEnabled), _onMultiSelectionToggled); + + // Remember scroll position across width changes (orientation / rail toggle) + // so it can be restored after the segments regenerate. + ref.listen(timelineArgsProvider.select((a) => a.maxWidth), (previous, next) { + if (previous != null && previous != next) { + final segments = ref.read(timelineSegmentProvider).valueOrNull; + if (segments != null && controller.hasClients) { + _restoreAssetIndex = _getCurrentAssetIndex(segments); + } + } + }); + + ref.onDispose(() { + WidgetsBinding.instance.removeObserver(this); + controller.dispose(); + _eventSubscription?.cancel(); + }); + + return TimelineScrollState(perRow: perRow, scaleFactor: _baseScaleFactor); + } + + // Capture iOS status bar tap + @override + void handleStatusBarTap() => scrollToTop(); + + void _onEvent(Event event) { + switch (event) { + case ScrollToTopEvent(): + scrollToTop(); + case ScrollToDateEvent scrollToDateEvent: + scrollToDate(scrollToDateEvent.date); + default: + break; + } + } + + void _restoreAssetPosition(_) { + if (_restoreAssetIndex == null) { + return; + } + + final asyncSegments = ref.read(timelineSegmentProvider); + asyncSegments.whenData((segments) { + final targetSegment = segments.lastWhereOrNull((segment) => segment.firstAssetIndex <= _restoreAssetIndex!); + if (targetSegment != null) { + final assetIndexInSegment = _restoreAssetIndex! - targetSegment.firstAssetIndex; + final newColumnCount = ref.read(timelineArgsProvider).columnCount; + final rowIndexInSegment = (assetIndexInSegment / newColumnCount).floor(); + final targetRowIndex = targetSegment.firstIndex + 1 + rowIndexInSegment; + final targetOffset = targetSegment.indexToLayoutOffset(targetRowIndex); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (controller.hasClients) { + controller.jumpTo(targetOffset.clamp(0.0, controller.position.maxScrollExtent)); + } + }); + } + }); + _restoreAssetIndex = null; + } + + void _onMultiSelectionToggled(_, bool isEnabled) { + EventStream.shared.emit(MultiSelectToggleEvent(isEnabled)); + } + + int? getCurrentAssetIndex(List segments) => _getCurrentAssetIndex(segments); + + int? _getCurrentAssetIndex(List segments) { + final currentOffset = controller.offset.clamp(0.0, controller.position.maxScrollExtent); + final segment = segments.findByOffset(currentOffset) ?? segments.lastOrNull; + int? targetAssetIndex; + if (segment != null) { + final rowIndex = segment.getMinChildIndexForScrollOffset(currentOffset); + if (rowIndex > segment.firstIndex) { + final rowIndexInSegment = rowIndex - (segment.firstIndex + 1); + final assetsPerRow = ref.read(timelineArgsProvider).columnCount; + final assetIndexInSegment = rowIndexInSegment * assetsPerRow; + targetAssetIndex = segment.firstAssetIndex + assetIndexInSegment; + } else { + targetAssetIndex = segment.firstAssetIndex; + } + } + return targetAssetIndex; + } + + void scrollToTop() { + if (!controller.hasClients) { + return; + } + + final timelineState = ref.read(timelineStateProvider.notifier); + timelineState.setScrubbing(true); + controller + .animateTo(0, duration: const Duration(milliseconds: 250), curve: Curves.easeInOut) + .whenComplete(() => timelineState.setScrubbing(false)); + } + + void scrollToDate(DateTime date) { + final timelineState = ref.read(timelineStateProvider.notifier); + final asyncSegments = ref.read(timelineSegmentProvider); + asyncSegments.whenData((segments) { + final targetSegment = segments.firstWhereOrNull((segment) { + if (segment.bucket is TimeBucket) { + final segmentDate = (segment.bucket as TimeBucket).date; + return segmentDate.year == date.year && segmentDate.month == date.month && segmentDate.day == date.day; + } + return false; + }); + + final fallbackSegment = + targetSegment ?? + segments.firstWhereOrNull((segment) { + if (segment.bucket is TimeBucket) { + final segmentDate = (segment.bucket as TimeBucket).date; + return segmentDate.year == date.year && segmentDate.month == date.month; + } + return false; + }); + + if (fallbackSegment != null) { + final targetOffset = fallbackSegment.startOffset - 50; + timelineState.setScrubbing(true); + controller + .animateTo( + targetOffset.clamp(0.0, controller.position.maxScrollExtent), + duration: const Duration(milliseconds: 500), + curve: Curves.easeInOut, + ) + .whenComplete(() => timelineState.setScrubbing(false)); + } else { + timelineState.setScrubbing(false); + } + }); + } + + // Pinch to scale + void onScaleStart() { + _baseScaleFactor = state.scaleFactor; + } + + void onScaleUpdate(double scale, List segments) { + final newScaleFactor = math.max(math.min(5.0, _baseScaleFactor * scale), 1.0); + final newPerRow = 7 - newScaleFactor.toInt(); + if (newPerRow != state.perRow) { + _restoreAssetIndex = _getCurrentAssetIndex(segments); + state = state.copyWith(scaleFactor: newScaleFactor, perRow: newPerRow, physics: state.physics); + ref.read(settingsProvider).write(SettingsKey.timelineTilesPerRow, newPerRow); + } + } + + // Drag selection + void setDragStartIndex(TimelineAssetIndex index) { + _dragAnchorIndex = index; + state = state.copyWith(dragging: true, physics: const ClampingScrollPhysics()); + } + + void stopDrag() { + WidgetsBinding.instance.addPostFrameCallback((_) { + // Reset physics post frame to prevent a sudden change on iOS. + state = state.copyWith(dragging: state.dragging, physics: null); + }); + _draggedAssets.clear(); + state = state.copyWith(dragging: false, physics: state.physics); + final timelineState = ref.read(timelineStateProvider.notifier); + Future.delayed(const Duration(milliseconds: 300), () { + timelineState.setScrolling(false); + }); + } + + void dragScroll(ScrollDirection direction) { + controller.animateTo( + controller.offset + (direction == ScrollDirection.forward ? 175 : -175), + duration: const Duration(milliseconds: 125), + curve: Curves.easeOut, + ); + } + + void onScrollStart() { + ref.read(timelineStateProvider.notifier).setScrolling(true); + } + + void handleDragAssetEnter(TimelineAssetIndex index) { + if (_dragAnchorIndex == null || !state.dragging) { + return; + } + + final timelineService = ref.read(timelineServiceProvider); + final dragAnchorIndex = _dragAnchorIndex!; + + final startIndex = math.min(dragAnchorIndex.assetIndex, index.assetIndex); + final endIndex = math.max(dragAnchorIndex.assetIndex, index.assetIndex); + final count = endIndex - startIndex + 1; + + if (timelineService.hasRange(startIndex, count)) { + final selectedAssets = timelineService.getAssets(startIndex, count); + final multiSelectNotifier = ref.read(multiSelectProvider.notifier); + for (final asset in _draggedAssets) { + multiSelectNotifier.deselectAsset(asset); + } + _draggedAssets.clear(); + for (final asset in selectedAssets) { + multiSelectNotifier.selectAsset(asset); + _draggedAssets.add(asset); + } + } + } +} + +final timelineScrollProvider = NotifierProvider( + TimelineScrollController.new, + dependencies: [timelineServiceProvider, timelineArgsProvider, multiSelectProvider, timelineSegmentProvider], +); diff --git a/mobile/lib/widgets/common/page_chrome/page_chrome.dart b/mobile/lib/widgets/common/page_chrome/page_chrome.dart new file mode 100644 index 0000000000..f3968a13ec --- /dev/null +++ b/mobile/lib/widgets/common/page_chrome/page_chrome.dart @@ -0,0 +1,64 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/widgets/common/page_chrome/types.dart'; + +/// The shared page chrome. Authored once; tabs never instantiate it directly +/// (they extend `TabPage`, whose `build` renders this beneath the tab's scope). +/// +/// Owns the [Scaffold], the single [CustomScrollView] and its +/// [PrimaryScrollController]. Reads the active tab's [PageChromeContent] from +/// [content] (available in the same frame, since this is a descendant of the +/// tab's scope) and renders its app bar + content slivers. +class PageChrome extends ConsumerStatefulWidget { + final ProviderListenable content; + + const PageChrome({super.key, required this.content}); + + @override + ConsumerState createState() => _PageChromeState(); +} + +class _PageChromeState extends ConsumerState { + ScrollController? _ownController; + + @override + void dispose() { + _ownController?.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final content = ref.watch(widget.content); + + // The tab's controller, or a chrome-owned default created lazily. + final controller = content.controller ?? (_ownController ??= ScrollController()); + + final scrollView = CustomScrollView( + controller: controller, + physics: content.physics, + scrollCacheExtent: content.scrollCacheExtent, + slivers: [content.appBar, ...content.slivers], + ); + + Widget body = content.viewportBuilder != null ? content.viewportBuilder!(scrollView) : scrollView; + + if (content.overlays.isNotEmpty) { + body = Stack(clipBehavior: Clip.none, children: [body, ...content.overlays]); + } + + body = Scaffold( + // Removes the built-in Scaffold `handleStatusBarTap`, avoiding duplicate + // events when a tab (e.g. the timeline) provides its own. + primary: false, + resizeToAvoidBottomInset: false, + floatingActionButton: content.floatingActionButton, + body: body, + ); + + // The chrome owns the single scroll view; mark its controller primary so + // the scrubber's `PrimaryScrollController.of` and iOS status-bar-tap both + // resolve it. Wrapping the Scaffold preserves status-bar-tap resolution. + return PrimaryScrollController(controller: controller, child: body); + } +} diff --git a/mobile/lib/widgets/common/page_chrome/tab_page.dart b/mobile/lib/widgets/common/page_chrome/tab_page.dart new file mode 100644 index 0000000000..021a9ff557 --- /dev/null +++ b/mobile/lib/widgets/common/page_chrome/tab_page.dart @@ -0,0 +1,30 @@ +import 'package:flutter/widgets.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/widgets/common/page_chrome/page_chrome.dart'; +import 'package:immich_mobile/widgets/common/page_chrome/types.dart'; + +/// The base type for a page rendered as a primary tab. +/// +/// Extending [TabPage] is the *only* place that knows the page is a tab: a +/// subclass declares its chrome [content] provider and, if needed, the +/// [scopeBuilder] the provider must be read within. The [PageChrome] is +/// rendered automatically beneath that scope, so it reads the tab's content +/// provider as a descendant (same frame). Nothing beneath the chrome has any +/// knowledge of tabs. +abstract class TabPage extends ConsumerWidget { + const TabPage({super.key}); + + /// The provider that yields this tab's chrome content (app bar + slivers + + /// controller + decorators). Read beneath [scopeBuilder]. + ProviderListenable get content; + + /// Produces the scope the [content] provider must be read within (e.g. the + /// timeline's `LayoutBuilder` + `timelineArgsProvider` override). Defaults to + /// the identity scope. + Widget scopeBuilder(BuildContext context, Widget child) => child; + + @override + Widget build(BuildContext context, WidgetRef ref) { + return scopeBuilder(context, PageChrome(content: content)); + } +} diff --git a/mobile/lib/widgets/common/page_chrome/types.dart b/mobile/lib/widgets/common/page_chrome/types.dart new file mode 100644 index 0000000000..e90d2cfb2f --- /dev/null +++ b/mobile/lib/widgets/common/page_chrome/types.dart @@ -0,0 +1,51 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; + +/// Everything a tab contributes to the single shared `PageChrome`. +/// +/// Published per-tab via a `Provider`; the chrome reads the +/// active tab's value (within that tab's scope) and renders it. Data flows +/// downward, so the chrome renders with the active tab's content in the same +/// frame. +class PageChromeContent { + /// The app bar sliver placed first in the shared scroll view. The tab decides + /// which bar it wants (e.g. a primary bar, or a selection bar during + /// multiselect). + final Widget appBar; + + /// The tab's content slivers, rendered after [appBar]. + final List slivers; + + /// Scroll controller for the shared scroll view. Provided by the tab when it + /// needs custom behaviour (e.g. the timeline's position restore). When `null`, + /// the chrome creates and owns a default controller. + final ScrollController? controller; + + /// Optionally decorates the shared scroll view, e.g. wrapping it in the + /// timeline's `Scrubber`. Receives the built scroll view and returns the + /// widget to place in the body. + final Widget Function(CustomScrollView scrollView)? viewportBuilder; + + final Widget? floatingActionButton; + + /// Widgets stacked on top of the scroll view (e.g. bottom sheet, multiselect + /// status button). + final List overlays; + + final ScrollPhysics? physics; + + /// Forwarded to the shared [CustomScrollView] (e.g. the timeline's larger + /// cache extent). + final ScrollCacheExtent? scrollCacheExtent; + + const PageChromeContent({ + required this.appBar, + this.slivers = const [], + this.controller, + this.viewportBuilder, + this.floatingActionButton, + this.overlays = const [], + this.physics, + this.scrollCacheExtent, + }); +} diff --git a/mobile/lib/widgets/common/immich_sliver_app_bar.dart b/mobile/lib/widgets/common/primary_app_bar.dart similarity index 88% rename from mobile/lib/widgets/common/immich_sliver_app_bar.dart rename to mobile/lib/widgets/common/primary_app_bar.dart index 6905b5b430..8a7a143861 100644 --- a/mobile/lib/widgets/common/immich_sliver_app_bar.dart +++ b/mobile/lib/widgets/common/primary_app_bar.dart @@ -20,17 +20,23 @@ import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/widgets/asset_viewer/cast_dialog.dart'; import 'package:immich_mobile/widgets/common/app_bar_dialog/app_bar_dialog.dart'; import 'package:immich_mobile/widgets/common/user_circle_avatar.dart'; +import 'package:immich_ui/immich_ui.dart'; -class ImmichSliverAppBar extends ConsumerWidget { +/// The app's standard app bar. Composes Immich-specific indicators (sync, +/// backup, profile, cast) and the multiselect dim behaviour onto the generic +/// [ImmichSliverAppBar] primitive from the ui package. +class PrimaryAppBar extends ConsumerWidget { final List? actions; final bool showUploadButton; final bool floating; final bool pinned; final bool snap; final Widget? title; + final Widget? leading; + final bool automaticallyImplyLeading; final double? expandedHeight; - const ImmichSliverAppBar({ + const PrimaryAppBar({ super.key, this.actions, this.showUploadButton = true, @@ -38,6 +44,8 @@ class ImmichSliverAppBar extends ConsumerWidget { this.pinned = false, this.snap = true, this.title, + this.leading, + this.automaticallyImplyLeading = false, this.expandedHeight, }); @@ -47,38 +55,27 @@ class ImmichSliverAppBar extends ConsumerWidget { final isReadonlyModeEnabled = ref.watch(readonlyModeProvider); final isMultiSelectEnabled = ref.watch(multiSelectProvider.select((s) => s.isEnabled)); - return SliverIgnorePointer( - ignoring: isMultiSelectEnabled, - sliver: SliverAnimatedOpacity( - duration: Durations.medium1, - opacity: isMultiSelectEnabled ? 0 : 1, - sliver: SliverAppBar( - backgroundColor: context.colorScheme.surface, - surfaceTintColor: context.colorScheme.surfaceTint, - elevation: 0, - scrolledUnderElevation: 1.0, - floating: floating, - pinned: pinned, - snap: snap, - expandedHeight: expandedHeight, - shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(bottom: Radius.circular(5))), - automaticallyImplyLeading: false, - centerTitle: false, - title: title ?? const _ImmichLogoWithText(), - actions: [ - const _SyncStatusIndicator(), - if (isCasting && !isReadonlyModeEnabled) - IconButton( - onPressed: () => showDialog(context: context, builder: (context) => const CastDialog()), - icon: Icon(isCasting ? Icons.cast_connected_rounded : Icons.cast_rounded), - ), - if (actions != null) ...actions!, - if (showUploadButton && !isReadonlyModeEnabled) const _BackupIndicator(), - const _ProfileIndicator(), - const SizedBox(width: 8), - ], - ), - ), + return ImmichSliverAppBar( + floating: floating, + pinned: pinned, + snap: snap, + expandedHeight: expandedHeight, + dimmed: isMultiSelectEnabled, + automaticallyImplyLeading: automaticallyImplyLeading, + leading: leading, + title: title ?? const _ImmichLogoWithText(), + actions: [ + const _SyncStatusIndicator(), + if (isCasting && !isReadonlyModeEnabled) + IconButton( + onPressed: () => showDialog(context: context, builder: (context) => const CastDialog()), + icon: Icon(isCasting ? Icons.cast_connected_rounded : Icons.cast_rounded), + ), + if (actions != null) ...actions!, + if (showUploadButton && !isReadonlyModeEnabled) const _BackupIndicator(), + const _ProfileIndicator(), + const SizedBox(width: 8), + ], ); } } diff --git a/mobile/packages/ui/lib/immich_ui.dart b/mobile/packages/ui/lib/immich_ui.dart index 8ea88135e5..e57ec6f030 100644 --- a/mobile/packages/ui/lib/immich_ui.dart +++ b/mobile/packages/ui/lib/immich_ui.dart @@ -1,4 +1,5 @@ export 'src/color_override.dart'; +export 'src/components/app_bar/app_bar.dart'; export 'src/components/close_button.dart'; export 'src/components/column_button.dart'; export 'src/components/form.dart'; diff --git a/mobile/packages/ui/lib/src/components/app_bar/app_bar.dart b/mobile/packages/ui/lib/src/components/app_bar/app_bar.dart new file mode 100644 index 0000000000..7a150a9426 --- /dev/null +++ b/mobile/packages/ui/lib/src/components/app_bar/app_bar.dart @@ -0,0 +1,62 @@ +import 'package:flutter/material.dart'; +import 'package:immich_ui/src/constants.dart'; + +/// A generic, domain-free sliver app bar primitive. +/// +/// It only handles layout, theming and the optional dim animation. Callers are +/// responsible for supplying the [title], [leading] and [actions] widgets. +class ImmichSliverAppBar extends StatelessWidget { + final Widget? title; + final Widget? leading; + final bool automaticallyImplyLeading; + final List actions; + final bool floating; + final bool pinned; + final bool snap; + final double? expandedHeight; + + /// When `true`, the bar fades out and stops receiving pointer events. + final bool dimmed; + + const ImmichSliverAppBar({ + super.key, + this.title, + this.leading, + this.automaticallyImplyLeading = false, + this.actions = const [], + this.floating = true, + this.pinned = false, + this.snap = true, + this.expandedHeight, + this.dimmed = false, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return SliverIgnorePointer( + ignoring: dimmed, + sliver: SliverAnimatedOpacity( + duration: ImmichDuration.moderate, + opacity: dimmed ? 0 : 1, + sliver: SliverAppBar( + backgroundColor: colorScheme.surface, + surfaceTintColor: colorScheme.surfaceTint, + elevation: ImmichElevation.none, + scrolledUnderElevation: ImmichElevation.xs, + floating: floating, + pinned: pinned, + snap: snap, + expandedHeight: expandedHeight, + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(bottom: Radius.circular(5))), + automaticallyImplyLeading: automaticallyImplyLeading, + leading: leading, + centerTitle: false, + title: title, + actions: actions, + ), + ), + ); + } +}