feat: sliver appbar and snap scrubbing (#19446)

This commit is contained in:
Alex 2025-06-24 20:02:46 -05:00 committed by GitHub
parent 522cdbac99
commit 05064f87f0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 780 additions and 58 deletions

View File

@ -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<Color>(
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),
),
);
},
);
}
}

View File

@ -91,7 +91,7 @@ final _features = [
_Feature( _Feature(
name: 'Main Timeline', name: 'Main Timeline',
icon: Icons.timeline_rounded, icon: Icons.timeline_rounded,
onTap: (ctx, _) => ctx.pushRoute(const MainTimelineRoute()), onTap: (ctx, _) => ctx.pushRoute(const TabShellRoute()),
), ),
]; ];

View File

@ -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/domain/models/timeline.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_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/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
@ -80,12 +81,15 @@ class TimelineHeader extends ConsumerWidget {
if (header != HeaderType.monthAndDay) if (header != HeaderType.monthAndDay)
_BulkSelectIconButton( _BulkSelectIconButton(
isAllSelected: isAllSelected, isAllSelected: isAllSelected,
onPressed: () => ref onPressed: () {
.read(multiSelectProvider.notifier) ref
.toggleBucketSelection( .read(multiSelectProvider.notifier)
assetOffset, .toggleBucketSelection(
bucket.assetCount, assetOffset,
), bucket.assetCount,
);
ref.read(hapticFeedbackProvider.notifier).heavyImpact();
},
), ),
], ],
), ),
@ -101,9 +105,15 @@ class TimelineHeader extends ConsumerWidget {
const Spacer(), const Spacer(),
_BulkSelectIconButton( _BulkSelectIconButton(
isAllSelected: isAllSelected, isAllSelected: isAllSelected,
onPressed: () => ref onPressed: () {
.read(multiSelectProvider.notifier) ref
.toggleBucketSelection(assetOffset, bucket.assetCount), .read(multiSelectProvider.notifier)
.toggleBucketSelection(
assetOffset,
bucket.assetCount,
);
ref.read(hapticFeedbackProvider.notifier).heavyImpact();
},
), ),
], ],
), ),

View File

@ -44,12 +44,16 @@ List<_Segment> _buildSegments({
required List<Segment> layoutSegments, required List<Segment> layoutSegments,
required double timelineHeight, required double timelineHeight,
}) { }) {
const double offsetThreshold = 20.0;
final segments = <_Segment>[]; final segments = <_Segment>[];
if (layoutSegments.isEmpty || layoutSegments.first.bucket is! TimeBucket) { if (layoutSegments.isEmpty || layoutSegments.first.bucket is! TimeBucket) {
return []; return [];
} }
final formatter = DateFormat.yMMM(); final formatter = DateFormat.yMMM();
DateTime? lastDate;
double lastOffset = -offsetThreshold;
for (final layoutSegment in layoutSegments) { for (final layoutSegment in layoutSegments) {
final scrollPercentage = final scrollPercentage =
layoutSegment.startOffset / layoutSegments.last.endOffset; layoutSegment.startOffset / layoutSegments.last.endOffset;
@ -58,13 +62,21 @@ List<_Segment> _buildSegments({
final date = (layoutSegment.bucket as TimeBucket).date; final date = (layoutSegment.bucket as TimeBucket).date;
final label = formatter.format(date); final label = formatter.format(date);
final showSegment = lastOffset + offsetThreshold <= startOffset &&
(lastDate == null || date.year != lastDate.year);
segments.add( segments.add(
_Segment( _Segment(
date: date, date: date,
startOffset: startOffset, startOffset: startOffset,
scrollLabel: label, scrollLabel: label,
showSegment: showSegment,
), ),
); );
lastDate = date;
if (showSegment) {
lastOffset = startOffset;
}
} }
return segments; return segments;
@ -85,12 +97,15 @@ class ScrubberState extends State<Scrubber> with TickerProviderStateMixin {
double get _scrubberHeight => double get _scrubberHeight =>
widget.timelineHeight - widget.topPadding - widget.bottomPadding; widget.timelineHeight - widget.topPadding - widget.bottomPadding;
late final ScrollController _scrollController; late ScrollController _scrollController;
double get _currentOffset => double get _currentOffset {
_scrollController.offset * if (_scrollController.hasClients != true) return 0.0;
_scrubberHeight /
_scrollController.position.maxScrollExtent; return _scrollController.offset *
_scrubberHeight /
_scrollController.position.maxScrollExtent;
}
@override @override
void initState() { void initState() {
@ -194,28 +209,102 @@ class ScrubberState extends State<Scrubber> with TickerProviderStateMixin {
_thumbAnimationController.forward(); _thumbAnimationController.forward();
} }
final newOffset = final dragPosition = _calculateDragPosition(details);
details.globalPosition.dy - widget.topPadding - widget.bottomPadding; 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(() { setState(() {
_thumbTopOffset = newOffset.clamp(0, _scrubberHeight); _thumbTopOffset = segment.startOffset;
final scrollPercentage = _thumbTopOffset / _scrubberHeight;
final maxScrollExtent = _scrollController.position.maxScrollExtent; final layoutSegmentIndex = _findLayoutSegmentIndex(segment);
_scrollController.jumpTo(maxScrollExtent * scrollPercentage);
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) { void _onDragEnd(WidgetRef ref) {
ref.read(timelineStateProvider.notifier).setScrubbing(false); ref.read(timelineStateProvider.notifier).setScrubbing(false);
_labelAnimationController.reverse(); _labelAnimationController.reverse();
_isDragging = false; _isDragging = false;
_resetThumbTimer(); _resetThumbTimer();
} }
@override @override
Widget build(BuildContext ctx) { Widget build(BuildContext ctx) {
Text? label; Text? label;
if (_scrollController.hasClients) { if (_scrollController.hasClients == true) {
// Cache to avoid multiple calls to [_currentOffset] // Cache to avoid multiple calls to [_currentOffset]
final scrollOffset = _currentOffset; final scrollOffset = _currentOffset;
final labelText = _segments final labelText = _segments
@ -240,20 +329,31 @@ class ScrubberState extends State<Scrubber> with TickerProviderStateMixin {
child: Stack( child: Stack(
children: [ children: [
RepaintBoundary(child: widget.child), 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( PositionedDirectional(
top: _thumbTopOffset + widget.topPadding, top: _thumbTopOffset + widget.topPadding,
end: 0, end: 0,
child: Consumer( child: RepaintBoundary(
builder: (_, ref, child) => GestureDetector( child: Consumer(
onVerticalDragStart: (_) => _onDragStart(ref), builder: (_, ref, child) => GestureDetector(
onVerticalDragUpdate: _onDragUpdate, onVerticalDragStart: (_) => _onDragStart(ref),
onVerticalDragEnd: (_) => _onDragEnd(ref), onVerticalDragUpdate: _onDragUpdate,
child: child, onVerticalDragEnd: (_) => _onDragEnd(ref),
), child: child,
child: _Scrubber( ),
thumbAnimation: _thumbAnimation, child: _Scrubber(
labelAnimation: _labelAnimation, thumbAnimation: _thumbAnimation,
label: label, labelAnimation: _labelAnimation,
label: label,
),
), ),
), ),
), ),
@ -263,6 +363,72 @@ class ScrubberState extends State<Scrubber> 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 { class _ScrollLabel extends StatelessWidget {
final Text label; final Text label;
final Color backgroundColor; final Color backgroundColor;
@ -429,22 +595,26 @@ class _Segment {
final DateTime date; final DateTime date;
final double startOffset; final double startOffset;
final String scrollLabel; final String scrollLabel;
final bool showSegment;
const _Segment({ const _Segment({
required this.date, required this.date,
required this.startOffset, required this.startOffset,
required this.scrollLabel, required this.scrollLabel,
this.showSegment = false,
}); });
_Segment copyWith({ _Segment copyWith({
DateTime? date, DateTime? date,
double? startOffset, double? startOffset,
String? scrollLabel, String? scrollLabel,
bool? showSegment,
}) { }) {
return _Segment( return _Segment(
date: date ?? this.date, date: date ?? this.date,
startOffset: startOffset ?? this.startOffset, startOffset: startOffset ?? this.startOffset,
scrollLabel: scrollLabel ?? this.scrollLabel, scrollLabel: scrollLabel ?? this.scrollLabel,
showSegment: showSegment ?? this.showSegment,
); );
} }

View File

@ -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/presentation/widgets/timeline/timeline.state.dart';
import 'package:immich_mobile/providers/infrastructure/setting.provider.dart'; import 'package:immich_mobile/providers/infrastructure/setting.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.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 { class Timeline extends StatelessWidget {
const Timeline({super.key}); const Timeline({super.key});
@ -63,38 +65,68 @@ class _SliverTimelineState extends State<_SliverTimeline> {
final asyncSegments = ref.watch(timelineSegmentProvider); final asyncSegments = ref.watch(timelineSegmentProvider);
final maxHeight = final maxHeight =
ref.watch(timelineArgsProvider.select((args) => args.maxHeight)); ref.watch(timelineArgsProvider.select((args) => args.maxHeight));
final isMultiSelectEnabled =
ref.watch(multiSelectProvider.select((s) => s.isEnabled));
return asyncSegments.widgetWhen( return asyncSegments.widgetWhen(
onData: (segments) { onData: (segments) {
final childCount = (segments.lastOrNull?.lastIndex ?? -1) + 1; final childCount = (segments.lastOrNull?.lastIndex ?? -1) + 1;
final statusBarHeight = context.padding.top;
final totalAppBarHeight = statusBarHeight + kToolbarHeight;
const scrubberBottomPadding = 100.0;
return PrimaryScrollController( return PrimaryScrollController(
controller: _scrollController, controller: _scrollController,
child: Scrubber( child: Stack(
layoutSegments: segments, children: [
timelineHeight: maxHeight, Scrubber(
topPadding: context.padding.top + 10, layoutSegments: segments,
bottomPadding: context.padding.bottom + 10, timelineHeight: maxHeight,
child: CustomScrollView( topPadding: totalAppBarHeight + 10,
primary: true, bottomPadding:
cacheExtent: maxHeight * 2, context.padding.bottom + scrubberBottomPadding,
slivers: [ child: CustomScrollView(
_SliverSegmentedList( primary: true,
segments: segments, cacheExtent: maxHeight * 2,
delegate: SliverChildBuilderDelegate( slivers: [
(ctx, index) { SliverAnimatedOpacity(
if (index >= childCount) return null; duration: Durations.medium1,
final segment = segments.findByIndex(index); opacity: isMultiSelectEnabled ? 0 : 1,
return segment?.builder(ctx, index) ?? sliver: const ImmichSliverAppBar(
const SizedBox.shrink(); floating: true,
}, pinned: false,
childCount: childCount, snap: false,
addAutomaticKeepAlives: false, ),
// We add repaint boundary around tiles, so skip the auto boundaries ),
addRepaintBoundaries: 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(); 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,
),
),
);
}
}

View File

@ -83,6 +83,12 @@ class MultiSelectNotifier extends Notifier<MultiSelectState> {
} }
} }
void clearSelection() {
state = state.copyWith(
selectedAssets: {},
);
}
/// Bucket bulk operations /// Bucket bulk operations
void selectBucket(int offset, int bucketCount) async { void selectBucket(int offset, int bucketCount) async {
final assets = await _timelineService.loadAssets(offset, bucketCount); final assets = await _timelineService.loadAssets(offset, bucketCount);

View File

@ -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/settings.page.dart';
import 'package:immich_mobile/pages/common/splash_screen.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_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/crop.page.dart';
import 'package:immich_mobile/pages/editing/edit.page.dart'; import 'package:immich_mobile/pages/editing/edit.page.dart';
import 'package:immich_mobile/pages/editing/filter.page.dart'; import 'package:immich_mobile/pages/editing/filter.page.dart';
@ -152,6 +153,30 @@ class AppRouter extends RootStackRouter {
], ],
transitionsBuilder: TransitionsBuilders.fadeIn, 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( CustomRoute(
page: GalleryViewerRoute.page, page: GalleryViewerRoute.page,
guards: [_authGuard, _duplicateGuard], guards: [_authGuard, _duplicateGuard],

View File

@ -1662,6 +1662,22 @@ class TabControllerRoute extends PageRouteInfo<void> {
); );
} }
/// generated route for
/// [TabShellPage]
class TabShellRoute extends PageRouteInfo<void> {
const TabShellRoute({List<PageRouteInfo>? children})
: super(TabShellRoute.name, initialChildren: children);
static const String name = 'TabShellRoute';
static PageInfo page = PageInfo(
name,
builder: (data) {
return const TabShellPage();
},
);
}
/// generated route for /// generated route for
/// [TrashPage] /// [TrashPage]
class TrashRoute extends PageRouteInfo<void> { class TrashRoute extends PageRouteInfo<void> {

View File

@ -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<Widget>? 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<Color>(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;
}
}