Compare commits

...

1 Commits

Author SHA1 Message Date
Adam Gastineau
649c32e4c8 Playing with AppBar 2026-07-20 10:04:17 -07:00
11 changed files with 816 additions and 132 deletions

View File

@@ -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<MainTimelinePage> createState() => _MainTimelinePageState();
}
class _MainTimelinePageState extends ConsumerState<MainTimelinePage> {
@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<PageChromeContent> get content => timelineChromeProvider;
}

View File

@@ -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<DriftAlbumsPage> createState() => _DriftAlbumsPageState();
}
class _DriftAlbumsPageState extends ConsumerState<DriftAlbumsPage> {
final ScrollController _scrollController = ScrollController();
Future<void> onRefresh() async {
await ref.read(remoteAlbumProvider.notifier).refresh();
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
ProviderListenable<PageChromeContent> get content => Provider<PageChromeContent>((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),

View File

@@ -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<PageChromeContent> get content => Provider<PageChromeContent>((ref) {
return const PageChromeContent(
appBar: PrimaryAppBar(snap: false, floating: false, pinned: true, showUploadButton: false),
slivers: [_ActionButtonGrid(), _CollectionCards(), _QuickAccessButtonList()],
);
}
});
}
class _ActionButtonGrid extends ConsumerWidget {

View File

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

View File

@@ -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<TimelineScrollState> with WidgetsBindingObserver {
late final ScrollController controller;
StreamSubscription? _eventSubscription;
// Drag selection
TimelineAssetIndex? _dragAnchorIndex;
final Set<BaseAsset> _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<Segment> segments) => _getCurrentAssetIndex(segments);
int? _getCurrentAssetIndex(List<Segment> 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<Segment> 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, TimelineScrollState>(
TimelineScrollController.new,
dependencies: [timelineServiceProvider, timelineArgsProvider, multiSelectProvider, timelineSegmentProvider],
);

View File

@@ -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<PageChromeContent> content;
const PageChrome({super.key, required this.content});
@override
ConsumerState<PageChrome> createState() => _PageChromeState();
}
class _PageChromeState extends ConsumerState<PageChrome> {
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);
}
}

View File

@@ -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<PageChromeContent> 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));
}
}

View File

@@ -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<PageChromeContent>`; 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<Widget> 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<Widget> 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,
});
}

View File

@@ -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<Widget>? 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),
],
);
}
}

View File

@@ -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';

View File

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