From 7f45dfac9c08d12b0e4f78bef0a439ee5ac0e972 Mon Sep 17 00:00:00 2001 From: Yaros Date: Mon, 10 Aug 2026 17:01:17 +0200 Subject: [PATCH 1/5] fix: do not exit screen on back multiselect search --- mobile/lib/pages/common/tab_shell.page.dart | 28 +++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/mobile/lib/pages/common/tab_shell.page.dart b/mobile/lib/pages/common/tab_shell.page.dart index f834e4ac51..6c79d3f36f 100644 --- a/mobile/lib/pages/common/tab_shell.page.dart +++ b/mobile/lib/pages/common/tab_shell.page.dart @@ -27,6 +27,23 @@ class TabShellPage extends ConsumerStatefulWidget { } class _TabShellPageState extends ConsumerState { + bool _isMultiSelectEnabled = false; + StreamSubscription? _eventSubscription; + + @override + void initState() { + super.initState(); + _eventSubscription = EventStream.shared.listen( + (event) => setState(() => _isMultiSelectEnabled = event.isEnabled), + ); + } + + @override + void dispose() { + unawaited(_eventSubscription?.cancel()); + super.dispose(); + } + @override Widget build(BuildContext context) { final isScreenLandscape = context.orientation == Orientation.landscape; @@ -84,8 +101,15 @@ class _TabShellPageState extends ConsumerState { builder: (context, child) { final tabsRouter = AutoTabsRouter.of(context); return PopScope( - canPop: tabsRouter.activeIndex == 0, - onPopInvokedWithResult: (didPop, _) => !didPop ? tabsRouter.setActiveIndex(0) : null, + canPop: tabsRouter.activeIndex == 0 && !_isMultiSelectEnabled, + onPopInvokedWithResult: (didPop, _) { + // When a multi-select is active, step aside and let the timeline's own PopScope + // reset the selection instead of switching back to the photos tab. + if (didPop || _isMultiSelectEnabled) { + return; + } + tabsRouter.setActiveIndex(0); + }, child: Scaffold( resizeToAvoidBottomInset: false, body: isScreenLandscape From 475ef22cccbd3975c02384cb97a8e98b79940220 Mon Sep 17 00:00:00 2001 From: Yaros Date: Mon, 10 Aug 2026 19:40:44 +0200 Subject: [PATCH 2/5] chore: apply suggestion Co-authored-by: Alex --- mobile/lib/pages/common/tab_shell.page.dart | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/mobile/lib/pages/common/tab_shell.page.dart b/mobile/lib/pages/common/tab_shell.page.dart index 6c79d3f36f..15f2131976 100644 --- a/mobile/lib/pages/common/tab_shell.page.dart +++ b/mobile/lib/pages/common/tab_shell.page.dart @@ -102,14 +102,7 @@ class _TabShellPageState extends ConsumerState { final tabsRouter = AutoTabsRouter.of(context); return PopScope( canPop: tabsRouter.activeIndex == 0 && !_isMultiSelectEnabled, - onPopInvokedWithResult: (didPop, _) { - // When a multi-select is active, step aside and let the timeline's own PopScope - // reset the selection instead of switching back to the photos tab. - if (didPop || _isMultiSelectEnabled) { - return; - } - tabsRouter.setActiveIndex(0); - }, + onPopInvokedWithResult: (didPop, _) => (didPop || _isMultiSelectEnabled) ? null : tabsRouter.setActiveIndex(0), child: Scaffold( resizeToAvoidBottomInset: false, body: isScreenLandscape From fe8e08cfb2e91929ab475756ab6b7a315d38a10e Mon Sep 17 00:00:00 2001 From: Yaros Date: Fri, 14 Aug 2026 15:54:29 +0200 Subject: [PATCH 3/5] refactor: fix in timeline widget --- mobile/lib/pages/common/tab_shell.page.dart | 21 +- .../widgets/timeline/timeline.widget.dart | 239 +++++++++--------- 2 files changed, 126 insertions(+), 134 deletions(-) diff --git a/mobile/lib/pages/common/tab_shell.page.dart b/mobile/lib/pages/common/tab_shell.page.dart index 15f2131976..f834e4ac51 100644 --- a/mobile/lib/pages/common/tab_shell.page.dart +++ b/mobile/lib/pages/common/tab_shell.page.dart @@ -27,23 +27,6 @@ class TabShellPage extends ConsumerStatefulWidget { } class _TabShellPageState extends ConsumerState { - bool _isMultiSelectEnabled = false; - StreamSubscription? _eventSubscription; - - @override - void initState() { - super.initState(); - _eventSubscription = EventStream.shared.listen( - (event) => setState(() => _isMultiSelectEnabled = event.isEnabled), - ); - } - - @override - void dispose() { - unawaited(_eventSubscription?.cancel()); - super.dispose(); - } - @override Widget build(BuildContext context) { final isScreenLandscape = context.orientation == Orientation.landscape; @@ -101,8 +84,8 @@ class _TabShellPageState extends ConsumerState { builder: (context, child) { final tabsRouter = AutoTabsRouter.of(context); return PopScope( - canPop: tabsRouter.activeIndex == 0 && !_isMultiSelectEnabled, - onPopInvokedWithResult: (didPop, _) => (didPop || _isMultiSelectEnabled) ? null : tabsRouter.setActiveIndex(0), + canPop: tabsRouter.activeIndex == 0, + onPopInvokedWithResult: (didPop, _) => !didPop ? tabsRouter.setActiveIndex(0) : null, child: Scaffold( resizeToAvoidBottomInset: false, body: isScreenLandscape diff --git a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart index 9e65dcb72c..4569f8f2f6 100644 --- a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart +++ b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart @@ -405,127 +405,136 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> with WidgetsBi ref.read(multiSelectProvider.notifier).reset(); } }, - child: PrimaryScrollController( - controller: _scrollController, - child: Scaffold( - // This removes the built in Scaffold `handleStatusBarTap` implementation, preventing duplicate - // events when we provide our own - primary: false, - resizeToAvoidBottomInset: false, - floatingActionButton: const DownloadStatusFloatingButton(), - body: asyncSegments.widgetWhen( - onLoading: widget.loadingWidget != null ? () => widget.loadingWidget! : null, - onData: (segments) { - final childCount = (segments.lastOrNull?.lastIndex ?? -1) + 1; - final double appBarExpandedHeight = widget.appBar != null && widget.appBar is MesmerizingSliverAppBar - ? 200 - : 0; - final topPadding = context.padding.top + (widget.appBar == null ? 0 : kToolbarHeight) + 10; + child: BackButtonListener( + onBackButtonPressed: () async { + if (!isMultiSelectEnabled) { + return false; + } + ref.read(multiSelectProvider.notifier).reset(); + return true; + }, + child: PrimaryScrollController( + controller: _scrollController, + child: Scaffold( + // This removes the built in Scaffold `handleStatusBarTap` implementation, preventing duplicate + // events when we provide our own + primary: false, + resizeToAvoidBottomInset: false, + floatingActionButton: const DownloadStatusFloatingButton(), + body: asyncSegments.widgetWhen( + onLoading: widget.loadingWidget != null ? () => widget.loadingWidget! : null, + onData: (segments) { + final childCount = (segments.lastOrNull?.lastIndex ?? -1) + 1; + final double appBarExpandedHeight = widget.appBar != null && widget.appBar is MesmerizingSliverAppBar + ? 200 + : 0; + final topPadding = context.padding.top + (widget.appBar == null ? 0 : kToolbarHeight) + 10; - const bottomSheetOpenModifier = 120.0; - final contentBottomPadding = - context.padding.bottom + (isMultiSelectEnabled ? bottomSheetOpenModifier : 0); - final scrubberBottomPadding = contentBottomPadding + kScrubberThumbHeight; + const bottomSheetOpenModifier = 120.0; + final contentBottomPadding = + context.padding.bottom + (isMultiSelectEnabled ? bottomSheetOpenModifier : 0); + final scrubberBottomPadding = contentBottomPadding + kScrubberThumbHeight; - final grid = CustomScrollView( - primary: true, - physics: _scrollPhysics, - scrollCacheExtent: .pixels(maxHeight * 2), - slivers: [ - if (isSelectionMode) const SelectionSliverAppBar() else if (widget.appBar != null) widget.appBar!, - if (widget.topSliverWidget != null) widget.topSliverWidget!, - _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(); + final grid = CustomScrollView( + primary: true, + physics: _scrollPhysics, + scrollCacheExtent: .pixels(maxHeight * 2), + slivers: [ + if (isSelectionMode) const SelectionSliverAppBar() else if (widget.appBar != null) widget.appBar!, + if (widget.topSliverWidget != null) widget.topSliverWidget!, + _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, + ), + ), + if (widget.bottomSliverWidget != null) widget.bottomSliverWidget!, + SliverPadding(padding: EdgeInsets.only(bottom: contentBottomPadding)), + ], + ); + + final Widget timeline; + if (widget.withScrubber) { + timeline = Scrubber( + snapToMonth: widget.snapToMonth, + layoutSegments: segments, + timelineHeight: maxHeight, + topPadding: topPadding, + bottomPadding: scrubberBottomPadding, + monthSegmentSnappingOffset: widget.topSliverWidgetHeight ?? 0 + appBarExpandedHeight, + hasAppBar: widget.appBar != null, + child: grid, + ); + } else { + timeline = grid; + } + + return RawGestureDetector( + gestures: { + CustomScaleGestureRecognizer: GestureRecognizerFactoryWithHandlers( + () => CustomScaleGestureRecognizer(), + (CustomScaleGestureRecognizer scale) { + scale.onStart = (details) { + _baseScaleFactor = _scaleFactor; + }; + + scale.onUpdate = (details) { + final newScaleFactor = math.max(math.min(5.0, _baseScaleFactor * details.scale), 1.0); + final newPerRow = 7 - newScaleFactor.toInt(); + + if (newPerRow != _perRow) { + final targetAssetIndex = _getCurrentAssetIndex(segments); + setState(() { + _scaleFactor = newScaleFactor; + _perRow = newPerRow; + _restoreAssetIndex = targetAssetIndex; + }); + + unawaited(ref.read(settingsProvider).write(.timelineTilesPerRow, _perRow)); + } + }; }, - childCount: childCount, - addAutomaticKeepAlives: false, - // We add repaint boundary around tiles, so skip the auto boundaries - addRepaintBoundaries: false, + ), + }, + child: TimelineDragRegion( + onStart: !isReadonlyModeEnabled ? _setDragStartIndex : null, + onAssetEnter: _handleDragAssetEnter, + onEnd: !isReadonlyModeEnabled ? _stopDrag : null, + onScroll: (direction) => unawaited(_dragScroll(direction)), + onScrollStart: () { + // Minimize the bottom sheet when drag selection starts + ref.read(timelineStateProvider.notifier).setScrolling(true); + }, + child: Stack( + clipBehavior: Clip.none, + children: [ + timeline, + if (isBottomWidgetVisible) + Positioned( + top: MediaQuery.paddingOf(context).top, + left: 25, + child: const SizedBox( + height: kToolbarHeight, + child: Center(child: _MultiSelectStatusButton()), + ), + ), + if (isBottomWidgetVisible) widget.bottomSheet!, + ], ), ), - if (widget.bottomSliverWidget != null) widget.bottomSliverWidget!, - SliverPadding(padding: EdgeInsets.only(bottom: contentBottomPadding)), - ], - ); - - final Widget timeline; - if (widget.withScrubber) { - timeline = Scrubber( - snapToMonth: widget.snapToMonth, - layoutSegments: segments, - timelineHeight: maxHeight, - topPadding: topPadding, - bottomPadding: scrubberBottomPadding, - monthSegmentSnappingOffset: widget.topSliverWidgetHeight ?? 0 + appBarExpandedHeight, - hasAppBar: widget.appBar != null, - child: grid, ); - } else { - timeline = grid; - } - - return RawGestureDetector( - gestures: { - CustomScaleGestureRecognizer: GestureRecognizerFactoryWithHandlers( - () => CustomScaleGestureRecognizer(), - (CustomScaleGestureRecognizer scale) { - scale.onStart = (details) { - _baseScaleFactor = _scaleFactor; - }; - - scale.onUpdate = (details) { - final newScaleFactor = math.max(math.min(5.0, _baseScaleFactor * details.scale), 1.0); - final newPerRow = 7 - newScaleFactor.toInt(); - - if (newPerRow != _perRow) { - final targetAssetIndex = _getCurrentAssetIndex(segments); - setState(() { - _scaleFactor = newScaleFactor; - _perRow = newPerRow; - _restoreAssetIndex = targetAssetIndex; - }); - - unawaited(ref.read(settingsProvider).write(.timelineTilesPerRow, _perRow)); - } - }; - }, - ), - }, - child: TimelineDragRegion( - onStart: !isReadonlyModeEnabled ? _setDragStartIndex : null, - onAssetEnter: _handleDragAssetEnter, - onEnd: !isReadonlyModeEnabled ? _stopDrag : null, - onScroll: (direction) => unawaited(_dragScroll(direction)), - onScrollStart: () { - // Minimize the bottom sheet when drag selection starts - ref.read(timelineStateProvider.notifier).setScrolling(true); - }, - child: Stack( - clipBehavior: Clip.none, - children: [ - timeline, - if (isBottomWidgetVisible) - Positioned( - top: MediaQuery.paddingOf(context).top, - left: 25, - child: const SizedBox( - height: kToolbarHeight, - child: Center(child: _MultiSelectStatusButton()), - ), - ), - if (isBottomWidgetVisible) widget.bottomSheet!, - ], - ), - ), - ); - }, + }, + ), ), ), ), From c0647382cd63d046fee893ea0ee69503484d19f6 Mon Sep 17 00:00:00 2001 From: Yaros Date: Sat, 15 Aug 2026 11:36:02 +0200 Subject: [PATCH 4/5] chore(server): AssetOcrSyncReset.ts --- .../migrations/1786716028459-AssetOcrSyncReset.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 server/src/schema/migrations/1786716028459-AssetOcrSyncReset.ts diff --git a/server/src/schema/migrations/1786716028459-AssetOcrSyncReset.ts b/server/src/schema/migrations/1786716028459-AssetOcrSyncReset.ts new file mode 100644 index 0000000000..767778e6a1 --- /dev/null +++ b/server/src/schema/migrations/1786716028459-AssetOcrSyncReset.ts @@ -0,0 +1,12 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + // OCR visibility updates did not bump updateId before the asset_ocr_updatedAt trigger was added, + // so clients never received those changes. There is no way to know which rows were missed, so + // reset the checkpoint to backfill all OCR rows on the next sync. + await sql`DELETE FROM session_sync_checkpoint WHERE type = 'AssetOcrV1'`.execute(db); +} + +export async function down(): Promise { + // Not implemented +} From 51a4e838d4cdf42424af74ef339b3835b72429bd Mon Sep 17 00:00:00 2001 From: Yaros Date: Sat, 15 Aug 2026 11:36:28 +0200 Subject: [PATCH 5/5] Revert "chore(server): AssetOcrSyncReset.ts" This reverts commit c0647382cd63d046fee893ea0ee69503484d19f6. --- .../migrations/1786716028459-AssetOcrSyncReset.ts | 12 ------------ 1 file changed, 12 deletions(-) delete mode 100644 server/src/schema/migrations/1786716028459-AssetOcrSyncReset.ts diff --git a/server/src/schema/migrations/1786716028459-AssetOcrSyncReset.ts b/server/src/schema/migrations/1786716028459-AssetOcrSyncReset.ts deleted file mode 100644 index 767778e6a1..0000000000 --- a/server/src/schema/migrations/1786716028459-AssetOcrSyncReset.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Kysely, sql } from 'kysely'; - -export async function up(db: Kysely): Promise { - // OCR visibility updates did not bump updateId before the asset_ocr_updatedAt trigger was added, - // so clients never received those changes. There is no way to know which rows were missed, so - // reset the checkpoint to backfill all OCR rows on the next sync. - await sql`DELETE FROM session_sync_checkpoint WHERE type = 'AssetOcrV1'`.execute(db); -} - -export async function down(): Promise { - // Not implemented -}