From 05064f87f0321bf99787b9a63923192f8a1cfc1a Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 24 Jun 2025 20:02:46 -0500 Subject: [PATCH] feat: sliver appbar and snap scrubbing (#19446) --- mobile/lib/pages/common/tab_shell.page.dart | 183 +++++++++++++ .../pages/dev/feat_in_development.page.dart | 2 +- .../widgets/timeline/header.widget.dart | 28 +- .../widgets/timeline/scrubber.widget.dart | 216 +++++++++++++-- .../widgets/timeline/timeline.widget.dart | 106 ++++++-- .../timeline/multiselect.provider.dart | 6 + mobile/lib/routing/router.dart | 25 ++ mobile/lib/routing/router.gr.dart | 16 ++ .../widgets/common/immich_sliver_app_bar.dart | 256 ++++++++++++++++++ 9 files changed, 780 insertions(+), 58 deletions(-) create mode 100644 mobile/lib/pages/common/tab_shell.page.dart create mode 100644 mobile/lib/widgets/common/immich_sliver_app_bar.dart diff --git a/mobile/lib/pages/common/tab_shell.page.dart b/mobile/lib/pages/common/tab_shell.page.dart new file mode 100644 index 0000000000..7315f05c7f --- /dev/null +++ b/mobile/lib/pages/common/tab_shell.page.dart @@ -0,0 +1,183 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/providers/asset_viewer/scroll_notifier.provider.dart'; +import 'package:immich_mobile/providers/multiselect.provider.dart'; +import 'package:immich_mobile/providers/search/search_input_focus.provider.dart'; +import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/providers/haptic_feedback.provider.dart'; +import 'package:immich_mobile/providers/tab.provider.dart'; + +@RoutePage() +class TabShellPage extends ConsumerWidget { + const TabShellPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isScreenLandscape = context.orientation == Orientation.landscape; + + Widget buildIcon({required Widget icon, required bool isProcessing}) { + if (!isProcessing) return icon; + return Stack( + alignment: Alignment.center, + clipBehavior: Clip.none, + children: [ + icon, + Positioned( + right: -18, + child: SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation( + context.primaryColor, + ), + ), + ), + ), + ], + ); + } + + void onNavigationSelected(TabsRouter router, int index) { + // On Photos page menu tapped + if (router.activeIndex == 0 && index == 0) { + scrollToTopNotifierProvider.scrollToTop(); + } + + // On Search page tapped + if (router.activeIndex == 1 && index == 1) { + ref.read(searchInputFocusProvider).requestFocus(); + } + + ref.read(hapticFeedbackProvider.notifier).selectionClick(); + router.setActiveIndex(index); + ref.read(tabProvider.notifier).state = TabEnum.values[index]; + } + + final navigationDestinations = [ + NavigationDestination( + label: 'photos'.tr(), + icon: const Icon( + Icons.photo_library_outlined, + ), + selectedIcon: buildIcon( + isProcessing: false, + icon: Icon( + Icons.photo_library, + color: context.primaryColor, + ), + ), + ), + NavigationDestination( + label: 'search'.tr(), + icon: const Icon( + Icons.search_rounded, + ), + selectedIcon: Icon( + Icons.search, + color: context.primaryColor, + ), + ), + NavigationDestination( + label: 'albums'.tr(), + icon: const Icon( + Icons.photo_album_outlined, + ), + selectedIcon: buildIcon( + isProcessing: false, + icon: Icon( + Icons.photo_album_rounded, + color: context.primaryColor, + ), + ), + ), + NavigationDestination( + label: 'library'.tr(), + icon: const Icon( + Icons.space_dashboard_outlined, + ), + selectedIcon: buildIcon( + isProcessing: false, + icon: Icon( + Icons.space_dashboard_rounded, + color: context.primaryColor, + ), + ), + ), + ]; + + Widget bottomNavigationBar(TabsRouter tabsRouter) { + return NavigationBar( + selectedIndex: tabsRouter.activeIndex, + onDestinationSelected: (index) => + onNavigationSelected(tabsRouter, index), + destinations: navigationDestinations, + ); + } + + Widget navigationRail(TabsRouter tabsRouter) { + return NavigationRail( + destinations: navigationDestinations + .map( + (e) => NavigationRailDestination( + icon: e.icon, + label: Text(e.label), + selectedIcon: e.selectedIcon, + ), + ) + .toList(), + onDestinationSelected: (index) => + onNavigationSelected(tabsRouter, index), + selectedIndex: tabsRouter.activeIndex, + labelType: NavigationRailLabelType.all, + groupAlignment: 0.0, + ); + } + + final multiselectEnabled = ref.watch(multiselectProvider); + return AutoTabsRouter( + routes: [ + const MainTimelineRoute(), + SearchRoute(), + const AlbumsRoute(), + const LibraryRoute(), + ], + duration: const Duration(milliseconds: 600), + transitionBuilder: (context, child, animation) => FadeTransition( + opacity: animation, + child: child, + ), + builder: (context, child) { + final tabsRouter = AutoTabsRouter.of(context); + final heroedChild = HeroControllerScope( + controller: HeroController(), + child: child, + ); + return PopScope( + canPop: tabsRouter.activeIndex == 0, + onPopInvokedWithResult: (didPop, _) => + !didPop ? tabsRouter.setActiveIndex(0) : null, + child: Scaffold( + resizeToAvoidBottomInset: false, + body: isScreenLandscape + ? Row( + children: [ + navigationRail(tabsRouter), + const VerticalDivider(), + Expanded(child: heroedChild), + ], + ) + : heroedChild, + bottomNavigationBar: multiselectEnabled || isScreenLandscape + ? null + : bottomNavigationBar(tabsRouter), + ), + ); + }, + ); + } +} diff --git a/mobile/lib/presentation/pages/dev/feat_in_development.page.dart b/mobile/lib/presentation/pages/dev/feat_in_development.page.dart index 6fbb83185e..c53b7fe0dc 100644 --- a/mobile/lib/presentation/pages/dev/feat_in_development.page.dart +++ b/mobile/lib/presentation/pages/dev/feat_in_development.page.dart @@ -91,7 +91,7 @@ final _features = [ _Feature( name: 'Main Timeline', icon: Icons.timeline_rounded, - onTap: (ctx, _) => ctx.pushRoute(const MainTimelineRoute()), + onTap: (ctx, _) => ctx.pushRoute(const TabShellRoute()), ), ]; diff --git a/mobile/lib/presentation/widgets/timeline/header.widget.dart b/mobile/lib/presentation/widgets/timeline/header.widget.dart index 5c69f92a5a..43d6b53b3d 100644 --- a/mobile/lib/presentation/widgets/timeline/header.widget.dart +++ b/mobile/lib/presentation/widgets/timeline/header.widget.dart @@ -5,6 +5,7 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/timeline.model.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; +import 'package:immich_mobile/providers/haptic_feedback.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; @@ -80,12 +81,15 @@ class TimelineHeader extends ConsumerWidget { if (header != HeaderType.monthAndDay) _BulkSelectIconButton( isAllSelected: isAllSelected, - onPressed: () => ref - .read(multiSelectProvider.notifier) - .toggleBucketSelection( - assetOffset, - bucket.assetCount, - ), + onPressed: () { + ref + .read(multiSelectProvider.notifier) + .toggleBucketSelection( + assetOffset, + bucket.assetCount, + ); + ref.read(hapticFeedbackProvider.notifier).heavyImpact(); + }, ), ], ), @@ -101,9 +105,15 @@ class TimelineHeader extends ConsumerWidget { const Spacer(), _BulkSelectIconButton( isAllSelected: isAllSelected, - onPressed: () => ref - .read(multiSelectProvider.notifier) - .toggleBucketSelection(assetOffset, bucket.assetCount), + onPressed: () { + ref + .read(multiSelectProvider.notifier) + .toggleBucketSelection( + assetOffset, + bucket.assetCount, + ); + ref.read(hapticFeedbackProvider.notifier).heavyImpact(); + }, ), ], ), diff --git a/mobile/lib/presentation/widgets/timeline/scrubber.widget.dart b/mobile/lib/presentation/widgets/timeline/scrubber.widget.dart index d68d9cfd67..63f8f9926a 100644 --- a/mobile/lib/presentation/widgets/timeline/scrubber.widget.dart +++ b/mobile/lib/presentation/widgets/timeline/scrubber.widget.dart @@ -44,12 +44,16 @@ List<_Segment> _buildSegments({ required List layoutSegments, required double timelineHeight, }) { + const double offsetThreshold = 20.0; + final segments = <_Segment>[]; if (layoutSegments.isEmpty || layoutSegments.first.bucket is! TimeBucket) { return []; } final formatter = DateFormat.yMMM(); + DateTime? lastDate; + double lastOffset = -offsetThreshold; for (final layoutSegment in layoutSegments) { final scrollPercentage = layoutSegment.startOffset / layoutSegments.last.endOffset; @@ -58,13 +62,21 @@ List<_Segment> _buildSegments({ final date = (layoutSegment.bucket as TimeBucket).date; final label = formatter.format(date); + final showSegment = lastOffset + offsetThreshold <= startOffset && + (lastDate == null || date.year != lastDate.year); + segments.add( _Segment( date: date, startOffset: startOffset, scrollLabel: label, + showSegment: showSegment, ), ); + lastDate = date; + if (showSegment) { + lastOffset = startOffset; + } } return segments; @@ -85,12 +97,15 @@ class ScrubberState extends State with TickerProviderStateMixin { double get _scrubberHeight => widget.timelineHeight - widget.topPadding - widget.bottomPadding; - late final ScrollController _scrollController; + late ScrollController _scrollController; - double get _currentOffset => - _scrollController.offset * - _scrubberHeight / - _scrollController.position.maxScrollExtent; + double get _currentOffset { + if (_scrollController.hasClients != true) return 0.0; + + return _scrollController.offset * + _scrubberHeight / + _scrollController.position.maxScrollExtent; + } @override void initState() { @@ -194,28 +209,102 @@ class ScrubberState extends State with TickerProviderStateMixin { _thumbAnimationController.forward(); } - final newOffset = - details.globalPosition.dy - widget.topPadding - widget.bottomPadding; + final dragPosition = _calculateDragPosition(details); + final nearestMonthSegment = _findNearestMonthSegment(dragPosition); + if (nearestMonthSegment != null) { + _snapToSegment(nearestMonthSegment); + } + } + + /// Calculate the drag position relative to the scrubber area + /// + /// This method converts the global drag coordinates from the gesture detector + /// into a position relative to the scrubber's active area (excluding padding). + /// + /// The scrubber has padding at the top and bottom, so we need to: + /// 1. Calculate the actual draggable area (timelineHeight - topPadding - bottomPadding) + /// 2. Convert the global Y position to a position within this draggable area + /// 3. Clamp the result to ensure it stays within bounds (0 to dragAreaHeight) + /// + /// Example: + /// - If timelineHeight = 800, topPadding = 50, bottomPadding = 50 + /// - Then dragAreaHeight = 700 (the actual scrubber area) + /// - If user drags to global Y position that's 100 pixels from the top + /// - The relative position would be 100 - 50 = 50 (50 pixels into the scrubber area) + double _calculateDragPosition(DragUpdateDetails details) { + final dragAreaTop = widget.topPadding; + final dragAreaBottom = widget.timelineHeight - widget.bottomPadding; + final dragAreaHeight = dragAreaBottom - dragAreaTop; + + final relativePosition = details.globalPosition.dy - dragAreaTop; + + // Make sure the position stays within the scrubber's bounds + return relativePosition.clamp(0.0, dragAreaHeight); + } + + /// Find the segment closest to the given position + _Segment? _findNearestMonthSegment(double position) { + _Segment? nearestSegment; + double minDistance = double.infinity; + + for (final segment in _segments) { + final distance = (segment.startOffset - position).abs(); + if (distance < minDistance) { + minDistance = distance; + nearestSegment = segment; + } + } + + return nearestSegment; + } + + /// Snap the scrubber thumb and scroll view to the given segment + void _snapToSegment(_Segment segment) { setState(() { - _thumbTopOffset = newOffset.clamp(0, _scrubberHeight); - final scrollPercentage = _thumbTopOffset / _scrubberHeight; - final maxScrollExtent = _scrollController.position.maxScrollExtent; - _scrollController.jumpTo(maxScrollExtent * scrollPercentage); + _thumbTopOffset = segment.startOffset; + + final layoutSegmentIndex = _findLayoutSegmentIndex(segment); + + if (layoutSegmentIndex >= 0) { + _scrollToLayoutSegment(layoutSegmentIndex); + } }); } + int _findLayoutSegmentIndex(_Segment segment) { + return widget.layoutSegments.indexWhere( + (layoutSegment) { + final bucket = layoutSegment.bucket as TimeBucket; + return bucket.date.year == segment.date.year && + bucket.date.month == segment.date.month; + }, + ); + } + + void _scrollToLayoutSegment(int layoutSegmentIndex) { + final layoutSegment = widget.layoutSegments[layoutSegmentIndex]; + final maxScrollExtent = _scrollController.position.maxScrollExtent; + final viewportHeight = _scrollController.position.viewportDimension; + + final targetScrollOffset = layoutSegment.startOffset; + final centeredOffset = targetScrollOffset - (viewportHeight / 4) + 100; + + _scrollController.jumpTo(centeredOffset.clamp(0.0, maxScrollExtent)); + } + void _onDragEnd(WidgetRef ref) { ref.read(timelineStateProvider.notifier).setScrubbing(false); _labelAnimationController.reverse(); _isDragging = false; + _resetThumbTimer(); } @override Widget build(BuildContext ctx) { Text? label; - if (_scrollController.hasClients) { + if (_scrollController.hasClients == true) { // Cache to avoid multiple calls to [_currentOffset] final scrollOffset = _currentOffset; final labelText = _segments @@ -240,20 +329,31 @@ class ScrubberState extends State with TickerProviderStateMixin { child: Stack( children: [ RepaintBoundary(child: widget.child), + // Scroll Segments - wrapped in RepaintBoundary for better performance + RepaintBoundary( + child: _SegmentsLayer( + key: ValueKey('segments_${_isDragging}_${_segments.length}'), + segments: _segments, + topPadding: widget.topPadding, + isDragging: _isDragging, + ), + ), PositionedDirectional( top: _thumbTopOffset + widget.topPadding, end: 0, - child: Consumer( - builder: (_, ref, child) => GestureDetector( - onVerticalDragStart: (_) => _onDragStart(ref), - onVerticalDragUpdate: _onDragUpdate, - onVerticalDragEnd: (_) => _onDragEnd(ref), - child: child, - ), - child: _Scrubber( - thumbAnimation: _thumbAnimation, - labelAnimation: _labelAnimation, - label: label, + child: RepaintBoundary( + child: Consumer( + builder: (_, ref, child) => GestureDetector( + onVerticalDragStart: (_) => _onDragStart(ref), + onVerticalDragUpdate: _onDragUpdate, + onVerticalDragEnd: (_) => _onDragEnd(ref), + child: child, + ), + child: _Scrubber( + thumbAnimation: _thumbAnimation, + labelAnimation: _labelAnimation, + label: label, + ), ), ), ), @@ -263,6 +363,72 @@ class ScrubberState extends State with TickerProviderStateMixin { } } +class _SegmentsLayer extends StatelessWidget { + final List<_Segment> segments; + final double topPadding; + final bool isDragging; + + const _SegmentsLayer({ + super.key, + required this.segments, + required this.topPadding, + required this.isDragging, + }); + + @override + Widget build(BuildContext context) { + return Visibility( + visible: isDragging, + child: Stack( + children: segments + .where((segment) => segment.showSegment) + .map( + (segment) => PositionedDirectional( + key: ValueKey('segment_${segment.date.millisecondsSinceEpoch}'), + top: topPadding + segment.startOffset, + end: 100, + child: RepaintBoundary( + child: _SegmentWidget(segment), + ), + ), + ) + .toList(), + ), + ); + } +} + +class _SegmentWidget extends StatelessWidget { + final _Segment _segment; + + const _SegmentWidget(this._segment); + + @override + Widget build(BuildContext context) { + return IgnorePointer( + child: Container( + margin: const EdgeInsets.only(right: 12.0), + child: Material( + color: context.colorScheme.surface, + borderRadius: const BorderRadius.all(Radius.circular(16.0)), + child: Container( + constraints: const BoxConstraints(maxHeight: 28), + padding: const EdgeInsets.symmetric(horizontal: 10.0), + alignment: Alignment.center, + child: Text( + _segment.date.year.toString(), + style: context.textTheme.labelMedium?.copyWith( + fontFamily: "OverpassMono", + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ), + ); + } +} + class _ScrollLabel extends StatelessWidget { final Text label; final Color backgroundColor; @@ -429,22 +595,26 @@ class _Segment { final DateTime date; final double startOffset; final String scrollLabel; + final bool showSegment; const _Segment({ required this.date, required this.startOffset, required this.scrollLabel, + this.showSegment = false, }); _Segment copyWith({ DateTime? date, double? startOffset, String? scrollLabel, + bool? showSegment, }) { return _Segment( date: date ?? this.date, startOffset: startOffset ?? this.startOffset, scrollLabel: scrollLabel ?? this.scrollLabel, + showSegment: showSegment ?? this.showSegment, ); } diff --git a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart index 6ea3ddaf44..109c4e0de0 100644 --- a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart +++ b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart @@ -13,6 +13,8 @@ import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart'; import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart'; import 'package:immich_mobile/providers/infrastructure/setting.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'; class Timeline extends StatelessWidget { const Timeline({super.key}); @@ -63,38 +65,68 @@ class _SliverTimelineState extends State<_SliverTimeline> { final asyncSegments = ref.watch(timelineSegmentProvider); final maxHeight = ref.watch(timelineArgsProvider.select((args) => args.maxHeight)); + final isMultiSelectEnabled = + ref.watch(multiSelectProvider.select((s) => s.isEnabled)); return asyncSegments.widgetWhen( onData: (segments) { final childCount = (segments.lastOrNull?.lastIndex ?? -1) + 1; + final statusBarHeight = context.padding.top; + final totalAppBarHeight = statusBarHeight + kToolbarHeight; + const scrubberBottomPadding = 100.0; return PrimaryScrollController( controller: _scrollController, - child: Scrubber( - layoutSegments: segments, - timelineHeight: maxHeight, - topPadding: context.padding.top + 10, - bottomPadding: context.padding.bottom + 10, - child: CustomScrollView( - primary: true, - cacheExtent: maxHeight * 2, - slivers: [ - _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, - // We add repaint boundary around tiles, so skip the auto boundaries - addRepaintBoundaries: false, - ), + child: Stack( + children: [ + Scrubber( + layoutSegments: segments, + timelineHeight: maxHeight, + topPadding: totalAppBarHeight + 10, + bottomPadding: + context.padding.bottom + scrubberBottomPadding, + child: CustomScrollView( + primary: true, + cacheExtent: maxHeight * 2, + slivers: [ + SliverAnimatedOpacity( + duration: Durations.medium1, + opacity: isMultiSelectEnabled ? 0 : 1, + sliver: const ImmichSliverAppBar( + floating: true, + pinned: false, + snap: false, + ), + ), + _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, + // We add repaint boundary around tiles, so skip the auto boundaries + addRepaintBoundaries: false, + ), + ), + const SliverPadding( + padding: EdgeInsets.only( + bottom: scrubberBottomPadding, + ), + ), + ], ), - ], - ), + ), + if (isMultiSelectEnabled) + const Positioned( + top: 60, + left: 25, + child: _MultiSelectStatusButton(), + ), + ], ), ); }, @@ -363,3 +395,27 @@ class _RenderSliverTimelineBoxAdaptor extends RenderSliverMultiBoxAdaptor { childManager.didFinishLayout(); } } + +class _MultiSelectStatusButton extends ConsumerWidget { + const _MultiSelectStatusButton(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final selectCount = + ref.watch(multiSelectProvider.select((s) => s.selectedAssets.length)); + return ElevatedButton.icon( + onPressed: () => ref.read(multiSelectProvider.notifier).clearSelection(), + icon: Icon( + Icons.close_rounded, + color: context.colorScheme.onPrimary, + ), + label: Text( + selectCount.toString(), + style: context.textTheme.titleMedium?.copyWith( + height: 2.5, + color: context.colorScheme.onPrimary, + ), + ), + ); + } +} diff --git a/mobile/lib/providers/timeline/multiselect.provider.dart b/mobile/lib/providers/timeline/multiselect.provider.dart index df9e999036..121bcba343 100644 --- a/mobile/lib/providers/timeline/multiselect.provider.dart +++ b/mobile/lib/providers/timeline/multiselect.provider.dart @@ -83,6 +83,12 @@ class MultiSelectNotifier extends Notifier { } } + void clearSelection() { + state = state.copyWith( + selectedAssets: {}, + ); + } + /// Bucket bulk operations void selectBucket(int offset, int bucketCount) async { final assets = await _timelineService.loadAssets(offset, bucketCount); diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart index 79f74bc5ee..5e8f018ae8 100644 --- a/mobile/lib/routing/router.dart +++ b/mobile/lib/routing/router.dart @@ -31,6 +31,7 @@ import 'package:immich_mobile/pages/common/native_video_viewer.page.dart'; import 'package:immich_mobile/pages/common/settings.page.dart'; import 'package:immich_mobile/pages/common/splash_screen.page.dart'; import 'package:immich_mobile/pages/common/tab_controller.page.dart'; +import 'package:immich_mobile/pages/common/tab_shell.page.dart'; import 'package:immich_mobile/pages/editing/crop.page.dart'; import 'package:immich_mobile/pages/editing/edit.page.dart'; import 'package:immich_mobile/pages/editing/filter.page.dart'; @@ -152,6 +153,30 @@ class AppRouter extends RootStackRouter { ], transitionsBuilder: TransitionsBuilders.fadeIn, ), + CustomRoute( + page: TabShellRoute.page, + guards: [_authGuard, _duplicateGuard], + children: [ + AutoRoute( + page: MainTimelineRoute.page, + guards: [_authGuard, _duplicateGuard], + ), + AutoRoute( + page: SearchRoute.page, + guards: [_authGuard, _duplicateGuard], + maintainState: false, + ), + AutoRoute( + page: LibraryRoute.page, + guards: [_authGuard, _duplicateGuard], + ), + AutoRoute( + page: AlbumsRoute.page, + guards: [_authGuard, _duplicateGuard], + ), + ], + transitionsBuilder: TransitionsBuilders.fadeIn, + ), CustomRoute( page: GalleryViewerRoute.page, guards: [_authGuard, _duplicateGuard], diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index efc9e71a23..797b519ddb 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -1662,6 +1662,22 @@ class TabControllerRoute extends PageRouteInfo { ); } +/// generated route for +/// [TabShellPage] +class TabShellRoute extends PageRouteInfo { + const TabShellRoute({List? children}) + : super(TabShellRoute.name, initialChildren: children); + + static const String name = 'TabShellRoute'; + + static PageInfo page = PageInfo( + name, + builder: (data) { + return const TabShellPage(); + }, + ); +} + /// generated route for /// [TrashPage] class TrashRoute extends PageRouteInfo { diff --git a/mobile/lib/widgets/common/immich_sliver_app_bar.dart b/mobile/lib/widgets/common/immich_sliver_app_bar.dart new file mode 100644 index 0000000000..4970a3a0c8 --- /dev/null +++ b/mobile/lib/widgets/common/immich_sliver_app_bar.dart @@ -0,0 +1,256 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_svg/svg.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/models/backup/backup_state.model.dart'; +import 'package:immich_mobile/models/server_info/server_info.model.dart'; +import 'package:immich_mobile/providers/backup/backup.provider.dart'; +import 'package:immich_mobile/providers/cast.provider.dart'; +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/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'; + +class ImmichSliverAppBar extends ConsumerWidget { + final List? actions; + final bool showUploadButton; + final bool floating; + final bool pinned; + final bool snap; + final Widget? title; + final double? expandedHeight; + + const ImmichSliverAppBar({ + super.key, + this.actions, + this.showUploadButton = true, + this.floating = true, + this.pinned = false, + this.snap = true, + this.title, + this.expandedHeight, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isCasting = ref.watch(castProvider.select((c) => c.isCasting)); + + return SliverAppBar( + floating: floating, + pinned: pinned, + snap: snap, + expandedHeight: expandedHeight, + backgroundColor: context.colorScheme.surfaceContainer, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all( + Radius.circular(5), + ), + ), + automaticallyImplyLeading: false, + centerTitle: false, + title: title ?? const _ImmichLogoWithText(), + actions: [ + if (actions != null) + ...actions!.map( + (action) => Padding( + padding: const EdgeInsets.only(right: 16), + child: action, + ), + ), + IconButton( + icon: const Icon(Icons.science_rounded), + onPressed: () => context.pushRoute(const FeatInDevRoute()), + ), + if (isCasting) + Padding( + padding: const EdgeInsets.only(right: 12), + child: IconButton( + onPressed: () { + showDialog( + context: context, + builder: (context) => const CastDialog(), + ); + }, + icon: Icon( + isCasting ? Icons.cast_connected_rounded : Icons.cast_rounded, + ), + ), + ), + if (showUploadButton) + const Padding( + padding: EdgeInsets.only(right: 20), + child: _BackupIndicator(), + ), + const Padding( + padding: EdgeInsets.only(right: 20), + child: _ProfileIndicator(), + ), + ], + ); + } +} + +class _ImmichLogoWithText extends StatelessWidget { + const _ImmichLogoWithText(); + + @override + Widget build(BuildContext context) { + return Builder( + builder: (BuildContext context) { + return Row( + children: [ + Builder( + builder: (context) { + return Padding( + padding: const EdgeInsets.only(top: 3.0), + child: SvgPicture.asset( + context.isDarkTheme + ? 'assets/immich-logo-inline-dark.svg' + : 'assets/immich-logo-inline-light.svg', + height: 40, + ), + ); + }, + ), + ], + ); + }, + ); + } +} + +class _ProfileIndicator extends ConsumerWidget { + const _ProfileIndicator(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final ServerInfo serverInfoState = ref.watch(serverInfoProvider); + final user = ref.watch(currentUserProvider); + const widgetSize = 30.0; + + return InkWell( + onTap: () => showDialog( + context: context, + useRootNavigator: false, + builder: (ctx) => const ImmichAppBarDialog(), + ), + borderRadius: BorderRadius.circular(12), + child: Badge( + label: Container( + decoration: BoxDecoration( + color: Colors.black, + borderRadius: BorderRadius.circular(widgetSize / 2), + ), + child: const Icon( + Icons.info, + color: Color.fromARGB(255, 243, 188, 106), + size: widgetSize / 2, + ), + ), + backgroundColor: Colors.transparent, + alignment: Alignment.bottomRight, + isLabelVisible: serverInfoState.isVersionMismatch || + ((user?.isAdmin ?? false) && serverInfoState.isNewReleaseAvailable), + offset: const Offset(-2, -12), + child: user == null + ? const Icon( + Icons.face_outlined, + size: widgetSize, + ) + : Semantics( + label: "logged_in_as".tr(namedArgs: {"user": user.name}), + child: UserCircleAvatar( + radius: 17, + size: 31, + user: user, + ), + ), + ), + ); + } +} + +class _BackupIndicator extends ConsumerWidget { + const _BackupIndicator(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + const widgetSize = 30.0; + final indicatorIcon = _getBackupBadgeIcon(context, ref); + final badgeBackground = context.colorScheme.surfaceContainer; + + return InkWell( + onTap: () => context.pushRoute(const BackupControllerRoute()), + borderRadius: BorderRadius.circular(12), + child: Badge( + label: Container( + width: widgetSize / 2, + height: widgetSize / 2, + decoration: BoxDecoration( + color: badgeBackground, + border: Border.all( + color: context.colorScheme.outline.withValues(alpha: .3), + ), + borderRadius: BorderRadius.circular(widgetSize / 2), + ), + child: indicatorIcon, + ), + backgroundColor: Colors.transparent, + alignment: Alignment.bottomRight, + isLabelVisible: indicatorIcon != null, + offset: const Offset(-2, -12), + child: Icon( + Icons.backup_rounded, + size: widgetSize, + color: context.primaryColor, + ), + ), + ); + } + + Widget? _getBackupBadgeIcon(BuildContext context, WidgetRef ref) { + final BackUpState backupState = ref.watch(backupProvider); + final bool isEnableAutoBackup = + backupState.backgroundBackup || backupState.autoBackup; + final isDarkTheme = context.isDarkTheme; + final iconColor = isDarkTheme ? Colors.white : Colors.black; + + if (isEnableAutoBackup) { + if (backupState.backupProgress == BackUpProgressEnum.inProgress) { + return Container( + padding: const EdgeInsets.all(3.5), + child: CircularProgressIndicator( + strokeWidth: 2, + strokeCap: StrokeCap.round, + valueColor: AlwaysStoppedAnimation(iconColor), + semanticsLabel: 'backup_controller_page_backup'.tr(), + ), + ); + } else if (backupState.backupProgress != + BackUpProgressEnum.inBackground && + backupState.backupProgress != BackUpProgressEnum.manualInProgress) { + return Icon( + Icons.check_outlined, + size: 9, + color: iconColor, + semanticLabel: 'backup_controller_page_backup'.tr(), + ); + } + } + + if (!isEnableAutoBackup) { + return Icon( + Icons.cloud_off_rounded, + size: 9, + color: iconColor, + semanticLabel: 'backup_controller_page_backup'.tr(), + ); + } + + return null; + } +}