From b7a33ea4414bf72672fbe24384a2d103acc392a7 Mon Sep 17 00:00:00 2001 From: Ayushya Amitabh Date: Mon, 5 Oct 2026 20:38:17 -0400 Subject: [PATCH 01/31] iOS: raise deployment target to 15.0 (required by current Flutter) Co-Authored-By: Claude Sonnet 5.5 --- ios/Runner.xcodeproj/project.pbxproj | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index f833847..8da8431 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -360,7 +360,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; @@ -486,7 +486,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; @@ -537,7 +537,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; From 7cdae1eedbc4be238de1349803efbb9896c0a1f7 Mon Sep 17 00:00:00 2001 From: Ayushya Amitabh Date: Mon, 5 Oct 2026 21:36:35 -0400 Subject: [PATCH 02/31] Floating bottom bar: use Android row (icon-only idle tabs) on iOS too Co-Authored-By: Claude Sonnet 5.5 --- .claude/context/styling.md | 4 +++- lib/widgets/noo/nav/noo_bottom_bar.dart | 11 +++++++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/.claude/context/styling.md b/.claude/context/styling.md index c0d51b1..039c9c4 100644 --- a/.claude/context/styling.md +++ b/.claude/context/styling.md @@ -229,7 +229,9 @@ blocks are noted where they matter: don't confuse the two: this one is still non-blurred, just inset with a `line` border and `nooDialogShadow` (the app's one other shadow user - see that constant's doc comment) instead of edge to edge (no opacity/blur knob - either). The host `Scaffold` needs `extendBody: true` while it's active + either). `floating` ignores `NooNavStyle` and always uses the Android row + (icon-only idle tabs, sliding pill, 64px), so iOS matches Android there; + only the attached bar still has a distinct iOS row. The host `Scaffold` needs `extendBody: true` while it's active (`main.dart` already wires this off `SettingsController.bottomBarStyle`), which also means every tab's own scrollable list has to reserve enough bottom padding to clear the bar - nothing does that automatically once the diff --git a/lib/widgets/noo/nav/noo_bottom_bar.dart b/lib/widgets/noo/nav/noo_bottom_bar.dart index 61fbbd7..d4784aa 100644 --- a/lib/widgets/noo/nav/noo_bottom_bar.dart +++ b/lib/widgets/noo/nav/noo_bottom_bar.dart @@ -26,6 +26,9 @@ const double _kAndroidPillHeight = 32; /// 56x32 accent-soft pill that slides between tabs as selection moves, /// rather than popping in/out on the destination item itself. /// +/// [NooBottomBarStyle.floating] ignores [style] and always uses the Android +/// row below, so iOS gets the same icon-only idle tabs. +/// /// [barStyle] (user-configurable in Settings, Appearance) picks between /// that edge-to-edge [NooBottomBarStyle.attached] bar and /// [NooBottomBarStyle.floating] - inset 16px from both side edges, 28px @@ -75,7 +78,9 @@ class NooBottomBar extends StatelessWidget { /// scrollable body sharing the same `Scaffold` can reserve exactly this /// much clearance (see the class doc comment) instead of guessing. static double rowHeight(NooNavStyle style, NooBottomBarStyle barStyle) { - if (style == NooNavStyle.ios) return 50; + if (style == NooNavStyle.ios && barStyle != NooBottomBarStyle.floating) { + return 50; + } return barStyle == NooBottomBarStyle.floating ? 64 : 80; } @@ -86,8 +91,10 @@ class NooBottomBar extends StatelessWidget { @override Widget build(BuildContext context) { final colors = context.nooColors; - final ios = style == NooNavStyle.ios; final floating = barStyle == NooBottomBarStyle.floating; + // Floating always gets the Android row (icon-only idle tabs, sliding + // pill) - iOS's icon-over-label row doesn't fit a pill that short. + final ios = style == NooNavStyle.ios && !floating; final hasSearch = searchDestination != null && onSearchTap != null; // Attached folds Search into the row itself (last item); floating // gives it a separate satellite circle instead (built below), so the From b9d62acf85bb0807a698f397504b6ebad13a2207 Mon Sep 17 00:00:00 2001 From: Ayushya Amitabh Date: Mon, 5 Oct 2026 22:13:45 -0400 Subject: [PATCH 03/31] Files: don't context.watch from the item tap handler watch outside build asserts in debug builds, so taps on rows silently did nothing in debug (release strips the assert, which hid it on Android). Co-Authored-By: Claude Sonnet 5.5 --- lib/views/files_view.dart | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/lib/views/files_view.dart b/lib/views/files_view.dart index f834097..32ab264 100644 --- a/lib/views/files_view.dart +++ b/lib/views/files_view.dart @@ -245,9 +245,19 @@ class _FilesViewState extends State { /// The folder data source for this tab. Watches the concrete controller /// (that's what's registered as a provider), then hands it back as the /// [FolderBrowser] the rest of the view actually needs. - FolderBrowser _browserOf(BuildContext context) => _offline - ? context.watch() - : context.watch(); + /// + /// Pass `listen: false` from tap/gesture callbacks: `context.watch` outside + /// of `build` asserts in debug builds (silently swallowing the tap). + FolderBrowser _browserOf(BuildContext context, {bool listen = true}) { + if (listen) { + return _offline + ? context.watch() + : context.watch(); + } + return _offline + ? context.read() + : context.read(); + } /// The on-device copy of [item] for the Offline tab; null online, where /// thumbnails come from server previews instead. @@ -287,7 +297,7 @@ class _FilesViewState extends State { NextcloudItem item, { required bool picking, }) { - final browser = _browserOf(context); + final browser = _browserOf(context, listen: false); if (picking) { _handlePickTap(context, item); } else if (_isSelecting) { From e4023cc39f79d3b7e73cae9ca5f4823f5f95b310 Mon Sep 17 00:00:00 2001 From: Ayushya Amitabh Date: Mon, 5 Oct 2026 22:16:27 -0400 Subject: [PATCH 04/31] Bottom bar: add frosted glass option (Settings > Appearance) Translucent fill over a backdrop blur, both platforms and both bar styles. Frosted bars draw over an extended body, so the shell's extendBody and the tab clearance now key off NooBottomBar.drawsBehindBody. Co-Authored-By: Claude Sonnet 5.5 --- .claude/context/styling.md | 11 ++- lib/main.dart | 11 ++- lib/providers/settings_controller.dart | 12 +++ lib/views/files_view.dart | 11 ++- lib/widgets/bottom_nav_bar.dart | 3 + lib/widgets/noo/nav/noo_bottom_bar.dart | 84 +++++++++++++++---- lib/widgets/settings/settings_appearance.dart | 11 +++ lib/widgets/tabs/tab_state_slivers.dart | 15 +++- test/widgets/noo/nav_test.dart | 63 ++++++++++++++ 9 files changed, 191 insertions(+), 30 deletions(-) diff --git a/.claude/context/styling.md b/.claude/context/styling.md index 039c9c4..83d2087 100644 --- a/.claude/context/styling.md +++ b/.claude/context/styling.md @@ -231,7 +231,16 @@ blocks are noted where they matter: that constant's doc comment) instead of edge to edge (no opacity/blur knob either). `floating` ignores `NooNavStyle` and always uses the Android row (icon-only idle tabs, sliding pill, 64px), so iOS matches Android there; - only the attached bar still has a distinct iOS row. The host `Scaffold` needs `extendBody: true` while it's active + only the attached bar still has a distinct iOS row. A separate, orthogonal + `SettingsController.bottomBarFrosted` toggle (Settings → Appearance → + "Frosted glass bottom bar", off by default, both platforms and both bar + styles) swaps the bar's solid `surface` for a 0.72-alpha one over a + 20-sigma `BackdropFilter` (same sigma as `FrostedGlassContainer`) and drops + `nooDialogShadow` (it would show through the glass). A frosted bar only + reads as glass with content behind it, so the shell uses + `NooBottomBar.drawsBehindBody(barStyle, frosted)` for `extendBody`, and + `bottomBarClearance` (and Files' own trailing sliver) reserve the bar's + footprint for attached+frosted too, not just floating. The host `Scaffold` needs `extendBody: true` while it's active (`main.dart` already wires this off `SettingsController.bottomBarStyle`), which also means every tab's own scrollable list has to reserve enough bottom padding to clear the bar - nothing does that automatically once the diff --git a/lib/main.dart b/lib/main.dart index 69a5951..1a4006f 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -36,7 +36,7 @@ import 'widgets/create_menu.dart'; import 'widgets/noo/core/noo_avatar.dart'; import 'widgets/noo/core/noo_button.dart'; import 'widgets/noo/core/noo_fab.dart'; -import 'widgets/noo/nav/noo_nav_style.dart'; +import 'widgets/noo/nav/noo_bottom_bar.dart'; import 'widgets/noo/nav/noo_sidebar.dart'; import 'widgets/noo/nav/noo_toolbar.dart'; import 'widgets/noo/noo_layout.dart'; @@ -526,8 +526,12 @@ class _MainShellViewState extends State { settings.navMenuStyle == NooNavMenuStyle.drawer, // Floating needs the body to draw behind the bar's own transparent // margin (see NooBottomBarStyle's doc comment) instead of stopping - // short of it like attached does. - extendBody: bottomBarStyle == NooBottomBarStyle.floating, + // short of it like attached does; a frosted bar needs it too, or + // there'd be nothing behind it to blur. + extendBody: NooBottomBar.drawsBehindBody( + bottomBarStyle, + settings.bottomBarFrosted, + ), // Android-only extended Upload FAB - iOS uses the top bar's `plus` // instead (see AppTopBar). Stays mounted across every tab (picking // aside) and collapses to an icon-only circle off Files/Photos, @@ -549,6 +553,7 @@ class _MainShellViewState extends State { bottomNavigationBar: BottomNavBar( style: navStyle, barStyle: bottomBarStyle, + frosted: settings.bottomBarFrosted, tabs: pinnedTabs, selectedIndex: pinnedIndex, onSearchTap: showBottomBarSearch ? () => openSearch(context) : null, diff --git a/lib/providers/settings_controller.dart b/lib/providers/settings_controller.dart index 3a1340b..b01098a 100644 --- a/lib/providers/settings_controller.dart +++ b/lib/providers/settings_controller.dart @@ -38,6 +38,7 @@ class SettingsController extends ChangeNotifier { static const _prefSwipeRightAction = 'ui_swipe_right_action'; static const _prefFabStyle = 'ui_fab_style'; static const _prefBottomBarStyle = 'ui_bottom_bar_style'; + static const _prefBottomBarFrosted = 'ui_bottom_bar_frosted'; static const _prefNavMenuStyle = 'ui_nav_menu_style'; static const _prefSearchInBottomBar = 'ui_search_in_bottom_bar'; static const _prefAmoledDark = 'ui_amoled_dark'; @@ -51,6 +52,7 @@ class SettingsController extends ChangeNotifier { ThemeMode _themeMode = ThemeMode.system; NooBottomBarStyle _bottomBarStyle = NooBottomBarStyle.attached; NooNavMenuStyle _navMenuStyle = NooNavMenuStyle.drawer; + bool _bottomBarFrosted = false; bool _searchInBottomBar = false; bool _useDynamicColor = true; bool _amoledDark = false; @@ -95,6 +97,7 @@ class SettingsController extends ChangeNotifier { Color get seedColor => _seedColor; ThemeMode get themeMode => _themeMode; NooBottomBarStyle get bottomBarStyle => _bottomBarStyle; + bool get bottomBarFrosted => _bottomBarFrosted; NooNavMenuStyle get navMenuStyle => _navMenuStyle; bool get searchInBottomBar => _searchInBottomBar; bool get useDynamicColor => _useDynamicColor; @@ -155,6 +158,8 @@ class SettingsController extends ChangeNotifier { orElse: () => _navMenuStyle, ); } + _bottomBarFrosted = + prefs.getBool(_prefBottomBarFrosted) ?? _bottomBarFrosted; _searchInBottomBar = prefs.getBool(_prefSearchInBottomBar) ?? _searchInBottomBar; _useDynamicColor = @@ -313,6 +318,13 @@ class SettingsController extends ChangeNotifier { _prefsFuture.then((p) => p.setString(_prefBottomBarStyle, style.name)); } + void setBottomBarFrosted(bool value) { + if (_bottomBarFrosted == value) return; + _bottomBarFrosted = value; + notifyListeners(); + _prefsFuture.then((p) => p.setBool(_prefBottomBarFrosted, value)); + } + void setNavMenuStyle(NooNavMenuStyle style) { if (_navMenuStyle == style) return; _navMenuStyle = style; diff --git a/lib/views/files_view.dart b/lib/views/files_view.dart index 32ab264..c15e109 100644 --- a/lib/views/files_view.dart +++ b/lib/views/files_view.dart @@ -837,10 +837,13 @@ class _FilesViewState extends State { ), // List/grid/table above only pad NooSpace.lg at the bottom - plenty // once Scaffold shrinks the body above an attached bar, but floating - // draws the body behind the bar instead, so it needs the bar's own - // footprint added on top or the last row ends up under it. - if (context.watch().bottomBarStyle == - NooBottomBarStyle.floating) + // draws the body behind the bar instead (as does a frosted bar), so it + // needs the bar's own footprint added on top or the last row ends up + // under it. + if (NooBottomBar.drawsBehindBody( + context.watch().bottomBarStyle, + context.watch().bottomBarFrosted, + )) SliverToBoxAdapter( child: SizedBox(height: bottomBarClearance(context)), ), diff --git a/lib/widgets/bottom_nav_bar.dart b/lib/widgets/bottom_nav_bar.dart index f116a38..c2dce07 100644 --- a/lib/widgets/bottom_nav_bar.dart +++ b/lib/widgets/bottom_nav_bar.dart @@ -16,6 +16,7 @@ import 'noo/nav/noo_bottom_bar.dart'; class BottomNavBar extends StatelessWidget { final NooNavStyle style; final NooBottomBarStyle barStyle; + final bool frosted; final List tabs; final int selectedIndex; final ValueChanged onDestinationSelected; @@ -25,6 +26,7 @@ class BottomNavBar extends StatelessWidget { super.key, required this.style, this.barStyle = NooBottomBarStyle.attached, + this.frosted = false, required this.tabs, required this.selectedIndex, required this.onDestinationSelected, @@ -36,6 +38,7 @@ class BottomNavBar extends StatelessWidget { return NooBottomBar( style: style, barStyle: barStyle, + frosted: frosted, destinations: [ for (final tab in tabs) NooNavDestination(icon: tab.icon, label: tab.label), diff --git a/lib/widgets/noo/nav/noo_bottom_bar.dart b/lib/widgets/noo/nav/noo_bottom_bar.dart index d4784aa..95a3ce0 100644 --- a/lib/widgets/noo/nav/noo_bottom_bar.dart +++ b/lib/widgets/noo/nav/noo_bottom_bar.dart @@ -1,3 +1,4 @@ +import 'dart:ui' show ImageFilter; import 'package:flutter/material.dart'; import '../../../theme/design_tokens.dart'; import 'noo_nav_style.dart'; @@ -13,6 +14,11 @@ const double _kFloatingPillTop = 8; const double _kAndroidPillWidth = 56; const double _kAndroidPillHeight = 32; +/// Frosted glass: the surface fill's opacity and the backdrop blur sigma - +/// the same sigma [FrostedGlassContainer] uses for the media viewer chrome. +const double _kFrostedAlpha = 0.72; +const double _kFrostedSigma = 20; + /// The mobile bottom bar (DESIGN_SYSTEM.md 3, "Mobile"; `iNav`/`aNav` in /// `Mobile Screen.dc.html`). Shows the 5 pinned tabs - which ones, and in /// what order, is the caller's business; this only draws [destinations]. @@ -48,6 +54,13 @@ const double _kAndroidPillHeight = 32; /// body draws behind it - see `tab_state_slivers.dart`'s /// `bottomBarClearance`. /// +/// [frosted] (Settings, Appearance) swaps the solid `surface` fill for a +/// translucent one over a backdrop blur, on both bar styles and both nav +/// styles, and drops [nooDialogShadow] (a shadow would show through the +/// glass). It only reads as glass if content draws behind the bar, so the +/// host `Scaffold` needs `extendBody: true` and each scrollable body needs +/// the same trailing clearance floating does - see [drawsBehindBody]. +/// /// [searchDestination]/[onSearchTap] (set together, from Settings' /// "Search in bottom bar" - see `DESIGN_SYSTEM.md`'s floating bottom bar /// entry) add a Search entry that's never highlighted (tapping it pushes @@ -57,6 +70,7 @@ const double _kAndroidPillHeight = 32; class NooBottomBar extends StatelessWidget { final NooNavStyle style; final NooBottomBarStyle barStyle; + final bool frosted; final List destinations; final int selectedIndex; final ValueChanged onSelected; @@ -67,6 +81,7 @@ class NooBottomBar extends StatelessWidget { super.key, required this.style, this.barStyle = NooBottomBarStyle.attached, + this.frosted = false, required this.destinations, required this.selectedIndex, required this.onSelected, @@ -84,6 +99,12 @@ class NooBottomBar extends StatelessWidget { return barStyle == NooBottomBarStyle.floating ? 64 : 80; } + /// Whether the host `Scaffold` must draw its body behind the bar + /// (`extendBody`) - true for floating (its transparent margin) and for any + /// frosted bar (there's nothing to blur otherwise). + static bool drawsBehindBody(NooBottomBarStyle barStyle, bool frosted) => + frosted || barStyle == NooBottomBarStyle.floating; + /// Gap between the floating bar's bottom edge and the safe area below it /// (itself inside the [SafeArea] that consumes the actual device inset). static const double floatingBottomMargin = 12; @@ -111,17 +132,19 @@ class NooBottomBar extends StatelessWidget { final barHeight = NooBottomBar.rowHeight(style, barStyle); if (floating) { - final pill = Container( + final pillRadius = BorderRadius.circular(28); + final pillBox = Container( height: barHeight, clipBehavior: Clip.antiAlias, decoration: BoxDecoration( - color: colors.surface, + color: _fill(colors), border: Border.all(color: colors.line), - borderRadius: BorderRadius.circular(28), - boxShadow: const [nooDialogShadow], + borderRadius: pillRadius, + boxShadow: frosted ? null : const [nooDialogShadow], ), child: row, ); + final pill = frosted ? _blurred(pillBox, pillRadius) : pillBox; return SafeArea( top: false, child: Padding( @@ -152,6 +175,7 @@ class NooBottomBar extends StatelessWidget { destination: searchDestination!, onTap: onSearchTap!, size: barHeight, + frosted: frosted, ), ], ), @@ -161,9 +185,9 @@ class NooBottomBar extends StatelessWidget { ); } - return Container( + final bar = Container( decoration: BoxDecoration( - color: colors.surface, + color: _fill(colors), border: ios ? Border(top: BorderSide(color: colors.line)) : null, ), child: SafeArea( @@ -171,8 +195,13 @@ class NooBottomBar extends StatelessWidget { child: SizedBox(height: barHeight, child: row), ), ); + return frosted ? _blurred(bar, BorderRadius.zero) : bar; } + Color _fill(NooColors colors) => frosted + ? colors.surface.withValues(alpha: _kFrostedAlpha) + : colors.surface; + Widget _buildIosRow({NooNavDestination? trailingSearch}) { return Row( crossAxisAlignment: CrossAxisAlignment.stretch, @@ -255,6 +284,16 @@ class NooBottomBar extends StatelessWidget { } } +/// Clips [child] to [radius] and blurs whatever is drawn behind it - the +/// frosted bar's backdrop. [child] supplies the translucent fill on top. +Widget _blurred(Widget child, BorderRadius radius) => ClipRRect( + borderRadius: radius, + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: _kFrostedSigma, sigmaY: _kFrostedSigma), + child: child, + ), +); + /// The floating bar's own Search entry point, next to the pill rather than /// inside it - always fully round (see [NooBottomBar]'s doc comment), /// same fill/border as the pill so the two still read as one family. @@ -262,11 +301,13 @@ class _SearchSatellite extends StatelessWidget { final NooNavDestination destination; final VoidCallback onTap; final double size; + final bool frosted; const _SearchSatellite({ required this.destination, required this.onTap, required this.size, + required this.frosted, }); @override @@ -278,21 +319,28 @@ class _SearchSatellite extends StatelessWidget { child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: onTap, - child: Container( - width: size, - height: size, - alignment: Alignment.center, - decoration: BoxDecoration( - color: colors.surface, - border: Border.all(color: colors.line), - shape: BoxShape.circle, - boxShadow: const [nooDialogShadow], - ), - child: Icon(destination.icon, size: 24, color: colors.fg1), - ), + child: _satelliteBody(colors), ), ); } + + Widget _satelliteBody(NooColors colors) { + final disc = Container( + width: size, + height: size, + alignment: Alignment.center, + decoration: BoxDecoration( + color: frosted + ? colors.surface.withValues(alpha: _kFrostedAlpha) + : colors.surface, + border: Border.all(color: colors.line), + shape: BoxShape.circle, + boxShadow: frosted ? null : const [nooDialogShadow], + ), + child: Icon(destination.icon, size: 24, color: colors.fg1), + ); + return frosted ? _blurred(disc, BorderRadius.circular(size / 2)) : disc; + } } class _IosItem extends StatelessWidget { diff --git a/lib/widgets/settings/settings_appearance.dart b/lib/widgets/settings/settings_appearance.dart index 23ce453..23d73c5 100644 --- a/lib/widgets/settings/settings_appearance.dart +++ b/lib/widgets/settings/settings_appearance.dart @@ -53,6 +53,17 @@ class SettingsAppearanceSection extends StatelessWidget { children: [ _ThemeRow(settings: settings), _BottomBarStyleRow(settings: settings), + NooSettingsRow( + icon: LucideIcons.glassWater, + label: const Text('Frosted glass bottom bar'), + subtitle: const Text( + 'A translucent, blurred bottom bar - content scrolls behind it', + ), + trailing: NooToggle( + checked: settings.bottomBarFrosted, + onChanged: settings.setBottomBarFrosted, + ), + ), _NavMenuStyleRow(settings: settings), NooSettingsRow( icon: LucideIcons.search, diff --git a/lib/widgets/tabs/tab_state_slivers.dart b/lib/widgets/tabs/tab_state_slivers.dart index d1a4e89..957f8b0 100644 --- a/lib/widgets/tabs/tab_state_slivers.dart +++ b/lib/widgets/tabs/tab_state_slivers.dart @@ -97,19 +97,26 @@ Widget tabEmptySliver( /// attached] bars aren't drawn behind (no `Scaffold.extendBody`), so /// Scaffold already shrinks the body above them - this is then pure /// breathing room, not overlap prevention. [NooBottomBarStyle.floating] -/// bars draw over an extended body instead, so nothing reserves space for +/// and frosted bars (see [NooBottomBar.drawsBehindBody]) draw over an +/// extended body instead, so nothing reserves space for /// them automatically: the clearance has to cover the bar's own footprint /// (see [NooBottomBar.rowHeight]/[NooBottomBar.floatingBottomMargin]) plus /// the safe-area inset below it, or the last row ends up hidden under it. double bottomBarClearance(BuildContext context) { final settings = context.watch(); - if (settings.bottomBarStyle == NooBottomBarStyle.attached) return 100; + final barStyle = settings.bottomBarStyle; + if (!NooBottomBar.drawsBehindBody(barStyle, settings.bottomBarFrosted)) { + return 100; + } final barHeight = NooBottomBar.rowHeight( NooLayout.navStyle(context), - NooBottomBarStyle.floating, + barStyle, ); + final margin = barStyle == NooBottomBarStyle.floating + ? NooBottomBar.floatingBottomMargin + : 0; final safeBottom = MediaQuery.paddingOf(context).bottom; - return barHeight + NooBottomBar.floatingBottomMargin + safeBottom + 24; + return barHeight + margin + safeBottom + 24; } /// Bottom padding sliver so the last row/card clears the bottom nav/FAB - diff --git a/test/widgets/noo/nav_test.dart b/test/widgets/noo/nav_test.dart index 9a0ccf5..39d3fb6 100644 --- a/test/widgets/noo/nav_test.dart +++ b/test/widgets/noo/nav_test.dart @@ -25,6 +25,69 @@ void main() { setUpNooTests(); group('NooBottomBar', () { + for (final style in NooNavStyle.values) { + for (final barStyle in NooBottomBarStyle.values) { + testNooWidgets( + '$style/$barStyle: frosted draws a backdrop blur, solid does not', + (tester, theme, c) async { + for (final frosted in [false, true]) { + await pumpNoo( + tester, + SizedBox( + width: 360, + height: 120, + child: NooBottomBar( + style: style, + barStyle: barStyle, + frosted: frosted, + destinations: _destinations, + selectedIndex: 0, + onSelected: (_) {}, + searchDestination: const NooNavDestination( + icon: LucideIcons.search, + label: 'Search', + ), + onSearchTap: () {}, + ), + ), + theme: theme, + ); + expect( + find.byType(BackdropFilter), + frosted ? findsWidgets : findsNothing, + ); + } + }, + ); + } + } + + test('drawsBehindBody: floating or frosted', () { + expect( + NooBottomBar.drawsBehindBody(NooBottomBarStyle.attached, false), + isFalse, + ); + expect( + NooBottomBar.drawsBehindBody(NooBottomBarStyle.attached, true), + isTrue, + ); + expect( + NooBottomBar.drawsBehindBody(NooBottomBarStyle.floating, false), + isTrue, + ); + }); + + test('floating row height is the Android one on both nav styles', () { + expect( + NooBottomBar.rowHeight(NooNavStyle.ios, NooBottomBarStyle.floating), + 64, + ); + expect( + NooBottomBar.rowHeight(NooNavStyle.ios, NooBottomBarStyle.attached), + 50, + ); + }); + testNooWidgets('android: label sits below the icon, not beside it', ( tester, theme, From 1ebfb6d2a3dd3b9561ce2b29b93709c4ba48b72f Mon Sep 17 00:00:00 2001 From: Ayushya Amitabh Date: Mon, 5 Oct 2026 22:34:11 -0400 Subject: [PATCH 05/31] Filters: three-way hidden files and external storage Hidden: hide / only hidden / all + hidden. External: cloud only / only external / all + external; with 'all' Files shows the external items in a collapsible section. Old show-hidden bool prefs are still read as fallback. Co-Authored-By: Claude Sonnet 5.5 --- .claude/context/styling.md | 12 ++ lib/providers/files_controller.dart | 92 ++++++--- lib/providers/photos_controller.dart | 25 ++- lib/views/files_view.dart | 263 ++++++++++++++++-------- lib/views/photos_view.dart | 31 ++- lib/widgets/files_controls_row.dart | 36 ++-- lib/widgets/filter_mode_rows.dart | 130 ++++++++++++ test/providers/files_filters_test.dart | 129 ++++++++++++ test/widgets/filter_mode_rows_test.dart | 76 +++++++ 9 files changed, 634 insertions(+), 160 deletions(-) create mode 100644 lib/widgets/filter_mode_rows.dart create mode 100644 test/providers/files_filters_test.dart create mode 100644 test/widgets/filter_mode_rows_test.dart diff --git a/.claude/context/styling.md b/.claude/context/styling.md index 83d2087..9facce9 100644 --- a/.claude/context/styling.md +++ b/.claude/context/styling.md @@ -133,6 +133,18 @@ blocks are noted where they matter: `NooChip`/`NooSegmentedControl`; still the sort/hidden/scope/type-filter/ view-mode row shared by Files and Offline (`showStorageScope: false` for Offline), and reused as-is by Favorites. +- Hidden files and external storage are each a three-way segmented row + (`HiddenFilesFilterRow`/`StorageScopeRow`, `lib/widgets/filter_mode_rows.dart`, + in both the Files and Photos filter sheets), not toggles: + `HiddenFilesFilter` hide (default) / only / include, and `StorageScope` + cloud (default) / external / all. Files and Photos each persist their own + hidden filter (`ui_hidden_filter`, `ui_hidden_filter_photos`; the old + `ui_show_hidden*` bools are still read as a fallback, `true` → include); + `StorageScope` stays shared. Leaving `hide` is still behind the + hidden-files lock gate. With `StorageScope.all`, Files splits the listing: + regular items first, then a collapsible "External storage" section + (`_ExternalStorageHeader`, local `_externalExpanded` state, default open) + holding the external ones; Photos/Favorites just merge them into one list. - `lib/widgets/files/file_breadcrumb_row.dart` — the noo-styled breadcrumb trail Files uses in place of the shared [`Breadcrumbs`](../../lib/widgets/breadcrumbs.dart) widget. `Breadcrumbs` diff --git a/lib/providers/files_controller.dart b/lib/providers/files_controller.dart index e3309b5..1ed4b8b 100644 --- a/lib/providers/files_controller.dart +++ b/lib/providers/files_controller.dart @@ -9,10 +9,16 @@ import 'session_controller.dart'; enum FileSortField { name, dateCreated, dateModified, size } -/// Which storage a listing shows — always exactly one, like the list/grid -/// view toggle, not an optional filter. Shared across Files/Photos/ -/// Favorites (see [applyCommonFilters]'s doc comment). -enum StorageScope { cloud, external } +/// Which storage a listing shows: [cloud] (the default - external mounts +/// hidden), only [external] ones, or [all] of both (Files shows the +/// external items in their own collapsible section then). Shared across +/// Files/Photos/Favorites (see [applyCommonFilters]'s doc comment). +enum StorageScope { cloud, external, all } + +/// Which dotfile/dotfolder items a listing shows: [hide] them (the +/// default), show [only] them, or [include] them alongside everything else. +/// Files and Photos each keep their own (see [applyCommonFilters]). +enum HiddenFilesFilter { hide, only, include } /// The Files tab's files/folders/both filter — independent of and applied /// after [StorageScope]/favorites/hidden filtering. @@ -72,6 +78,7 @@ class FilesController extends ChangeNotifier static const _prefStorageScope = 'ui_storage_scope'; static const _prefFilesTypeFilter = 'ui_files_type_filter'; static const _prefShowHiddenFiles = 'ui_show_hidden'; + static const _prefHiddenFilter = 'ui_hidden_filter'; static const _prefFolderSort = 'ui_folder_sort'; static const prefCachePolicy = 'ui_cache_policy'; static const prefCacheIntervalMinutes = 'ui_cache_interval_minutes'; @@ -82,7 +89,7 @@ class FilesController extends ChangeNotifier bool _isGridView = false; StorageScope _storageScope = StorageScope.cloud; FilesTypeFilter _filesTypeFilter = FilesTypeFilter.all; - bool _showHiddenFiles = false; + HiddenFilesFilter _hiddenFilter = HiddenFilesFilter.hide; final Map _folderSortField = {}; final Map _folderSortAscending = {}; @@ -135,7 +142,7 @@ class FilesController extends ChangeNotifier bool get isGridView => _isGridView; StorageScope get storageScope => _storageScope; FilesTypeFilter get filesTypeFilter => _filesTypeFilter; - bool get showHiddenFiles => _showHiddenFiles; + HiddenFilesFilter get hiddenFilter => _hiddenFilter; FileSortField get filesSortField => sortFieldFor(_currentFolderPath); bool get filesSortAscending => sortAscendingFor(_currentFolderPath); @@ -188,24 +195,30 @@ class FilesController extends ChangeNotifier List applyCommonFilters( List source, { required bool showFavoritesOnly, - required bool showHidden, + required HiddenFilesFilter hidden, bool applyStorageScope = true, }) { var filtered = source; if (showFavoritesOnly) { filtered = filtered.where((item) => item.isFavorite).toList(); } - if (!showHidden) { - filtered = filtered.where((item) => !_isHiddenItem(item)).toList(); + switch (hidden) { + case HiddenFilesFilter.hide: + filtered = filtered.where((item) => !_isHiddenItem(item)).toList(); + case HiddenFilesFilter.only: + filtered = filtered.where(_isHiddenItem).toList(); + case HiddenFilesFilter.include: + break; } if (applyStorageScope) { - filtered = filtered - .where( - (item) => _storageScope == StorageScope.external - ? item.isExternalStorage - : !item.isExternalStorage, - ) - .toList(); + switch (_storageScope) { + case StorageScope.cloud: + filtered = filtered.where((i) => !i.isExternalStorage).toList(); + case StorageScope.external: + filtered = filtered.where((i) => i.isExternalStorage).toList(); + case StorageScope.all: + break; + } } return filtered; } @@ -231,7 +244,7 @@ class FilesController extends ChangeNotifier // Files itself doesn't filter by favorite - that's the dedicated // Favorites tab's job. showFavoritesOnly: false, - showHidden: _showHiddenFiles, + hidden: _hiddenFilter, applyStorageScope: applyStorageScope, ); switch (_filesTypeFilter) { @@ -270,7 +283,7 @@ class FilesController extends ChangeNotifier _isGridView = false; _storageScope = StorageScope.cloud; _filesTypeFilter = FilesTypeFilter.all; - _showHiddenFiles = false; + _hiddenFilter = HiddenFilesFilter.hide; _cachePolicy = defaultCachePolicy; _cacheIntervalMinutes = defaultCacheIntervalMinutes; _isLoading = false; @@ -321,7 +334,11 @@ class FilesController extends ChangeNotifier (f) => f.name == filesTypeFilterName, orElse: () => FilesTypeFilter.all, ); - _showHiddenFiles = prefs.getBool(k(_prefShowHiddenFiles)) ?? false; + _hiddenFilter = loadHiddenFilter( + prefs, + k(_prefHiddenFilter), + k(_prefShowHiddenFiles), + ); final folderSortJson = prefs.getString(k(_prefFolderSort)); if (folderSortJson != null) { @@ -639,10 +656,12 @@ class FilesController extends ChangeNotifier ); } - /// Only *enabling* hidden-files visibility is gated - hiding them again - /// never exposes anything, so that direction is always allowed instantly. - Future toggleShowHiddenFiles() async { - if (!_showHiddenFiles) { + /// Only *leaving* [HiddenFilesFilter.hide] is gated - going back to it + /// never exposes anything, so that direction is always allowed instantly + /// (and switching between the two revealing modes needs no second prompt). + Future setHiddenFilter(HiddenFilesFilter filter) async { + if (_hiddenFilter == filter) return; + if (_hiddenFilter == HiddenFilesFilter.hide) { if (!await session.passGate( session.lockHiddenFiles, 'Unlock to show hidden files', @@ -650,14 +669,35 @@ class FilesController extends ChangeNotifier return; } } - _showHiddenFiles = !_showHiddenFiles; + _hiddenFilter = filter; notifyListeners(); _persistAccountPref( - _prefShowHiddenFiles, - (p, key) => p.setBool(key, _showHiddenFiles), + _prefHiddenFilter, + (p, key) => p.setString(key, filter.name), ); } + /// Reads a persisted [HiddenFilesFilter], falling back to the old + /// show-hidden bool (`true` meant everything incl. hidden) so a pref + /// saved before the three-way filter keeps its meaning. [key]s are + /// already account-namespaced by the caller. + static HiddenFilesFilter loadHiddenFilter( + SharedPreferences prefs, + String key, + String legacyBoolKey, + ) { + final name = prefs.getString(key); + if (name != null) { + return HiddenFilesFilter.values.firstWhere( + (f) => f.name == name, + orElse: () => HiddenFilesFilter.hide, + ); + } + return (prefs.getBool(legacyBoolKey) ?? false) + ? HiddenFilesFilter.include + : HiddenFilesFilter.hide; + } + void _persistFolderSort() { final combined = {}; for (final path in { diff --git a/lib/providers/photos_controller.dart b/lib/providers/photos_controller.dart index b5b4665..80f1e48 100644 --- a/lib/providers/photos_controller.dart +++ b/lib/providers/photos_controller.dart @@ -17,6 +17,7 @@ class PhotosController extends ChangeNotifier { static const _prefShowFavoritesOnlyPhotos = 'ui_show_favorites_only_photos'; static const _prefShowHiddenPhotos = 'ui_show_hidden_photos'; + static const _prefHiddenFilterPhotos = 'ui_hidden_filter_photos'; static const _prefSortField = 'ui_sort_field'; static const _prefSortAscending = 'ui_sort_ascending'; @@ -25,7 +26,7 @@ class PhotosController extends ChangeNotifier { String? _errorMessage; bool _showFavoritesOnly = false; - bool _showHidden = false; + HiddenFilesFilter _hiddenFilter = HiddenFilesFilter.hide; FileSortField _sortField = FileSortField.name; bool _sortAscending = true; @@ -37,7 +38,7 @@ class PhotosController extends ChangeNotifier { bool get isLoading => _isLoading; String? get errorMessage => _errorMessage; bool get showFavoritesOnly => _showFavoritesOnly; - bool get showHidden => _showHidden; + HiddenFilesFilter get hiddenFilter => _hiddenFilter; FileSortField get sortField => _sortField; bool get sortAscending => _sortAscending; @@ -48,7 +49,7 @@ class PhotosController extends ChangeNotifier { final filtered = files.applyCommonFilters( media, showFavoritesOnly: _showFavoritesOnly, - showHidden: _showHidden, + hidden: _hiddenFilter, )..sort((a, b) => _compare(a, b)); return _sortAscending ? filtered : filtered.reversed.toList(); } @@ -86,7 +87,11 @@ class PhotosController extends ChangeNotifier { String k(String base) => session.accountStore.accountPrefKey(id, base); _showFavoritesOnly = prefs.getBool(k(_prefShowFavoritesOnlyPhotos)) ?? false; - _showHidden = prefs.getBool(k(_prefShowHiddenPhotos)) ?? false; + _hiddenFilter = FilesController.loadHiddenFilter( + prefs, + k(_prefHiddenFilterPhotos), + k(_prefShowHiddenPhotos), + ); final sortFieldName = prefs.getString(k(_prefSortField)); _sortField = FileSortField.values.firstWhere( (f) => f.name == sortFieldName, @@ -148,8 +153,10 @@ class PhotosController extends ChangeNotifier { ); } - Future toggleShowHidden() async { - if (!_showHidden) { + /// Gated the same way as [FilesController.setHiddenFilter]. + Future setHiddenFilter(HiddenFilesFilter filter) async { + if (_hiddenFilter == filter) return; + if (_hiddenFilter == HiddenFilesFilter.hide) { if (!await session.passGate( session.lockHiddenFiles, 'Unlock to show hidden files', @@ -157,11 +164,11 @@ class PhotosController extends ChangeNotifier { return; } } - _showHidden = !_showHidden; + _hiddenFilter = filter; notifyListeners(); _persistAccountPref( - _prefShowHiddenPhotos, - (p, key) => p.setBool(key, _showHidden), + _prefHiddenFilterPhotos, + (p, key) => p.setString(key, filter.name), ); } diff --git a/lib/views/files_view.dart b/lib/views/files_view.dart index c15e109..f0f7088 100644 --- a/lib/views/files_view.dart +++ b/lib/views/files_view.dart @@ -76,6 +76,57 @@ class FilesView extends StatefulWidget { State createState() => _FilesViewState(); } +/// The collapsible "External storage" header [StorageScope.all] puts above +/// the external items. +class _ExternalStorageHeader extends StatelessWidget { + final int count; + final bool expanded; + final VoidCallback onTap; + + const _ExternalStorageHeader({ + required this.count, + required this.expanded, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final colors = context.nooColors; + return Semantics( + button: true, + expanded: expanded, + label: 'External storage', + child: InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: NooSpace.md, + vertical: 14, + ), + child: Row( + children: [ + Icon(LucideIcons.hardDrive, size: 18, color: colors.fg2), + const SizedBox(width: 10), + Text( + 'External storage', + style: NooText.bodyL.copyWith(color: colors.fg1), + ), + const SizedBox(width: 8), + Text('$count', style: NooText.body.copyWith(color: colors.fg3)), + const Spacer(), + Icon( + expanded ? LucideIcons.chevronUp : LucideIcons.chevronDown, + size: 18, + color: colors.fg3, + ), + ], + ), + ), + ), + ); + } +} + /// Slides+fades its child in on first build. Give it a [Key] that changes /// whenever the folder changes (folder path + item id) so Flutter discards /// and remounts the Element instead of just updating it in place - that's @@ -236,6 +287,9 @@ class _GridThumbnail extends StatelessWidget { class _FilesViewState extends State { final Set _selectedIds = {}; + + /// Whether [StorageScope.all]'s external-storage section is open. + bool _externalExpanded = true; int _lastPathDepth = 1; bool get _isSelecting => _selectedIds.isNotEmpty; @@ -669,6 +723,131 @@ class _FilesViewState extends State { ), ); + // One sliver group per layout (grid / desktop table / mobile list) for + // an arbitrary slice of items - [StorageScope.all] renders two of them, + // the regular items and then the external ones under a collapsible + // header, instead of one list over `browser.items`. + List itemSlivers( + List items, { + required String section, + bool tableHeader = true, + }) { + if (files.isGridView) { + return [ + SliverPadding( + key: ValueKey('files-grid-$section'), + padding: EdgeInsets.fromLTRB( + gutter, + NooSpace.xs, + gutter, + NooSpace.lg, + ), + sliver: SliverGrid( + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: isDesktop + ? 5 + : NooLayout.gridColumns(context, phone: 2, minTile: 180), + childAspectRatio: isDesktop ? 1.05 : 0.92, + crossAxisSpacing: isDesktop ? 16 : 10, + mainAxisSpacing: isDesktop ? 16 : 10, + ), + delegate: SliverChildBuilderDelegate((context, index) { + final item = items[index]; + return _FolderEnterAnimation( + key: ValueKey('${browser.currentFolderPath}::${item.id}'), + index: index, + fromRight: navigatingDeeper, + child: _buildGridCard(context, item), + ); + }, childCount: items.length), + ), + ), + ]; + } + if (isDesktop) { + return [ + if (tableHeader) + SliverPadding( + key: const ValueKey('files-table-header'), + padding: EdgeInsets.fromLTRB(gutter, NooSpace.xs, gutter, 0), + sliver: SliverToBoxAdapter( + child: _buildDesktopHeader( + context, + files, + browser.currentFolderPath, + ), + ), + ), + SliverPadding( + key: ValueKey('files-table-$section'), + padding: EdgeInsets.fromLTRB(gutter, 0, gutter, NooSpace.lg), + sliver: SliverList( + delegate: SliverChildBuilderDelegate((context, index) { + final item = items[index]; + return _FolderEnterAnimation( + key: ValueKey('${browser.currentFolderPath}::${item.id}'), + index: index, + fromRight: navigatingDeeper, + child: _buildDesktopRow(context, item), + ); + }, childCount: items.length), + ), + ), + ]; + } + return [ + SliverPadding( + key: ValueKey('files-list-$section'), + padding: const EdgeInsets.fromLTRB( + NooSpace.sm, + NooSpace.xs, + NooSpace.sm, + NooSpace.lg, + ), + sliver: SliverList( + delegate: SliverChildBuilderDelegate((context, index) { + final item = items[index]; + return _FolderEnterAnimation( + key: ValueKey('${browser.currentFolderPath}::${item.id}'), + index: index, + fromRight: navigatingDeeper, + child: _buildMobileRow(context, item, index, items.length), + ); + }, childCount: items.length), + ), + ), + ]; + } + + // "All + external": external items leave the main list for their own + // collapsible section below it. Offline has no such distinction. + final splitExternal = !_offline && files.storageScope == StorageScope.all; + final mainItems = splitExternal + ? browser.items.where((i) => !i.isExternalStorage).toList() + : browser.items; + final externalItems = splitExternal + ? browser.items.where((i) => i.isExternalStorage).toList() + : const []; + final bodySlivers = [ + if (mainItems.isNotEmpty) ...itemSlivers(mainItems, section: 'main'), + if (externalItems.isNotEmpty) ...[ + SliverToBoxAdapter( + child: _ExternalStorageHeader( + count: externalItems.length, + expanded: _externalExpanded, + onTap: () => + setState(() => _externalExpanded = !_externalExpanded), + ), + ), + if (_externalExpanded) + ...itemSlivers( + externalItems, + section: 'external', + tableHeader: mainItems.isEmpty, + ), + ], + ]; + final List contentSlivers = [ if (widget.topBar != null) topBarSliver(widget.topBar!), // Pinned in both states - while browsing this is the controls row @@ -753,88 +932,8 @@ class _FilesViewState extends State { ), ), ) - else if (files.isGridView) - SliverPadding( - key: const ValueKey('files-grid'), - padding: EdgeInsets.fromLTRB( - gutter, - NooSpace.xs, - gutter, - NooSpace.lg, - ), - sliver: SliverGrid( - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: isDesktop - ? 5 - : NooLayout.gridColumns(context, phone: 2, minTile: 180), - childAspectRatio: isDesktop ? 1.05 : 0.92, - crossAxisSpacing: isDesktop ? 16 : 10, - mainAxisSpacing: isDesktop ? 16 : 10, - ), - delegate: SliverChildBuilderDelegate((context, index) { - final item = browser.items[index]; - return _FolderEnterAnimation( - key: ValueKey('${browser.currentFolderPath}::${item.id}'), - index: index, - fromRight: navigatingDeeper, - child: _buildGridCard(context, item), - ); - }, childCount: browser.items.length), - ), - ) - else if (isDesktop) ...[ - SliverPadding( - key: const ValueKey('files-table-header'), - padding: EdgeInsets.fromLTRB(gutter, NooSpace.xs, gutter, 0), - sliver: SliverToBoxAdapter( - child: _buildDesktopHeader( - context, - files, - browser.currentFolderPath, - ), - ), - ), - SliverPadding( - key: const ValueKey('files-table'), - padding: EdgeInsets.fromLTRB(gutter, 0, gutter, NooSpace.lg), - sliver: SliverList( - delegate: SliverChildBuilderDelegate((context, index) { - final item = browser.items[index]; - return _FolderEnterAnimation( - key: ValueKey('${browser.currentFolderPath}::${item.id}'), - index: index, - fromRight: navigatingDeeper, - child: _buildDesktopRow(context, item), - ); - }, childCount: browser.items.length), - ), - ), - ] else - SliverPadding( - key: const ValueKey('files-list'), - padding: const EdgeInsets.fromLTRB( - NooSpace.sm, - NooSpace.xs, - NooSpace.sm, - NooSpace.lg, - ), - sliver: SliverList( - delegate: SliverChildBuilderDelegate((context, index) { - final item = browser.items[index]; - return _FolderEnterAnimation( - key: ValueKey('${browser.currentFolderPath}::${item.id}'), - index: index, - fromRight: navigatingDeeper, - child: _buildMobileRow( - context, - item, - index, - browser.items.length, - ), - ); - }, childCount: browser.items.length), - ), - ), + else + ...bodySlivers, // List/grid/table above only pad NooSpace.lg at the bottom - plenty // once Scaffold shrinks the body above an attached bar, but floating // draws the body behind the bar instead (as does a frosted bar), so it diff --git a/lib/views/photos_view.dart b/lib/views/photos_view.dart index 51bd79b..a358b61 100644 --- a/lib/views/photos_view.dart +++ b/lib/views/photos_view.dart @@ -15,6 +15,7 @@ import '../providers/settings_controller.dart'; import '../services/download_service.dart'; import '../theme/design_tokens.dart'; import '../widgets/details/details_sheet.dart'; +import '../widgets/filter_mode_rows.dart'; import '../widgets/noo/core/noo_button.dart'; import '../widgets/noo/core/noo_chip.dart'; import '../widgets/noo/core/noo_segmented_control.dart'; @@ -404,7 +405,7 @@ class _PhotosViewState extends State { final filtersActive = _typeFilter != _PhotoTypeFilter.all || photos.showFavoritesOnly || - photos.showHidden || + photos.hiddenFilter != HiddenFilesFilter.hide || files.storageScope != StorageScope.cloud; // A plain Row, not a horizontally-scrolling one - see @@ -559,26 +560,18 @@ class _PhotosViewState extends State { onChanged: (_) => photos.toggleFavoritesFilter(), ), ), - NooSettingsRow( - icon: LucideIcons.eye, - label: const Text('Show hidden files'), - trailing: NooToggle( - checked: photos.showHidden, - onChanged: (_) => photos.toggleShowHidden(), - ), - ), - NooSettingsRow( - icon: LucideIcons.hardDrive, - label: const Text('External storage'), - trailing: NooToggle( - checked: files.storageScope == StorageScope.external, - onChanged: (external) => files.setStorageScope( - external ? StorageScope.external : StorageScope.cloud, - ), - ), - ), ], ), + const SizedBox(height: 22), + HiddenFilesFilterRow( + value: photos.hiddenFilter, + onChanged: photos.setHiddenFilter, + ), + const SizedBox(height: 22), + StorageScopeRow( + value: files.storageScope, + onChanged: files.setStorageScope, + ), ], ), ), diff --git a/lib/widgets/files_controls_row.dart b/lib/widgets/files_controls_row.dart index abde3ee..ac69d60 100644 --- a/lib/widgets/files_controls_row.dart +++ b/lib/widgets/files_controls_row.dart @@ -5,7 +5,7 @@ import '../providers/files_controller.dart'; import '../theme/design_tokens.dart'; import 'noo/core/noo_chip.dart'; import 'noo/core/noo_segmented_control.dart'; -import 'noo/core/noo_toggle.dart'; +import 'filter_mode_rows.dart'; import 'noo/lists/noo_grouped_list.dart'; import 'noo/lists/noo_settings_row.dart'; import 'noo/overlays/noo_sheet.dart'; @@ -54,7 +54,7 @@ class FilesControlsRow extends StatelessWidget { final files = context.watch(); final filtersActive = files.filesTypeFilter != FilesTypeFilter.all || - files.showHiddenFiles || + files.hiddenFilter != HiddenFilesFilter.hide || (showStorageScope && files.storageScope != StorageScope.cloud); return SizedBox( @@ -196,29 +196,17 @@ class FilesControlsRow extends StatelessWidget { ], ), const SizedBox(height: 22), - NooGroupedList( - children: [ - NooSettingsRow( - icon: LucideIcons.eye, - label: const Text('Show hidden files'), - trailing: NooToggle( - checked: files.showHiddenFiles, - onChanged: (_) => files.toggleShowHiddenFiles(), - ), - ), - if (showStorageScope) - NooSettingsRow( - icon: LucideIcons.hardDrive, - label: const Text('External storage'), - trailing: NooToggle( - checked: files.storageScope == StorageScope.external, - onChanged: (external) => files.setStorageScope( - external ? StorageScope.external : StorageScope.cloud, - ), - ), - ), - ], + HiddenFilesFilterRow( + value: files.hiddenFilter, + onChanged: files.setHiddenFilter, ), + if (showStorageScope) ...[ + const SizedBox(height: 22), + StorageScopeRow( + value: files.storageScope, + onChanged: files.setStorageScope, + ), + ], ], ), ), diff --git a/lib/widgets/filter_mode_rows.dart b/lib/widgets/filter_mode_rows.dart new file mode 100644 index 0000000..d2ed936 --- /dev/null +++ b/lib/widgets/filter_mode_rows.dart @@ -0,0 +1,130 @@ +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import '../providers/files_controller.dart'; +import '../theme/design_tokens.dart'; +import 'noo/core/noo_segmented_control.dart'; + +/// "Hidden files" row of the Files/Photos filter sheets: hide them (the +/// default), show only them, or show them alongside everything else. Same +/// icon-always/label-when-selected pill as the type filter above it in +/// both sheets. +class HiddenFilesFilterRow extends StatelessWidget { + final HiddenFilesFilter value; + final ValueChanged onChanged; + + const HiddenFilesFilterRow({ + super.key, + required this.value, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) { + return _FilterModeRow( + icon: LucideIcons.eye, + title: 'Hidden files', + value: value, + onChanged: onChanged, + options: const [ + NooSegmentOption( + value: HiddenFilesFilter.hide, + icon: LucideIcons.eyeOff, + label: 'Hide', + ), + NooSegmentOption( + value: HiddenFilesFilter.only, + icon: LucideIcons.eye, + label: 'Only hidden', + ), + NooSegmentOption( + value: HiddenFilesFilter.include, + icon: LucideIcons.layers, + label: 'All + hidden', + ), + ], + ); + } +} + +/// "External storage" row of the Files/Photos filter sheets: cloud only +/// (the default), only external storage, or both. Files shows the external +/// items in a collapsible section for [StorageScope.all]. +class StorageScopeRow extends StatelessWidget { + final StorageScope value; + final ValueChanged onChanged; + + const StorageScopeRow({ + super.key, + required this.value, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) { + return _FilterModeRow( + icon: LucideIcons.hardDrive, + title: 'External storage', + value: value, + onChanged: onChanged, + options: const [ + NooSegmentOption( + value: StorageScope.cloud, + icon: LucideIcons.cloud, + label: 'Cloud only', + ), + NooSegmentOption( + value: StorageScope.external, + icon: LucideIcons.hardDrive, + label: 'Only external', + ), + NooSegmentOption( + value: StorageScope.all, + icon: LucideIcons.layers, + label: 'All + external', + ), + ], + ); + } +} + +class _FilterModeRow extends StatelessWidget { + final IconData icon; + final String title; + final T value; + final ValueChanged onChanged; + final List> options; + + const _FilterModeRow({ + required this.icon, + required this.title, + required this.value, + required this.onChanged, + required this.options, + }); + + @override + Widget build(BuildContext context) { + final colors = context.nooColors; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(icon, size: 18, color: colors.fg2), + const SizedBox(width: 10), + Text(title, style: NooText.bodyL.copyWith(color: colors.fg1)), + ], + ), + const SizedBox(height: 10), + NooSegmentedControl( + fill: true, + onSurface: true, + labelOnlySelected: true, + value: value, + onChanged: onChanged, + options: options, + ), + ], + ); + } +} diff --git a/test/providers/files_filters_test.dart b/test/providers/files_filters_test.dart new file mode 100644 index 0000000..060c3ce --- /dev/null +++ b/test/providers/files_filters_test.dart @@ -0,0 +1,129 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:noo/models/nextcloud_item.dart'; +import 'package:noo/providers/connectivity_controller.dart'; +import 'package:noo/providers/files_controller.dart'; +import 'package:noo/providers/session_controller.dart'; + +NextcloudItem _item(String path, {String? mountType}) => NextcloudItem( + id: path, + name: path.split('/').last, + path: path, + type: NextcloudItemType.file, + size: 1, + lastModified: DateTime(2026), + mountType: mountType, +); + +/// The shared hidden / storage-scope filtering (`applyCommonFilters`) and +/// how a pref saved before the three-way hidden filter is read back. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUpAll(() { + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + messenger.setMockMethodCallHandler( + const MethodChannel('plugins.it_nomads.com/flutter_secure_storage'), + (call) async => call.method == 'readAll' ? {} : null, + ); + messenger.setMockMethodCallHandler( + const MethodChannel('dev.fluttercommunity.plus/connectivity'), + (call) async => call.method == 'check' ? ['none'] : null, + ); + messenger.setMockStreamHandler( + const EventChannel('dev.fluttercommunity.plus/connectivity_status'), + MockStreamHandler.inline(onListen: (arguments, events) {}), + ); + }); + + setUp(() => SharedPreferences.setMockInitialValues({})); + + final items = [ + _item('/a.txt'), + _item('/.secret'), + _item('/docs/.git/config'), + _item('/nas', mountType: 'external'), + _item('/.nas-hidden', mountType: 'external'), + ]; + List paths(List l) => l.map((i) => i.path).toList(); + + FilesController build() => + FilesController(SessionController(ConnectivityController())); + + test('hidden filter: hide / only / include', () { + final files = build()..setStorageScope(StorageScope.all); + List run(HiddenFilesFilter h) => paths( + files.applyCommonFilters(items, showFavoritesOnly: false, hidden: h), + ); + expect(run(HiddenFilesFilter.hide), ['/a.txt', '/nas']); + expect(run(HiddenFilesFilter.only), [ + '/.secret', + '/docs/.git/config', + '/.nas-hidden', + ]); + expect(run(HiddenFilesFilter.include), paths(items)); + }); + + test('storage scope: cloud / external / all', () { + final files = build(); + List run(StorageScope s) { + files.setStorageScope(s); + return paths( + files.applyCommonFilters( + items, + showFavoritesOnly: false, + hidden: HiddenFilesFilter.include, + ), + ); + } + + expect(run(StorageScope.cloud), [ + '/a.txt', + '/.secret', + '/docs/.git/config', + ]); + expect(run(StorageScope.external), ['/nas', '/.nas-hidden']); + expect(run(StorageScope.all), paths(items)); + }); + + test('hidden + external combine', () { + final files = build()..setStorageScope(StorageScope.external); + expect( + paths( + files.applyCommonFilters( + items, + showFavoritesOnly: false, + hidden: HiddenFilesFilter.only, + ), + ), + ['/.nas-hidden'], + ); + }); + + group('loadHiddenFilter', () { + Future load(Map values) async { + SharedPreferences.setMockInitialValues(values); + return FilesController.loadHiddenFilter( + await SharedPreferences.getInstance(), + 'new', + 'old', + ); + } + + test('defaults to hide', () async { + expect(await load({}), HiddenFilesFilter.hide); + }); + + test('the old show-hidden bool maps to hide / include', () async { + expect(await load({'old': true}), HiddenFilesFilter.include); + expect(await load({'old': false}), HiddenFilesFilter.hide); + }); + + test('the new key wins and an unknown value falls back to hide', () async { + expect(await load({'new': 'only', 'old': true}), HiddenFilesFilter.only); + expect(await load({'new': 'bogus'}), HiddenFilesFilter.hide); + }); + }); +} diff --git a/test/widgets/filter_mode_rows_test.dart b/test/widgets/filter_mode_rows_test.dart new file mode 100644 index 0000000..ac51644 --- /dev/null +++ b/test/widgets/filter_mode_rows_test.dart @@ -0,0 +1,76 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:noo/providers/files_controller.dart'; +import 'package:noo/widgets/filter_mode_rows.dart'; + +import 'noo/noo_test_utils.dart'; + +void main() { + setUpNooTests(); + + group('HiddenFilesFilterRow', () { + testNooWidgets( + 'only the selected option shows its label, tapping another reports it', + (tester, theme, c) async { + var value = HiddenFilesFilter.hide; + await pumpNoo( + tester, + StatefulBuilder( + builder: (context, setState) => SizedBox( + width: 360, + child: HiddenFilesFilterRow( + value: value, + onChanged: (v) => setState(() => value = v), + ), + ), + ), + theme: theme, + ); + expect(find.text('Hidden files'), findsOneWidget); + expect(find.text('Hide'), findsOneWidget); + expect(find.text('Only hidden'), findsNothing); + + await tester.tap(find.byIcon(LucideIcons.layers)); + await tester.pumpAndSettle(); + expect(value, HiddenFilesFilter.include); + expect(find.text('All + hidden'), findsOneWidget); + }, + ); + }); + + group('StorageScopeRow', () { + testNooWidgets('offers cloud only / only external / all + external', ( + tester, + theme, + c, + ) async { + var value = StorageScope.cloud; + await pumpNoo( + tester, + StatefulBuilder( + builder: (context, setState) => SizedBox( + width: 360, + child: StorageScopeRow( + value: value, + onChanged: (v) => setState(() => value = v), + ), + ), + ), + theme: theme, + ); + expect(find.text('External storage'), findsOneWidget); + expect(find.text('Cloud only'), findsOneWidget); + + await tester.tap(find.byIcon(LucideIcons.hardDrive).last); + await tester.pumpAndSettle(); + expect(value, StorageScope.external); + expect(find.text('Only external'), findsOneWidget); + + await tester.tap(find.byIcon(LucideIcons.layers)); + await tester.pumpAndSettle(); + expect(value, StorageScope.all); + expect(find.text('All + external'), findsOneWidget); + }); + }); +} From ef3315d0b2b7eca582036b08e0ef267e4b1f2b33 Mon Sep 17 00:00:00 2001 From: Ayushya Amitabh Date: Mon, 5 Oct 2026 22:38:37 -0400 Subject: [PATCH 06/31] Filter sheets: 'Type' label above the type control; shorten 'All + ...' to 'All' Co-Authored-By: Claude Sonnet 5.5 --- lib/views/photos_view.dart | 56 ++++++++++--------- lib/widgets/files_controls_row.dart | 50 +++++++++-------- lib/widgets/filter_mode_rows.dart | 71 +++++++++++++++++-------- test/widgets/filter_mode_rows_test.dart | 6 +-- 4 files changed, 108 insertions(+), 75 deletions(-) diff --git a/lib/views/photos_view.dart b/lib/views/photos_view.dart index a358b61..e618056 100644 --- a/lib/views/photos_view.dart +++ b/lib/views/photos_view.dart @@ -522,32 +522,36 @@ class _PhotosViewState extends State { // Same "icon always, label only when selected" pill as // Files' own type filter - not a fully-labelled track, so // the two screens' filter sheets look and behave the same. - NooSegmentedControl<_PhotoTypeFilter>( - fill: true, - onSurface: true, - labelOnlySelected: true, - value: _typeFilter, - onChanged: (filter) => setState(() { - _typeFilter = filter; - setSheetState(() {}); - }), - options: const [ - NooSegmentOption( - value: _PhotoTypeFilter.all, - icon: LucideIcons.layoutGrid, - label: 'All', - ), - NooSegmentOption( - value: _PhotoTypeFilter.image, - icon: LucideIcons.image, - label: 'Photos', - ), - NooSegmentOption( - value: _PhotoTypeFilter.video, - icon: LucideIcons.film, - label: 'Videos', - ), - ], + FilterSection( + icon: LucideIcons.layoutGrid, + title: 'Type', + child: NooSegmentedControl<_PhotoTypeFilter>( + fill: true, + onSurface: true, + labelOnlySelected: true, + value: _typeFilter, + onChanged: (filter) => setState(() { + _typeFilter = filter; + setSheetState(() {}); + }), + options: const [ + NooSegmentOption( + value: _PhotoTypeFilter.all, + icon: LucideIcons.layoutGrid, + label: 'All', + ), + NooSegmentOption( + value: _PhotoTypeFilter.image, + icon: LucideIcons.image, + label: 'Photos', + ), + NooSegmentOption( + value: _PhotoTypeFilter.video, + icon: LucideIcons.film, + label: 'Videos', + ), + ], + ), ), const SizedBox(height: 22), NooGroupedList( diff --git a/lib/widgets/files_controls_row.dart b/lib/widgets/files_controls_row.dart index ac69d60..f2db4a6 100644 --- a/lib/widgets/files_controls_row.dart +++ b/lib/widgets/files_controls_row.dart @@ -171,29 +171,33 @@ class FilesControlsRow extends StatelessWidget { // Same "icon always, label only when selected" pill as // Photos' own type filter below - not a checkmark list, so // the two screens' filter sheets look and behave the same. - NooSegmentedControl( - fill: true, - onSurface: true, - labelOnlySelected: true, - value: files.filesTypeFilter, - onChanged: files.setFilesTypeFilter, - options: const [ - NooSegmentOption( - value: FilesTypeFilter.all, - icon: LucideIcons.layoutGrid, - label: 'All', - ), - NooSegmentOption( - value: FilesTypeFilter.filesOnly, - icon: LucideIcons.file, - label: 'Files', - ), - NooSegmentOption( - value: FilesTypeFilter.foldersOnly, - icon: LucideIcons.folder, - label: 'Folders', - ), - ], + FilterSection( + icon: LucideIcons.layoutGrid, + title: 'Type', + child: NooSegmentedControl( + fill: true, + onSurface: true, + labelOnlySelected: true, + value: files.filesTypeFilter, + onChanged: files.setFilesTypeFilter, + options: const [ + NooSegmentOption( + value: FilesTypeFilter.all, + icon: LucideIcons.layoutGrid, + label: 'All', + ), + NooSegmentOption( + value: FilesTypeFilter.filesOnly, + icon: LucideIcons.file, + label: 'Files', + ), + NooSegmentOption( + value: FilesTypeFilter.foldersOnly, + icon: LucideIcons.folder, + label: 'Folders', + ), + ], + ), ), const SizedBox(height: 22), HiddenFilesFilterRow( diff --git a/lib/widgets/filter_mode_rows.dart b/lib/widgets/filter_mode_rows.dart index d2ed936..a02eba3 100644 --- a/lib/widgets/filter_mode_rows.dart +++ b/lib/widgets/filter_mode_rows.dart @@ -39,7 +39,7 @@ class HiddenFilesFilterRow extends StatelessWidget { NooSegmentOption( value: HiddenFilesFilter.include, icon: LucideIcons.layers, - label: 'All + hidden', + label: 'All', ), ], ); @@ -80,13 +80,48 @@ class StorageScopeRow extends StatelessWidget { NooSegmentOption( value: StorageScope.all, icon: LucideIcons.layers, - label: 'All + external', + label: 'All', ), ], ); } } +/// An icon + title line above [child] - the shape every row of the Files/ +/// Photos filter sheets shares, including their type (All/Files/Folders) +/// control, which lives in its own file. +class FilterSection extends StatelessWidget { + final IconData icon; + final String title; + final Widget child; + + const FilterSection({ + super.key, + required this.icon, + required this.title, + required this.child, + }); + + @override + Widget build(BuildContext context) { + final colors = context.nooColors; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(icon, size: 18, color: colors.fg2), + const SizedBox(width: 10), + Text(title, style: NooText.bodyL.copyWith(color: colors.fg1)), + ], + ), + const SizedBox(height: 10), + child, + ], + ); + } +} + class _FilterModeRow extends StatelessWidget { final IconData icon; final String title; @@ -104,27 +139,17 @@ class _FilterModeRow extends StatelessWidget { @override Widget build(BuildContext context) { - final colors = context.nooColors; - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon(icon, size: 18, color: colors.fg2), - const SizedBox(width: 10), - Text(title, style: NooText.bodyL.copyWith(color: colors.fg1)), - ], - ), - const SizedBox(height: 10), - NooSegmentedControl( - fill: true, - onSurface: true, - labelOnlySelected: true, - value: value, - onChanged: onChanged, - options: options, - ), - ], + return FilterSection( + icon: icon, + title: title, + child: NooSegmentedControl( + fill: true, + onSurface: true, + labelOnlySelected: true, + value: value, + onChanged: onChanged, + options: options, + ), ); } } diff --git a/test/widgets/filter_mode_rows_test.dart b/test/widgets/filter_mode_rows_test.dart index ac51644..e55fd1b 100644 --- a/test/widgets/filter_mode_rows_test.dart +++ b/test/widgets/filter_mode_rows_test.dart @@ -34,13 +34,13 @@ void main() { await tester.tap(find.byIcon(LucideIcons.layers)); await tester.pumpAndSettle(); expect(value, HiddenFilesFilter.include); - expect(find.text('All + hidden'), findsOneWidget); + expect(find.text('All'), findsOneWidget); }, ); }); group('StorageScopeRow', () { - testNooWidgets('offers cloud only / only external / all + external', ( + testNooWidgets('offers cloud only / only external / all', ( tester, theme, c, @@ -70,7 +70,7 @@ void main() { await tester.tap(find.byIcon(LucideIcons.layers)); await tester.pumpAndSettle(); expect(value, StorageScope.all); - expect(find.text('All + external'), findsOneWidget); + expect(find.text('All'), findsOneWidget); }); }); } From fea7c3302ef80d164b051e8dfd0db64082fb4b9c Mon Sep 17 00:00:00 2001 From: Ayushya Amitabh Date: Mon, 5 Oct 2026 22:42:30 -0400 Subject: [PATCH 07/31] iOS: replace default Flutter app icons with the Noo icon Generated from assets/icon/app_icon.png, opaque RGB at every size in the set. Co-Authored-By: Claude Sonnet 5.5 --- .../Icon-App-1024x1024@1x.png | Bin 10932 -> 263850 bytes .../AppIcon.appiconset/Icon-App-20x20@1x.png | Bin 295 -> 930 bytes .../AppIcon.appiconset/Icon-App-20x20@2x.png | Bin 406 -> 2357 bytes .../AppIcon.appiconset/Icon-App-20x20@3x.png | Bin 450 -> 4283 bytes .../AppIcon.appiconset/Icon-App-29x29@1x.png | Bin 282 -> 1531 bytes .../AppIcon.appiconset/Icon-App-29x29@2x.png | Bin 462 -> 4018 bytes .../AppIcon.appiconset/Icon-App-29x29@3x.png | Bin 704 -> 7268 bytes .../AppIcon.appiconset/Icon-App-40x40@1x.png | Bin 406 -> 2357 bytes .../AppIcon.appiconset/Icon-App-40x40@2x.png | Bin 586 -> 6423 bytes .../AppIcon.appiconset/Icon-App-40x40@3x.png | Bin 862 -> 12163 bytes .../AppIcon.appiconset/Icon-App-60x60@2x.png | Bin 862 -> 12163 bytes .../AppIcon.appiconset/Icon-App-60x60@3x.png | Bin 1674 -> 22313 bytes .../AppIcon.appiconset/Icon-App-76x76@1x.png | Bin 762 -> 6197 bytes .../AppIcon.appiconset/Icon-App-76x76@2x.png | Bin 1226 -> 17823 bytes .../Icon-App-83.5x83.5@2x.png | Bin 1418 -> 19136 bytes 15 files changed, 0 insertions(+), 0 deletions(-) diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png index dc9ada4725e9b0ddb1deab583e5b5102493aa332..ecc64da5a6766915e476368c94364a9e2b8404d6 100644 GIT binary patch literal 263850 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�hR_a&e{^jfOXQj0`l}A1i^pu_9)0|k8MD1jPxEw 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 z

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 literal 10932 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 diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png index 797d452e458972bab9d994556c8305db4c827017..b5b5172ae4709d5390042b0d4e0e0b22128f9a42 100644 GIT binary patch literal 2357 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 bCUoon!SAGb=inG}00000NkvXXu0mjfeo%fi delta 390 zcmV;10eSwl5|#sy8Gi!+006rnNM8T|0E$pdR7L;)|5U~J0au$Tw)XJ){%+3s=lA~6 z@BMVp`S<<*VaoaP`~U3u{%g(ou*=|m)B4`@{`33)?ezIj#Q6OF|6IuUF}e2O>+>eB z?J{?+FLkYu+4_Uk`r_>LHF~flZm0oBf#vr8%vJ>#p~!KNvqGG3)|f1T_)ydeh8$vDceZ>oNbH^|*hJ*t?Yc*1`WB&W>VYVEzu) zq#7;;VjO)t*nbgf(!`OXJBr45rP>>AQr$6c7slJWvbpNW@KTwna6d?PP>hvXCcp=4 zF;=GR@R4E7{4VU^0p4F>v^#A|>07*qoM6N<$f}L{LcK`qY diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png index 6ed2d933e1120817fe9182483a228007b18ab6ae..9e90cdb571849621060c4c79da81d3d2be51e4ca 100644 GIT binary patch literal 4283 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 delta 435 zcmV;k0Zjh8A;JTY8Gi!+000dlDL?=K0EAFXR7L;)|5U~JDYo_jSDRPH_*uvJ?fL$s z;QQnD{*>GM-ShrilfUZt{^9lhT*&z4_x{-O{Rv#2V9EI}xb^~1iQe@7)8g(7UZ4B@ z|4zgB>+<*9=;^^)>d)H7pzGjuM>Jnezy3`@G2r z?{~a!Fj;`+8Gq^x2Jl;?IEV8)=fG217*|@)CCYgFze-x?IFODUIA>nWKpE+bn~n7; z-89sa>#DR>TSlqWk*!2hSN6D~Qb#VqbP~4Fk&m`@1$JGrXPIdeRE&b2Thd#{MtDK$ zpx*d3-Wx``>!oimf%|A-&-q*6KAH)e$3|6JV%HX{HY|nMnXd&JOovdH8X7OD zjPa2NO?>gqTS6jLB)q5(Bqo56k{BR9Gy;^m-#zFQswCU&)x{yomC>xyjZT4wlfD}Vi+uJF`u=dm_iVp1+DR8^@8;x2gz zncUWfKiQsm|LCP}e$EESLWD>uU3z_QcjMun_TCe@p?_J^pxOZQ;m1(u@sd7M0djVH zw>xlY_`UCMd0s6x42dxa;Zo!a`OkN@t!Ym8_7@Dd1}N25N&0em5E7*=(dMH5D+N2{ zDJaoyB^Xm`y57+%`Svt**bIw;Y6hxMaKRFF1F`xNTuMYtWT-q!aDxg~HE9q@DHm`Y zBoF2g#edRDf@9(&z}hA9TY+lxgE&l7?6*OIuJ<4?&^w_Zr#9B;7z&Wv{18I{ROTob zicHp@@fmn`K`Fr;V_%@jfM;g?xk!c*RAUhoBny!)KnyX`&%PUnt?pG>APljkl|0{( zz$~b>&1@OnDsn=JB)E(a7@)Vq+tXR&Qq=f z5d#4__HPV@8^QR;J&wn+JN9466u~`8Mo)X{%NI9-*bjf2I`quFqFma1`098B^)!&o zZhvVUKk-7xR46{WSfnOFWgn6z4U<-(WiB^0phueJlb;QbGS{w4NK6El-;I>mw|{*7 za@UWexuMMTp-VH9W;*FwP&CNg`8og1)0w`WwO{>JoQ$B%Se651LLqfpp<`TZZZZCx zC|)fX2`4ED5eDpRq`aCF8Gh{Y#J=vPeSZVl5$0IQgdhZtA4F}qss1rh2w8WdDTIWx zqkt?V5I$U>8qAnZqHKvnjbK{&6nb!yL1@a|Ka}lmwL4oqPEDACVAU!(ufzzjd8@=* z9gwIDsVoNmjG^itqv;Y`+i z>pZ%;hW)naFbqEfV~#2fbH1+>`$lk6a!< z_H@gJv=K4EBFUJ%$C7

(AdP{C_sfA<&9sIH+sB$OBq^_y~$Pui>)?pK!ZcYR-*? zH;X)FkxdO&Q$oCUYUJ#sk&wZGt*dt4-*|Q`8ZC+@hiq*!ug?@-Iz4fdc!Yv;E2+S% z59X_1Q`jJ65ejCu*ZYsJu^U{g7>SD$rQ_H947W`uC4}I9en%tjUGFtIbbl&fKa7@6 z-sC}|)<{?j`31eL2esZsZ+?!XANu9o1mb0NeoWIyCJob6RRTFuvT}r?P*q>eS|phS zS5%e81F+PcA3j)Yxh_rF!k+_u%BPA5<<+$&gxXX(b~Tl%KJ($v4-i;t;cc%ypl?AH zyvtK{AQsBI0M+Z&2GDeomTiQ>K5$a!xyo3Tu?hmv;8WX^RAr#5PLCB${h}?U_E;F` z-@)<~)*Vo-5gq}wOuG6Nr8H`#l@qmyrR6Jn1qx4U?s8C|t0yr^rqst+%c?A15V!;s a*8B^o9nh_qAU(eT0000qVZqE6)=lqo0`vF#&*75!I`TIh@_d&k*HoEtQyV-iD z%Xz2D9EQRbeYh5Nr~y=#0ZD;^+vz0$004MNL_t(2&&|%+4u6C&2tZM$Wf&dzefR%A z(^3-?6X>hnCz2Ba@RH&`m!pgy?n@#@AuLYB&}Q)FGY`?vcft0!vht0Z@M&ZeNCWXh75gzRTXR8EE3oN&6 Q00000NkvXXt^-0~f@7(L_y7O^ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png index fe730945a01f64a61e2235dbe3f45b08f7729182..2d0050fa1c46ff939d5b83001f496d4ccedcd9fa 100644 GIT binary patch literal 4018 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 Y0}~5L2O5B^A^-pY07*qoM6N<$g1QQx`Tzg` delta 447 zcmV;w0YLt;AI<}i8Gi!+007oyx*7lg0G3cpR7L;)|5U~J0au$Tw)URh`@-w}Xw3Np zS)Ix4{k7)&ujKrh-TO(x_}20L&+q+}+xr1ilg8}*yXgGl_5RcF{f*iBEV%Z~-t4>5 ziGV;=={^- z?sLQGb)?A{hr$_!z8HbH7kH=vM0x-*R~t>;jsO4v^GQTOR7l6|(&r9>FcgO2dg?%> z;=sK?5%;?Pn^T7LL?Y$@5u?06NuIR*0?Yf$Hf5Afk+lM<^ch*jvO$sU*m9J?JI7eI zGFV6+q|w~e;JI~C4Vf^@d>Wvj=fl`^u9x9wd9R%3*Q+)t%S!MU_`id^@& zY{y7-r98lZX0?YrHlfmwb?#}^1b{8g&KzmkE(L>Z&p6kME1_Z%?`+u)^el0!1<0sd p?Eyu!OMLDifi)An*I;?S-wj=m4RYIt!kPd8002ovPDHLkV1hu6^g;jt diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png index 321773cd857a8a0f0c9c7d3dc3f5ff4fb298dc10..a4a07ffc25fa75c5e6c6d4505663cc425de5bcfb 100644 GIT binary patch literal 7268 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 Zxpx7IC@PekH?;r&002ovPDHLkV1nI6YpehO diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png index 797d452e458972bab9d994556c8305db4c827017..b5b5172ae4709d5390042b0d4e0e0b22128f9a42 100644 GIT binary patch literal 2357 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 bCUoon!SAGb=inG}00000NkvXXu0mjfeo%fi delta 390 zcmV;10eSwl5|#sy8Gi!+006rnNM8T|0E$pdR7L;)|5U~J0au$Tw)XJ){%+3s=lA~6 z@BMVp`S<<*VaoaP`~U3u{%g(ou*=|m)B4`@{`33)?ezIj#Q6OF|6IuUF}e2O>+>eB z?J{?+FLkYu+4_Uk`r_>LHF~flZm0oBf#vr8%vJ>#p~!KNvqGG3)|f1T_)ydeh8$vDceZ>oNbH^|*hJ*t?Yc*1`WB&W>VYVEzu) zq#7;;VjO)t*nbgf(!`OXJBr45rP>>AQr$6c7slJWvbpNW@KTwna6d?PP>hvXCcp=4 zF;=GR@R4E7{4VU^0p4F>v^#A|>07*qoM6N<$f}L{LcK`qY diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png index 502f463a9bc882b461c96aadf492d1729e49e725..c09e8a8928a51bac941eb69ad5c65fc3b24c8a72 100644 GIT binary patch literal 6423 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 KMNUMnLSTX;)-^@| diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png index 0ec303439225b78712f49115768196d8d76f6790..a54e998774fff345f10523a36697d598fa80f1d7 100644 GIT binary patch literal 12163 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 FV1n1zML+-m delta 850 zcmV-Y1FignU)~0g8Gi!+000iU#^3+|0OwFlR7L;)|5U~J09TtSw)Xt~|5(QO`~Ck( z!T0|D|3<*~RmJ%E{r+;#`2ba!klFf7!uJMSo%Q?vP{jByxcAZE>;OrUCbaZYjJo^$ z{nGILmD~Da$@upC{`C6(Ey4dPw)Pyc^>5DkHoEo!QcuK-Jwl-l}t(fQKv z{dds$V#@dygS`PvhX6is7Z+@*x-d;$ zb=6f@U3Jw}_s+W3%*+b9H_vS)-R#9?zrXogeLVI2We2RFTTAL}&3C8PS~<5D&v@UI z+`s*$wqQ=yd$laNUY-|ovcS9~n_90tFUdl#qq0tEUXle|k{Op|DHpSrbxEeZ5~$>o%>OSe z^=41qvh3LlC2xXzu+-2eQoqs1^L>7ylB$bCP);(%(xYZL1 cY5!B-0ft0f?Lgb>C;$Ke07*qoM6N<$f)2OI{Qv*} diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png index 0ec303439225b78712f49115768196d8d76f6790..a54e998774fff345f10523a36697d598fa80f1d7 100644 GIT binary patch literal 12163 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 FV1n1zML+-m delta 850 zcmV-Y1FignU)~0g8Gi!+000iU#^3+|0OwFlR7L;)|5U~J09TtSw)Xt~|5(QO`~Ck( z!T0|D|3<*~RmJ%E{r+;#`2ba!klFf7!uJMSo%Q?vP{jByxcAZE>;OrUCbaZYjJo^$ z{nGILmD~Da$@upC{`C6(Ey4dPw)Pyc^>5DkHoEo!QcuK-Jwl-l}t(fQKv z{dds$V#@dygS`PvhX6is7Z+@*x-d;$ zb=6f@U3Jw}_s+W3%*+b9H_vS)-R#9?zrXogeLVI2We2RFTTAL}&3C8PS~<5D&v@UI z+`s*$wqQ=yd$laNUY-|ovcS9~n_90tFUdl#qq0tEUXle|k{Op|DHpSrbxEeZ5~$>o%>OSe z^=41qvh3LlC2xXzu+-2eQoqs1^L>7ylB$bCP);(%(xYZL1 cY5!B-0ft0f?Lgb>C;$Ke07*qoM6N<$f)2OI{Qv*} diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png index e9f5fea27c705180eb716271f41b582e76dcbd90..0b64712fe3f37e25539af9a26be4f05a9c0d40ce 100644 GIT binary patch literal 22313 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%0D{6KNos z&0?6NvVBcRQtJ$1aM4NxJJEE~kO8S~GwQHD6?`}|$Y9Ox5G~tmkJ2Tqgb+;7jrM2= zm6&7~tCA!Y5u-(|4F+SB&u>GLQ6$9l%`u9!1k{ZLJFQrOk6&bP47`7eFL&|#QM#$w z7L_LeE59?70$w#I-XWHN$N%!aBGlI%|+WZ1FGQE%h37)OTq%{nm|Z1nL{ zk9@QyE>q?(sH6l<)D<|_2@j-!h4zGON@0WMkM1CU2sPXtCo;mWww#2Kp+1$6fguNL z4%B?G1uGpQYQpK61sUQ$Z3=@*N(VV;aiT)ih8_UObE1R|P56dzt=Ftim1y^mE;3+B z(oi)cMvJGSXm^e=CL&5aWfJU~gVtE@x|oFp+v>t;3c+nCOG(O+j;<+&~hZh?%9Um#dIqJ04^N-NIrt03CBi*wdDi#v5u>Z0K~`o5v_~lo<93%TeYiG3*tV z@#SpecS!?U4k^m)BARJB;cF8cX(L(mIdw*R!eUlDGx33+6r1(waUvrY!>Id|m}D0l zk|eb=MvDjgBp?IIV*2J7Md(aZ?kGW~6+b~(zlRjt_tYUHH264>q1{aWz=uTEPPOx=#uWT5x?^Xm`mEqd^T6{Sq0*OE%bn)6`R;vutC)Y^N0$rw}W) zuM4M*Wds>kw!}Lb$S~wi6!@()(=}vEw<#CV$xbr@~}-)Us56R6FGi z2vnK2i>zsqByEb(;$l-0t>id!)UZcaLS|Y)wCrfDTWwtDylUl0rK9g<<)Fn8okM&f zV2g5g>KmskKCqW9&f|1P0}vy2l#?{x2#!feW9|5Da6Qt6sJbXI-P6{i%=WnhK6q(l z_+@5#odLlK=7k_bGEnGfCqc~&n+P?ZvFtGH(Q{_QgYO~W=g7EAW|`xG>EID|O3D;D zNs>yA(c)Hnt(D6w~Uai=^#;ra!Ck+|lkmo^0=X~R%ZVe8_ zvK(H&9H&Q8?2j8{q=(4*sYC|7=p5}rH7_#2nP8R(W#vG+Bf{gp%wc&6QcJcb1uBEv zdpVtup?aA=y50RT*PVqqe43Ogx{@Rn6r;t(rm*Jhn8OCgV+v7abB-b+Atq!#8|>AJ z?Q2}`W0}qw4i+$g%+k>XEZwSBdTGY$kj=<3Eop&{tOcj2lg|^oTdb=p3@o}G@7F4Q38Hn34FNwA9F+{DO2PmNh&Z#dr~3;!?izNbwToH z`N_Cy_0s5CdOYdeL@cE#L18Ded3sWUZRgSRgjC|AMtpQa1_UN$J_WB@qt3C@iuI=T zugoSwv=qys$7CSGWDw^MdO1N^&MZZlJv|vkCkh466H>K|hiYW!7c|A-(os%SbxcAU zYtEi1bCl@1qzX~yC^6O1Rin&NqQ41-&N$1>_|xkQG}mi(>0!AVb{YEO&)y2%|)H ziCG+FjuJ(Jy;||HIcdmngF+z$5UXLEj3Pk^T0K2BSY`A)LB~kZT)zlD)FVlmA}2{w zDKXlqL`KB+xVp}9jfjXcM~UprF{`Isw9?2xP+eThHFBLBW80z$d=&D2m~SA zLYW~wEPI}i+NlhDRBZl8hFs(%Nh%~pi-#(DvO-8S08!$}kr1;usxC_8sEvgGAA9hz UI>70;6951J07*qoM6N<$f)h{E6aWAK delta 1669 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 diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png index 8953cba09064923c5daf2d37e7c3c836ccdd794b..68d6cae3a79b9cea937f693a45dd9f146e2eccf7 100644 GIT binary patch literal 17823 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 diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png index 0467bf12aa4d28f374bb26596605a46dcbb3e7c8..0aad66e3aa69d82d18290272e5f4931ee95fdaf9 100644 GIT binary patch literal 19136 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 P00000NkvXXu0mjf7s=w2 delta 1411 zcmV-}1$_Fzl>v$ikQsji0000($h_VF008q)OjJex|Nm6Q_$jvb0au$-#Q6RG|NQ;_ z`u+b{#`ydG|5wKNPQ&*^zxM!2lL1zlir4y3!}tVRozU+516Z8?|Nrj!{>AJ3O2PMD z$oLqr^>U%d=l1<#%J_z@&PKrZF1Ysc`u@@H{E623uH*YZz4w1BzW*n(@|xWGCA9Sc zMt*e9`2kdxPQ&&>|-UCa7_51w+LUsW@ZzZSW0y$)Hp~e9% zPvP|a03ks1`~K?q{u;6NC8*{AOqIUq{CL&;p56Lf$oQGq^={4hPQv)y=I|4n+?>7F zim=dxt#sqj!A8000D1Nkl*v&^&=|+Hd{KUTbfjFY149`B9Z?JA;JODI|1Ri*a+Z z^#PDGc`IecWa-9LVrC5Yc`M^4C1>fnVoIQkNbF4J1hZR{8AI(*G83SSi04gDEzE4O znmIHdWbzu!0o~cerl;1X52(&2GX1Df7)YyS+AHMCK%#EC)SIp+C1-jvkP=@8QulHV zB+{lQHJX3Uvyw@D_1H_9IT`uctkHDlK-0asdie7--IsyHF3&)stx&Ct9Qmeua`m`$ z1IdA=`>~o{yoG8o-KdZ+Zz1OT4Jo{ZAiRanxq1w|8A$TNPqy$Da<=K7F#WlDSeV?8 zf#lB(>8dy1Ep~k%aVIKtnSU;n%Nh~QzJ<8n^qPMH(P-24A5=>a*R9#QvjzF8n%@1N zw@?CG@6(%>+-0ASK~jEmCV|&a*7-GKT72W<(TbSjf)&Eme6nGE>Gkj4Sq&2e+-G%| z+NM8OOm5zVl9{Z8Dd8A5z3y8mZ=4Bv4%>as_{9cN#bm~;h>62(dqY93Zy}v&c4n($ zVv&CoG~?z9=}cy1a%8Rynx7Bl=Qq8Hv#GCWpDl%I%)DgQtB?d}0E$dW)pQ*y)z&k{ zb*A3**JaZQm{=yp%~uZ`NX<0e4n?NaRx{%bH(iIajGNvnpxOy0%)jxXEG1&U%R}*0(7v|MOC;<~= zXCjn3}VQ-`MO)Jy?KTrtggxbyx9Bv2-SZ99MS z!MEEWV<3%y54UX0x5MHm2wJEBcyw!R)65*VuiPVt3L{i8$K!SsxdDB13e}h|#znim zfX4XFr-+nlMNQ0I1?^Q(${4-OViN1!6d z)+2K|9S)V#DX6HR$b2@N72{Kia9!*mQDq&n2{r`j};V| zuV%_wsP!zB?m%;FeaRe+X47K0e+KE!8C{gAWF8)lCd1u1%~|M!XNRvwvtqy3iz0WS zpWdhn6$hP8PaRBmp)q`#PCA`Vd#Tc$@f1tAcM>f_I@bC)hkI9|o(Iqvo}PPwx_d@r z;T5#w0aR`MiTh`C0rj9v-ax%+eePZBBRe#A0@~{sqCR=@13&;#uuS&+c?p6TD&YIC z+e`8i|D*o={t84pkFEFN*8wW1NMd^gIaE Date: Mon, 5 Oct 2026 22:53:37 -0400 Subject: [PATCH 08/31] Dynamic color: keep card surfaces lighter than the page In light mode surfaceContainerLow is slightly darker than the OS surface bg maps to, so the selection bar and other cards blended into the page. Use surfaceContainerLowest (light) / surfaceContainer (dark) for NooColors.surface. Co-Authored-By: Claude Sonnet 5.5 --- .claude/context/styling.md | 7 +++++++ lib/theme/design_tokens.dart | 13 +++++++++++-- test/widgets/noo/core_test.dart | 24 ++++++++++++++++++++++++ 3 files changed, 42 insertions(+), 2 deletions(-) diff --git a/.claude/context/styling.md b/.claude/context/styling.md index 9facce9..5f929d7 100644 --- a/.claude/context/styling.md +++ b/.claude/context/styling.md @@ -29,6 +29,13 @@ widgets. Key points: whole app in `main.dart`. When available and `useDynamicColor` is on, the OS-provided `ColorScheme` wins over the seed color — always thread both `dynamicScheme` and `useDynamicColor` through when adding a theme knob. + `NooColors.fromDynamicScheme` keeps `bg` on the OS `surface` but picks + `surface` (cards, selection bar, rows) from the container roles so it + stays lighter than `bg`: `surfaceContainerLowest` (white) in light, + `surfaceContainer` in dark. The dynamic_color package seeds the container + roles from the primary color rather than the OS palette, and in light mode + `surfaceContainerLow` is slightly *darker* than the OS `surface`, which + made cards blend into the page ("the selection bar has no background"). - **Font**: Inter via `google_fonts`, applied through `GoogleFonts.interTextTheme(...)`. Font files ship in `assets/google_fonts/` (Instrument Sans, Schibsted Grotesk) and diff --git a/lib/theme/design_tokens.dart b/lib/theme/design_tokens.dart index 4f2fbbe..672fbe4 100644 --- a/lib/theme/design_tokens.dart +++ b/lib/theme/design_tokens.dart @@ -139,11 +139,20 @@ class NooColors extends ThemeExtension { /// paints it, not painted directly - using it as-is for a sheet/dialog's /// `barrierColor` hid the screen behind completely instead of dimming it, /// so this uses the fixed palette's already-translucent scrim instead. + /// + /// `surface` (cards, bars, rows) has to read as lighter than `bg`, like + /// the fixed palettes' white-on-beige / lifted-on-near-black. In light + /// mode `surfaceContainerLow` is a hair *darker* than the OS-provided + /// `surface` that [bg] uses, so cards (the selection bar, list rows) + /// vanished into the page; `surfaceContainerLowest` (white) doesn't. Dark + /// mode uses `surfaceContainer` rather than `...Low` for a bigger step + /// off the page. factory NooColors.fromDynamicScheme(ColorScheme cs) { - final fixed = cs.brightness == Brightness.dark ? dark : light; + final dark = cs.brightness == Brightness.dark; + final fixed = dark ? NooColors.dark : NooColors.light; return NooColors( bg: cs.surface, - surface: cs.surfaceContainerLow, + surface: dark ? cs.surfaceContainer : cs.surfaceContainerLowest, surface2: cs.surfaceContainerHigh, surface3: cs.surfaceContainerHighest, line: cs.outlineVariant, diff --git a/test/widgets/noo/core_test.dart b/test/widgets/noo/core_test.dart index 0bd4af8..143d373 100644 --- a/test/widgets/noo/core_test.dart +++ b/test/widgets/noo/core_test.dart @@ -74,6 +74,30 @@ void main() { }); }); + group('NooColors.fromDynamicScheme surfaces', () { + for (final brightness in Brightness.values) { + test('cards are clearly lighter than the page (${brightness.name})', () { + // The OS palette overrides `surface` (what `bg` maps to) but not the + // seeded container roles - mimic that mismatch. + final seeded = ColorScheme.fromSeed( + seedColor: const Color(0xFF3F6B8F), + brightness: brightness, + ); + final os = seeded.copyWith( + surface: brightness == Brightness.light + ? const Color(0xFFF7F9FC) + : const Color(0xFF101417), + ); + final c = NooColors.fromDynamicScheme(os); + // WCAG-style contrast ratio between the card and the page. + final hi = c.surface.computeLuminance(); + final lo = c.bg.computeLuminance(); + expect(hi, greaterThan(lo)); + expect((hi + 0.05) / (lo + 0.05), greaterThan(1.04)); + }); + } + }); + group('NooAvatar', () { testNooWidgets('current user uses accent-soft/accent-text', ( tester, From 7434b901306b09c70e7250c57a89da4d686c102d Mon Sep 17 00:00:00 2001 From: Ayushya Amitabh Date: Tue, 6 Oct 2026 19:06:19 -0400 Subject: [PATCH 09/31] iOS: degrade gracefully where the Android-only native services are missing Share/pick/sync calls and streams no longer throw MissingPluginException at startup; upload/download/sync-now show 'X isn't available on this platform yet'. Offline/sync mirror dir no longer uses the Android-only external dir. Info.plist: Face ID and photo library usage strings. Co-Authored-By: Claude Sonnet 5.5 --- .claude/context/server.md | 31 ++++++++++++ ios/Runner/Info.plist | 4 ++ lib/providers/offline_controller.dart | 7 ++- lib/services/download_service.dart | 5 +- lib/services/native_channel.dart | 55 +++++++++++++++++++++ lib/services/pick_intent_service.dart | 11 +++-- lib/services/share_intent_service.dart | 6 ++- lib/services/sync_service.dart | 36 +++++++++----- lib/services/upload_service.dart | 9 ++-- test/services/native_channel_test.dart | 67 ++++++++++++++++++++++++++ 10 files changed, 204 insertions(+), 27 deletions(-) create mode 100644 test/services/native_channel_test.dart diff --git a/.claude/context/server.md b/.claude/context/server.md index 4080700..1ab5158 100644 --- a/.claude/context/server.md +++ b/.claude/context/server.md @@ -811,3 +811,34 @@ rather than relying on this async path throwing naturally. bitmask (1 read, 2 update, 4 create, 8 delete, 16 reshare). The share sheet's per-person pill opens a menu with Can view (1) / Can edit (3 for files, 15 for folders), keeping the existing reshare bit, plus Remove access. + +## Native services on iOS (in progress) + +The five `dev.ayushya.noo/*` channels (`share_intent`, `pick_intent`, +`upload_service`, `download_service`, `sync_service`, plus their status +`EventChannel`s) are implemented in Kotlin only; iOS has no handler for any +of them yet, so every call there throws `MissingPluginException`. +`lib/services/native_channel.dart` makes that safe until each one is built +in Swift: + +- `invokeIfAvailable` - a missing implementation returns null. For cold-start + checks (`getInitialShare`, `getPickRequest`, `getSyncStatus`) and + scheduling/cleanup (`reschedule`, `cancel`, `removeLocalSync`, + `finishPick`/`cancelPick`). +- `invokeOrExplain` - throws `NativeServiceUnavailable("")`, whose + message callers already show in a snackbar ("Uploading isn't available on + this platform yet."). For things the user just asked for: `startUpload`, + `startDownload`, `syncNow`, `resolveConflict`. +- `quietEvents` - wraps an `EventChannel` so the missing-implementation error + is dropped instead of surfacing as an unhandled stream error. + +When a channel gets a real iOS implementation nothing changes on the Dart +side - the helpers only act on `MissingPluginException`. + +`SyncService.baseDirectory()` is where the `sync//...` mirror +lives: `getExternalStorageDirectory()` on Android (what `SyncEngine.kt` +writes), the app support directory elsewhere, because that call throws on +iOS. `OfflineController` and `localSyncedFilePath` both go through it. +Not built on iOS yet: Share Extension (needs an App Group), the picker +(File Provider), background uploads/downloads (`URLSession`), and sync +(`BGTaskScheduler`) - see the iOS handoff notes. diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 29e8c6b..89fef25 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -53,6 +53,10 @@ LaunchScreen UIMainStoryboardFile Main + NSFaceIDUsageDescription + Noo uses Face ID to unlock the app and to protect hidden files. + NSPhotoLibraryUsageDescription + Noo needs access to your photos and videos so you can choose which ones to upload to your server. UISupportedInterfaceOrientations UIInterfaceOrientationPortrait diff --git a/lib/providers/offline_controller.dart b/lib/providers/offline_controller.dart index 3939e7e..d744bb2 100644 --- a/lib/providers/offline_controller.dart +++ b/lib/providers/offline_controller.dart @@ -2,7 +2,6 @@ import 'dart:async'; import 'dart:io'; import 'package:flutter/material.dart'; import 'package:path/path.dart' as p; -import 'package:path_provider/path_provider.dart'; import '../models/nextcloud_item.dart'; import '../services/sync_service.dart'; import 'files_controller.dart'; @@ -132,7 +131,7 @@ class OfflineController extends ChangeNotifier notifyListeners(); try { - _syncBasePath ??= (await getExternalStorageDirectory())?.path; + _syncBasePath ??= (await SyncService.baseDirectory())?.path; // A sync can delete the very folder being browsed (deleted on the // server) - step out to the nearest ancestor that still exists rather // than showing a phantom empty folder. @@ -186,7 +185,7 @@ class OfflineController extends ChangeNotifier Future localPathFor(NextcloudItem item) async { final accountId = session.activeAccountId; if (accountId == null) return null; - final base = await getExternalStorageDirectory(); + final base = await SyncService.baseDirectory(); if (base == null) return null; final relPath = item.path.startsWith('/') ? item.path.substring(1) @@ -199,7 +198,7 @@ class OfflineController extends ChangeNotifier String accountId, String folderPath, ) async { - final base = await getExternalStorageDirectory(); + final base = await SyncService.baseDirectory(); if (base == null) return []; final relFolder = folderPath == '/' ? '' : folderPath.replaceFirst('/', ''); final dir = Directory(p.join(base.path, 'sync', accountId, relFolder)); diff --git a/lib/services/download_service.dart b/lib/services/download_service.dart index 146a70c..348517a 100644 --- a/lib/services/download_service.dart +++ b/lib/services/download_service.dart @@ -38,6 +38,9 @@ class DownloadService { .toList(), ); - await _channel.invokeMethod('startDownload', {...args, 'files': filesJson}); + await invokeOrExplain(_channel, 'startDownload', 'Downloading', { + ...args, + 'files': filesJson, + }); } } diff --git a/lib/services/native_channel.dart b/lib/services/native_channel.dart index ef12b69..b2de9f1 100644 --- a/lib/services/native_channel.dart +++ b/lib/services/native_channel.dart @@ -1,3 +1,4 @@ +import 'package:flutter/services.dart'; import '../providers/session_controller.dart'; /// The "get this account's auth header, or bail" guard every native- @@ -21,3 +22,57 @@ Map baseChannelArgs(SessionController session) { 'authHeader': requireAuthHeader(session), }; } + +/// Thrown by user-triggered native services (upload, download, sync now...) +/// on a platform whose native side isn't implemented yet - the message is +/// shown to the user as-is, e.g. in a snackbar. +class NativeServiceUnavailable implements Exception { + final String feature; + + const NativeServiceUnavailable(this.feature); + + @override + String toString() => "$feature isn't available on this platform yet."; +} + +/// Calls [method], treating a missing native implementation (no +/// `MethodChannel` handler registered, as on iOS until each service is +/// built there) as "nothing to report": returns null instead of throwing +/// `MissingPluginException`. For cold-start checks, scheduling and other +/// calls nobody is waiting on. +Future invokeIfAvailable( + MethodChannel channel, + String method, [ + Object? arguments, +]) async { + try { + return await channel.invokeMethod(method, arguments); + } on MissingPluginException { + return null; + } +} + +/// Like [invokeIfAvailable], but for an action the user just asked for: +/// throws [NativeServiceUnavailable] naming [feature] rather than silently +/// doing nothing. +Future invokeOrExplain( + MethodChannel channel, + String method, + String feature, [ + Object? arguments, +]) async { + try { + return await channel.invokeMethod(method, arguments); + } on MissingPluginException { + throw NativeServiceUnavailable(feature); + } +} + +/// [channel]'s broadcast stream mapped through [convert], with the +/// "no native implementation" error dropped - the stream just never emits. +Stream quietEvents(EventChannel channel, T Function(dynamic) convert) { + return channel + .receiveBroadcastStream() + .handleError((Object _) {}, test: (e) => e is MissingPluginException) + .map(convert); +} diff --git a/lib/services/pick_intent_service.dart b/lib/services/pick_intent_service.dart index 7781b5d..f9e4852 100644 --- a/lib/services/pick_intent_service.dart +++ b/lib/services/pick_intent_service.dart @@ -1,5 +1,6 @@ import 'package:flutter/services.dart'; import '../models/pick_request.dart'; +import 'native_channel.dart'; /// Talks to `MainActivity.kt`'s hand-rolled GET_CONTENT picker handling /// (mirrors `ShareIntentService`'s split between a cold-start check and a @@ -14,7 +15,8 @@ class PickIntentService { /// Non-null if the app was launched cold as another app's file/photo /// picker. static Future getPickRequest() async { - final result = await _methodChannel.invokeMethod>( + final result = await invokeIfAvailable>( + _methodChannel, 'getPickRequest', ); return result == null ? null : PickRequest.fromMap(result); @@ -23,7 +25,8 @@ class PickIntentService { /// Emits whenever the app is asked to act as a picker while already /// running. static Stream get onNewPickRequest { - return _newPickChannel.receiveBroadcastStream().map( + return quietEvents( + _newPickChannel, (event) => PickRequest.fromMap(event as Map), ); } @@ -34,7 +37,7 @@ class PickIntentService { List localPaths, List mimeTypes, ) { - return _methodChannel.invokeMethod('finishPick', { + return invokeIfAvailable(_methodChannel, 'finishPick', { 'paths': localPaths, 'mimeTypes': mimeTypes, }); @@ -42,6 +45,6 @@ class PickIntentService { /// Backs out of picking mode with no result, closing the picker. static Future cancelPick() { - return _methodChannel.invokeMethod('cancelPick'); + return invokeIfAvailable(_methodChannel, 'cancelPick'); } } diff --git a/lib/services/share_intent_service.dart b/lib/services/share_intent_service.dart index 85ec749..272d648 100644 --- a/lib/services/share_intent_service.dart +++ b/lib/services/share_intent_service.dart @@ -1,4 +1,5 @@ import 'package:flutter/services.dart'; +import 'native_channel.dart'; /// A file shared to Noo from another app's "Share to..." sheet, before its /// bytes have been touched - just the `content://` Uri and whatever cheap @@ -40,7 +41,8 @@ class ShareIntentService { /// Whatever was shared to launch the app cold (empty if it was launched /// normally, not via a share). static Future> getInitialShare() async { - final result = await _methodChannel.invokeMethod>( + final result = await invokeIfAvailable>( + _methodChannel, 'getInitialShare', ); return (result ?? []) @@ -51,7 +53,7 @@ class ShareIntentService { /// Emits whenever another share arrives while the app is already running. static Stream> get onNewShare { - return _newShareChannel.receiveBroadcastStream().map((event) { + return quietEvents(_newShareChannel, (event) { return (event as List) .cast>() .map(SharedFileRef.fromMap) diff --git a/lib/services/sync_service.dart b/lib/services/sync_service.dart index aa870be..7c15213 100644 --- a/lib/services/sync_service.dart +++ b/lib/services/sync_service.dart @@ -10,6 +10,7 @@ import '../models/saved_account.dart'; import '../providers/files_controller.dart'; import '../providers/session_controller.dart'; import '../providers/sync_status_controller.dart'; +import 'native_channel.dart'; /// A snapshot of native device-sync status - see `SyncStatusBus.kt` (the /// in-process pub/sub it mirrors) and `MainActivity.kt`'s `syncStatusMap`. @@ -110,7 +111,7 @@ class SyncService { await cancelAccount(account.id); continue; } - await _channel.invokeMethod('reschedule', { + await invokeIfAvailable(_channel, 'reschedule', { 'accountId': account.id, 'serverUrl': account.serverUrl, 'username': account.username, @@ -132,7 +133,7 @@ class SyncService { /// Stops [accountId]'s periodic job (the account was removed, or has /// nothing left to sync). static Future cancelAccount(String accountId) async { - await _channel.invokeMethod('cancel', {'accountId': accountId}); + await invokeIfAvailable(_channel, 'cancel', {'accountId': accountId}); } /// Runs a one-off sync pass immediately, independent of the periodic @@ -158,7 +159,7 @@ class SyncService { } final paths = sync.syncEverything ? ['/'] : sync.syncedPaths; - await _channel.invokeMethod('syncNow', { + await invokeOrExplain(_channel, 'syncNow', 'Syncing', { 'accountId': accountId, 'serverUrl': session.serverUrl, 'username': session.username, @@ -178,7 +179,8 @@ class SyncService { /// app start - the native bus only knows an account once a sync pass has /// run in this process. static Future getStatus(String? accountId) async { - final result = await _channel.invokeMethod>( + final result = await invokeIfAvailable>( + _channel, 'getSyncStatus', {'accountId': accountId}, ); @@ -196,11 +198,10 @@ class SyncService { /// (`SyncStatusController`), so sync badges stopped updating until the app /// was restarted. This is a broadcast stream, so any number of Dart /// listeners share the one native subscription. - static final Stream statusStream = _statusChannel - .receiveBroadcastStream() - .map( - (event) => SyncStatusSnapshot.fromMap(event as Map), - ); + static final Stream statusStream = quietEvents( + _statusChannel, + (event) => SyncStatusSnapshot.fromMap(event as Map), + ); /// In-app conflict resolution (the header's "Keep local"/"Use server" /// buttons) - shares the exact same native `ConflictResolveWorker` @@ -213,7 +214,7 @@ class SyncService { ) async { final authHeader = session.service?.authHeaders['Authorization']; if (authHeader == null) throw Exception('Not logged in.'); - await _channel.invokeMethod('resolveConflict', { + await invokeOrExplain(_channel, 'resolveConflict', 'Resolving conflicts', { 'accountId': conflict.accountId, 'serverUrl': session.serverUrl, 'username': session.username, @@ -236,12 +237,23 @@ class SyncService { ) async { final accountId = session.activeAccountId; if (accountId == null) return; - await _channel.invokeMethod('removeLocalSync', { + await invokeIfAvailable(_channel, 'removeLocalSync', { 'accountId': accountId, 'path': path, }); } + /// Where the `sync//...` mirror lives: Android's app-specific + /// external files dir (what `SyncEngine.kt` writes to); everywhere else - + /// iOS has no external storage, `getExternalStorageDirectory` throws + /// there - the app's private support directory. + static Future baseDirectory() async { + if (defaultTargetPlatform == TargetPlatform.android) { + return getExternalStorageDirectory(); + } + return getApplicationSupportDirectory(); + } + /// The deterministic local mirror path for [remoteItemPath] under /// account [accountId] - mirrors `SyncEngine.kt#syncRoot`'s /// `/sync//...` layout exactly, so this @@ -251,7 +263,7 @@ class SyncService { String accountId, String remoteItemPath, ) async { - final base = await getExternalStorageDirectory(); + final base = await baseDirectory(); if (base == null) return null; final relPath = remoteItemPath.startsWith('/') ? remoteItemPath.substring(1) diff --git a/lib/services/upload_service.dart b/lib/services/upload_service.dart index ebda2c2..9e6c434 100644 --- a/lib/services/upload_service.dart +++ b/lib/services/upload_service.dart @@ -56,7 +56,7 @@ class UploadService { .toList(), ); - await _channel.invokeMethod('startUpload', { + await invokeOrExplain(_channel, 'startUpload', 'Uploading', { ...args, 'files': filesJson, 'remoteFolder': files.currentFolderPath, @@ -70,7 +70,8 @@ class UploadService { /// deliberately: see `SyncService.statusStream`'s identical doc comment - /// `EventChannel.receiveBroadcastStream()` opens its own native /// subscription per call, and the native side only keeps the latest one. - static final Stream completions = _statusChannel - .receiveBroadcastStream() - .map((event) => UploadCompletion.fromMap(event as Map)); + static final Stream completions = quietEvents( + _statusChannel, + (event) => UploadCompletion.fromMap(event as Map), + ); } diff --git a/test/services/native_channel_test.dart b/test/services/native_channel_test.dart new file mode 100644 index 0000000..d4f3f6f --- /dev/null +++ b/test/services/native_channel_test.dart @@ -0,0 +1,67 @@ +import 'dart:async'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:noo/services/native_channel.dart'; +import 'package:noo/services/share_intent_service.dart'; + +/// iOS has no handler for the `dev.ayushya.noo/*` channels yet, so these use +/// channels with nothing registered - the same `MissingPluginException` the +/// simulator showed at startup. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const channel = MethodChannel('test/no_native_side'); + const events = EventChannel('test/no_native_side/events'); + + test( + 'invokeIfAvailable returns null when nothing implements the channel', + () { + expect( + invokeIfAvailable(channel, 'anything'), + completion(isNull), + ); + }, + ); + + test('invokeIfAvailable still returns a real result', () async { + const live = MethodChannel('test/live'); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(live, (call) async => 'pong'); + addTearDown( + () => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(live, null), + ); + expect(await invokeIfAvailable(live, 'ping'), 'pong'); + }); + + test('invokeOrExplain names the feature the user asked for', () { + expect( + invokeOrExplain(channel, 'startUpload', 'Uploading'), + throwsA( + isA().having( + (e) => e.toString(), + 'message', + "Uploading isn't available on this platform yet.", + ), + ), + ); + }); + + test('quietEvents swallows the missing-implementation error', () async { + final errors = []; + final values = []; + final done = Completer(); + quietEvents( + events, + (e) => e as int, + ).listen(values.add, onError: errors.add, onDone: done.complete); + // Give the (failing) native `listen` call time to come back. + await Future.delayed(const Duration(milliseconds: 50)); + expect(errors, isEmpty); + expect(values, isEmpty); + }); + + test('ShareIntentService has nothing to report without a native side', () { + expect(ShareIntentService.getInitialShare(), completion(isEmpty)); + }); +} From 6a1d0eb23e225b515ebca1de8688f1850bc49666 Mon Sep 17 00:00:00 2001 From: Ayushya Amitabh Date: Tue, 6 Oct 2026 19:12:15 -0400 Subject: [PATCH 10/31] iOS: native upload and download services (background URLSession) Swift implementation of the upload_service/download_service channels: background PUT/GET against the account's WebDAV root, one summary local notification per batch, Downloads visible in the Files app. Pure helpers covered by RunnerTests. Co-Authored-By: Claude Sonnet 5.5 --- .claude/context/server.md | 43 +++- ios/Runner.xcodeproj/project.pbxproj | 21 ++ ios/Runner/AppDelegate.swift | 15 ++ ios/Runner/Info.plist | 4 + ios/Runner/Native/NativeServices.swift | 114 +++++++++ ios/Runner/Native/TransferManager.swift | 292 ++++++++++++++++++++++++ ios/Runner/Native/WebDAV.swift | 53 +++++ ios/RunnerTests/RunnerTests.swift | 46 +++- 8 files changed, 579 insertions(+), 9 deletions(-) create mode 100644 ios/Runner/Native/NativeServices.swift create mode 100644 ios/Runner/Native/TransferManager.swift create mode 100644 ios/Runner/Native/WebDAV.swift diff --git a/.claude/context/server.md b/.claude/context/server.md index 1ab5158..17681b4 100644 --- a/.claude/context/server.md +++ b/.claude/context/server.md @@ -816,10 +816,11 @@ files, 15 for folders), keeping the existing reshare bit, plus Remove access. The five `dev.ayushya.noo/*` channels (`share_intent`, `pick_intent`, `upload_service`, `download_service`, `sync_service`, plus their status -`EventChannel`s) are implemented in Kotlin only; iOS has no handler for any -of them yet, so every call there throws `MissingPluginException`. -`lib/services/native_channel.dart` makes that safe until each one is built -in Swift: +`EventChannel`s) were written for Android in Kotlin. iOS implements +`upload_service` (+ its status stream) and `download_service` so far +(`ios/Runner/Native/`, see below); for the other three there's no handler, so +calls throw `MissingPluginException`. `lib/services/native_channel.dart` +makes that safe until each one is built in Swift: - `invokeIfAvailable` - a missing implementation returns null. For cold-start checks (`getInitialShare`, `getPickRequest`, `getSyncStatus`) and @@ -839,6 +840,36 @@ side - the helpers only act on `MissingPluginException`. lives: `getExternalStorageDirectory()` on Android (what `SyncEngine.kt` writes), the app support directory elsewhere, because that call throws on iOS. `OfflineController` and `localSyncedFilePath` both go through it. +### iOS upload/download (`ios/Runner/Native/`) + +- `NativeServices.swift` registers the channels from + `AppDelegate.didInitializeImplicitFlutterEngine` (via + `engineBridge.applicationRegistrar.messenger()`) and forwards + `startUpload`/`startDownload` to `TransferManager`; a batch's completion + goes back over `upload_service/status` so `FilesController` refreshes the + folder like on Android. +- `TransferManager.swift` runs everything on one *background* `URLSession` + (`dev.ayushya.noo.transfers`, `sessionSendsLaunchEvents`), so transfers + finish after the app is suspended/closed; `AppDelegate` hands the system's + `handleEventsForBackgroundURLSession` completion handler to it and calls + `reconnect()` at launch. Each task carries its info in `taskDescription` + and its batch's running totals live in `UserDefaults`, so the one summary + local notification ("Uploaded 3 files") still fires after a relaunch. +- Uploads: one `PUT` per file to `/remote.php/dav/files//...` + from a staged copy in Caches (no chunking, so very large files are bound by + the server's single-request limit; an existing file with the same name is + overwritten). Sends `X-OC-Mtime`. Downloads: `GET` into + `Documents/Downloads`, with `UIFileSharingEnabled` + + `LSSupportsOpeningDocumentsInPlace` set so it shows in the Files app under + "On My iPhone > Noo"; a name clash becomes `a (1).txt`. +- No progress notification (iOS can't update one from a background session + the way Android's foreground service does) and no cancel yet - only the + final summary. The Dart snackbar text still says "see the notification for + progress". +- `WebDAV.swift` (URL building) and `LocalFiles.uniqueURL` are pure and + covered by `ios/RunnerTests` (`xcodebuild test -workspace + ios/Runner.xcworkspace -scheme Runner -destination 'platform=iOS + Simulator,id='`). + Not built on iOS yet: Share Extension (needs an App Group), the picker -(File Provider), background uploads/downloads (`URLSession`), and sync -(`BGTaskScheduler`) - see the iOS handoff notes. +(File Provider), and sync (`BGTaskScheduler`) - see the iOS handoff notes. diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 8da8431..325749d 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -10,12 +10,15 @@ 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 5694337ECF8D3ADD3695CBCD /* NativeServices.swift in Sources */ = {isa = PBXBuildFile; fileRef = 901C0CDB4FB819302E2A7563 /* NativeServices.swift */; }; + 66D3CBDE6B3CD3DC578CECBE /* WebDAV.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F798220CB598B611CA03EFC /* WebDAV.swift */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; }; 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; + FDFA55C4921747A9CBCBBEEC /* TransferManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8064CC1652B8F0A44E371638 /* TransferManager.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -47,11 +50,14 @@ 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 6F798220CB598B611CA03EFC /* WebDAV.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WebDAV.swift; sourceTree = ""; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 8064CC1652B8F0A44E371638 /* TransferManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TransferManager.swift; sourceTree = ""; }; + 901C0CDB4FB819302E2A7563 /* NativeServices.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NativeServices.swift; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -81,6 +87,17 @@ path = RunnerTests; sourceTree = ""; }; + 5055E53DC55C0BCABD5F04F4 /* Native */ = { + isa = PBXGroup; + children = ( + 6F798220CB598B611CA03EFC /* WebDAV.swift */, + 8064CC1652B8F0A44E371638 /* TransferManager.swift */, + 901C0CDB4FB819302E2A7563 /* NativeServices.swift */, + ); + name = Native; + path = Native; + sourceTree = ""; + }; 9740EEB11CF90186004384FC /* Flutter */ = { isa = PBXGroup; children = ( @@ -124,6 +141,7 @@ 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */, 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + 5055E53DC55C0BCABD5F04F4 /* Native */, ); path = Runner; sourceTree = ""; @@ -284,6 +302,9 @@ 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */, + 66D3CBDE6B3CD3DC578CECBE /* WebDAV.swift in Sources */, + FDFA55C4921747A9CBCBBEEC /* TransferManager.swift in Sources */, + 5694337ECF8D3ADD3695CBCD /* NativeServices.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index c30b367..93ed6ed 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -7,10 +7,25 @@ import UIKit _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { + TransferManager.shared.reconnect() return super.application(application, didFinishLaunchingWithOptions: launchOptions) } + override func application( + _ application: UIApplication, + handleEventsForBackgroundURLSession identifier: String, + completionHandler: @escaping () -> Void + ) { + if identifier == TransferManager.sessionIdentifier { + TransferManager.shared.backgroundCompletionHandler = completionHandler + } else { + super.application( + application, handleEventsForBackgroundURLSession: identifier, completionHandler: completionHandler) + } + } + func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) + NativeServices.register(messenger: engineBridge.applicationRegistrar.messenger()) } } diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 89fef25..7f64075 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -53,6 +53,10 @@ LaunchScreen UIMainStoryboardFile Main + UIFileSharingEnabled + + LSSupportsOpeningDocumentsInPlace + NSFaceIDUsageDescription Noo uses Face ID to unlock the app and to protect hidden files. NSPhotoLibraryUsageDescription diff --git a/ios/Runner/Native/NativeServices.swift b/ios/Runner/Native/NativeServices.swift new file mode 100644 index 0000000..cdc24b8 --- /dev/null +++ b/ios/Runner/Native/NativeServices.swift @@ -0,0 +1,114 @@ +import Flutter +import Foundation + +/// Pushes upload-batch completions to Dart's `UploadService.completions`. +final class UploadStatusStreamHandler: NSObject, FlutterStreamHandler { + var sink: FlutterEventSink? + + func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) + -> FlutterError? + { + sink = events + return nil + } + + func onCancel(withArguments arguments: Any?) -> FlutterError? { + sink = nil + return nil + } +} + +/// The iOS side of the `dev.ayushya.noo/*` channels `lib/services/` talks to +/// (Android's lives in `MainActivity.kt`). Only the upload and download +/// services are implemented so far; the rest still throw +/// `MissingPluginException`, which the Dart side treats as "unavailable" +/// (`lib/services/native_channel.dart`). +enum NativeServices { + private static let uploadStatus = UploadStatusStreamHandler() + + static func register(messenger: FlutterBinaryMessenger) { + FlutterMethodChannel(name: "dev.ayushya.noo/upload_service", binaryMessenger: messenger) + .setMethodCallHandler { call, result in + guard call.method == "startUpload" else { return result(FlutterMethodNotImplemented) } + handle(call, result) { args in + let files = try parseJSONList(args["files"]) + try TransferManager.shared.startUpload( + serverUrl: try string(args, "serverUrl"), + username: try string(args, "username"), + authHeader: try string(args, "authHeader"), + files: files.compactMap { entry in + guard let uri = entry["uri"] as? String, let name = entry["name"] as? String else { + return nil + } + return UploadFile(uri: uri, name: name) + }, + remoteFolder: (args["remoteFolder"] as? String) ?? "/" + ) + } + } + + FlutterMethodChannel(name: "dev.ayushya.noo/download_service", binaryMessenger: messenger) + .setMethodCallHandler { call, result in + guard call.method == "startDownload" else { return result(FlutterMethodNotImplemented) } + handle(call, result) { args in + let items = try parseJSONList(args["files"]) + try TransferManager.shared.startDownload( + serverUrl: try string(args, "serverUrl"), + username: try string(args, "username"), + authHeader: try string(args, "authHeader"), + items: items.compactMap { entry in + guard let path = entry["path"] as? String, let name = entry["name"] as? String else { + return nil + } + return (path: path, name: name) + } + ) + } + } + + FlutterEventChannel(name: "dev.ayushya.noo/upload_service/status", binaryMessenger: messenger) + .setStreamHandler(uploadStatus) + TransferManager.shared.onUploadBatchFinished = { folder, succeeded, failed in + uploadStatus.sink?(["remoteFolder": folder, "succeeded": succeeded, "failed": failed]) + } + TransferManager.shared.reconnect() + } + + // MARK: - Argument helpers + + /// Runs [work] with the call's argument map, turning a thrown error into + /// the `FlutterError` Dart's `catch` shows the user. + private static func handle( + _ call: FlutterMethodCall, + _ result: @escaping FlutterResult, + work: ([String: Any]) throws -> Void + ) { + guard let args = call.arguments as? [String: Any] else { + return result(FlutterError(code: "bad_args", message: "Missing arguments.", details: nil)) + } + do { + try work(args) + result(nil) + } catch { + result(FlutterError(code: "transfer_failed", message: error.localizedDescription, details: nil)) + } + } + + private static func string(_ args: [String: Any], _ key: String) throws -> String { + guard let value = args[key] as? String, !value.isEmpty else { + throw TransferError(message: "Missing \(key).") + } + return value + } + + /// The Dart services pass their file list as one JSON string. + private static func parseJSONList(_ value: Any?) throws -> [[String: Any]] { + guard let json = value as? String, + let data = json.data(using: .utf8), + let list = try JSONSerialization.jsonObject(with: data) as? [[String: Any]] + else { + throw TransferError(message: "Missing file list.") + } + return list + } +} diff --git a/ios/Runner/Native/TransferManager.swift b/ios/Runner/Native/TransferManager.swift new file mode 100644 index 0000000..d682b0b --- /dev/null +++ b/ios/Runner/Native/TransferManager.swift @@ -0,0 +1,292 @@ +import Foundation +import UserNotifications + +/// What a background upload/download task is, stored in the task's +/// `taskDescription` so it's still known if the app was relaunched by the +/// system to deliver the result. +struct TransferTaskInfo: Codable { + enum Kind: String, Codable { case upload, download } + + let kind: Kind + let batch: String + let name: String + let remoteFolder: String? + let stagedPath: String? +} + +/// Running totals for one "upload these 3 files" / "download these 2 files" +/// request, persisted so the one summary notification (and the Dart-side +/// "folder changed" event) fires exactly once, on the last file, even across +/// an app relaunch. +struct TransferBatch: Codable { + let kind: TransferTaskInfo.Kind + let remoteFolder: String? + let total: Int + var succeeded: Int + var failed: Int + + var finished: Int { succeeded + failed } +} + +struct UploadFile { + let uri: String + let name: String +} + +struct TransferError: LocalizedError { + let message: String + var errorDescription: String? { message } +} + +/// Uploads and downloads on a background `URLSession`, so a transfer keeps +/// going - and finishes - after the app is suspended or closed. The iOS +/// counterpart of Android's `ShareUploadService`/`DownloadService` +/// foreground services, behind the same Dart channels (`NativeServices`). +/// +/// Uploads are one `PUT` per file straight to the account's WebDAV root +/// (no chunking), from a staged copy in Caches - a background upload task +/// needs a file that stays put, and the picker's temp file may not. Downloads +/// land in `Documents/Downloads`, which is visible in the Files app +/// (`UIFileSharingEnabled` + `LSSupportsOpeningDocumentsInPlace`). +final class TransferManager: NSObject { + static let shared = TransferManager() + static let sessionIdentifier = "dev.ayushya.noo.transfers" + + /// Called on the main thread when an upload batch ends with at least one + /// file uploaded: (remote folder, succeeded, failed). + var onUploadBatchFinished: ((String, Int, Int) -> Void)? + + /// The system's completion handler from + /// `application(_:handleEventsForBackgroundURLSession:)`, called once the + /// session has delivered every pending event. + var backgroundCompletionHandler: (() -> Void)? + + private let defaults = UserDefaults.standard + private let lock = NSLock() + + private lazy var session: URLSession = { + let config = URLSessionConfiguration.background(withIdentifier: Self.sessionIdentifier) + config.sessionSendsLaunchEvents = true + config.isDiscretionary = false + config.waitsForConnectivity = true + return URLSession(configuration: config, delegate: self, delegateQueue: nil) + }() + + /// Touch the session at launch so a system relaunch for finished + /// background transfers reconnects to them. + func reconnect() { + _ = session + } + + // MARK: - Starting transfers + + func startUpload( + serverUrl: String, + username: String, + authHeader: String, + files: [UploadFile], + remoteFolder: String + ) throws { + guard !files.isEmpty else { throw TransferError(message: "No files to upload.") } + let batch = UUID().uuidString + let stageDir = try Self.stagingDirectory().appendingPathComponent(batch, isDirectory: true) + try FileManager.default.createDirectory(at: stageDir, withIntermediateDirectories: true) + + var tasks: [URLSessionUploadTask] = [] + for (index, file) in files.enumerated() { + guard let source = URL(string: file.uri), source.isFileURL else { + throw TransferError(message: "Can't read \(file.name).") + } + let staged = stageDir.appendingPathComponent("\(index)-\(file.name)") + do { + try FileManager.default.copyItem(at: source, to: staged) + } catch { + throw TransferError(message: "Can't read \(file.name): \(error.localizedDescription)") + } + guard let url = WebDAV.fileURL( + serverUrl: serverUrl, + username: username, + remotePath: WebDAV.join(remoteFolder, file.name) + ) else { + throw TransferError(message: "Invalid server address.") + } + var request = URLRequest(url: url) + request.httpMethod = "PUT" + request.setValue(authHeader, forHTTPHeaderField: "Authorization") + if let modified = (try? FileManager.default.attributesOfItem(atPath: staged.path))?[.modificationDate] as? Date { + request.setValue(String(Int(modified.timeIntervalSince1970)), forHTTPHeaderField: "X-OC-Mtime") + } + let task = session.uploadTask(with: request, fromFile: staged) + task.taskDescription = Self.encode( + TransferTaskInfo( + kind: .upload, batch: batch, name: file.name, + remoteFolder: remoteFolder, stagedPath: staged.path + ) + ) + tasks.append(task) + } + saveBatch( + TransferBatch(kind: .upload, remoteFolder: remoteFolder, total: tasks.count, succeeded: 0, failed: 0), + id: batch + ) + tasks.forEach { $0.resume() } + } + + func startDownload( + serverUrl: String, + username: String, + authHeader: String, + items: [(path: String, name: String)] + ) throws { + guard !items.isEmpty else { throw TransferError(message: "No files to download.") } + let batch = UUID().uuidString + var tasks: [URLSessionDownloadTask] = [] + for item in items { + guard let url = WebDAV.fileURL(serverUrl: serverUrl, username: username, remotePath: item.path) else { + throw TransferError(message: "Invalid server address.") + } + var request = URLRequest(url: url) + request.setValue(authHeader, forHTTPHeaderField: "Authorization") + let task = session.downloadTask(with: request) + task.taskDescription = Self.encode( + TransferTaskInfo(kind: .download, batch: batch, name: item.name, remoteFolder: nil, stagedPath: nil) + ) + tasks.append(task) + } + saveBatch( + TransferBatch(kind: .download, remoteFolder: nil, total: tasks.count, succeeded: 0, failed: 0), + id: batch + ) + tasks.forEach { $0.resume() } + } + + // MARK: - Locations + + private static func stagingDirectory() throws -> URL { + let caches = try FileManager.default.url( + for: .cachesDirectory, in: .userDomainMask, appropriateFor: nil, create: true + ) + return caches.appendingPathComponent("uploads", isDirectory: true) + } + + static func downloadsDirectory() throws -> URL { + let documents = try FileManager.default.url( + for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: true + ) + let dir = documents.appendingPathComponent("Downloads", isDirectory: true) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + return dir + } + + // MARK: - Batch bookkeeping + + private static func encode(_ info: TransferTaskInfo) -> String? { + (try? JSONEncoder().encode(info)).flatMap { String(data: $0, encoding: .utf8) } + } + + private static func info(for task: URLSessionTask) -> TransferTaskInfo? { + task.taskDescription + .flatMap { $0.data(using: .utf8) } + .flatMap { try? JSONDecoder().decode(TransferTaskInfo.self, from: $0) } + } + + private func key(_ id: String) -> String { "transfer.batch.\(id)" } + + private func saveBatch(_ batch: TransferBatch, id: String) { + lock.lock() + defer { lock.unlock() } + if let data = try? JSONEncoder().encode(batch) { defaults.set(data, forKey: key(id)) } + } + + /// Counts one file's outcome; fires the batch summary when it was the last. + private func record(_ info: TransferTaskInfo, success: Bool) { + lock.lock() + let storageKey = key(info.batch) + var batch = + (defaults.data(forKey: storageKey).flatMap { try? JSONDecoder().decode(TransferBatch.self, from: $0) }) + ?? TransferBatch(kind: info.kind, remoteFolder: info.remoteFolder, total: 1, succeeded: 0, failed: 0) + if success { batch.succeeded += 1 } else { batch.failed += 1 } + let done = batch.finished >= batch.total + if done { + defaults.removeObject(forKey: storageKey) + } else if let data = try? JSONEncoder().encode(batch) { + defaults.set(data, forKey: storageKey) + } + lock.unlock() + if done { finish(batch) } + } + + private func finish(_ batch: TransferBatch) { + let verb = batch.kind == .upload ? "upload" : "download" + let past = batch.kind == .upload ? "Uploaded" : "Downloaded" + let body: String + if batch.failed == 0 { + body = "\(past) \(Self.files(batch.succeeded))" + } else if batch.succeeded == 0 { + body = "Couldn't \(verb) \(Self.files(batch.failed))" + } else { + body = "\(past) \(Self.files(batch.succeeded)), \(batch.failed) failed" + } + notify(body) + if batch.kind == .upload, batch.succeeded > 0, let folder = batch.remoteFolder { + DispatchQueue.main.async { self.onUploadBatchFinished?(folder, batch.succeeded, batch.failed) } + } + } + + private static func files(_ count: Int) -> String { + count == 1 ? "1 file" : "\(count) files" + } + + private func notify(_ body: String) { + let content = UNMutableNotificationContent() + content.title = "Noo" + content.body = body + content.sound = .default + UNUserNotificationCenter.current().add( + UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil) + ) + } +} + +// MARK: - URLSession delegates + +extension TransferManager: URLSessionDownloadDelegate { + func urlSession( + _ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL + ) { + guard let info = Self.info(for: downloadTask) else { return } + let status = (downloadTask.response as? HTTPURLResponse)?.statusCode ?? 0 + guard (200..<300).contains(status) else { + record(info, success: false) + return + } + do { + let destination = LocalFiles.uniqueURL(in: try Self.downloadsDirectory(), name: info.name) + try FileManager.default.moveItem(at: location, to: destination) + record(info, success: true) + } catch { + record(info, success: false) + } + } + + func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { + guard let info = Self.info(for: task) else { return } + switch info.kind { + case .upload: + if let staged = info.stagedPath { try? FileManager.default.removeItem(atPath: staged) } + let status = (task.response as? HTTPURLResponse)?.statusCode ?? 0 + record(info, success: error == nil && (200..<300).contains(status)) + case .download: + // A finished download was already counted in didFinishDownloadingTo; + // only a transport failure arrives here. + if error != nil { record(info, success: false) } + } + } + + func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) { + DispatchQueue.main.async { + self.backgroundCompletionHandler?() + self.backgroundCompletionHandler = nil + } + } +} diff --git a/ios/Runner/Native/WebDAV.swift b/ios/Runner/Native/WebDAV.swift new file mode 100644 index 0000000..aefbae2 --- /dev/null +++ b/ios/Runner/Native/WebDAV.swift @@ -0,0 +1,53 @@ +import Foundation + +/// URL building for a Nextcloud account's WebDAV files root. Pure functions +/// (no networking), so they're unit-tested in `RunnerTests`. +enum WebDAV { + /// `urlPathAllowed` minus "/", so a file name containing a slash-like + /// character can't introduce an extra path segment. + private static let segmentAllowed: CharacterSet = { + var set = CharacterSet.urlPathAllowed + set.remove(charactersIn: "/") + return set + }() + + static func encodeSegment(_ segment: String) -> String { + segment.addingPercentEncoding(withAllowedCharacters: segmentAllowed) ?? segment + } + + /// `/remote.php/dav/files//` - [remotePath] is + /// relative to the user's root, with or without a leading slash. A server + /// installed under a sub-path (`https://host/nextcloud`) keeps it. + static func fileURL(serverUrl: String, username: String, remotePath: String) -> URL? { + var base = serverUrl + while base.hasSuffix("/") { base.removeLast() } + let segments = remotePath.split(separator: "/").map { encodeSegment(String($0)) } + var url = base + "/remote.php/dav/files/" + encodeSegment(username) + if !segments.isEmpty { url += "/" + segments.joined(separator: "/") } + return URL(string: url) + } + + /// `folder` + `name` with exactly one slash between them. + static func join(_ folder: String, _ name: String) -> String { + folder.hasSuffix("/") ? folder + name : folder + "/" + name + } +} + +enum LocalFiles { + /// A URL in [directory] for [name] that doesn't exist yet: "a.txt", then + /// "a (1).txt", "a (2).txt"... - so a second download of the same file + /// never overwrites the first. + static func uniqueURL(in directory: URL, name: String, fileManager: FileManager = .default) -> URL { + let candidate = directory.appendingPathComponent(name) + if !fileManager.fileExists(atPath: candidate.path) { return candidate } + let ext = (name as NSString).pathExtension + let stem = (name as NSString).deletingPathExtension + var index = 1 + while true { + let numbered = ext.isEmpty ? "\(stem) (\(index))" : "\(stem) (\(index)).\(ext)" + let url = directory.appendingPathComponent(numbered) + if !fileManager.fileExists(atPath: url.path) { return url } + index += 1 + } + } +} diff --git a/ios/RunnerTests/RunnerTests.swift b/ios/RunnerTests/RunnerTests.swift index 86a7c3b..602d74c 100644 --- a/ios/RunnerTests/RunnerTests.swift +++ b/ios/RunnerTests/RunnerTests.swift @@ -2,11 +2,51 @@ import Flutter import UIKit import XCTest +@testable import Runner + class RunnerTests: XCTestCase { - func testExample() { - // If you add code to the Runner application, consider adding tests here. - // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + func testFileURLEncodesEachSegment() { + let url = WebDAV.fileURL( + serverUrl: "https://cloud.example.com", username: "alice", + remotePath: "/Tax Forms/2026 #1/a&b.pdf") + XCTAssertEqual( + url?.absoluteString, + "https://cloud.example.com/remote.php/dav/files/alice/Tax%20Forms/2026%20%231/a&b.pdf") } + func testFileURLKeepsServerSubPathAndDropsTrailingSlash() { + let url = WebDAV.fileURL( + serverUrl: "https://example.com/nextcloud/", username: "bob@home", remotePath: "docs/x.txt") + XCTAssertEqual( + url?.absoluteString, + "https://example.com/nextcloud/remote.php/dav/files/bob@home/docs/x.txt") + } + + func testFileURLForRoot() { + XCTAssertEqual( + WebDAV.fileURL(serverUrl: "https://c.io", username: "u", remotePath: "/")?.absoluteString, + "https://c.io/remote.php/dav/files/u") + } + + func testJoinUsesOneSlash() { + XCTAssertEqual(WebDAV.join("/", "a.txt"), "/a.txt") + XCTAssertEqual(WebDAV.join("/docs", "a.txt"), "/docs/a.txt") + XCTAssertEqual(WebDAV.join("/docs/", "a.txt"), "/docs/a.txt") + } + + func testUniqueURLNumbersCollisions() throws { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("noo-tests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + + XCTAssertEqual(LocalFiles.uniqueURL(in: dir, name: "a.txt").lastPathComponent, "a.txt") + FileManager.default.createFile(atPath: dir.appendingPathComponent("a.txt").path, contents: nil) + XCTAssertEqual(LocalFiles.uniqueURL(in: dir, name: "a.txt").lastPathComponent, "a (1).txt") + FileManager.default.createFile(atPath: dir.appendingPathComponent("a (1).txt").path, contents: nil) + XCTAssertEqual(LocalFiles.uniqueURL(in: dir, name: "a.txt").lastPathComponent, "a (2).txt") + FileManager.default.createFile(atPath: dir.appendingPathComponent("README").path, contents: nil) + XCTAssertEqual(LocalFiles.uniqueURL(in: dir, name: "README").lastPathComponent, "README (1)") + } } From 870ca8ea17bc744033f7cdfca58a0949ee4060aa Mon Sep 17 00:00:00 2001 From: Ayushya Amitabh Date: Tue, 6 Oct 2026 19:15:59 -0400 Subject: [PATCH 11/31] iOS: show transfer notifications as banners while the app is open Co-Authored-By: Claude Sonnet 5.5 --- ios/Runner/AppDelegate.swift | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 93ed6ed..c86e0c2 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -1,5 +1,6 @@ import Flutter import UIKit +import UserNotifications @main @objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { @@ -8,7 +9,20 @@ import UIKit didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { TransferManager.shared.reconnect() - return super.application(application, didFinishLaunchingWithOptions: launchOptions) + let launched = super.application(application, didFinishLaunchingWithOptions: launchOptions) + UNUserNotificationCenter.current().delegate = self + return launched + } + + /// A transfer summary ("Downloaded 1 file") usually finishes while the app + /// is open; without this iOS delivers it silently to Notification Center + /// instead of showing the banner. + override func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void + ) { + completionHandler([.banner, .list, .sound]) } override func application( From 89c3680f32fa41b569af2aa4ab41e92f5022a529 Mon Sep 17 00:00:00 2001 From: Ayushya Amitabh Date: Tue, 6 Oct 2026 19:23:37 -0400 Subject: [PATCH 12/31] iOS: Share Extension (share to Noo) via an App Group inbox ShareExtension target copies shared files into group.dev.ayushya.noo and leaves a manifest; the app consumes it on launch/activation through the existing share_intent channel into the destination picker. Extension is embedded before Flutter's script phases; versions follow FLUTTER_BUILD_*. Co-Authored-By: Claude Sonnet 5.5 --- .claude/context/server.md | 45 +++- ios/Runner.xcodeproj/project.pbxproj | 218 ++++++++++++++++++ ios/Runner/Native/NativeServices.swift | 56 ++++- ios/Runner/Native/TransferManager.swift | 5 + ios/Runner/Runner.entitlements | 10 + ios/RunnerTests/RunnerTests.swift | 48 ++++ ios/ShareExtension/Info.plist | 43 ++++ .../ShareExtension.entitlements | 10 + ios/ShareExtension/ShareViewController.swift | 122 ++++++++++ ios/Shared/SharedInbox.swift | 92 ++++++++ 10 files changed, 637 insertions(+), 12 deletions(-) create mode 100644 ios/Runner/Runner.entitlements create mode 100644 ios/ShareExtension/Info.plist create mode 100644 ios/ShareExtension/ShareExtension.entitlements create mode 100644 ios/ShareExtension/ShareViewController.swift create mode 100644 ios/Shared/SharedInbox.swift diff --git a/.claude/context/server.md b/.claude/context/server.md index 17681b4..6cc1370 100644 --- a/.claude/context/server.md +++ b/.claude/context/server.md @@ -817,9 +817,10 @@ files, 15 for folders), keeping the existing reshare bit, plus Remove access. The five `dev.ayushya.noo/*` channels (`share_intent`, `pick_intent`, `upload_service`, `download_service`, `sync_service`, plus their status `EventChannel`s) were written for Android in Kotlin. iOS implements -`upload_service` (+ its status stream) and `download_service` so far -(`ios/Runner/Native/`, see below); for the other three there's no handler, so -calls throw `MissingPluginException`. `lib/services/native_channel.dart` +`upload_service` (+ its status stream), `download_service` and +`share_intent` so far (`ios/Runner/Native/` + `ios/ShareExtension/`, see +below); for `pick_intent` and `sync_service` there's no handler, so calls +throw `MissingPluginException`. `lib/services/native_channel.dart` makes that safe until each one is built in Swift: - `invokeIfAvailable` - a missing implementation returns null. For cold-start @@ -871,5 +872,39 @@ iOS. `OfflineController` and `localSyncedFilePath` both go through it. ios/Runner.xcworkspace -scheme Runner -destination 'platform=iOS Simulator,id='`). -Not built on iOS yet: Share Extension (needs an App Group), the picker -(File Provider), and sync (`BGTaskScheduler`) - see the iOS handoff notes. +### iOS Share Extension (`ios/ShareExtension/`, `ios/Shared/`) + +"Share to Noo" from any app's share sheet. An extension can't pick a +destination, hold a session, or launch its host app, so it only *hands off*: + +1. `ShareViewController` (extension) copies each shared file + (`NSItemProvider.loadFileRepresentation`, public.item) into the App Group + container (`group.dev.ayushya.noo`) under `SharedInbox//`, appends + them to `SharedInbox/pending.json`, and posts a local notification ("Open + Noo to choose where to upload N files") since it can't open the app. +2. The app reads that manifest through `SharedInbox.consume` - once on cold + start via `share_intent.getInitialShare`, and whenever it becomes active + (`UIApplication.didBecomeActiveNotification`) via the `share_intent/new` + event stream - and from there it's the same Dart flow as an Android + share: `main.dart`'s `_handleSharedFiles` -> `ShareUploadView` -> + `UploadService`. The `file://` URI points into the App Group container; + `TransferManager.startUpload` deletes that copy once it has staged it. +3. `ios/Shared/SharedInbox.swift` is compiled into both targets (manifest + read/append/consume under an `NSFileCoordinator`, entries whose file is + gone are dropped, each share is delivered once) and unit-tested in + `RunnerTests`. + +Both targets carry `group.dev.ayushya.noo` in their `.entitlements` +(`Runner/Runner.entitlements`, `ShareExtension/ShareExtension.entitlements`); +the extension's bundle id is `dev.ayushya.noo.ShareExtension`, and its +version/build come from the same `FLUTTER_BUILD_NAME`/`FLUTTER_BUILD_NUMBER` +xcconfig as the app (iOS rejects a mismatch). The extension is embedded via an +"Embed Foundation Extensions" phase placed *before* Flutter's script phases +(after them Xcode reports a dependency cycle). The simulator honours the App +Group without a signing team; a real device needs a team that owns the group +id and both bundle ids registered. To try it without the share sheet, put a +file and a `pending.json` in the simulator's group container +(`xcrun simctl get_app_container dev.ayushya.noo groups`) and launch. + +Not built on iOS yet: the picker (File Provider) and sync +(`BGTaskScheduler`) - see the iOS handoff notes. diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 325749d..080248d 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -8,16 +8,21 @@ /* Begin PBXBuildFile section */ 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 223784E5DAE5A653F0F61771 /* ShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 405A5408F4071B4D12F08B21 /* ShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; + 33B3C3BED4C1F26439DD6825 /* SharedInbox.swift in Sources */ = {isa = PBXBuildFile; fileRef = 836D37F2376AFCE0EFDD30A7 /* SharedInbox.swift */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 5694337ECF8D3ADD3695CBCD /* NativeServices.swift in Sources */ = {isa = PBXBuildFile; fileRef = 901C0CDB4FB819302E2A7563 /* NativeServices.swift */; }; + 63229CACE4281880037D33E6 /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27BDF61F5197F9B8F648E8AE /* ShareViewController.swift */; }; 66D3CBDE6B3CD3DC578CECBE /* WebDAV.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F798220CB598B611CA03EFC /* WebDAV.swift */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; }; 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; + 91C1658DC7467CACB2AAC7FF /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0AE6F681A3100A461B4A5318 /* Foundation.framework */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; + F8D51B4045340275FBD12D16 /* SharedInbox.swift in Sources */ = {isa = PBXBuildFile; fileRef = 836D37F2376AFCE0EFDD30A7 /* SharedInbox.swift */; }; FDFA55C4921747A9CBCBBEEC /* TransferManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8064CC1652B8F0A44E371638 /* TransferManager.swift */; }; /* End PBXBuildFile section */ @@ -29,9 +34,27 @@ remoteGlobalIDString = 97C146ED1CF9000F007C117D; remoteInfo = Runner; }; + 850C66E713F7C56BDA822FB7 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = F9BE9B2F18B10324D7B84776; + remoteInfo = ShareExtension; + }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ + 201269C4EFA9E5FDBC74F86F /* Embed Foundation Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + 223784E5DAE5A653F0F61771 /* ShareExtension.appex in Embed Foundation Extensions */, + ); + name = "Embed Foundation Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; 9705A1C41CF9048500538489 /* Embed Frameworks */ = { isa = PBXCopyFilesBuildPhase; buildActionMask = 2147483647; @@ -45,11 +68,16 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 0AE6F681A3100A461B4A5318 /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; }; 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 27BDF61F5197F9B8F648E8AE /* ShareViewController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = ""; }; 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 405A5408F4071B4D12F08B21 /* ShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = ShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + 49213989F0FA1B83B0B8A523 /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 65F57A1EB0C62774E561860D /* ShareExtension.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = ShareExtension.entitlements; sourceTree = ""; }; 6F798220CB598B611CA03EFC /* WebDAV.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WebDAV.swift; sourceTree = ""; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; @@ -57,6 +85,8 @@ 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; 8064CC1652B8F0A44E371638 /* TransferManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TransferManager.swift; sourceTree = ""; }; + 836D37F2376AFCE0EFDD30A7 /* SharedInbox.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SharedInbox.swift; sourceTree = ""; }; + 857A0E7637776A71AFBB0614 /* Runner.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = ""; }; 901C0CDB4FB819302E2A7563 /* NativeServices.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NativeServices.swift; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; @@ -68,6 +98,14 @@ /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ + 807C81744907462A25A6C1F8 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 91C1658DC7467CACB2AAC7FF /* Foundation.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 97C146EB1CF9000F007C117D /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -79,6 +117,14 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 2B7F16B0F0EA2C5A842ED860 /* iOS */ = { + isa = PBXGroup; + children = ( + 0AE6F681A3100A461B4A5318 /* Foundation.framework */, + ); + name = iOS; + sourceTree = ""; + }; 331C8082294A63A400263BE5 /* RunnerTests */ = { isa = PBXGroup; children = ( @@ -87,6 +133,17 @@ path = RunnerTests; sourceTree = ""; }; + 3664F08C433ECEB00D1C4BDB /* ShareExtension */ = { + isa = PBXGroup; + children = ( + 27BDF61F5197F9B8F648E8AE /* ShareViewController.swift */, + 49213989F0FA1B83B0B8A523 /* Info.plist */, + 65F57A1EB0C62774E561860D /* ShareExtension.entitlements */, + ); + name = ShareExtension; + path = ShareExtension; + sourceTree = ""; + }; 5055E53DC55C0BCABD5F04F4 /* Native */ = { isa = PBXGroup; children = ( @@ -98,6 +155,15 @@ path = Native; sourceTree = ""; }; + 5DF0A9032A8500B67D6C89AE /* Shared */ = { + isa = PBXGroup; + children = ( + 836D37F2376AFCE0EFDD30A7 /* SharedInbox.swift */, + ); + name = Shared; + path = Shared; + sourceTree = ""; + }; 9740EEB11CF90186004384FC /* Flutter */ = { isa = PBXGroup; children = ( @@ -117,6 +183,9 @@ 97C146F01CF9000F007C117D /* Runner */, 97C146EF1CF9000F007C117D /* Products */, 331C8082294A63A400263BE5 /* RunnerTests */, + 5DF0A9032A8500B67D6C89AE /* Shared */, + 3664F08C433ECEB00D1C4BDB /* ShareExtension */, + A5B80AB7F4AB17F90B745D0D /* Frameworks */, ); sourceTree = ""; }; @@ -125,6 +194,7 @@ children = ( 97C146EE1CF9000F007C117D /* Runner.app */, 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + 405A5408F4071B4D12F08B21 /* ShareExtension.appex */, ); name = Products; sourceTree = ""; @@ -142,10 +212,19 @@ 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */, 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, 5055E53DC55C0BCABD5F04F4 /* Native */, + 857A0E7637776A71AFBB0614 /* Runner.entitlements */, ); path = Runner; sourceTree = ""; }; + A5B80AB7F4AB17F90B745D0D /* Frameworks */ = { + isa = PBXGroup; + children = ( + 2B7F16B0F0EA2C5A842ED860 /* iOS */, + ); + name = Frameworks; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -170,6 +249,7 @@ isa = PBXNativeTarget; buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( + 201269C4EFA9E5FDBC74F86F /* Embed Foundation Extensions */, 9740EEB61CF901F6004384FC /* Run Script */, 97C146EA1CF9000F007C117D /* Sources */, 97C146EB1CF9000F007C117D /* Frameworks */, @@ -180,6 +260,7 @@ buildRules = ( ); dependencies = ( + 6E0BAC83BFDF8623DBFB9D75 /* PBXTargetDependency */, ); name = Runner; packageProductDependencies = ( @@ -189,6 +270,23 @@ productReference = 97C146EE1CF9000F007C117D /* Runner.app */; productType = "com.apple.product-type.application"; }; + F9BE9B2F18B10324D7B84776 /* ShareExtension */ = { + isa = PBXNativeTarget; + buildConfigurationList = E1076120E395D274D37607ED /* Build configuration list for PBXNativeTarget "ShareExtension" */; + buildPhases = ( + 3CD40C8747E650E6F817CDDB /* Sources */, + 807C81744907462A25A6C1F8 /* Frameworks */, + 4EDBC7D61EA0C0974DA27BD6 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = ShareExtension; + productName = ShareExtension; + productReference = 405A5408F4071B4D12F08B21 /* ShareExtension.appex */; + productType = "com.apple.product-type.app-extension"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -227,6 +325,7 @@ targets = ( 97C146ED1CF9000F007C117D /* Runner */, 331C8080294A63A400263BE5 /* RunnerTests */, + F9BE9B2F18B10324D7B84776 /* ShareExtension */, ); }; /* End PBXProject section */ @@ -239,6 +338,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 4EDBC7D61EA0C0974DA27BD6 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 97C146EC1CF9000F007C117D /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -295,6 +401,15 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 3CD40C8747E650E6F817CDDB /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 63229CACE4281880037D33E6 /* ShareViewController.swift in Sources */, + 33B3C3BED4C1F26439DD6825 /* SharedInbox.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 97C146EA1CF9000F007C117D /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -305,6 +420,7 @@ 66D3CBDE6B3CD3DC578CECBE /* WebDAV.swift in Sources */, FDFA55C4921747A9CBCBBEEC /* TransferManager.swift in Sources */, 5694337ECF8D3ADD3695CBCD /* NativeServices.swift in Sources */, + F8D51B4045340275FBD12D16 /* SharedInbox.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -316,6 +432,12 @@ target = 97C146ED1CF9000F007C117D /* Runner */; targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; }; + 6E0BAC83BFDF8623DBFB9D75 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + name = ShareExtension; + target = F9BE9B2F18B10324D7B84776 /* ShareExtension */; + targetProxy = 850C66E713F7C56BDA822FB7 /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin PBXVariantGroup section */ @@ -396,6 +518,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -458,6 +581,89 @@ }; name = Profile; }; + 3B057BF66761E30DEABF8B30 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CLANG_ENABLE_OBJC_WEAK = NO; + CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = ShareExtension/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)"; + PRODUCT_BUNDLE_IDENTIFIER = dev.ayushya.noo.ShareExtension; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + 6BF252CEA7A55024A2F58A36 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CLANG_ENABLE_OBJC_WEAK = NO; + CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = ShareExtension/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)"; + PRODUCT_BUNDLE_IDENTIFIER = dev.ayushya.noo.ShareExtension; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 6FB3F72FB5A94DE254FF4FBF /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CLANG_ENABLE_OBJC_WEAK = NO; + CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = ShareExtension/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)"; + PRODUCT_BUNDLE_IDENTIFIER = dev.ayushya.noo.ShareExtension; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; 97C147031CF9000F007C117D /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -575,6 +781,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -597,6 +804,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -645,6 +853,16 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + E1076120E395D274D37607ED /* Build configuration list for PBXNativeTarget "ShareExtension" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 6BF252CEA7A55024A2F58A36 /* Release */, + 6FB3F72FB5A94DE254FF4FBF /* Debug */, + 3B057BF66761E30DEABF8B30 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ /* Begin XCLocalSwiftPackageReference section */ diff --git a/ios/Runner/Native/NativeServices.swift b/ios/Runner/Native/NativeServices.swift index cdc24b8..6430998 100644 --- a/ios/Runner/Native/NativeServices.swift +++ b/ios/Runner/Native/NativeServices.swift @@ -1,8 +1,10 @@ import Flutter import Foundation +import UIKit -/// Pushes upload-batch completions to Dart's `UploadService.completions`. -final class UploadStatusStreamHandler: NSObject, FlutterStreamHandler { +/// Holds the one native-side listener of an `EventChannel` so Swift code can +/// push events to Dart whenever they happen. +final class SinkStreamHandler: NSObject, FlutterStreamHandler { var sink: FlutterEventSink? func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) @@ -19,12 +21,12 @@ final class UploadStatusStreamHandler: NSObject, FlutterStreamHandler { } /// The iOS side of the `dev.ayushya.noo/*` channels `lib/services/` talks to -/// (Android's lives in `MainActivity.kt`). Only the upload and download -/// services are implemented so far; the rest still throw -/// `MissingPluginException`, which the Dart side treats as "unavailable" -/// (`lib/services/native_channel.dart`). +/// (Android's lives in `MainActivity.kt`). Upload, download and share (the +/// receiving half of the Share Extension) are implemented so far; the rest +/// still throw `MissingPluginException`, which the Dart side treats as +/// "unavailable" (`lib/services/native_channel.dart`). enum NativeServices { - private static let uploadStatus = UploadStatusStreamHandler() + private static let uploadStatus = SinkStreamHandler() static func register(messenger: FlutterBinaryMessenger) { FlutterMethodChannel(name: "dev.ayushya.noo/upload_service", binaryMessenger: messenger) @@ -72,6 +74,46 @@ enum NativeServices { uploadStatus.sink?(["remoteFolder": folder, "succeeded": succeeded, "failed": failed]) } TransferManager.shared.reconnect() + registerShare(messenger: messenger) + } + + // MARK: - Share Extension inbox + + private static let shareEvents = SinkStreamHandler() + + /// `share_intent`: files the Share Extension left in the App Group inbox. + /// Cold start asks once (`getInitialShare`); a share that arrives while the + /// app is running is pushed when it next becomes active (the extension + /// can't signal the app any other way). + private static func registerShare(messenger: FlutterBinaryMessenger) { + FlutterMethodChannel(name: "dev.ayushya.noo/share_intent", binaryMessenger: messenger) + .setMethodCallHandler { call, result in + guard call.method == "getInitialShare" else { return result(FlutterMethodNotImplemented) } + result(consumeShared().map(encode)) + } + FlutterEventChannel(name: "dev.ayushya.noo/share_intent/new", binaryMessenger: messenger) + .setStreamHandler(shareEvents) + NotificationCenter.default.addObserver( + forName: UIApplication.didBecomeActiveNotification, object: nil, queue: .main + ) { _ in + guard let sink = shareEvents.sink else { return } + let items = consumeShared() + if !items.isEmpty { sink(items.map(encode)) } + } + } + + private static func consumeShared() -> [SharedItem] { + guard let root = AppGroup.containerURL else { return [] } + return SharedInbox.consume(in: root) + } + + private static func encode(_ item: SharedItem) -> [String: Any] { + [ + "uri": URL(fileURLWithPath: item.path).absoluteString, + "name": item.name, + "mimeType": item.mimeType ?? NSNull(), + "size": item.size, + ] } // MARK: - Argument helpers diff --git a/ios/Runner/Native/TransferManager.swift b/ios/Runner/Native/TransferManager.swift index d682b0b..85c4a1d 100644 --- a/ios/Runner/Native/TransferManager.swift +++ b/ios/Runner/Native/TransferManager.swift @@ -100,6 +100,11 @@ final class TransferManager: NSObject { let staged = stageDir.appendingPathComponent("\(index)-\(file.name)") do { try FileManager.default.copyItem(at: source, to: staged) + // A file the Share Extension left in the App Group is ours to clean + // up once it's staged; a picker's temp file isn't. + if source.path.contains("/\(SharedInbox.folderName)/") { + try? FileManager.default.removeItem(at: source) + } } catch { throw TransferError(message: "Can't read \(file.name): \(error.localizedDescription)") } diff --git a/ios/Runner/Runner.entitlements b/ios/Runner/Runner.entitlements new file mode 100644 index 0000000..cd9db7f --- /dev/null +++ b/ios/Runner/Runner.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.dev.ayushya.noo + + + diff --git a/ios/RunnerTests/RunnerTests.swift b/ios/RunnerTests/RunnerTests.swift index 602d74c..fca6dfd 100644 --- a/ios/RunnerTests/RunnerTests.swift +++ b/ios/RunnerTests/RunnerTests.swift @@ -49,4 +49,52 @@ class RunnerTests: XCTestCase { FileManager.default.createFile(atPath: dir.appendingPathComponent("README").path, contents: nil) XCTAssertEqual(LocalFiles.uniqueURL(in: dir, name: "README").lastPathComponent, "README (1)") } + + // MARK: - SharedInbox (Share Extension hand-off) + + private func makeRoot() throws -> URL { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("noo-inbox-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + addTeardownBlock { try? FileManager.default.removeItem(at: root) } + return root + } + + private func makeSharedFile(in root: URL, name: String, contents: String = "hi") throws -> SharedItem { + let batch = try SharedInbox.makeBatchDirectory(in: root) + let file = batch.appendingPathComponent(name) + try contents.write(to: file, atomically: true, encoding: .utf8) + return SharedItem(path: file.path, name: name, mimeType: "text/plain", size: Int64(contents.utf8.count)) + } + + func testInboxConsumeReturnsAppendedItemsOnceThenNothing() throws { + let root = try makeRoot() + let a = try makeSharedFile(in: root, name: "a.txt") + let b = try makeSharedFile(in: root, name: "b.txt") + try SharedInbox.append([a], in: root) + try SharedInbox.append([b], in: root) + + XCTAssertEqual(SharedInbox.consume(in: root), [a, b]) + XCTAssertEqual(SharedInbox.consume(in: root), [], "each share is delivered once") + } + + func testInboxConsumeDropsFilesThatNoLongerExist() throws { + let root = try makeRoot() + let kept = try makeSharedFile(in: root, name: "kept.txt") + let gone = try makeSharedFile(in: root, name: "gone.txt") + try SharedInbox.append([kept, gone], in: root) + try FileManager.default.removeItem(atPath: gone.path) + + XCTAssertEqual(SharedInbox.consume(in: root), [kept]) + } + + func testInboxConsumeWithNothingPendingIsEmpty() throws { + XCTAssertEqual(SharedInbox.consume(in: try makeRoot()), []) + } + + func testInboxAppendOfNothingCreatesNoManifest() throws { + let root = try makeRoot() + try SharedInbox.append([], in: root) + XCTAssertEqual(SharedInbox.consume(in: root), []) + } } diff --git a/ios/ShareExtension/Info.plist b/ios/ShareExtension/Info.plist new file mode 100644 index 0000000..4c57b84 --- /dev/null +++ b/ios/ShareExtension/Info.plist @@ -0,0 +1,43 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Noo + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + NSExtension + + NSExtensionAttributes + + NSExtensionActivationRule + + NSExtensionActivationSupportsFileWithMaxCount + 50 + NSExtensionActivationSupportsImageWithMaxCount + 50 + NSExtensionActivationSupportsMovieWithMaxCount + 50 + + + NSExtensionPointIdentifier + com.apple.share-services + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).ShareViewController + + + diff --git a/ios/ShareExtension/ShareExtension.entitlements b/ios/ShareExtension/ShareExtension.entitlements new file mode 100644 index 0000000..cd9db7f --- /dev/null +++ b/ios/ShareExtension/ShareExtension.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.dev.ayushya.noo + + + diff --git a/ios/ShareExtension/ShareViewController.swift b/ios/ShareExtension/ShareViewController.swift new file mode 100644 index 0000000..d482f04 --- /dev/null +++ b/ios/ShareExtension/ShareViewController.swift @@ -0,0 +1,122 @@ +import UIKit +import UniformTypeIdentifiers +import UserNotifications + +/// "Share to Noo": copies whatever was shared into the App Group container +/// (`SharedInbox`) and finishes. The extension can't pick a destination or +/// upload - it has no session and a tight memory/time budget - so the app +/// does that when it next opens, through its existing destination picker. A +/// notification tells the user to open it, because an extension can't launch +/// its host app. +final class ShareViewController: UIViewController { + private let label = UILabel() + private let spinner = UIActivityIndicatorView(style: .large) + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .systemBackground + label.text = "Saving to Noo…" + label.font = .preferredFont(forTextStyle: .headline) + label.textAlignment = .center + label.numberOfLines = 0 + spinner.startAnimating() + + let stack = UIStackView(arrangedSubviews: [spinner, label]) + stack.axis = .vertical + stack.spacing = 16 + stack.alignment = .center + stack.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(stack) + NSLayoutConstraint.activate([ + stack.centerXAnchor.constraint(equalTo: view.centerXAnchor), + stack.centerYAnchor.constraint(equalTo: view.centerYAnchor), + stack.leadingAnchor.constraint(greaterThanOrEqualTo: view.leadingAnchor, constant: 24), + stack.trailingAnchor.constraint(lessThanOrEqualTo: view.trailingAnchor, constant: -24), + ]) + + Task { await importSharedItems() } + } + + private func importSharedItems() async { + guard let root = AppGroup.containerURL else { + return finish(message: "Noo isn't set up to receive files yet.", saved: 0) + } + let providers = (extensionContext?.inputItems as? [NSExtensionItem])? + .flatMap { $0.attachments ?? [] } ?? [] + + var saved: [SharedItem] = [] + do { + let batch = try SharedInbox.makeBatchDirectory(in: root) + for provider in providers where provider.hasItemConformingToTypeIdentifier(UTType.item.identifier) { + if let item = try? await copy(provider, into: batch) { saved.append(item) } + } + try SharedInbox.append(saved, in: root) + } catch { + return finish(message: "Couldn't save to Noo: \(error.localizedDescription)", saved: 0) + } + + if saved.isEmpty { + finish(message: "Noo can only receive files and photos.", saved: 0) + } else { + notify(count: saved.count) + finish(message: saved.count == 1 ? "Saved 1 file to Noo" : "Saved \(saved.count) files to Noo", saved: saved.count) + } + } + + /// `loadFileRepresentation`'s URL only lives for the duration of its + /// callback, so the copy happens inside it. + private func copy(_ provider: NSItemProvider, into directory: URL) async throws -> SharedItem { + try await withCheckedThrowingContinuation { continuation in + provider.loadFileRepresentation(forTypeIdentifier: UTType.item.identifier) { url, error in + guard let url else { + return continuation.resume(throwing: error ?? CocoaError(.fileReadUnknown)) + } + do { + let destination = Self.uniqueURL(in: directory, name: url.lastPathComponent) + try FileManager.default.copyItem(at: url, to: destination) + let size = (try? FileManager.default.attributesOfItem(atPath: destination.path))?[.size] as? Int64 ?? 0 + let mime = UTType(filenameExtension: destination.pathExtension)?.preferredMIMEType + continuation.resume( + returning: SharedItem( + path: destination.path, name: destination.lastPathComponent, mimeType: mime, size: size)) + } catch { + continuation.resume(throwing: error) + } + } + } + } + + private static func uniqueURL(in directory: URL, name: String) -> URL { + var candidate = directory.appendingPathComponent(name) + var index = 1 + while FileManager.default.fileExists(atPath: candidate.path) { + let ext = (name as NSString).pathExtension + let stem = (name as NSString).deletingPathExtension + candidate = directory.appendingPathComponent(ext.isEmpty ? "\(stem) (\(index))" : "\(stem) (\(index)).\(ext)") + index += 1 + } + return candidate + } + + private func notify(count: Int) { + let content = UNMutableNotificationContent() + content.title = "Noo" + content.body = + count == 1 + ? "Open Noo to choose where to upload 1 file." + : "Open Noo to choose where to upload \(count) files." + content.sound = .default + UNUserNotificationCenter.current().add( + UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)) + } + + private func finish(message: String, saved: Int) { + DispatchQueue.main.async { + self.spinner.stopAnimating() + self.label.text = message + DispatchQueue.main.asyncAfter(deadline: .now() + (saved > 0 ? 1.2 : 2.5)) { + self.extensionContext?.completeRequest(returningItems: nil) + } + } + } +} diff --git a/ios/Shared/SharedInbox.swift b/ios/Shared/SharedInbox.swift new file mode 100644 index 0000000..0082cba --- /dev/null +++ b/ios/Shared/SharedInbox.swift @@ -0,0 +1,92 @@ +import Foundation + +/// The App Group both the app and its Share Extension belong to - the one +/// place they can exchange files. +enum AppGroup { + static let identifier = "group.dev.ayushya.noo" + + static var containerURL: URL? { + FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: identifier) + } +} + +/// One file the Share Extension copied into the group container. +struct SharedItem: Codable, Equatable { + let path: String + let name: String + let mimeType: String? + let size: Int64 +} + +/// Files the Share Extension received, waiting for the app to ask the user +/// where to upload them. The extension appends; the app consumes (reads and +/// clears) when it launches or becomes active. Compiled into both targets. +/// +/// Layout under `/SharedInbox/`: `pending.json` (the manifest) and one +/// `/` folder per share holding the copied files. +enum SharedInbox { + static let folderName = "SharedInbox" + private static let manifestName = "pending.json" + + static func directory(in root: URL) -> URL { + root.appendingPathComponent(folderName, isDirectory: true) + } + + /// A fresh folder for one share's files. + static func makeBatchDirectory(in root: URL) throws -> URL { + let dir = directory(in: root).appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + return dir + } + + static func append(_ items: [SharedItem], in root: URL) throws { + guard !items.isEmpty else { return } + try withManifestLock(in: root) { + let existing = (try? readManifest(in: root)) ?? [] + try writeManifest(existing + items, in: root) + } + } + + /// Everything pending, dropping entries whose file has gone missing, and + /// clears the manifest so each share is delivered once. + static func consume(in root: URL) -> [SharedItem] { + var items: [SharedItem] = [] + try? withManifestLock(in: root) { + items = ((try? readManifest(in: root)) ?? []).filter { + FileManager.default.fileExists(atPath: $0.path) + } + try? FileManager.default.removeItem(at: manifestURL(in: root)) + } + return items + } + + private static func manifestURL(in root: URL) -> URL { + directory(in: root).appendingPathComponent(manifestName) + } + + private static func readManifest(in root: URL) throws -> [SharedItem] { + let data = try Data(contentsOf: manifestURL(in: root)) + return try JSONDecoder().decode([SharedItem].self, from: data) + } + + private static func writeManifest(_ items: [SharedItem], in root: URL) throws { + try FileManager.default.createDirectory( + at: directory(in: root), withIntermediateDirectories: true) + try JSONEncoder().encode(items).write(to: manifestURL(in: root), options: .atomic) + } + + /// The extension and the app are separate processes that can both touch the + /// manifest, so read-modify-write goes through a file coordinator. + private static func withManifestLock(in root: URL, _ body: () throws -> Void) throws { + try FileManager.default.createDirectory( + at: directory(in: root), withIntermediateDirectories: true) + var coordinationError: NSError? + var thrown: Error? + NSFileCoordinator().coordinate( + writingItemAt: directory(in: root), options: [], error: &coordinationError + ) { _ in + do { try body() } catch { thrown = error } + } + if let error = coordinationError ?? thrown.map({ $0 as NSError }) { throw error } + } +} From 8628f72c9dc41ae58ca55ffe01d617531056c6e4 Mon Sep 17 00:00:00 2001 From: Ayushya Amitabh Date: Tue, 6 Oct 2026 19:36:04 -0400 Subject: [PATCH 13/31] iOS Share Extension: pick the destination inside the share sheet SwiftUI folder picker in the extension (WebDAV PROPFIND), account shared via a Keychain group, upload on a background session that outlives the extension and is finished by the app. The inbox + notification hand-off remains as the fallback (no account / 'choose later'). Shared code moves to ios/Shared. Co-Authored-By: Claude Sonnet 5.5 --- .claude/context/server.md | 73 +++++--- ios/Runner.xcodeproj/project.pbxproj | 38 +++- ios/Runner/AppDelegate.swift | 2 +- ios/Runner/Info.plist | 2 + ios/Runner/Native/NativeServices.swift | 29 +++ ios/Runner/Native/TransferManager.swift | 71 ++------ ios/Runner/Runner.entitlements | 4 + ios/RunnerTests/RunnerTests.swift | 88 +++++++++ ios/ShareExtension/Info.plist | 2 + .../ShareExtension.entitlements | 4 + ios/ShareExtension/SharePickerView.swift | 168 ++++++++++++++++++ ios/ShareExtension/ShareViewController.swift | 144 +++++++++------ ios/Shared/DavFolders.swift | 120 +++++++++++++ ios/Shared/ShareUpload.swift | 73 ++++++++ ios/Shared/SharedAccount.swift | 68 +++++++ ios/Shared/TransferTypes.swift | 67 +++++++ ios/{Runner/Native => Shared}/WebDAV.swift | 0 lib/main.dart | 12 +- lib/services/share_account_service.dart | 30 ++++ 19 files changed, 841 insertions(+), 154 deletions(-) create mode 100644 ios/ShareExtension/SharePickerView.swift create mode 100644 ios/Shared/DavFolders.swift create mode 100644 ios/Shared/ShareUpload.swift create mode 100644 ios/Shared/SharedAccount.swift create mode 100644 ios/Shared/TransferTypes.swift rename ios/{Runner/Native => Shared}/WebDAV.swift (100%) create mode 100644 lib/services/share_account_service.dart diff --git a/.claude/context/server.md b/.claude/context/server.md index 6cc1370..2199dc6 100644 --- a/.claude/context/server.md +++ b/.claude/context/server.md @@ -874,37 +874,52 @@ iOS. `OfflineController` and `localSyncedFilePath` both go through it. ### iOS Share Extension (`ios/ShareExtension/`, `ios/Shared/`) -"Share to Noo" from any app's share sheet. An extension can't pick a -destination, hold a session, or launch its host app, so it only *hands off*: +"Share to Noo" from any app's share sheet. A Share Extension can't open its +host app (`extensionContext.open` is Today-widget-only), so - like +Reminders/Notes - the destination is picked *inside* the sheet: -1. `ShareViewController` (extension) copies each shared file - (`NSItemProvider.loadFileRepresentation`, public.item) into the App Group - container (`group.dev.ayushya.noo`) under `SharedInbox//`, appends - them to `SharedInbox/pending.json`, and posts a local notification ("Open - Noo to choose where to upload N files") since it can't open the app. -2. The app reads that manifest through `SharedInbox.consume` - once on cold - start via `share_intent.getInitialShare`, and whenever it becomes active - (`UIApplication.didBecomeActiveNotification`) via the `share_intent/new` - event stream - and from there it's the same Dart flow as an Android - share: `main.dart`'s `_handleSharedFiles` -> `ShareUploadView` -> - `UploadService`. The `file://` URI points into the App Group container; - `TransferManager.startUpload` deletes that copy once it has staged it. -3. `ios/Shared/SharedInbox.swift` is compiled into both targets (manifest - read/append/consume under an `NSFileCoordinator`, entries whose file is - gone are dropped, each share is delivered once) and unit-tested in - `RunnerTests`. +1. `ShareViewController` copies each shared file (`NSItemProvider. + loadFileRepresentation`, public.item) into the App Group container + (`group.dev.ayushya.noo`, `SharedInbox//`) and hosts the SwiftUI + `SharePickerView` (`ShareModel` holds its state). +2. It reads the signed-in account from the shared Keychain + (`SharedAccountStore`) and lists folders over WebDAV (`DavClient` + `PROPFIND` Depth 1, parsed by `DavFolderParser`; dot-folders hidden). + The Dart side keeps that account current: `ShareAccountService.publish` + (`lib/services/share_account_service.dart`) runs on every account-ready + event and `clear` on account-cleared, through the native `share_account` + channel - both wired where `SessionController` is created in `main.dart`. +3. **Upload** calls `ShareUpload.enqueue`: one `PUT` per file on a *background* + `URLSession` (`dev.ayushya.noo.transfers.share`, with + `sharedContainerIdentifier`) that outlives the extension. The app recreates + that session at launch (`TransferManager.reconnect`) and is relaunched by + the system to receive the results, post the "Uploaded N files" summary and + delete the copies; `TransferBatchStore` keeps batch totals in the App + Group's `UserDefaults` because the extension and the app are different + processes. +4. Fallbacks: with no account (or the user taps "Choose a folder later in + Noo") the files go into `SharedInbox/pending.json` instead and a local + notification asks the user to open Noo; the app consumes that through + `share_intent` (`getInitialShare` at cold start, `share_intent/new` on + `UIApplication.didBecomeActiveNotification`) into the same Dart + `ShareUploadView` flow as an Android share, and `TransferManager. + startUpload` deletes the inbox copy once staged. -Both targets carry `group.dev.ayushya.noo` in their `.entitlements` -(`Runner/Runner.entitlements`, `ShareExtension/ShareExtension.entitlements`); -the extension's bundle id is `dev.ayushya.noo.ShareExtension`, and its -version/build come from the same `FLUTTER_BUILD_NAME`/`FLUTTER_BUILD_NUMBER` -xcconfig as the app (iOS rejects a mismatch). The extension is embedded via an -"Embed Foundation Extensions" phase placed *before* Flutter's script phases -(after them Xcode reports a dependency cycle). The simulator honours the App -Group without a signing team; a real device needs a team that owns the group -id and both bundle ids registered. To try it without the share sheet, put a -file and a `pending.json` in the simulator's group container -(`xcrun simctl get_app_container dev.ayushya.noo groups`) and launch. +The keychain group is `$(AppIdentifierPrefix)dev.ayushya.noo.shared` +(`keychain-access-groups` in both `.entitlements`); both Info.plists also +carry it as `NooKeychainAccessGroup`, which `SharedAccountStore` reads, so the +two processes always agree on the team-prefixed id. The account is stored as +JSON in one generic-password item (`kSecAttrAccessibleAfterFirstUnlock`). + +`ios/Shared/` (compiled into both targets): `SharedInbox`, `SharedAccount`, +`ShareUpload`, `DavFolders`, `TransferTypes`, `WebDAV`. Everything in it that +isn't UI is unit-tested in `RunnerTests`. The extension's bundle id is +`dev.ayushya.noo.ShareExtension` and its version/build come from the same +`FLUTTER_BUILD_NAME`/`FLUTTER_BUILD_NUMBER` xcconfig as the app (iOS rejects a +mismatch); it's embedded by an "Embed Foundation Extensions" phase placed +*before* Flutter's script phases (after them Xcode reports a dependency +cycle). The simulator honours the App Group and keychain group without a +signing team; a real device needs a team that owns both ids. Not built on iOS yet: the picker (File Provider) and sync (`BGTaskScheduler`) - see the iOS handoff notes. diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 080248d..36c92b5 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -7,21 +7,31 @@ objects = { /* Begin PBXBuildFile section */ + 134157C4C8F53C9997FF265C /* TransferTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20B0C3085FA6467C1A4A2947 /* TransferTypes.swift */; }; 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 167672C5B1FBD70656C2FEED /* TransferTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20B0C3085FA6467C1A4A2947 /* TransferTypes.swift */; }; 223784E5DAE5A653F0F61771 /* ShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 405A5408F4071B4D12F08B21 /* ShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; 33B3C3BED4C1F26439DD6825 /* SharedInbox.swift in Sources */ = {isa = PBXBuildFile; fileRef = 836D37F2376AFCE0EFDD30A7 /* SharedInbox.swift */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 3C1A0DAA5F9B37FEA4C604BE /* DavFolders.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51604DEE43E937A5FDCA7A58 /* DavFolders.swift */; }; + 3DEDE94F2A4C921407EF4382 /* ShareUpload.swift in Sources */ = {isa = PBXBuildFile; fileRef = B93DB1F595F44E2F2057BB2A /* ShareUpload.swift */; }; + 43DB9AB631DD359AEC613A1D /* WebDAV.swift in Sources */ = {isa = PBXBuildFile; fileRef = 871EDB5791D5B3DA0ECF3D4E /* WebDAV.swift */; }; 5694337ECF8D3ADD3695CBCD /* NativeServices.swift in Sources */ = {isa = PBXBuildFile; fileRef = 901C0CDB4FB819302E2A7563 /* NativeServices.swift */; }; + 62028E64976F15B4FDEDA9F1 /* ShareUpload.swift in Sources */ = {isa = PBXBuildFile; fileRef = B93DB1F595F44E2F2057BB2A /* ShareUpload.swift */; }; 63229CACE4281880037D33E6 /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27BDF61F5197F9B8F648E8AE /* ShareViewController.swift */; }; - 66D3CBDE6B3CD3DC578CECBE /* WebDAV.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F798220CB598B611CA03EFC /* WebDAV.swift */; }; + 66AFC246E251554A80B2476F /* SharePickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BE7B8C31FF78E212BD71DFC /* SharePickerView.swift */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; }; 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; + 852C0C8EBA96CB6389DF6917 /* SharedAccount.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F2C956C98F58A97B5595307 /* SharedAccount.swift */; }; 91C1658DC7467CACB2AAC7FF /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0AE6F681A3100A461B4A5318 /* Foundation.framework */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; + A3B3AB501C29516E20455F23 /* DavFolders.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51604DEE43E937A5FDCA7A58 /* DavFolders.swift */; }; + E295E45196E3973A5F7E1D8E /* SharedAccount.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F2C956C98F58A97B5595307 /* SharedAccount.swift */; }; + F598B112F4D33953984FEFF4 /* WebDAV.swift in Sources */ = {isa = PBXBuildFile; fileRef = 871EDB5791D5B3DA0ECF3D4E /* WebDAV.swift */; }; F8D51B4045340275FBD12D16 /* SharedInbox.swift in Sources */ = {isa = PBXBuildFile; fileRef = 836D37F2376AFCE0EFDD30A7 /* SharedInbox.swift */; }; FDFA55C4921747A9CBCBBEEC /* TransferManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8064CC1652B8F0A44E371638 /* TransferManager.swift */; }; /* End PBXBuildFile section */ @@ -71,14 +81,17 @@ 0AE6F681A3100A461B4A5318 /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; }; 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 1F2C956C98F58A97B5595307 /* SharedAccount.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SharedAccount.swift; sourceTree = ""; }; + 20B0C3085FA6467C1A4A2947 /* TransferTypes.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TransferTypes.swift; sourceTree = ""; }; 27BDF61F5197F9B8F648E8AE /* ShareViewController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = ""; }; 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; 405A5408F4071B4D12F08B21 /* ShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = ShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 49213989F0FA1B83B0B8A523 /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 51604DEE43E937A5FDCA7A58 /* DavFolders.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DavFolders.swift; sourceTree = ""; }; + 5BE7B8C31FF78E212BD71DFC /* SharePickerView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SharePickerView.swift; sourceTree = ""; }; 65F57A1EB0C62774E561860D /* ShareExtension.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = ShareExtension.entitlements; sourceTree = ""; }; - 6F798220CB598B611CA03EFC /* WebDAV.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WebDAV.swift; sourceTree = ""; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; @@ -87,6 +100,7 @@ 8064CC1652B8F0A44E371638 /* TransferManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TransferManager.swift; sourceTree = ""; }; 836D37F2376AFCE0EFDD30A7 /* SharedInbox.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SharedInbox.swift; sourceTree = ""; }; 857A0E7637776A71AFBB0614 /* Runner.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = ""; }; + 871EDB5791D5B3DA0ECF3D4E /* WebDAV.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WebDAV.swift; sourceTree = ""; }; 901C0CDB4FB819302E2A7563 /* NativeServices.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NativeServices.swift; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; @@ -95,6 +109,7 @@ 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + B93DB1F595F44E2F2057BB2A /* ShareUpload.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ShareUpload.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -139,6 +154,7 @@ 27BDF61F5197F9B8F648E8AE /* ShareViewController.swift */, 49213989F0FA1B83B0B8A523 /* Info.plist */, 65F57A1EB0C62774E561860D /* ShareExtension.entitlements */, + 5BE7B8C31FF78E212BD71DFC /* SharePickerView.swift */, ); name = ShareExtension; path = ShareExtension; @@ -147,7 +163,6 @@ 5055E53DC55C0BCABD5F04F4 /* Native */ = { isa = PBXGroup; children = ( - 6F798220CB598B611CA03EFC /* WebDAV.swift */, 8064CC1652B8F0A44E371638 /* TransferManager.swift */, 901C0CDB4FB819302E2A7563 /* NativeServices.swift */, ); @@ -159,6 +174,11 @@ isa = PBXGroup; children = ( 836D37F2376AFCE0EFDD30A7 /* SharedInbox.swift */, + 871EDB5791D5B3DA0ECF3D4E /* WebDAV.swift */, + 20B0C3085FA6467C1A4A2947 /* TransferTypes.swift */, + 1F2C956C98F58A97B5595307 /* SharedAccount.swift */, + B93DB1F595F44E2F2057BB2A /* ShareUpload.swift */, + 51604DEE43E937A5FDCA7A58 /* DavFolders.swift */, ); name = Shared; path = Shared; @@ -407,6 +427,12 @@ files = ( 63229CACE4281880037D33E6 /* ShareViewController.swift in Sources */, 33B3C3BED4C1F26439DD6825 /* SharedInbox.swift in Sources */, + F598B112F4D33953984FEFF4 /* WebDAV.swift in Sources */, + 167672C5B1FBD70656C2FEED /* TransferTypes.swift in Sources */, + 852C0C8EBA96CB6389DF6917 /* SharedAccount.swift in Sources */, + 3DEDE94F2A4C921407EF4382 /* ShareUpload.swift in Sources */, + 3C1A0DAA5F9B37FEA4C604BE /* DavFolders.swift in Sources */, + 66AFC246E251554A80B2476F /* SharePickerView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -417,10 +443,14 @@ 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */, - 66D3CBDE6B3CD3DC578CECBE /* WebDAV.swift in Sources */, FDFA55C4921747A9CBCBBEEC /* TransferManager.swift in Sources */, 5694337ECF8D3ADD3695CBCD /* NativeServices.swift in Sources */, F8D51B4045340275FBD12D16 /* SharedInbox.swift in Sources */, + 43DB9AB631DD359AEC613A1D /* WebDAV.swift in Sources */, + 134157C4C8F53C9997FF265C /* TransferTypes.swift in Sources */, + E295E45196E3973A5F7E1D8E /* SharedAccount.swift in Sources */, + 62028E64976F15B4FDEDA9F1 /* ShareUpload.swift in Sources */, + A3B3AB501C29516E20455F23 /* DavFolders.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index c86e0c2..ae86176 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -30,7 +30,7 @@ import UserNotifications handleEventsForBackgroundURLSession identifier: String, completionHandler: @escaping () -> Void ) { - if identifier == TransferManager.sessionIdentifier { + if identifier == TransferManager.sessionIdentifier || identifier == ShareUpload.sessionIdentifier { TransferManager.shared.backgroundCompletionHandler = completionHandler } else { super.application( diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 7f64075..5d33b03 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -57,6 +57,8 @@ LSSupportsOpeningDocumentsInPlace + NooKeychainAccessGroup + $(AppIdentifierPrefix)dev.ayushya.noo.shared NSFaceIDUsageDescription Noo uses Face ID to unlock the app and to protect hidden files. NSPhotoLibraryUsageDescription diff --git a/ios/Runner/Native/NativeServices.swift b/ios/Runner/Native/NativeServices.swift index 6430998..e9ea450 100644 --- a/ios/Runner/Native/NativeServices.swift +++ b/ios/Runner/Native/NativeServices.swift @@ -75,6 +75,35 @@ enum NativeServices { } TransferManager.shared.reconnect() registerShare(messenger: messenger) + registerShareAccount(messenger: messenger) + } + + // MARK: - Account for the Share Extension + + /// `share_account`: Dart tells native which account is active so the Share + /// Extension - a separate process with no Flutter engine - can list folders + /// and upload as it. Stored in the shared Keychain group. + private static func registerShareAccount(messenger: FlutterBinaryMessenger) { + FlutterMethodChannel(name: "dev.ayushya.noo/share_account", binaryMessenger: messenger) + .setMethodCallHandler { call, result in + switch call.method { + case "setAccount": + handle(call, result) { args in + try SharedAccountStore.save( + SharedAccount( + serverUrl: try string(args, "serverUrl"), + username: try string(args, "username"), + authHeader: try string(args, "authHeader"), + displayName: (args["displayName"] as? String) ?? "" + )) + } + case "clearAccount": + SharedAccountStore.clear() + result(nil) + default: + result(FlutterMethodNotImplemented) + } + } } // MARK: - Share Extension inbox diff --git a/ios/Runner/Native/TransferManager.swift b/ios/Runner/Native/TransferManager.swift index 85c4a1d..f28fc9a 100644 --- a/ios/Runner/Native/TransferManager.swift +++ b/ios/Runner/Native/TransferManager.swift @@ -1,43 +1,11 @@ import Foundation import UserNotifications -/// What a background upload/download task is, stored in the task's -/// `taskDescription` so it's still known if the app was relaunched by the -/// system to deliver the result. -struct TransferTaskInfo: Codable { - enum Kind: String, Codable { case upload, download } - - let kind: Kind - let batch: String - let name: String - let remoteFolder: String? - let stagedPath: String? -} - -/// Running totals for one "upload these 3 files" / "download these 2 files" -/// request, persisted so the one summary notification (and the Dart-side -/// "folder changed" event) fires exactly once, on the last file, even across -/// an app relaunch. -struct TransferBatch: Codable { - let kind: TransferTaskInfo.Kind - let remoteFolder: String? - let total: Int - var succeeded: Int - var failed: Int - - var finished: Int { succeeded + failed } -} - struct UploadFile { let uri: String let name: String } -struct TransferError: LocalizedError { - let message: String - var errorDescription: String? { message } -} - /// Uploads and downloads on a background `URLSession`, so a transfer keeps /// going - and finishes - after the app is suspended or closed. The iOS /// counterpart of Android's `ShareUploadService`/`DownloadService` @@ -61,9 +29,6 @@ final class TransferManager: NSObject { /// session has delivered every pending event. var backgroundCompletionHandler: (() -> Void)? - private let defaults = UserDefaults.standard - private let lock = NSLock() - private lazy var session: URLSession = { let config = URLSessionConfiguration.background(withIdentifier: Self.sessionIdentifier) config.sessionSendsLaunchEvents = true @@ -72,10 +37,17 @@ final class TransferManager: NSObject { return URLSession(configuration: config, delegate: self, delegateQueue: nil) }() - /// Touch the session at launch so a system relaunch for finished + /// The session the Share Extension's uploads run on (see `ShareUpload`). + /// The extension is gone by the time they finish, so the app recreates it - + /// same identifier and shared container - to receive the results. + private lazy var shareSession = URLSession( + configuration: ShareUpload.configuration(), delegate: self, delegateQueue: nil) + + /// Touch both sessions at launch so a system relaunch for finished /// background transfers reconnects to them. func reconnect() { _ = session + _ = shareSession } // MARK: - Starting transfers @@ -130,7 +102,7 @@ final class TransferManager: NSObject { ) tasks.append(task) } - saveBatch( + TransferBatchStore.save( TransferBatch(kind: .upload, remoteFolder: remoteFolder, total: tasks.count, succeeded: 0, failed: 0), id: batch ) @@ -158,7 +130,7 @@ final class TransferManager: NSObject { ) tasks.append(task) } - saveBatch( + TransferBatchStore.save( TransferBatch(kind: .download, remoteFolder: nil, total: tasks.count, succeeded: 0, failed: 0), id: batch ) @@ -195,30 +167,9 @@ final class TransferManager: NSObject { .flatMap { try? JSONDecoder().decode(TransferTaskInfo.self, from: $0) } } - private func key(_ id: String) -> String { "transfer.batch.\(id)" } - - private func saveBatch(_ batch: TransferBatch, id: String) { - lock.lock() - defer { lock.unlock() } - if let data = try? JSONEncoder().encode(batch) { defaults.set(data, forKey: key(id)) } - } - /// Counts one file's outcome; fires the batch summary when it was the last. private func record(_ info: TransferTaskInfo, success: Bool) { - lock.lock() - let storageKey = key(info.batch) - var batch = - (defaults.data(forKey: storageKey).flatMap { try? JSONDecoder().decode(TransferBatch.self, from: $0) }) - ?? TransferBatch(kind: info.kind, remoteFolder: info.remoteFolder, total: 1, succeeded: 0, failed: 0) - if success { batch.succeeded += 1 } else { batch.failed += 1 } - let done = batch.finished >= batch.total - if done { - defaults.removeObject(forKey: storageKey) - } else if let data = try? JSONEncoder().encode(batch) { - defaults.set(data, forKey: storageKey) - } - lock.unlock() - if done { finish(batch) } + if let batch = TransferBatchStore.record(info, success: success) { finish(batch) } } private func finish(_ batch: TransferBatch) { diff --git a/ios/Runner/Runner.entitlements b/ios/Runner/Runner.entitlements index cd9db7f..3ebfa4a 100644 --- a/ios/Runner/Runner.entitlements +++ b/ios/Runner/Runner.entitlements @@ -6,5 +6,9 @@ group.dev.ayushya.noo + keychain-access-groups + + $(AppIdentifierPrefix)dev.ayushya.noo.shared + diff --git a/ios/RunnerTests/RunnerTests.swift b/ios/RunnerTests/RunnerTests.swift index fca6dfd..def112b 100644 --- a/ios/RunnerTests/RunnerTests.swift +++ b/ios/RunnerTests/RunnerTests.swift @@ -97,4 +97,92 @@ class RunnerTests: XCTestCase { try SharedInbox.append([], in: root) XCTAssertEqual(SharedInbox.consume(in: root), []) } + + // MARK: - DavFolderParser (Share Extension folder picker) + + private func multistatus(_ responses: [(href: String, collection: Bool)]) -> Data { + let body = responses.map { r in + """ + \(r.href)\ + \(r.collection ? "" : "")\ + HTTP/1.1 200 OK + """ + }.joined() + return Data( + "\(body)".utf8) + } + + func testFoldersListsOnlyChildFoldersSortedWithoutHidden() { + let xml = multistatus([ + ("/remote.php/dav/files/alice/Docs/", true), + ("/remote.php/dav/files/alice/Docs/zeta/", true), + ("/remote.php/dav/files/alice/Docs/Tax%20Forms/", true), + ("/remote.php/dav/files/alice/Docs/note.txt", false), + ("/remote.php/dav/files/alice/Docs/.git/", true), + ]) + XCTAssertEqual( + DavFolderParser.folders(from: xml, excluding: "/Docs"), + [DavFolder(name: "Tax Forms", path: "/Docs/Tax Forms"), DavFolder(name: "zeta", path: "/Docs/zeta")]) + XCTAssertEqual( + DavFolderParser.folders(from: xml, excluding: "/Docs", includeHidden: true).map(\.name), + [".git", "Tax Forms", "zeta"]) + } + + func testFoldersAtRootAndUnderServerSubPath() { + let xml = multistatus([ + ("/nextcloud/remote.php/dav/files/bob/", true), + ("/nextcloud/remote.php/dav/files/bob/Photos/", true), + ]) + XCTAssertEqual( + DavFolderParser.folders(from: xml, excluding: "/"), + [DavFolder(name: "Photos", path: "/Photos")]) + } + + func testRelativePath() { + XCTAssertEqual(DavFolderParser.relativePath(fromHref: "/remote.php/dav/files/a/B%20C/D/"), "/B C/D") + XCTAssertEqual(DavFolderParser.relativePath(fromHref: "/remote.php/dav/files/a/"), "/") + XCTAssertNil(DavFolderParser.relativePath(fromHref: "/somewhere/else")) + } + + func testFoldersIgnoresGarbage() { + XCTAssertEqual(DavFolderParser.folders(from: Data("not xml".utf8), excluding: "/"), []) + } + + // MARK: - Shared account (Keychain group shared with the extension) + + func testSharedAccountRoundTripsThroughTheKeychain() throws { + let account = SharedAccount( + serverUrl: "https://cloud.example.com", username: "alice", + authHeader: "Basic YWxpY2U6c2VjcmV0", displayName: "alice@cloud.example.com") + addTeardownBlock { SharedAccountStore.clear() } + + SharedAccountStore.clear() + XCTAssertNil(SharedAccountStore.load()) + try SharedAccountStore.save(account) + XCTAssertEqual(SharedAccountStore.load(), account) + + let replacement = SharedAccount( + serverUrl: "https://other.example.org", username: "bob", authHeader: "Basic Ym9i", displayName: "bob") + try SharedAccountStore.save(replacement) + XCTAssertEqual(SharedAccountStore.load(), replacement, "saving replaces, never duplicates") + + SharedAccountStore.clear() + XCTAssertNil(SharedAccountStore.load()) + } + + // MARK: - TransferBatchStore + + func testBatchSummaryFiresOnlyOnTheLastFile() { + let batchId = UUID().uuidString + TransferBatchStore.save( + TransferBatch(kind: .upload, remoteFolder: "/Docs", total: 3, succeeded: 0, failed: 0), id: batchId) + let info = TransferTaskInfo(kind: .upload, batch: batchId, name: "a", remoteFolder: "/Docs", stagedPath: nil) + + XCTAssertNil(TransferBatchStore.record(info, success: true)) + XCTAssertNil(TransferBatchStore.record(info, success: false)) + let finished = TransferBatchStore.record(info, success: true) + XCTAssertEqual(finished?.succeeded, 2) + XCTAssertEqual(finished?.failed, 1) + XCTAssertEqual(finished?.remoteFolder, "/Docs") + } } diff --git a/ios/ShareExtension/Info.plist b/ios/ShareExtension/Info.plist index 4c57b84..5206ecc 100644 --- a/ios/ShareExtension/Info.plist +++ b/ios/ShareExtension/Info.plist @@ -20,6 +20,8 @@ $(MARKETING_VERSION) CFBundleVersion $(CURRENT_PROJECT_VERSION) + NooKeychainAccessGroup + $(AppIdentifierPrefix)dev.ayushya.noo.shared NSExtension NSExtensionAttributes diff --git a/ios/ShareExtension/ShareExtension.entitlements b/ios/ShareExtension/ShareExtension.entitlements index cd9db7f..3ebfa4a 100644 --- a/ios/ShareExtension/ShareExtension.entitlements +++ b/ios/ShareExtension/ShareExtension.entitlements @@ -6,5 +6,9 @@ group.dev.ayushya.noo + keychain-access-groups + + $(AppIdentifierPrefix)dev.ayushya.noo.shared + diff --git a/ios/ShareExtension/SharePickerView.swift b/ios/ShareExtension/SharePickerView.swift new file mode 100644 index 0000000..f2165ec --- /dev/null +++ b/ios/ShareExtension/SharePickerView.swift @@ -0,0 +1,168 @@ +import SwiftUI + +/// State behind the share sheet's folder picker. +@MainActor +final class ShareModel: ObservableObject { + enum Stage: Equatable { + case preparing + case picking + case uploading + case noAccount + case failed(String) + } + + @Published var stage: Stage = .preparing + @Published var items: [SharedItem] = [] + @Published var path = "/" + @Published var folders: [DavFolder] = [] + @Published var isLoadingFolders = false + @Published var folderError: String? + + let account: SharedAccount? + + /// Set by the view controller: what each button actually does. + var onUpload: () -> Void = {} + var onSaveForLater: () -> Void = {} + var onCancel: () -> Void = {} + + init(account: SharedAccount?) { + self.account = account + } + + var currentFolderName: String { + path == "/" ? "All files" : (path as NSString).lastPathComponent + } + + var fileCountText: String { + items.count == 1 ? "1 file" : "\(items.count) files" + } + + func open(_ path: String) async { + guard let account else { return } + self.path = path + isLoadingFolders = true + folderError = nil + defer { isLoadingFolders = false } + do { + folders = try await DavClient.listFolders(account: account, path: path) + } catch { + folders = [] + folderError = error.localizedDescription + } + } + + func openParent() async { + let parent = (path as NSString).deletingLastPathComponent + await open(parent.isEmpty ? "/" : parent) + } +} + +struct SharePickerView: View { + @ObservedObject var model: ShareModel + + var body: some View { + VStack(spacing: 0) { + header + Divider() + content + Divider() + footer + } + .background(Color(.systemBackground)) + } + + private var header: some View { + HStack { + Button("Cancel", action: model.onCancel) + Spacer() + Text("Upload to Noo").font(.headline) + Spacer() + // Balances Cancel so the title stays centred. + Button("Cancel", action: {}).hidden() + } + .padding(.horizontal) + .padding(.vertical, 12) + } + + @ViewBuilder + private var content: some View { + switch model.stage { + case .preparing: + centered { ProgressView("Preparing \(model.items.isEmpty ? "" : model.fileCountText)…") } + case .uploading: + centered { + VStack(spacing: 8) { + ProgressView() + Text("Uploading \(model.fileCountText) in the background").font(.subheadline) + } + } + case .noAccount: + centered { + Text("Sign in to Noo first, then share again. You can also save these files and choose a folder from inside Noo.") + .multilineTextAlignment(.center) + .foregroundColor(.secondary) + .padding() + } + case .failed(let message): + centered { + Text(message).multilineTextAlignment(.center).foregroundColor(.secondary).padding() + } + case .picking: + folderList + } + } + + private var folderList: some View { + List { + Section(header: Text(model.account?.displayName ?? "")) { + if model.path != "/" { + Button { + Task { await model.openParent() } + } label: { + Label("Up to \((model.path as NSString).deletingLastPathComponent == "/" ? "All files" : ((model.path as NSString).deletingLastPathComponent as NSString).lastPathComponent)", systemImage: "arrow.turn.left.up") + } + } + if model.isLoadingFolders { + HStack { Spacer(); ProgressView(); Spacer() } + } else if let error = model.folderError { + Text(error).foregroundColor(.secondary) + } else if model.folders.isEmpty { + Text("No folders here").foregroundColor(.secondary) + } else { + ForEach(model.folders, id: \.path) { folder in + Button { + Task { await model.open(folder.path) } + } label: { + Label(folder.name, systemImage: "folder") + } + } + } + } + } + .listStyle(.insetGrouped) + } + + private var footer: some View { + VStack(spacing: 8) { + if model.stage == .picking { + Button(action: model.onUpload) { + Text("Upload \(model.fileCountText) to \(model.currentFolderName)") + .font(.headline) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + } + .buttonStyle(.borderedProminent) + .disabled(model.isLoadingFolders) + } + if model.stage == .picking || model.stage == .noAccount { + Button("Choose a folder later in Noo", action: model.onSaveForLater) + .font(.subheadline) + } + } + .padding() + } + + private func centered(@ViewBuilder _ content: () -> Content) -> some View { + VStack { Spacer(); content(); Spacer() }.frame(maxWidth: .infinity) + } +} diff --git a/ios/ShareExtension/ShareViewController.swift b/ios/ShareExtension/ShareViewController.swift index d482f04..0aba663 100644 --- a/ios/ShareExtension/ShareViewController.swift +++ b/ios/ShareExtension/ShareViewController.swift @@ -1,66 +1,79 @@ +import SwiftUI import UIKit import UniformTypeIdentifiers import UserNotifications -/// "Share to Noo": copies whatever was shared into the App Group container -/// (`SharedInbox`) and finishes. The extension can't pick a destination or -/// upload - it has no session and a tight memory/time budget - so the app -/// does that when it next opens, through its existing destination picker. A -/// notification tells the user to open it, because an extension can't launch -/// its host app. +/// "Share to Noo". Like Reminders' or Notes' share sheet, the destination is +/// chosen *inside* the sheet - an extension can't open its host app - so this +/// shows a folder picker, then starts a background upload and closes: +/// +/// 1. Copies whatever was shared into the App Group (`SharedInbox`). +/// 2. Reads the signed-in account the app left in the shared Keychain +/// (`SharedAccountStore`) and lists folders over WebDAV (`DavClient`). +/// 3. "Upload" queues the files on a background session that outlives this +/// process (`ShareUpload`); the app is relaunched to finish and notify. +/// +/// With no account, or if the user prefers, the files are instead left in the +/// inbox for the app to offer its own destination picker next time it opens +/// (with a notification saying so). final class ShareViewController: UIViewController { - private let label = UILabel() - private let spinner = UIActivityIndicatorView(style: .large) + private var model: ShareModel! + private var batchDirectory: URL? override func viewDidLoad() { super.viewDidLoad() - view.backgroundColor = .systemBackground - label.text = "Saving to Noo…" - label.font = .preferredFont(forTextStyle: .headline) - label.textAlignment = .center - label.numberOfLines = 0 - spinner.startAnimating() + model = ShareModel(account: SharedAccountStore.load()) + model.onUpload = { [weak self] in self?.upload() } + model.onSaveForLater = { [weak self] in self?.saveForLater() } + model.onCancel = { [weak self] in self?.cancel() } - let stack = UIStackView(arrangedSubviews: [spinner, label]) - stack.axis = .vertical - stack.spacing = 16 - stack.alignment = .center - stack.translatesAutoresizingMaskIntoConstraints = false - view.addSubview(stack) + let host = UIHostingController(rootView: SharePickerView(model: model)) + addChild(host) + host.view.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(host.view) NSLayoutConstraint.activate([ - stack.centerXAnchor.constraint(equalTo: view.centerXAnchor), - stack.centerYAnchor.constraint(equalTo: view.centerYAnchor), - stack.leadingAnchor.constraint(greaterThanOrEqualTo: view.leadingAnchor, constant: 24), - stack.trailingAnchor.constraint(lessThanOrEqualTo: view.trailingAnchor, constant: -24), + host.view.topAnchor.constraint(equalTo: view.topAnchor), + host.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), + host.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + host.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), ]) + host.didMove(toParent: self) - Task { await importSharedItems() } + Task { await prepare() } } - private func importSharedItems() async { + // MARK: - Importing + + @MainActor + private func prepare() async { guard let root = AppGroup.containerURL else { - return finish(message: "Noo isn't set up to receive files yet.", saved: 0) + model.stage = .failed("Noo isn't set up to receive files yet.") + return } - let providers = (extensionContext?.inputItems as? [NSExtensionItem])? - .flatMap { $0.attachments ?? [] } ?? [] + let providers = + (extensionContext?.inputItems as? [NSExtensionItem])?.flatMap { $0.attachments ?? [] } ?? [] - var saved: [SharedItem] = [] do { let batch = try SharedInbox.makeBatchDirectory(in: root) + batchDirectory = batch for provider in providers where provider.hasItemConformingToTypeIdentifier(UTType.item.identifier) { - if let item = try? await copy(provider, into: batch) { saved.append(item) } + if let item = try? await copy(provider, into: batch) { model.items.append(item) } } - try SharedInbox.append(saved, in: root) } catch { - return finish(message: "Couldn't save to Noo: \(error.localizedDescription)", saved: 0) + model.stage = .failed("Couldn't save to Noo: \(error.localizedDescription)") + return } - if saved.isEmpty { - finish(message: "Noo can only receive files and photos.", saved: 0) - } else { - notify(count: saved.count) - finish(message: saved.count == 1 ? "Saved 1 file to Noo" : "Saved \(saved.count) files to Noo", saved: saved.count) + guard !model.items.isEmpty else { + model.stage = .failed("Noo can only receive files and photos.") + return } + guard model.account != nil else { + model.stage = .noAccount + return + } + model.stage = .picking + await model.open("/") } /// `loadFileRepresentation`'s URL only lives for the duration of its @@ -72,7 +85,7 @@ final class ShareViewController: UIViewController { return continuation.resume(throwing: error ?? CocoaError(.fileReadUnknown)) } do { - let destination = Self.uniqueURL(in: directory, name: url.lastPathComponent) + let destination = LocalFiles.uniqueURL(in: directory, name: url.lastPathComponent) try FileManager.default.copyItem(at: url, to: destination) let size = (try? FileManager.default.attributesOfItem(atPath: destination.path))?[.size] as? Int64 ?? 0 let mime = UTType(filenameExtension: destination.pathExtension)?.preferredMIMEType @@ -86,19 +99,42 @@ final class ShareViewController: UIViewController { } } - private static func uniqueURL(in directory: URL, name: String) -> URL { - var candidate = directory.appendingPathComponent(name) - var index = 1 - while FileManager.default.fileExists(atPath: candidate.path) { - let ext = (name as NSString).pathExtension - let stem = (name as NSString).deletingPathExtension - candidate = directory.appendingPathComponent(ext.isEmpty ? "\(stem) (\(index))" : "\(stem) (\(index)).\(ext)") - index += 1 + // MARK: - Actions + + private func upload() { + guard let account = model.account else { return } + do { + try ShareUpload.enqueue(items: model.items, account: account, remoteFolder: model.path) + model.stage = .uploading + complete(after: 1.2) + } catch { + model.stage = .failed("Couldn't start the upload: \(error.localizedDescription)") } - return candidate } - private func notify(count: Int) { + private func saveForLater() { + guard let root = AppGroup.containerURL else { return cancel() } + do { + try SharedInbox.append(model.items, in: root) + notifyOpenNoo(count: model.items.count) + complete(after: 0) + } catch { + model.stage = .failed("Couldn't save to Noo: \(error.localizedDescription)") + } + } + + private func cancel() { + if let batchDirectory { try? FileManager.default.removeItem(at: batchDirectory) } + extensionContext?.cancelRequest(withError: CocoaError(.userCancelled)) + } + + private func complete(after delay: TimeInterval) { + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { + self.extensionContext?.completeRequest(returningItems: nil) + } + } + + private func notifyOpenNoo(count: Int) { let content = UNMutableNotificationContent() content.title = "Noo" content.body = @@ -109,14 +145,4 @@ final class ShareViewController: UIViewController { UNUserNotificationCenter.current().add( UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)) } - - private func finish(message: String, saved: Int) { - DispatchQueue.main.async { - self.spinner.stopAnimating() - self.label.text = message - DispatchQueue.main.asyncAfter(deadline: .now() + (saved > 0 ? 1.2 : 2.5)) { - self.extensionContext?.completeRequest(returningItems: nil) - } - } - } } diff --git a/ios/Shared/DavFolders.swift b/ios/Shared/DavFolders.swift new file mode 100644 index 0000000..3838af4 --- /dev/null +++ b/ios/Shared/DavFolders.swift @@ -0,0 +1,120 @@ +import Foundation + +/// A folder in the account's WebDAV tree. [path] is relative to the user's +/// root with a leading slash ("/Documents/Tax Forms"); "/" is the root. +struct DavFolder: Equatable { + let name: String + let path: String +} + +/// Parses a WebDAV `PROPFIND` multistatus into folders. Pure (no +/// networking), so it's unit-tested. Compiled into both targets. +enum DavFolderParser { + private struct Response { + var href = "" + var isCollection = false + } + + private final class Delegate: NSObject, XMLParserDelegate { + var responses: [Response] = [] + private var current: Response? + private var text = "" + + private func local(_ name: String) -> String { + name.split(separator: ":").last.map(String.init) ?? name + } + + func parser( + _ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?, + qualifiedName qName: String?, attributes attributeDict: [String: String] = [:] + ) { + switch local(elementName) { + case "response": current = Response() + case "href": text = "" + case "collection": current?.isCollection = true + default: break + } + } + + func parser(_ parser: XMLParser, foundCharacters string: String) { + text += string + } + + func parser( + _ parser: XMLParser, didEndElement elementName: String, namespaceURI: String?, + qualifiedName qName: String? + ) { + switch local(elementName) { + case "href": current?.href = text.trimmingCharacters(in: .whitespacesAndNewlines) + case "response": + if let current { responses.append(current) } + current = nil + default: break + } + } + } + + /// "/nextcloud/remote.php/dav/files/alice/Tax%20Forms/" -> "/Tax Forms". + /// The server may live under a sub-path, so the files root is located by + /// its marker instead of assumed to start at the beginning. + static func relativePath(fromHref href: String) -> String? { + let decoded = href.removingPercentEncoding ?? href + let marker = "/remote.php/dav/files/" + guard let range = decoded.range(of: marker) else { return nil } + var parts = decoded[range.upperBound...].split(separator: "/", omittingEmptySubsequences: true) + guard !parts.isEmpty else { return nil } + parts.removeFirst() // the username + return "/" + parts.joined(separator: "/") + } + + /// Child folders of [currentPath], sorted by name. The requested folder + /// itself (which a Depth-1 PROPFIND also returns) and - unless + /// [includeHidden] - dot-folders are left out. + static func folders( + from xml: Data, excluding currentPath: String, includeHidden: Bool = false + ) -> [DavFolder] { + let delegate = Delegate() + let parser = XMLParser(data: xml) + parser.delegate = delegate + parser.parse() + + let current = currentPath == "/" ? "/" : currentPath.replacingOccurrences(of: "/+$", with: "", options: .regularExpression) + return delegate.responses + .filter(\.isCollection) + .compactMap { response -> DavFolder? in + guard let path = relativePath(fromHref: response.href), path != current else { return nil } + let name = (path as NSString).lastPathComponent + guard !name.isEmpty, includeHidden || !name.hasPrefix(".") else { return nil } + return DavFolder(name: name, path: path) + } + .sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + } +} + +/// Lists folders over WebDAV for the Share Extension's picker, in the +/// foreground (the sheet is on screen while it runs). +enum DavClient { + private static let body = """ + + + """ + + static func listFolders(account: SharedAccount, path: String) async throws -> [DavFolder] { + guard let url = WebDAV.fileURL(serverUrl: account.serverUrl, username: account.username, remotePath: path) + else { throw TransferError(message: "Invalid server address.") } + var request = URLRequest(url: url) + request.httpMethod = "PROPFIND" + request.setValue("1", forHTTPHeaderField: "Depth") + request.setValue("application/xml", forHTTPHeaderField: "Content-Type") + request.setValue(account.authHeader, forHTTPHeaderField: "Authorization") + request.httpBody = body.data(using: .utf8) + request.timeoutInterval = 20 + + let (data, response) = try await URLSession.shared.data(for: request) + let status = (response as? HTTPURLResponse)?.statusCode ?? 0 + guard status == 207 else { + throw TransferError(message: status == 401 ? "Signed out - open Noo to sign in again." : "Server returned \(status).") + } + return DavFolderParser.folders(from: data, excluding: path) + } +} diff --git a/ios/Shared/ShareUpload.swift b/ios/Shared/ShareUpload.swift new file mode 100644 index 0000000..1394963 --- /dev/null +++ b/ios/Shared/ShareUpload.swift @@ -0,0 +1,73 @@ +import Foundation + +/// Starts the Share Extension's uploads on a background `URLSession` that +/// outlives the extension. The extension process is torn down as soon as its +/// sheet closes; because the session has a `sharedContainerIdentifier` and +/// the same identifier is recreated by the app at launch +/// (`TransferManager.reconnect`), the *app* is what gets relaunched to +/// receive the results, post the summary notification and clean up. +/// Compiled into both targets. +enum ShareUpload { + static let sessionIdentifier = "dev.ayushya.noo.transfers.share" + + static func configuration() -> URLSessionConfiguration { + let config = URLSessionConfiguration.background(withIdentifier: sessionIdentifier) + config.sharedContainerIdentifier = AppGroup.identifier + config.sessionSendsLaunchEvents = true + config.isDiscretionary = false + config.waitsForConnectivity = true + return config + } + + /// A `PUT` for [item] into [remoteFolder] - shared by the extension's + /// [enqueue] and the app's own uploads so both build requests identically. + static func uploadRequest(for item: SharedItem, account: SharedAccount, remoteFolder: String) -> URLRequest? { + guard + let url = WebDAV.fileURL( + serverUrl: account.serverUrl, + username: account.username, + remotePath: WebDAV.join(remoteFolder, item.name) + ) + else { return nil } + var request = URLRequest(url: url) + request.httpMethod = "PUT" + request.setValue(account.authHeader, forHTTPHeaderField: "Authorization") + if let modified = (try? FileManager.default.attributesOfItem(atPath: item.path))?[.modificationDate] as? Date { + request.setValue(String(Int(modified.timeIntervalSince1970)), forHTTPHeaderField: "X-OC-Mtime") + } + return request + } + + /// Queues every item (already copied into the App Group by the extension) + /// and returns once the system has accepted them. + static func enqueue(items: [SharedItem], account: SharedAccount, remoteFolder: String) throws { + guard !items.isEmpty else { return } + let batch = UUID().uuidString + // No delegate here: the extension is gone before the results arrive. + let session = URLSession(configuration: configuration()) + + var tasks: [URLSessionUploadTask] = [] + for item in items { + guard let request = uploadRequest(for: item, account: account, remoteFolder: remoteFolder) else { + throw TransferError(message: "Invalid server address.") + } + let task = session.uploadTask(with: request, fromFile: URL(fileURLWithPath: item.path)) + task.taskDescription = + (try? JSONEncoder().encode( + TransferTaskInfo( + kind: .upload, batch: batch, name: item.name, remoteFolder: remoteFolder, stagedPath: item.path) + )).flatMap { String(data: $0, encoding: .utf8) } + tasks.append(task) + } + TransferBatchStore.save( + TransferBatch(kind: .upload, remoteFolder: remoteFolder, total: tasks.count, succeeded: 0, failed: 0), + id: batch) + tasks.forEach { $0.resume() } + session.finishTasksAndInvalidate() + } +} + +struct TransferError: LocalizedError { + let message: String + var errorDescription: String? { message } +} diff --git a/ios/Shared/SharedAccount.swift b/ios/Shared/SharedAccount.swift new file mode 100644 index 0000000..939268e --- /dev/null +++ b/ios/Shared/SharedAccount.swift @@ -0,0 +1,68 @@ +import Foundation +import Security + +/// The signed-in account, as much of it as the Share Extension needs to list +/// folders and upload: the app writes it when an account becomes active and +/// clears it on sign-out. Compiled into both targets. +struct SharedAccount: Codable, Equatable { + let serverUrl: String + let username: String + /// `Basic ...` - the app password never leaves the Keychain as plain text. + let authHeader: String + /// Shown in the picker, e.g. "alice@cloud.example.com". + let displayName: String +} + +/// Keeps the [SharedAccount] in a Keychain access group both the app and the +/// extension are entitled to (`keychain-access-groups`). The group's full id +/// carries the signing team's prefix, so it's injected into each target's +/// Info.plist as `NooKeychainAccessGroup` (= `$(AppIdentifierPrefix)` + +/// `dev.ayushya.noo.shared`) instead of being hard-coded - both processes +/// then always agree on it, with or without a team. +enum SharedAccountStore { + private static let service = "dev.ayushya.noo.shared-account" + private static let account = "active" + + static var accessGroup: String? { + Bundle.main.object(forInfoDictionaryKey: "NooKeychainAccessGroup") as? String + } + + private static func baseQuery() -> [String: Any] { + var query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + ] + if let group = accessGroup, !group.isEmpty { + query[kSecAttrAccessGroup as String] = group + } + return query + } + + static func save(_ value: SharedAccount) throws { + let data = try JSONEncoder().encode(value) + clear() + var query = baseQuery() + query[kSecValueData as String] = data + query[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock + let status = SecItemAdd(query as CFDictionary, nil) + guard status == errSecSuccess else { + throw NSError(domain: NSOSStatusErrorDomain, code: Int(status)) + } + } + + static func load() -> SharedAccount? { + var query = baseQuery() + query[kSecReturnData as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne + var result: AnyObject? + guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess, + let data = result as? Data + else { return nil } + return try? JSONDecoder().decode(SharedAccount.self, from: data) + } + + static func clear() { + SecItemDelete(baseQuery() as CFDictionary) + } +} diff --git a/ios/Shared/TransferTypes.swift b/ios/Shared/TransferTypes.swift new file mode 100644 index 0000000..f9d76c7 --- /dev/null +++ b/ios/Shared/TransferTypes.swift @@ -0,0 +1,67 @@ +import Foundation + +/// What a background upload/download task is, stored in the task's +/// `taskDescription` so it's still known if the app was relaunched by the +/// system to deliver the result - possibly for a task the Share Extension +/// started. Compiled into both targets. +struct TransferTaskInfo: Codable { + enum Kind: String, Codable { case upload, download } + + let kind: Kind + let batch: String + let name: String + let remoteFolder: String? + /// A file to delete once the task ends (the upload's staged copy). + let stagedPath: String? +} + +/// Running totals for one "upload these 3 files" / "download these 2 files" +/// request, so the one summary notification (and the Dart-side "folder +/// changed" event) fires exactly once, on the last file. +struct TransferBatch: Codable { + let kind: TransferTaskInfo.Kind + let remoteFolder: String? + let total: Int + var succeeded: Int + var failed: Int + + var finished: Int { succeeded + failed } +} + +/// Persists [TransferBatch]es in the App Group's `UserDefaults`, not the +/// process's own: the Share Extension starts an upload batch, and the app - +/// a different process, possibly relaunched in the background - finishes +/// counting it. +enum TransferBatchStore { + private static let lock = NSLock() + + private static var defaults: UserDefaults { + UserDefaults(suiteName: AppGroup.identifier) ?? .standard + } + + private static func key(_ id: String) -> String { "transfer.batch.\(id)" } + + static func save(_ batch: TransferBatch, id: String) { + lock.lock() + defer { lock.unlock() } + if let data = try? JSONEncoder().encode(batch) { defaults.set(data, forKey: key(id)) } + } + + /// Counts one file's outcome. Returns the batch when that was its last + /// file (and forgets it), nil while others are still running. + static func record(_ info: TransferTaskInfo, success: Bool) -> TransferBatch? { + lock.lock() + defer { lock.unlock() } + let storageKey = key(info.batch) + var batch = + defaults.data(forKey: storageKey).flatMap { try? JSONDecoder().decode(TransferBatch.self, from: $0) } + ?? TransferBatch(kind: info.kind, remoteFolder: info.remoteFolder, total: 1, succeeded: 0, failed: 0) + if success { batch.succeeded += 1 } else { batch.failed += 1 } + if batch.finished >= batch.total { + defaults.removeObject(forKey: storageKey) + return batch + } + if let data = try? JSONEncoder().encode(batch) { defaults.set(data, forKey: storageKey) } + return nil + } +} diff --git a/ios/Runner/Native/WebDAV.swift b/ios/Shared/WebDAV.swift similarity index 100% rename from ios/Runner/Native/WebDAV.swift rename to ios/Shared/WebDAV.swift diff --git a/lib/main.dart b/lib/main.dart index 1a4006f..0e253d0 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -22,6 +22,7 @@ import 'providers/shares_controller.dart'; import 'providers/sync_status_controller.dart'; import 'providers/trash_controller.dart'; import 'services/pick_intent_service.dart'; +import 'services/share_account_service.dart'; import 'services/share_intent_service.dart'; import 'theme/app_theme.dart'; import 'theme/design_tokens.dart'; @@ -58,7 +59,16 @@ void main() { providers: [ ChangeNotifierProvider(create: (_) => ConnectivityController()), ChangeNotifierProvider( - create: (context) => SessionController(context.read()), + create: (context) { + final session = SessionController(context.read()); + // iOS's Share Extension has no Flutter engine; it picks its + // folders/uploads as whichever account was last published here. + session.addAccountReadyListener( + () => ShareAccountService.publish(session), + ); + session.addAccountClearedListener(ShareAccountService.clear); + return session; + }, ), ChangeNotifierProvider(create: (_) => SettingsController()), // Before SyncStatusController, which follows its Files Cache rule diff --git a/lib/services/share_account_service.dart b/lib/services/share_account_service.dart new file mode 100644 index 0000000..df2efdc --- /dev/null +++ b/lib/services/share_account_service.dart @@ -0,0 +1,30 @@ +import 'package:flutter/services.dart'; +import '../providers/session_controller.dart'; +import 'native_channel.dart'; + +/// Tells the native side which account is active, so iOS's Share Extension - +/// a separate process with no Flutter engine - can list folders and upload as +/// it without opening the app (`ios/Runner/Native/NativeServices.swift`'s +/// `share_account`, stored in a Keychain group shared with the extension). +/// Android has no equivalent (its share intent opens the app itself), so +/// there the channel simply doesn't exist and both calls are no-ops. +class ShareAccountService { + static const _channel = MethodChannel('dev.ayushya.noo/share_account'); + + /// Publishes the active account (call whenever one becomes active). + static Future publish(SessionController session) async { + final authHeader = session.service?.authHeaders['Authorization']; + if (authHeader == null || session.serverUrl.isEmpty) return; + final host = Uri.tryParse(session.serverUrl)?.host ?? session.serverUrl; + await invokeIfAvailable(_channel, 'setAccount', { + 'serverUrl': session.serverUrl, + 'username': session.username, + 'authHeader': authHeader, + 'displayName': '${session.username}@$host', + }); + } + + /// Forgets it (sign-out / switching away) so the extension stops offering + /// uploads to an account that's no longer signed in. + static Future clear() => invokeIfAvailable(_channel, 'clearAccount'); +} From 8ef3d3471c4c48d28964d8ff69de271000139e2c Mon Sep 17 00:00:00 2001 From: Ayushya Amitabh Date: Tue, 6 Oct 2026 19:44:19 -0400 Subject: [PATCH 14/31] iOS transfers: 'Uploading...' then 'Uploaded' notification, and count share uploads The Share Extension's session had no delegate, so a fast upload finished unseen and the invalidated session was discarded before the app could hear of it - no summary notification. Results are now handled by a shared delegate in whichever process is alive, started/finished notifications replace each other in place, and the app refreshes the folder the extension uploaded to. Co-Authored-By: Claude Sonnet 5.5 --- .claude/context/server.md | 29 ++++++--- ios/Runner.xcodeproj/project.pbxproj | 6 ++ ios/Runner/Native/NativeServices.swift | 1 + ios/Runner/Native/TransferManager.swift | 48 +++++++-------- ios/RunnerTests/RunnerTests.swift | 15 +++++ ios/Shared/ShareUpload.swift | 7 ++- ios/Shared/TransferNotifications.swift | 80 +++++++++++++++++++++++++ ios/Shared/TransferTypes.swift | 21 +++++++ 8 files changed, 168 insertions(+), 39 deletions(-) create mode 100644 ios/Shared/TransferNotifications.swift diff --git a/.claude/context/server.md b/.claude/context/server.md index 2199dc6..a3dfb15 100644 --- a/.claude/context/server.md +++ b/.claude/context/server.md @@ -863,10 +863,13 @@ iOS. `OfflineController` and `localSyncedFilePath` both go through it. `Documents/Downloads`, with `UIFileSharingEnabled` + `LSSupportsOpeningDocumentsInPlace` set so it shows in the Files app under "On My iPhone > Noo"; a name clash becomes `a (1).txt`. -- No progress notification (iOS can't update one from a background session - the way Android's foreground service does) and no cancel yet - only the - final summary. The Dart snackbar text still says "see the notification for - progress". +- Notifications (`ios/Shared/TransferNotifications.swift`): "Uploading 3 + files..." when a batch starts, replaced in place (same request id = + `transfer.`) by "Uploaded 3 files" when it ends. iOS can't show live + byte progress from a background session the way Android's foreground + service does (a Live Activity would, but needs a widget extension), and + there's no cancel yet. The Dart snackbar text still says "see the + notification for progress". - `WebDAV.swift` (URL building) and `LocalFiles.uniqueURL` are pure and covered by `ios/RunnerTests` (`xcodebuild test -workspace ios/Runner.xcworkspace -scheme Runner -destination 'platform=iOS @@ -891,12 +894,20 @@ Reminders/Notes - the destination is picked *inside* the sheet: channel - both wired where `SessionController` is created in `main.dart`. 3. **Upload** calls `ShareUpload.enqueue`: one `PUT` per file on a *background* `URLSession` (`dev.ayushya.noo.transfers.share`, with - `sharedContainerIdentifier`) that outlives the extension. The app recreates + `sharedContainerIdentifier`) that outlives the extension, and posts + "Uploading N files...". Results go to whichever process is alive: + files that finish while the sheet is still open are counted by the + extension's `ShareUploadDelegate`; the rest by the app, which recreates that session at launch (`TransferManager.reconnect`) and is relaunched by - the system to receive the results, post the "Uploaded N files" summary and - delete the copies; `TransferBatchStore` keeps batch totals in the App - Group's `UserDefaults` because the extension and the app are different - processes. + the system. Either way `UploadResults.handle` deletes the staged copy, + counts the file in `TransferBatchStore` (batch totals live in the App + Group's `UserDefaults` because the two are different processes) and + posts the "Uploaded N files" summary on the last one; an upload the + extension finished leaves a note (`noteFinishedUpload`) so the app + refreshes that folder on its next activation. **Don't give the + extension's session no delegate** - a fast upload then finishes unseen and + the invalidated session is discarded before the app can ever hear of it + (found the hard way: no notification, no count). 4. Fallbacks: with no account (or the user taps "Choose a folder later in Noo") the files go into `SharedInbox/pending.json` instead and a local notification asks the user to open Noo; the app consumes that through diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 36c92b5..583e205 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -17,6 +17,7 @@ 3C1A0DAA5F9B37FEA4C604BE /* DavFolders.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51604DEE43E937A5FDCA7A58 /* DavFolders.swift */; }; 3DEDE94F2A4C921407EF4382 /* ShareUpload.swift in Sources */ = {isa = PBXBuildFile; fileRef = B93DB1F595F44E2F2057BB2A /* ShareUpload.swift */; }; 43DB9AB631DD359AEC613A1D /* WebDAV.swift in Sources */ = {isa = PBXBuildFile; fileRef = 871EDB5791D5B3DA0ECF3D4E /* WebDAV.swift */; }; + 44078FC9474337A0E102B524 /* TransferNotifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3247AAA4B65FE0F1A7279FA1 /* TransferNotifications.swift */; }; 5694337ECF8D3ADD3695CBCD /* NativeServices.swift in Sources */ = {isa = PBXBuildFile; fileRef = 901C0CDB4FB819302E2A7563 /* NativeServices.swift */; }; 62028E64976F15B4FDEDA9F1 /* ShareUpload.swift in Sources */ = {isa = PBXBuildFile; fileRef = B93DB1F595F44E2F2057BB2A /* ShareUpload.swift */; }; 63229CACE4281880037D33E6 /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27BDF61F5197F9B8F648E8AE /* ShareViewController.swift */; }; @@ -29,6 +30,7 @@ 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; + A16680889B3A9D8672FA29C1 /* TransferNotifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3247AAA4B65FE0F1A7279FA1 /* TransferNotifications.swift */; }; A3B3AB501C29516E20455F23 /* DavFolders.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51604DEE43E937A5FDCA7A58 /* DavFolders.swift */; }; E295E45196E3973A5F7E1D8E /* SharedAccount.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F2C956C98F58A97B5595307 /* SharedAccount.swift */; }; F598B112F4D33953984FEFF4 /* WebDAV.swift in Sources */ = {isa = PBXBuildFile; fileRef = 871EDB5791D5B3DA0ECF3D4E /* WebDAV.swift */; }; @@ -84,6 +86,7 @@ 1F2C956C98F58A97B5595307 /* SharedAccount.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SharedAccount.swift; sourceTree = ""; }; 20B0C3085FA6467C1A4A2947 /* TransferTypes.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TransferTypes.swift; sourceTree = ""; }; 27BDF61F5197F9B8F648E8AE /* ShareViewController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = ""; }; + 3247AAA4B65FE0F1A7279FA1 /* TransferNotifications.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TransferNotifications.swift; sourceTree = ""; }; 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; @@ -179,6 +182,7 @@ 1F2C956C98F58A97B5595307 /* SharedAccount.swift */, B93DB1F595F44E2F2057BB2A /* ShareUpload.swift */, 51604DEE43E937A5FDCA7A58 /* DavFolders.swift */, + 3247AAA4B65FE0F1A7279FA1 /* TransferNotifications.swift */, ); name = Shared; path = Shared; @@ -433,6 +437,7 @@ 3DEDE94F2A4C921407EF4382 /* ShareUpload.swift in Sources */, 3C1A0DAA5F9B37FEA4C604BE /* DavFolders.swift in Sources */, 66AFC246E251554A80B2476F /* SharePickerView.swift in Sources */, + A16680889B3A9D8672FA29C1 /* TransferNotifications.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -451,6 +456,7 @@ E295E45196E3973A5F7E1D8E /* SharedAccount.swift in Sources */, 62028E64976F15B4FDEDA9F1 /* ShareUpload.swift in Sources */, A3B3AB501C29516E20455F23 /* DavFolders.swift in Sources */, + 44078FC9474337A0E102B524 /* TransferNotifications.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/ios/Runner/Native/NativeServices.swift b/ios/Runner/Native/NativeServices.swift index e9ea450..db47b4e 100644 --- a/ios/Runner/Native/NativeServices.swift +++ b/ios/Runner/Native/NativeServices.swift @@ -125,6 +125,7 @@ enum NativeServices { NotificationCenter.default.addObserver( forName: UIApplication.didBecomeActiveNotification, object: nil, queue: .main ) { _ in + TransferManager.shared.deliverExtensionUploads() guard let sink = shareEvents.sink else { return } let items = consumeShared() if !items.isEmpty { sink(items.map(encode)) } diff --git a/ios/Runner/Native/TransferManager.swift b/ios/Runner/Native/TransferManager.swift index f28fc9a..dae9465 100644 --- a/ios/Runner/Native/TransferManager.swift +++ b/ios/Runner/Native/TransferManager.swift @@ -106,6 +106,7 @@ final class TransferManager: NSObject { TransferBatch(kind: .upload, remoteFolder: remoteFolder, total: tasks.count, succeeded: 0, failed: 0), id: batch ) + TransferNotifications.started(kind: .upload, batch: batch, count: tasks.count) tasks.forEach { $0.resume() } } @@ -134,6 +135,7 @@ final class TransferManager: NSObject { TransferBatch(kind: .download, remoteFolder: nil, total: tasks.count, succeeded: 0, failed: 0), id: batch ) + TransferNotifications.started(kind: .download, batch: batch, count: tasks.count) tasks.forEach { $0.resume() } } @@ -169,38 +171,28 @@ final class TransferManager: NSObject { /// Counts one file's outcome; fires the batch summary when it was the last. private func record(_ info: TransferTaskInfo, success: Bool) { - if let batch = TransferBatchStore.record(info, success: success) { finish(batch) } + if let batch = TransferBatchStore.record(info, success: success) { finish(batch, id: info.batch) } } - private func finish(_ batch: TransferBatch) { - let verb = batch.kind == .upload ? "upload" : "download" - let past = batch.kind == .upload ? "Uploaded" : "Downloaded" - let body: String - if batch.failed == 0 { - body = "\(past) \(Self.files(batch.succeeded))" - } else if batch.succeeded == 0 { - body = "Couldn't \(verb) \(Self.files(batch.failed))" - } else { - body = "\(past) \(Self.files(batch.succeeded)), \(batch.failed) failed" - } - notify(body) + private func finish(_ batch: TransferBatch, id: String) { + TransferNotifications.finished(batch, id: id) + reportToDart(batch) + } + + /// Tells Dart (so `FilesController` can refresh the folder) once an upload + /// batch has landed at least one file. + private func reportToDart(_ batch: TransferBatch) { if batch.kind == .upload, batch.succeeded > 0, let folder = batch.remoteFolder { DispatchQueue.main.async { self.onUploadBatchFinished?(folder, batch.succeeded, batch.failed) } } } - private static func files(_ count: Int) -> String { - count == 1 ? "1 file" : "\(count) files" - } - - private func notify(_ body: String) { - let content = UNMutableNotificationContent() - content.title = "Noo" - content.body = body - content.sound = .default - UNUserNotificationCenter.current().add( - UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil) - ) + /// Folders the Share Extension finished uploading into while the app wasn't + /// running - call when the app becomes active. + func deliverExtensionUploads() { + for folder in TransferBatchStore.consumeFinishedUploadFolders() { + DispatchQueue.main.async { self.onUploadBatchFinished?(folder, 1, 0) } + } } } @@ -229,9 +221,9 @@ extension TransferManager: URLSessionDownloadDelegate { guard let info = Self.info(for: task) else { return } switch info.kind { case .upload: - if let staged = info.stagedPath { try? FileManager.default.removeItem(atPath: staged) } - let status = (task.response as? HTTPURLResponse)?.statusCode ?? 0 - record(info, success: error == nil && (200..<300).contains(status)) + if let result = UploadResults.handle(task, error: error), let batch = result.batch { + reportToDart(batch) + } case .download: // A finished download was already counted in didFinishDownloadingTo; // only a transport failure arrives here. diff --git a/ios/RunnerTests/RunnerTests.swift b/ios/RunnerTests/RunnerTests.swift index def112b..74ece5f 100644 --- a/ios/RunnerTests/RunnerTests.swift +++ b/ios/RunnerTests/RunnerTests.swift @@ -185,4 +185,19 @@ class RunnerTests: XCTestCase { XCTAssertEqual(finished?.failed, 1) XCTAssertEqual(finished?.remoteFolder, "/Docs") } + + func testFinishedUploadFoldersAreNotedOnceAndConsumed() { + _ = TransferBatchStore.consumeFinishedUploadFolders() // start clean + TransferBatchStore.noteFinishedUpload(folder: "/Docs") + TransferBatchStore.noteFinishedUpload(folder: "/Docs") + TransferBatchStore.noteFinishedUpload(folder: "/Photos") + + XCTAssertEqual(TransferBatchStore.consumeFinishedUploadFolders(), ["/Docs", "/Photos"]) + XCTAssertEqual(TransferBatchStore.consumeFinishedUploadFolders(), []) + } + + func testNotificationFileCountWording() { + XCTAssertEqual(TransferNotifications.files(1), "1 file") + XCTAssertEqual(TransferNotifications.files(3), "3 files") + } } diff --git a/ios/Shared/ShareUpload.swift b/ios/Shared/ShareUpload.swift index 1394963..cb1a9a2 100644 --- a/ios/Shared/ShareUpload.swift +++ b/ios/Shared/ShareUpload.swift @@ -43,8 +43,10 @@ enum ShareUpload { static func enqueue(items: [SharedItem], account: SharedAccount, remoteFolder: String) throws { guard !items.isEmpty else { return } let batch = UUID().uuidString - // No delegate here: the extension is gone before the results arrive. - let session = URLSession(configuration: configuration()) + // Small files can finish while the extension is still on screen; for + // those this delegate counts them. Whatever outlives the extension is + // reported to the app instead (see `TransferManager`). + let session = URLSession(configuration: configuration(), delegate: ShareUploadDelegate(), delegateQueue: nil) var tasks: [URLSessionUploadTask] = [] for item in items { @@ -62,6 +64,7 @@ enum ShareUpload { TransferBatchStore.save( TransferBatch(kind: .upload, remoteFolder: remoteFolder, total: tasks.count, succeeded: 0, failed: 0), id: batch) + TransferNotifications.started(kind: .upload, batch: batch, count: tasks.count) tasks.forEach { $0.resume() } session.finishTasksAndInvalidate() } diff --git a/ios/Shared/TransferNotifications.swift b/ios/Shared/TransferNotifications.swift new file mode 100644 index 0000000..32f4b89 --- /dev/null +++ b/ios/Shared/TransferNotifications.swift @@ -0,0 +1,80 @@ +import Foundation +import UserNotifications + +/// The local notifications for an upload/download batch - one "Uploading 3 +/// files…" when it starts, replaced in place by "Uploaded 3 files" when it +/// ends (both use the batch's id as the request identifier, so the second +/// *replaces* the first instead of stacking). Posted from whichever process +/// sees the event - the app, or the Share Extension while it's still alive. +/// iOS can't update a notification with live byte progress from a background +/// session, so start/finish is as much as a notification can say. +/// Compiled into both targets. +enum TransferNotifications { + static func started(kind: TransferTaskInfo.Kind, batch: String, count: Int) { + let verb = kind == .upload ? "Uploading" : "Downloading" + post(id: batch, body: "\(verb) \(files(count))…", sound: false) + } + + static func finished(_ batch: TransferBatch, id: String) { + let verb = batch.kind == .upload ? "upload" : "download" + let past = batch.kind == .upload ? "Uploaded" : "Downloaded" + let body: String + if batch.failed == 0 { + body = "\(past) \(files(batch.succeeded))" + } else if batch.succeeded == 0 { + body = "Couldn't \(verb) \(files(batch.failed))" + } else { + body = "\(past) \(files(batch.succeeded)), \(batch.failed) failed" + } + post(id: id, body: body, sound: true) + } + + static func files(_ count: Int) -> String { + count == 1 ? "1 file" : "\(count) files" + } + + private static func post(id: String, body: String, sound: Bool) { + let content = UNMutableNotificationContent() + content.title = "Noo" + content.body = body + if sound { content.sound = .default } + UNUserNotificationCenter.current().add( + UNNotificationRequest(identifier: "transfer.\(id)", content: content, trigger: nil)) + } +} + +/// Counting one finished upload task - shared so the result is handled the +/// same way whether the Share Extension (still alive) or the app (relaunched +/// later) receives it. +enum UploadResults { + /// Deletes the task's staged copy, records its outcome, and - if it was the + /// last file of its batch - posts the summary notification and returns the + /// finished batch. + static func handle(_ task: URLSessionTask, error: Error?) -> (info: TransferTaskInfo, batch: TransferBatch?)? { + guard let info = task.taskDescription + .flatMap({ $0.data(using: .utf8) }) + .flatMap({ try? JSONDecoder().decode(TransferTaskInfo.self, from: $0) }), + info.kind == .upload + else { return nil } + if let staged = info.stagedPath { try? FileManager.default.removeItem(atPath: staged) } + let status = (task.response as? HTTPURLResponse)?.statusCode ?? 0 + let ok = error == nil && (200..<300).contains(status) + let batch = TransferBatchStore.record(info, success: ok) + if let batch { TransferNotifications.finished(batch, id: info.batch) } + return (info, batch) + } +} + +/// Delegate for the Share Extension's upload session while the extension is +/// still alive: files that finish before it closes are counted here, the +/// rest by the app (`TransferManager`) once the system relaunches it. +final class ShareUploadDelegate: NSObject, URLSessionTaskDelegate { + func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { + guard let result = UploadResults.handle(task, error: error) else { return } + // The app wasn't running to hear about this, so leave a note for it to + // refresh that folder next time it's active. + if let batch = result.batch, batch.succeeded > 0, let folder = batch.remoteFolder { + TransferBatchStore.noteFinishedUpload(folder: folder) + } + } +} diff --git a/ios/Shared/TransferTypes.swift b/ios/Shared/TransferTypes.swift index f9d76c7..c7b284f 100644 --- a/ios/Shared/TransferTypes.swift +++ b/ios/Shared/TransferTypes.swift @@ -47,6 +47,27 @@ enum TransferBatchStore { if let data = try? JSONEncoder().encode(batch) { defaults.set(data, forKey: key(id)) } } + private static let finishedFoldersKey = "transfer.finishedUploadFolders" + + /// The Share Extension finished an upload into [folder] while the app + /// wasn't running; the app reads this on its next activation so its file + /// list can refresh. + static func noteFinishedUpload(folder: String) { + lock.lock() + defer { lock.unlock() } + var folders = defaults.stringArray(forKey: finishedFoldersKey) ?? [] + if !folders.contains(folder) { folders.append(folder) } + defaults.set(folders, forKey: finishedFoldersKey) + } + + static func consumeFinishedUploadFolders() -> [String] { + lock.lock() + defer { lock.unlock() } + let folders = defaults.stringArray(forKey: finishedFoldersKey) ?? [] + defaults.removeObject(forKey: finishedFoldersKey) + return folders + } + /// Counts one file's outcome. Returns the batch when that was its last /// file (and forgets it), nil while others are still running. static func record(_ info: TransferTaskInfo, success: Bool) -> TransferBatch? { From 673fb99d014606c691efa280abf57604a5cafb09 Mon Sep 17 00:00:00 2001 From: Ayushya Amitabh Date: Tue, 6 Oct 2026 19:46:54 -0400 Subject: [PATCH 15/31] Frosted bottom bar: adjustable blur and opacity with Less/Default/More presets Settings > Appearance shows a preset control plus Blur and Opacity sliders (new NooSlider) while frosted glass is on; values persist and feed NooBottomBar.frostedBlur/frostedOpacity. Default matches the old fixed look. Co-Authored-By: Claude Sonnet 5.5 --- .claude/context/styling.md | 15 ++- lib/main.dart | 2 + lib/providers/settings_controller.dart | 66 ++++++++++ lib/widgets/bottom_nav_bar.dart | 6 + lib/widgets/noo/core/noo_slider.dart | 51 ++++++++ lib/widgets/noo/nav/noo_bottom_bar.dart | 40 +++++-- lib/widgets/settings/settings_appearance.dart | 113 ++++++++++++++++++ test/providers/settings_frosted_test.dart | 85 +++++++++++++ test/widgets/noo/core_test.dart | 30 +++++ test/widgets/noo/nav_test.dart | 39 ++++++ 10 files changed, 431 insertions(+), 16 deletions(-) create mode 100644 lib/widgets/noo/core/noo_slider.dart create mode 100644 test/providers/settings_frosted_test.dart diff --git a/.claude/context/styling.md b/.claude/context/styling.md index 5f929d7..58c3bf3 100644 --- a/.claude/context/styling.md +++ b/.claude/context/styling.md @@ -86,7 +86,7 @@ Catalog: | Folder | Components | |---|---| -| `core/` | `NooButton`, `NooFab`, `NooChip`, `NooSegmentedControl`, `NooToggle`, `NooSearchField`, `NooAvatar`, `NooBadge`, `NooProgressBar` | +| `core/` | `NooButton`, `NooFab`, `NooChip`, `NooSegmentedControl`, `NooToggle`, `NooSlider`, `NooSearchField`, `NooAvatar`, `NooBadge`, `NooProgressBar` | | `lists/` | `NooGroupedList`, `NooSettingsRow`, `NooTabOrderRow`, `NooBanner`, `NooInfoNote` (tinted info callout; `SettingsSection(notice:)`), `NooSummaryCard`, `NooSelectionBar` | | `files/` | `NooFileKind` (spec §1.2 tiles; `NooFileKind.from(name:, mimeType:, isDirectory:)`), `NooFileTile`, `NooStatusIcon`/`NooSyncStatus`, `NooFileRow` (mobile 64px), `NooFileTableHeader`/`NooFileTableRow` (wide tablet), `NooSwipeAction` | | `media/` | `NooGridCard`, `NooPhotoTile` (video badge, selection), `NooPhotoGroupHeader`/`NooPhotoGrid` (sliver, or `.box`), `NooActivityItem`, `NooStatCard` | @@ -253,9 +253,16 @@ blocks are noted where they matter: only the attached bar still has a distinct iOS row. A separate, orthogonal `SettingsController.bottomBarFrosted` toggle (Settings → Appearance → "Frosted glass bottom bar", off by default, both platforms and both bar - styles) swaps the bar's solid `surface` for a 0.72-alpha one over a - 20-sigma `BackdropFilter` (same sigma as `FrostedGlassContainer`) and drops - `nooDialogShadow` (it would show through the glass). A frosted bar only + styles) swaps the bar's solid `surface` for a translucent one over a + `BackdropFilter` blur and drops + `nooDialogShadow` (it would show through the glass). Strength is user-set: + `FrostedGlassPreset` Less (blur 10 / opacity 0.88), Default (20 / 0.72, the + old fixed values) and More (32 / 0.55), plus Blur (0-40) and Opacity + (0.3-1.0) sliders (`NooSlider`, `core/`) in Settings -> Appearance, shown + only while frosted glass is on; both persist + (`ui_bottom_bar_frosted_blur`/`_opacity`) and reach the bar as + `NooBottomBar.frostedBlur`/`frostedOpacity`. No preset is highlighted once + the sliders are moved off all three. A frosted bar only reads as glass with content behind it, so the shell uses `NooBottomBar.drawsBehindBody(barStyle, frosted)` for `extendBody`, and `bottomBarClearance` (and Files' own trailing sliver) reserve the bar's diff --git a/lib/main.dart b/lib/main.dart index 0e253d0..d042399 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -564,6 +564,8 @@ class _MainShellViewState extends State { style: navStyle, barStyle: bottomBarStyle, frosted: settings.bottomBarFrosted, + frostedBlur: settings.bottomBarFrostedBlur, + frostedOpacity: settings.bottomBarFrostedOpacity, tabs: pinnedTabs, selectedIndex: pinnedIndex, onSearchTap: showBottomBarSearch ? () => openSearch(context) : null, diff --git a/lib/providers/settings_controller.dart b/lib/providers/settings_controller.dart index b01098a..46f7f92 100644 --- a/lib/providers/settings_controller.dart +++ b/lib/providers/settings_controller.dart @@ -21,6 +21,30 @@ enum FabStyle { auto, mini, expanded } /// animated wave with a tick-mark thumb ([squiggly]). enum MediaProgressBarStyle { classic, wavy, slim, squiggly } +/// The frosted-glass bottom bar's blur sigma and surface opacity, in three +/// ready-made strengths (Settings, Appearance). Moving either slider off all +/// three leaves no preset selected (a custom mix). +enum FrostedGlassPreset { + less(blur: 10, opacity: 0.88, label: 'Less'), + standard(blur: 20, opacity: 0.72, label: 'Default'), + more(blur: 32, opacity: 0.55, label: 'More'); + + final double blur; + final double opacity; + final String label; + + const FrostedGlassPreset({ + required this.blur, + required this.opacity, + required this.label, + }); +} + +const double minFrostedBlur = 0; +const double maxFrostedBlur = 40; +const double minFrostedOpacity = 0.3; +const double maxFrostedOpacity = 1.0; + /// Global (not per-account) UI settings: theme, bottom-nav appearance, tab /// configuration, swipe actions. Independent of login state - these mean /// the same thing whether any account is signed in or not, unlike @@ -39,6 +63,8 @@ class SettingsController extends ChangeNotifier { static const _prefFabStyle = 'ui_fab_style'; static const _prefBottomBarStyle = 'ui_bottom_bar_style'; static const _prefBottomBarFrosted = 'ui_bottom_bar_frosted'; + static const _prefFrostedBlur = 'ui_bottom_bar_frosted_blur'; + static const _prefFrostedOpacity = 'ui_bottom_bar_frosted_opacity'; static const _prefNavMenuStyle = 'ui_nav_menu_style'; static const _prefSearchInBottomBar = 'ui_search_in_bottom_bar'; static const _prefAmoledDark = 'ui_amoled_dark'; @@ -53,6 +79,8 @@ class SettingsController extends ChangeNotifier { NooBottomBarStyle _bottomBarStyle = NooBottomBarStyle.attached; NooNavMenuStyle _navMenuStyle = NooNavMenuStyle.drawer; bool _bottomBarFrosted = false; + double _frostedBlur = FrostedGlassPreset.standard.blur; + double _frostedOpacity = FrostedGlassPreset.standard.opacity; bool _searchInBottomBar = false; bool _useDynamicColor = true; bool _amoledDark = false; @@ -98,6 +126,14 @@ class SettingsController extends ChangeNotifier { ThemeMode get themeMode => _themeMode; NooBottomBarStyle get bottomBarStyle => _bottomBarStyle; bool get bottomBarFrosted => _bottomBarFrosted; + double get bottomBarFrostedBlur => _frostedBlur; + double get bottomBarFrostedOpacity => _frostedOpacity; + + /// The preset the current blur/opacity match exactly, or null when the + /// sliders have been moved to something custom. + FrostedGlassPreset? get frostedPreset => FrostedGlassPreset.values + .where((p) => p.blur == _frostedBlur && p.opacity == _frostedOpacity) + .firstOrNull; NooNavMenuStyle get navMenuStyle => _navMenuStyle; bool get searchInBottomBar => _searchInBottomBar; bool get useDynamicColor => _useDynamicColor; @@ -160,6 +196,15 @@ class SettingsController extends ChangeNotifier { } _bottomBarFrosted = prefs.getBool(_prefBottomBarFrosted) ?? _bottomBarFrosted; + _frostedBlur = (prefs.getDouble(_prefFrostedBlur) ?? _frostedBlur).clamp( + minFrostedBlur, + maxFrostedBlur, + ); + _frostedOpacity = + (prefs.getDouble(_prefFrostedOpacity) ?? _frostedOpacity).clamp( + minFrostedOpacity, + maxFrostedOpacity, + ); _searchInBottomBar = prefs.getBool(_prefSearchInBottomBar) ?? _searchInBottomBar; _useDynamicColor = @@ -318,6 +363,27 @@ class SettingsController extends ChangeNotifier { _prefsFuture.then((p) => p.setString(_prefBottomBarStyle, style.name)); } + void setFrostedBlur(double value) { + final v = value.clamp(minFrostedBlur, maxFrostedBlur); + if (_frostedBlur == v) return; + _frostedBlur = v; + notifyListeners(); + _prefsFuture.then((p) => p.setDouble(_prefFrostedBlur, v)); + } + + void setFrostedOpacity(double value) { + final v = value.clamp(minFrostedOpacity, maxFrostedOpacity); + if (_frostedOpacity == v) return; + _frostedOpacity = v; + notifyListeners(); + _prefsFuture.then((p) => p.setDouble(_prefFrostedOpacity, v)); + } + + void setFrostedPreset(FrostedGlassPreset preset) { + setFrostedBlur(preset.blur); + setFrostedOpacity(preset.opacity); + } + void setBottomBarFrosted(bool value) { if (_bottomBarFrosted == value) return; _bottomBarFrosted = value; diff --git a/lib/widgets/bottom_nav_bar.dart b/lib/widgets/bottom_nav_bar.dart index c2dce07..15418a9 100644 --- a/lib/widgets/bottom_nav_bar.dart +++ b/lib/widgets/bottom_nav_bar.dart @@ -17,6 +17,8 @@ class BottomNavBar extends StatelessWidget { final NooNavStyle style; final NooBottomBarStyle barStyle; final bool frosted; + final double frostedBlur; + final double frostedOpacity; final List tabs; final int selectedIndex; final ValueChanged onDestinationSelected; @@ -27,6 +29,8 @@ class BottomNavBar extends StatelessWidget { required this.style, this.barStyle = NooBottomBarStyle.attached, this.frosted = false, + this.frostedBlur = NooBottomBar.defaultFrostedBlur, + this.frostedOpacity = NooBottomBar.defaultFrostedOpacity, required this.tabs, required this.selectedIndex, required this.onDestinationSelected, @@ -39,6 +43,8 @@ class BottomNavBar extends StatelessWidget { style: style, barStyle: barStyle, frosted: frosted, + frostedBlur: frostedBlur, + frostedOpacity: frostedOpacity, destinations: [ for (final tab in tabs) NooNavDestination(icon: tab.icon, label: tab.label), diff --git a/lib/widgets/noo/core/noo_slider.dart b/lib/widgets/noo/core/noo_slider.dart new file mode 100644 index 0000000..ae9fc59 --- /dev/null +++ b/lib/widgets/noo/core/noo_slider.dart @@ -0,0 +1,51 @@ +import 'package:flutter/material.dart'; +import '../../../theme/design_tokens.dart'; + +/// A continuous (or [divisions]-stepped) value slider in the design system's +/// colors: accent fill on a `surface3` track, accent thumb. Data-agnostic - +/// the caller owns [value] and formats any readout beside it. +class NooSlider extends StatelessWidget { + final double value; + final double min; + final double max; + final int? divisions; + final ValueChanged? onChanged; + final String? semanticLabel; + + const NooSlider({ + super.key, + required this.value, + required this.min, + required this.max, + this.divisions, + this.onChanged, + this.semanticLabel, + }); + + @override + Widget build(BuildContext context) { + final colors = context.nooColors; + return SliderTheme( + data: SliderTheme.of(context).copyWith( + trackHeight: 4, + activeTrackColor: colors.accent, + inactiveTrackColor: colors.surface3, + thumbColor: colors.accent, + overlayColor: colors.accent.withValues(alpha: 0.12), + thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 9), + trackShape: const RoundedRectSliderTrackShape(), + showValueIndicator: ShowValueIndicator.never, + ), + child: Slider( + value: value.clamp(min, max), + min: min, + max: max, + divisions: divisions, + semanticFormatterCallback: semanticLabel == null + ? null + : (v) => '$semanticLabel ${v.toStringAsFixed(2)}', + onChanged: onChanged, + ), + ); + } +} diff --git a/lib/widgets/noo/nav/noo_bottom_bar.dart b/lib/widgets/noo/nav/noo_bottom_bar.dart index 95a3ce0..51a4f9e 100644 --- a/lib/widgets/noo/nav/noo_bottom_bar.dart +++ b/lib/widgets/noo/nav/noo_bottom_bar.dart @@ -14,11 +14,6 @@ const double _kFloatingPillTop = 8; const double _kAndroidPillWidth = 56; const double _kAndroidPillHeight = 32; -/// Frosted glass: the surface fill's opacity and the backdrop blur sigma - -/// the same sigma [FrostedGlassContainer] uses for the media viewer chrome. -const double _kFrostedAlpha = 0.72; -const double _kFrostedSigma = 20; - /// The mobile bottom bar (DESIGN_SYSTEM.md 3, "Mobile"; `iNav`/`aNav` in /// `Mobile Screen.dc.html`). Shows the 5 pinned tabs - which ones, and in /// what order, is the caller's business; this only draws [destinations]. @@ -71,6 +66,12 @@ class NooBottomBar extends StatelessWidget { final NooNavStyle style; final NooBottomBarStyle barStyle; final bool frosted; + + /// Backdrop blur sigma and surface-fill opacity while [frosted] (user- + /// adjustable in Settings; the defaults match [FrostedGlassContainer]'s + /// blur). + final double frostedBlur; + final double frostedOpacity; final List destinations; final int selectedIndex; final ValueChanged onSelected; @@ -82,6 +83,8 @@ class NooBottomBar extends StatelessWidget { required this.style, this.barStyle = NooBottomBarStyle.attached, this.frosted = false, + this.frostedBlur = defaultFrostedBlur, + this.frostedOpacity = defaultFrostedOpacity, required this.destinations, required this.selectedIndex, required this.onSelected, @@ -99,6 +102,9 @@ class NooBottomBar extends StatelessWidget { return barStyle == NooBottomBarStyle.floating ? 64 : 80; } + static const double defaultFrostedBlur = 20; + static const double defaultFrostedOpacity = 0.72; + /// Whether the host `Scaffold` must draw its body behind the bar /// (`extendBody`) - true for floating (its transparent margin) and for any /// frosted bar (there's nothing to blur otherwise). @@ -144,7 +150,9 @@ class NooBottomBar extends StatelessWidget { ), child: row, ); - final pill = frosted ? _blurred(pillBox, pillRadius) : pillBox; + final pill = frosted + ? _blurred(pillBox, pillRadius, frostedBlur) + : pillBox; return SafeArea( top: false, child: Padding( @@ -176,6 +184,8 @@ class NooBottomBar extends StatelessWidget { onTap: onSearchTap!, size: barHeight, frosted: frosted, + frostedBlur: frostedBlur, + frostedOpacity: frostedOpacity, ), ], ), @@ -195,11 +205,11 @@ class NooBottomBar extends StatelessWidget { child: SizedBox(height: barHeight, child: row), ), ); - return frosted ? _blurred(bar, BorderRadius.zero) : bar; + return frosted ? _blurred(bar, BorderRadius.zero, frostedBlur) : bar; } Color _fill(NooColors colors) => frosted - ? colors.surface.withValues(alpha: _kFrostedAlpha) + ? colors.surface.withValues(alpha: frostedOpacity) : colors.surface; Widget _buildIosRow({NooNavDestination? trailingSearch}) { @@ -286,10 +296,10 @@ class NooBottomBar extends StatelessWidget { /// Clips [child] to [radius] and blurs whatever is drawn behind it - the /// frosted bar's backdrop. [child] supplies the translucent fill on top. -Widget _blurred(Widget child, BorderRadius radius) => ClipRRect( +Widget _blurred(Widget child, BorderRadius radius, double sigma) => ClipRRect( borderRadius: radius, child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: _kFrostedSigma, sigmaY: _kFrostedSigma), + filter: ImageFilter.blur(sigmaX: sigma, sigmaY: sigma), child: child, ), ); @@ -302,12 +312,16 @@ class _SearchSatellite extends StatelessWidget { final VoidCallback onTap; final double size; final bool frosted; + final double frostedBlur; + final double frostedOpacity; const _SearchSatellite({ required this.destination, required this.onTap, required this.size, required this.frosted, + required this.frostedBlur, + required this.frostedOpacity, }); @override @@ -331,7 +345,7 @@ class _SearchSatellite extends StatelessWidget { alignment: Alignment.center, decoration: BoxDecoration( color: frosted - ? colors.surface.withValues(alpha: _kFrostedAlpha) + ? colors.surface.withValues(alpha: frostedOpacity) : colors.surface, border: Border.all(color: colors.line), shape: BoxShape.circle, @@ -339,7 +353,9 @@ class _SearchSatellite extends StatelessWidget { ), child: Icon(destination.icon, size: 24, color: colors.fg1), ); - return frosted ? _blurred(disc, BorderRadius.circular(size / 2)) : disc; + return frosted + ? _blurred(disc, BorderRadius.circular(size / 2), frostedBlur) + : disc; } } diff --git a/lib/widgets/settings/settings_appearance.dart b/lib/widgets/settings/settings_appearance.dart index 23d73c5..50267eb 100644 --- a/lib/widgets/settings/settings_appearance.dart +++ b/lib/widgets/settings/settings_appearance.dart @@ -5,6 +5,7 @@ import '../../providers/settings_controller.dart'; import '../../theme/app_theme.dart'; import '../../theme/design_tokens.dart'; import '../noo/core/noo_segmented_control.dart'; +import '../noo/core/noo_slider.dart'; import '../noo/core/noo_toggle.dart'; import '../noo/lists/noo_settings_row.dart'; import '../noo/nav/noo_nav_style.dart'; @@ -64,6 +65,7 @@ class SettingsAppearanceSection extends StatelessWidget { onChanged: settings.setBottomBarFrosted, ), ), + if (settings.bottomBarFrosted) _FrostedGlassRow(settings: settings), _NavMenuStyleRow(settings: settings), NooSettingsRow( icon: LucideIcons.search, @@ -251,6 +253,117 @@ class _BottomBarStyleRow extends StatelessWidget { } } +/// How strong the frosted-glass bottom bar looks: three presets for a quick +/// pick, and the blur / opacity sliders they set, for anything in between. +/// Only shown while frosted glass is on. +class _FrostedGlassRow extends StatelessWidget { + final SettingsController settings; + + const _FrostedGlassRow({required this.settings}); + + @override + Widget build(BuildContext context) { + final colors = context.nooColors; + return Material( + color: colors.surface, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: NooSpace.md, + vertical: 12, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(LucideIcons.sparkles, size: 20, color: colors.fg2), + const SizedBox(width: 14), + Text( + 'Frost strength', + style: NooText.bodyL.copyWith(color: colors.fg1), + ), + ], + ), + const SizedBox(height: 12), + NooSegmentedControl( + fill: true, + // No segment selected once the sliders no longer match a preset. + value: settings.frostedPreset, + onChanged: (preset) { + if (preset != null) settings.setFrostedPreset(preset); + }, + options: [ + for (final preset in FrostedGlassPreset.values) + NooSegmentOption( + value: preset, + label: preset.label, + ), + ], + ), + const SizedBox(height: 8), + _SliderLine( + label: 'Blur', + readout: settings.bottomBarFrostedBlur.round().toString(), + slider: NooSlider( + value: settings.bottomBarFrostedBlur, + min: minFrostedBlur, + max: maxFrostedBlur, + semanticLabel: 'Blur', + onChanged: settings.setFrostedBlur, + ), + ), + _SliderLine( + label: 'Opacity', + readout: '${(settings.bottomBarFrostedOpacity * 100).round()}%', + slider: NooSlider( + value: settings.bottomBarFrostedOpacity, + min: minFrostedOpacity, + max: maxFrostedOpacity, + semanticLabel: 'Opacity', + onChanged: settings.setFrostedOpacity, + ), + ), + ], + ), + ), + ); + } +} + +class _SliderLine extends StatelessWidget { + final String label; + final String readout; + final Widget slider; + + const _SliderLine({ + required this.label, + required this.readout, + required this.slider, + }); + + @override + Widget build(BuildContext context) { + final colors = context.nooColors; + return Row( + children: [ + SizedBox( + width: 64, + child: Text(label, style: NooText.body.copyWith(color: colors.fg2)), + ), + Expanded(child: slider), + SizedBox( + width: 44, + child: Text( + readout, + textAlign: TextAlign.end, + style: NooText.body.copyWith(color: colors.fg3), + ), + ), + ], + ); + } +} + /// "Navigation menu" (Settings → Appearance): which widget opens hidden /// tabs + Settings - the original `menu` icon that opens a left `Drawer`, /// or the avatar button instead (opens `showAvatarMenu`, a dropdown diff --git a/test/providers/settings_frosted_test.dart b/test/providers/settings_frosted_test.dart new file mode 100644 index 0000000..c41e82a --- /dev/null +++ b/test/providers/settings_frosted_test.dart @@ -0,0 +1,85 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:noo/providers/settings_controller.dart'; + +/// The frosted-glass bottom bar's blur/opacity settings and their presets. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() => SharedPreferences.setMockInitialValues({})); + + Future build() async { + final settings = SettingsController(); + // `_load` is async and not exposed; one event-loop turn lets it finish. + await Future.delayed(Duration.zero); + return settings; + } + + test('starts on the Default preset', () async { + final settings = await build(); + expect(settings.frostedPreset, FrostedGlassPreset.standard); + expect(settings.bottomBarFrostedBlur, 20); + expect(settings.bottomBarFrostedOpacity, 0.72); + }); + + test('presets set both values and are recognised again', () async { + final settings = await build(); + for (final preset in FrostedGlassPreset.values) { + settings.setFrostedPreset(preset); + expect(settings.bottomBarFrostedBlur, preset.blur); + expect(settings.bottomBarFrostedOpacity, preset.opacity); + expect(settings.frostedPreset, preset); + } + }); + + test('presets run from lighter to stronger frosting', () { + expect( + FrostedGlassPreset.less.blur, + lessThan(FrostedGlassPreset.standard.blur), + ); + expect( + FrostedGlassPreset.standard.blur, + lessThan(FrostedGlassPreset.more.blur), + ); + expect( + FrostedGlassPreset.less.opacity, + greaterThan(FrostedGlassPreset.standard.opacity), + ); + expect( + FrostedGlassPreset.standard.opacity, + greaterThan(FrostedGlassPreset.more.opacity), + ); + }); + + test('moving a slider off every preset leaves none selected', () async { + final settings = await build(); + settings.setFrostedBlur(27); + expect(settings.frostedPreset, isNull); + settings.setFrostedBlur(FrostedGlassPreset.standard.blur); + expect(settings.frostedPreset, FrostedGlassPreset.standard); + }); + + test('values are clamped to their ranges', () async { + final settings = await build(); + settings + ..setFrostedBlur(500) + ..setFrostedOpacity(0); + expect(settings.bottomBarFrostedBlur, maxFrostedBlur); + expect(settings.bottomBarFrostedOpacity, minFrostedOpacity); + settings + ..setFrostedBlur(-3) + ..setFrostedOpacity(9); + expect(settings.bottomBarFrostedBlur, minFrostedBlur); + expect(settings.bottomBarFrostedOpacity, maxFrostedOpacity); + }); + + test('saved values are restored (and clamped) on the next launch', () async { + SharedPreferences.setMockInitialValues({ + 'ui_bottom_bar_frosted_blur': 32.0, + 'ui_bottom_bar_frosted_opacity': 7.0, + }); + final settings = await build(); + expect(settings.bottomBarFrostedBlur, 32); + expect(settings.bottomBarFrostedOpacity, maxFrostedOpacity); + }); +} diff --git a/test/widgets/noo/core_test.dart b/test/widgets/noo/core_test.dart index 143d373..504686a 100644 --- a/test/widgets/noo/core_test.dart +++ b/test/widgets/noo/core_test.dart @@ -10,6 +10,7 @@ import 'package:noo/widgets/noo/core/noo_chip.dart'; import 'package:noo/widgets/noo/core/noo_fab.dart'; import 'package:noo/widgets/noo/core/noo_progress_bar.dart'; import 'package:noo/widgets/noo/core/noo_search_field.dart'; +import 'package:noo/widgets/noo/core/noo_slider.dart'; import 'package:noo/widgets/noo/core/noo_segmented_control.dart'; import 'package:noo/widgets/noo/core/noo_toggle.dart'; import 'package:noo/widgets/noo/nav/noo_nav_style.dart'; @@ -98,6 +99,35 @@ void main() { } }); + group('NooSlider', () { + testNooWidgets('dragging reports a value within min..max', ( + tester, + theme, + c, + ) async { + var value = 10.0; + await pumpNoo( + tester, + StatefulBuilder( + builder: (context, setState) => SizedBox( + width: 300, + child: NooSlider( + value: value, + min: 0, + max: 40, + onChanged: (v) => setState(() => value = v), + ), + ), + ), + theme: theme, + ); + await tester.drag(find.byType(Slider), const Offset(150, 0)); + await tester.pump(); + expect(value, greaterThan(10)); + expect(value, lessThanOrEqualTo(40)); + }); + }); + group('NooAvatar', () { testNooWidgets('current user uses accent-soft/accent-text', ( tester, diff --git a/test/widgets/noo/nav_test.dart b/test/widgets/noo/nav_test.dart index 39d3fb6..84b1fd0 100644 --- a/test/widgets/noo/nav_test.dart +++ b/test/widgets/noo/nav_test.dart @@ -1,3 +1,4 @@ +import 'dart:ui' show ImageFilter; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; @@ -62,6 +63,44 @@ void main() { } } + testNooWidgets('frosted bar uses the given blur sigma and fill opacity', ( + tester, + theme, + c, + ) async { + await pumpNoo( + tester, + SizedBox( + width: 360, + height: 120, + child: NooBottomBar( + style: NooNavStyle.android, + frosted: true, + frostedBlur: 31, + frostedOpacity: 0.5, + destinations: _destinations, + selectedIndex: 0, + onSelected: (_) {}, + ), + ), + theme: theme, + ); + expect( + tester.widget(find.byType(BackdropFilter)).filter, + ImageFilter.blur(sigmaX: 31, sigmaY: 31), + ); + expect( + find.byWidgetPredicate( + (w) => + w is Container && + w.decoration is BoxDecoration && + (w.decoration as BoxDecoration).color == + c.surface.withValues(alpha: 0.5), + ), + findsOneWidget, + ); + }); + test('drawsBehindBody: floating or frosted', () { expect( NooBottomBar.drawsBehindBody(NooBottomBarStyle.attached, false), From 85eb0a3ceff4d6b7a8a81bea2dbdaf8b39f3b82b Mon Sep 17 00:00:00 2001 From: Ayushya Amitabh Date: Tue, 6 Oct 2026 19:59:29 -0400 Subject: [PATCH 16/31] Share Extension: account picker first, hidden folders follow the app's filter + unlock The app now publishes every uploadable account, the active one, the three lock settings and each account's own hidden-files filter (ShareAccountSync). The sheet lists accounts first when there are several, asks for unlock to use a non-active account (login lock + account-switching lock), and shows hidden folders per the account's app setting behind the hidden-files unlock. Co-Authored-By: Claude Sonnet 5.5 --- .claude/context/server.md | 37 +++- ios/Runner.xcodeproj/project.pbxproj | 4 + ios/Runner/Native/NativeServices.swift | 21 +- ios/RunnerTests/RunnerTests.swift | 66 ++++++- ios/ShareExtension/DeviceAuth.swift | 19 ++ ios/ShareExtension/Info.plist | 2 + ios/ShareExtension/SharePickerView.swift | 128 ++++++++++++- ios/ShareExtension/ShareViewController.swift | 17 +- ios/Shared/DavFolders.swift | 38 +++- ios/Shared/SharedAccount.swift | 54 ++++-- lib/main.dart | 17 +- lib/providers/files_controller.dart | 13 ++ lib/services/share_account_service.dart | 179 ++++++++++++++++-- test/services/share_account_service_test.dart | 73 +++++++ 14 files changed, 581 insertions(+), 87 deletions(-) create mode 100644 ios/ShareExtension/DeviceAuth.swift create mode 100644 test/services/share_account_service_test.dart diff --git a/.claude/context/server.md b/.claude/context/server.md index a3dfb15..ddac4dc 100644 --- a/.claude/context/server.md +++ b/.claude/context/server.md @@ -885,13 +885,34 @@ Reminders/Notes - the destination is picked *inside* the sheet: loadFileRepresentation`, public.item) into the App Group container (`group.dev.ayushya.noo`, `SharedInbox//`) and hosts the SwiftUI `SharePickerView` (`ShareModel` holds its state). -2. It reads the signed-in account from the shared Keychain - (`SharedAccountStore`) and lists folders over WebDAV (`DavClient` - `PROPFIND` Depth 1, parsed by `DavFolderParser`; dot-folders hidden). - The Dart side keeps that account current: `ShareAccountService.publish` - (`lib/services/share_account_service.dart`) runs on every account-ready - event and `clear` on account-cleared, through the native `share_account` - channel - both wired where `SessionController` is created in `main.dart`. +2. It reads the accounts from the shared Keychain (`SharedAccountStore` -> + `SharedAccounts`) and lists folders over WebDAV (`DavClient` `PROPFIND` + Depth 1, parsed by `DavFolderParser`). With **several accounts an account + list comes first** (the app's active one is marked "Active"; "Change + account" returns to it); with one it goes straight to its folders. The + Dart side keeps the keychain data current: `ShareAccountSync` + (`lib/services/share_account_service.dart`, started from the shell's + `initState`) republishes - through the native `share_account` channel's + `setAccounts` - when an account becomes ready or when its change + signature moves: the account list/active account, the three lock + settings, or the active account's hidden-files filter; sign-out calls + `clearAccount`. It publishes every saved account that has a stored + password and isn't signed out, each with its **own Files hidden-files + filter** (`FilesController.savedHiddenFilter`). + - **Hidden folders** follow that app setting - there is no share-sheet + setting of their own. `hide` (default) drops dot-folders, `only` lists + just them, `include` lists everything; a folder is hidden when it *or + any ancestor* starts with a dot (`HiddenFilter`, same rule as the app). + When the filter isn't `hide` and the app has login lock + "lock hidden + files" on, the sheet asks for Face ID/passcode first (`DeviceAuth`, the + `.deviceOwnerAuthentication` policy - biometrics with passcode fallback, + like `local_auth` with `biometricOnly: false`); cancelling falls back to + hiding them, with a note. + - **Account switching**: choosing any account other than the app's + active one asks for the same unlock when login lock + "lock account + switching" are on. One successful unlock covers the rest of that sheet. + - Opening the sheet itself is not gated - only these two actions are + (as in the app, where login lock guards launch and these toggles). 3. **Upload** calls `ShareUpload.enqueue`: one `PUT` per file on a *background* `URLSession` (`dev.ayushya.noo.transfers.share`, with `sharedContainerIdentifier`) that outlives the extension, and posts @@ -919,7 +940,7 @@ Reminders/Notes - the destination is picked *inside* the sheet: The keychain group is `$(AppIdentifierPrefix)dev.ayushya.noo.shared` (`keychain-access-groups` in both `.entitlements`); both Info.plists also carry it as `NooKeychainAccessGroup`, which `SharedAccountStore` reads, so the -two processes always agree on the team-prefixed id. The account is stored as +two processes always agree on the team-prefixed id. Everything is stored as JSON in one generic-password item (`kSecAttrAccessibleAfterFirstUnlock`). `ios/Shared/` (compiled into both targets): `SharedInbox`, `SharedAccount`, diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 583e205..65e4e79 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -26,6 +26,7 @@ 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; }; 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; 852C0C8EBA96CB6389DF6917 /* SharedAccount.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F2C956C98F58A97B5595307 /* SharedAccount.swift */; }; + 8C73B4CB445AEF383B7640E1 /* DeviceAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97ACCF43E1EC13AA2538E42C /* DeviceAuth.swift */; }; 91C1658DC7467CACB2AAC7FF /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0AE6F681A3100A461B4A5318 /* Foundation.framework */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; @@ -107,6 +108,7 @@ 901C0CDB4FB819302E2A7563 /* NativeServices.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NativeServices.swift; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 97ACCF43E1EC13AA2538E42C /* DeviceAuth.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DeviceAuth.swift; sourceTree = ""; }; 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; @@ -158,6 +160,7 @@ 49213989F0FA1B83B0B8A523 /* Info.plist */, 65F57A1EB0C62774E561860D /* ShareExtension.entitlements */, 5BE7B8C31FF78E212BD71DFC /* SharePickerView.swift */, + 97ACCF43E1EC13AA2538E42C /* DeviceAuth.swift */, ); name = ShareExtension; path = ShareExtension; @@ -438,6 +441,7 @@ 3C1A0DAA5F9B37FEA4C604BE /* DavFolders.swift in Sources */, 66AFC246E251554A80B2476F /* SharePickerView.swift in Sources */, A16680889B3A9D8672FA29C1 /* TransferNotifications.swift in Sources */, + 8C73B4CB445AEF383B7640E1 /* DeviceAuth.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/ios/Runner/Native/NativeServices.swift b/ios/Runner/Native/NativeServices.swift index db47b4e..954d235 100644 --- a/ios/Runner/Native/NativeServices.swift +++ b/ios/Runner/Native/NativeServices.swift @@ -80,22 +80,21 @@ enum NativeServices { // MARK: - Account for the Share Extension - /// `share_account`: Dart tells native which account is active so the Share - /// Extension - a separate process with no Flutter engine - can list folders - /// and upload as it. Stored in the shared Keychain group. + /// `share_account`: Dart hands native every account that can upload, which + /// one is active, and the app-lock settings, so the Share Extension - a + /// separate process with no Flutter engine - can list folders and upload as + /// them. Stored in the shared Keychain group. private static func registerShareAccount(messenger: FlutterBinaryMessenger) { FlutterMethodChannel(name: "dev.ayushya.noo/share_account", binaryMessenger: messenger) .setMethodCallHandler { call, result in switch call.method { - case "setAccount": + case "setAccounts": handle(call, result) { args in - try SharedAccountStore.save( - SharedAccount( - serverUrl: try string(args, "serverUrl"), - username: try string(args, "username"), - authHeader: try string(args, "authHeader"), - displayName: (args["displayName"] as? String) ?? "" - )) + guard let json = args["accounts"] as? String, + let data = json.data(using: .utf8), + let accounts = try? JSONDecoder().decode(SharedAccounts.self, from: data) + else { throw TransferError(message: "Bad accounts payload.") } + try SharedAccountStore.save(accounts) } case "clearAccount": SharedAccountStore.clear() diff --git a/ios/RunnerTests/RunnerTests.swift b/ios/RunnerTests/RunnerTests.swift index 74ece5f..ff1b899 100644 --- a/ios/RunnerTests/RunnerTests.swift +++ b/ios/RunnerTests/RunnerTests.swift @@ -124,8 +124,24 @@ class RunnerTests: XCTestCase { DavFolderParser.folders(from: xml, excluding: "/Docs"), [DavFolder(name: "Tax Forms", path: "/Docs/Tax Forms"), DavFolder(name: "zeta", path: "/Docs/zeta")]) XCTAssertEqual( - DavFolderParser.folders(from: xml, excluding: "/Docs", includeHidden: true).map(\.name), + DavFolderParser.folders(from: xml, excluding: "/Docs", hidden: .include).map(\.name), [".git", "Tax Forms", "zeta"]) + XCTAssertEqual( + DavFolderParser.folders(from: xml, excluding: "/Docs", hidden: .only).map(\.name), + [".git"]) + } + + func testHiddenFilterTreatsAnyDotSegmentAsHidden() { + XCTAssertTrue(HiddenFilter.isHidden(path: "/.cache")) + XCTAssertTrue(HiddenFilter.isHidden(path: "/.cache/inside/deeper"), "children of a hidden folder are hidden") + XCTAssertFalse(HiddenFilter.isHidden(path: "/Docs/Tax Forms")) + XCTAssertTrue(HiddenFilter.hide.shows(path: "/Docs")) + XCTAssertFalse(HiddenFilter.hide.shows(path: "/.git")) + XCTAssertTrue(HiddenFilter.only.shows(path: "/.git/hooks")) + XCTAssertFalse(HiddenFilter.only.shows(path: "/Docs")) + XCTAssertTrue(HiddenFilter.include.shows(path: "/Docs")) + XCTAssertEqual(HiddenFilter(raw: "include"), .include) + XCTAssertEqual(HiddenFilter(raw: "nonsense"), .hide, "unknown values fall back to hiding") } func testFoldersAtRootAndUnderServerSubPath() { @@ -150,19 +166,26 @@ class RunnerTests: XCTestCase { // MARK: - Shared account (Keychain group shared with the extension) - func testSharedAccountRoundTripsThroughTheKeychain() throws { - let account = SharedAccount( - serverUrl: "https://cloud.example.com", username: "alice", - authHeader: "Basic YWxpY2U6c2VjcmV0", displayName: "alice@cloud.example.com") + private func account(_ id: String, hidden: String = "hide") -> SharedAccount { + SharedAccount( + id: id, serverUrl: "https://\(id).example.com", username: id, + authHeader: "Basic \(id)", displayName: "\(id)@\(id).example.com", hiddenFilter: hidden) + } + + func testSharedAccountsRoundTripThroughTheKeychain() throws { + let accounts = SharedAccounts( + accounts: [account("alice", hidden: "include"), account("bob")], activeId: "bob", + loginLockEnabled: true, lockAccountSwitching: true, lockHiddenFiles: false) addTeardownBlock { SharedAccountStore.clear() } SharedAccountStore.clear() XCTAssertNil(SharedAccountStore.load()) - try SharedAccountStore.save(account) - XCTAssertEqual(SharedAccountStore.load(), account) + try SharedAccountStore.save(accounts) + XCTAssertEqual(SharedAccountStore.load(), accounts) - let replacement = SharedAccount( - serverUrl: "https://other.example.org", username: "bob", authHeader: "Basic Ym9i", displayName: "bob") + let replacement = SharedAccounts( + accounts: [account("carol")], activeId: "carol", + loginLockEnabled: false, lockAccountSwitching: false, lockHiddenFiles: false) try SharedAccountStore.save(replacement) XCTAssertEqual(SharedAccountStore.load(), replacement, "saving replaces, never duplicates") @@ -170,6 +193,31 @@ class RunnerTests: XCTestCase { XCTAssertNil(SharedAccountStore.load()) } + func testSharedAccountsActiveFallsBackToTheFirstAccount() { + var shared = SharedAccounts( + accounts: [account("alice"), account("bob")], activeId: "bob", + loginLockEnabled: false, lockAccountSwitching: false, lockHiddenFiles: false) + XCTAssertEqual(shared.active?.id, "bob") + shared.activeId = "gone" + XCTAssertEqual(shared.active?.id, "alice") + shared.activeId = nil + XCTAssertEqual(shared.active?.id, "alice") + } + + func testUnlockRulesNeedTheMasterLockAndTheirOwnToggle() { + func rules(master: Bool, switching: Bool, hidden: Bool) -> (Bool, Bool) { + let shared = SharedAccounts( + accounts: [account("a")], activeId: "a", + loginLockEnabled: master, lockAccountSwitching: switching, lockHiddenFiles: hidden) + return (shared.needsUnlockToSwitchAccount, shared.needsUnlockForHidden) + } + XCTAssertTrue(rules(master: true, switching: true, hidden: false) == (true, false)) + XCTAssertTrue(rules(master: true, switching: false, hidden: true) == (false, true)) + XCTAssertTrue( + rules(master: false, switching: true, hidden: true) == (false, false), + "the sub-toggles mean nothing while the login lock is off, as in the app") + } + // MARK: - TransferBatchStore func testBatchSummaryFiresOnlyOnTheLastFile() { diff --git a/ios/ShareExtension/DeviceAuth.swift b/ios/ShareExtension/DeviceAuth.swift new file mode 100644 index 0000000..b7c4cab --- /dev/null +++ b/ios/ShareExtension/DeviceAuth.swift @@ -0,0 +1,19 @@ +import LocalAuthentication + +/// Face ID / Touch ID with the device passcode as the fallback - the same +/// policy the app's lock uses (`local_auth` with `biometricOnly: false`), so +/// the share sheet asks for exactly what the app would. +enum DeviceAuth { + /// True only if the user actually authenticated; a cancel, a failure, or a + /// device with nothing to authenticate with all count as "not unlocked". + static func authenticate(reason: String) async -> Bool { + let context = LAContext() + var error: NSError? + guard context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &error) else { return false } + return await withCheckedContinuation { continuation in + context.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: reason) { success, _ in + continuation.resume(returning: success) + } + } + } +} diff --git a/ios/ShareExtension/Info.plist b/ios/ShareExtension/Info.plist index 5206ecc..68865d3 100644 --- a/ios/ShareExtension/Info.plist +++ b/ios/ShareExtension/Info.plist @@ -20,6 +20,8 @@ $(MARKETING_VERSION) CFBundleVersion $(CURRENT_PROJECT_VERSION) + NSFaceIDUsageDescription + Noo uses Face ID to unlock hidden folders and accounts when you share a file. NooKeychainAccessGroup $(AppIdentifierPrefix)dev.ayushya.noo.shared NSExtension diff --git a/ios/ShareExtension/SharePickerView.swift b/ios/ShareExtension/SharePickerView.swift index f2165ec..8ab7de6 100644 --- a/ios/ShareExtension/SharePickerView.swift +++ b/ios/ShareExtension/SharePickerView.swift @@ -1,10 +1,11 @@ import SwiftUI -/// State behind the share sheet's folder picker. +/// State behind the share sheet's account and folder pickers. @MainActor final class ShareModel: ObservableObject { enum Stage: Equatable { case preparing + case chooseAccount case picking case uploading case noAccount @@ -17,18 +18,30 @@ final class ShareModel: ObservableObject { @Published var folders: [DavFolder] = [] @Published var isLoadingFolders = false @Published var folderError: String? + /// A short explanation shown under the list - e.g. hidden folders stayed + /// hidden because the unlock was cancelled. + @Published var notice: String? + @Published private(set) var selected: SharedAccount? - let account: SharedAccount? + let shared: SharedAccounts? + + /// One successful unlock covers every gate for the rest of this sheet. + private var unlocked = false + private var hidden: HiddenFilter = .hide /// Set by the view controller: what each button actually does. var onUpload: () -> Void = {} var onSaveForLater: () -> Void = {} var onCancel: () -> Void = {} - init(account: SharedAccount?) { - self.account = account + init(shared: SharedAccounts?) { + self.shared = shared } + var accounts: [SharedAccount] { shared?.accounts ?? [] } + var canChangeAccount: Bool { accounts.count > 1 } + var activeAccountId: String? { shared?.active?.id } + var currentFolderName: String { path == "/" ? "All files" : (path as NSString).lastPathComponent } @@ -37,14 +50,66 @@ final class ShareModel: ObservableObject { items.count == 1 ? "1 file" : "\(items.count) files" } + /// With several accounts the list comes first; with one it goes straight + /// to its folders. + func start() async { + guard let shared, let first = shared.accounts.first else { + stage = .noAccount + return + } + if shared.accounts.count > 1 { + stage = .chooseAccount + } else { + await select(first) + } + } + + func showAccounts() { + notice = nil + stage = .chooseAccount + } + + /// Uploading to an account other than the one the app is on is "switching" + /// in the app's terms, so it asks for the same unlock when the app does. + /// Hidden folders follow the account's own app setting, behind the app's + /// "lock hidden files" unlock. + func select(_ account: SharedAccount) async { + guard let shared else { return } + notice = nil + if account.id != shared.active?.id, shared.needsUnlockToSwitchAccount { + guard await unlock("Unlock to upload to \(account.displayName)") else { + notice = "Unlock to upload to a different account." + stage = .chooseAccount + return + } + } + selected = account + hidden = HiddenFilter(raw: account.hiddenFilter) + if hidden != .hide, shared.needsUnlockForHidden { + if await !unlock("Unlock to show hidden folders") { + hidden = .hide + notice = "Hidden folders are locked, so they're not shown." + } + } + stage = .picking + await open("/") + } + + private func unlock(_ reason: String) async -> Bool { + if unlocked { return true } + let ok = await DeviceAuth.authenticate(reason: reason) + if ok { unlocked = true } + return ok + } + func open(_ path: String) async { - guard let account else { return } + guard let account = selected else { return } self.path = path isLoadingFolders = true folderError = nil defer { isLoadingFolders = false } do { - folders = try await DavClient.listFolders(account: account, path: path) + folders = try await DavClient.listFolders(account: account, path: path, hidden: hidden) } catch { folders = [] folderError = error.localizedDescription @@ -107,19 +172,48 @@ struct SharePickerView: View { centered { Text(message).multilineTextAlignment(.center).foregroundColor(.secondary).padding() } + case .chooseAccount: + accountList case .picking: folderList } } + private var accountList: some View { + List { + Section( + header: Text("Upload \(model.fileCountText) to which account?"), + footer: model.notice.map { Text($0) } + ) { + ForEach(model.accounts) { account in + Button { + Task { await model.select(account) } + } label: { + HStack { + Label(account.displayName, systemImage: "person.crop.circle") + Spacer() + if account.id == model.activeAccountId { + Text("Active").font(.caption).foregroundColor(.secondary) + } + } + } + } + } + } + .listStyle(.insetGrouped) + } + private var folderList: some View { List { - Section(header: Text(model.account?.displayName ?? "")) { + Section( + header: accountHeader, + footer: model.notice.map { Text($0) } + ) { if model.path != "/" { Button { Task { await model.openParent() } } label: { - Label("Up to \((model.path as NSString).deletingLastPathComponent == "/" ? "All files" : ((model.path as NSString).deletingLastPathComponent as NSString).lastPathComponent)", systemImage: "arrow.turn.left.up") + Label("Up to \(parentName)", systemImage: "arrow.turn.left.up") } } if model.isLoadingFolders { @@ -142,6 +236,22 @@ struct SharePickerView: View { .listStyle(.insetGrouped) } + private var accountHeader: some View { + HStack { + Text(model.selected?.displayName ?? "") + Spacer() + if model.canChangeAccount { + Button("Change account") { model.showAccounts() } + .font(.caption) + } + } + } + + private var parentName: String { + let parent = (model.path as NSString).deletingLastPathComponent + return parent.isEmpty || parent == "/" ? "All files" : (parent as NSString).lastPathComponent + } + private var footer: some View { VStack(spacing: 8) { if model.stage == .picking { @@ -154,7 +264,7 @@ struct SharePickerView: View { .buttonStyle(.borderedProminent) .disabled(model.isLoadingFolders) } - if model.stage == .picking || model.stage == .noAccount { + if model.stage == .picking || model.stage == .chooseAccount || model.stage == .noAccount { Button("Choose a folder later in Noo", action: model.onSaveForLater) .font(.subheadline) } diff --git a/ios/ShareExtension/ShareViewController.swift b/ios/ShareExtension/ShareViewController.swift index 0aba663..4a05cc3 100644 --- a/ios/ShareExtension/ShareViewController.swift +++ b/ios/ShareExtension/ShareViewController.swift @@ -8,8 +8,10 @@ import UserNotifications /// shows a folder picker, then starts a background upload and closes: /// /// 1. Copies whatever was shared into the App Group (`SharedInbox`). -/// 2. Reads the signed-in account the app left in the shared Keychain -/// (`SharedAccountStore`) and lists folders over WebDAV (`DavClient`). +/// 2. Reads the accounts the app left in the shared Keychain +/// (`SharedAccountStore`) - asking which one first if there are several - +/// and lists folders over WebDAV (`DavClient`), honouring the app's +/// hidden-files filter and its unlock settings (`ShareModel`). /// 3. "Upload" queues the files on a background session that outlives this /// process (`ShareUpload`); the app is relaunched to finish and notify. /// @@ -22,7 +24,7 @@ final class ShareViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() - model = ShareModel(account: SharedAccountStore.load()) + model = ShareModel(shared: SharedAccountStore.load()) model.onUpload = { [weak self] in self?.upload() } model.onSaveForLater = { [weak self] in self?.saveForLater() } model.onCancel = { [weak self] in self?.cancel() } @@ -68,12 +70,7 @@ final class ShareViewController: UIViewController { model.stage = .failed("Noo can only receive files and photos.") return } - guard model.account != nil else { - model.stage = .noAccount - return - } - model.stage = .picking - await model.open("/") + await model.start() } /// `loadFileRepresentation`'s URL only lives for the duration of its @@ -102,7 +99,7 @@ final class ShareViewController: UIViewController { // MARK: - Actions private func upload() { - guard let account = model.account else { return } + guard let account = model.selected else { return } do { try ShareUpload.enqueue(items: model.items, account: account, remoteFolder: model.path) model.stage = .uploading diff --git a/ios/Shared/DavFolders.swift b/ios/Shared/DavFolders.swift index 3838af4..a855b6e 100644 --- a/ios/Shared/DavFolders.swift +++ b/ios/Shared/DavFolders.swift @@ -7,6 +7,30 @@ struct DavFolder: Equatable { let path: String } +/// The app's Files "hidden files" filter (`HiddenFilesFilter` in Dart): +/// leave dot-folders out (`hide`, the default), list only them (`only`), or +/// list everything (`include`). A folder counts as hidden when it - or any +/// folder above it - starts with a dot, exactly as in the app. +enum HiddenFilter: String { + case hide, only, include + + init(raw: String) { + self = HiddenFilter(rawValue: raw) ?? .hide + } + + static func isHidden(path: String) -> Bool { + path.split(separator: "/").contains { $0.hasPrefix(".") } + } + + func shows(path: String) -> Bool { + switch self { + case .hide: return !Self.isHidden(path: path) + case .only: return Self.isHidden(path: path) + case .include: return true + } + } +} + /// Parses a WebDAV `PROPFIND` multistatus into folders. Pure (no /// networking), so it's unit-tested. Compiled into both targets. enum DavFolderParser { @@ -67,11 +91,11 @@ enum DavFolderParser { return "/" + parts.joined(separator: "/") } - /// Child folders of [currentPath], sorted by name. The requested folder - /// itself (which a Depth-1 PROPFIND also returns) and - unless - /// [includeHidden] - dot-folders are left out. + /// Child folders of [currentPath], sorted by name, filtered by [hidden]. + /// The requested folder itself (which a Depth-1 PROPFIND also returns) is + /// always left out. static func folders( - from xml: Data, excluding currentPath: String, includeHidden: Bool = false + from xml: Data, excluding currentPath: String, hidden: HiddenFilter = .hide ) -> [DavFolder] { let delegate = Delegate() let parser = XMLParser(data: xml) @@ -84,7 +108,7 @@ enum DavFolderParser { .compactMap { response -> DavFolder? in guard let path = relativePath(fromHref: response.href), path != current else { return nil } let name = (path as NSString).lastPathComponent - guard !name.isEmpty, includeHidden || !name.hasPrefix(".") else { return nil } + guard !name.isEmpty, hidden.shows(path: path) else { return nil } return DavFolder(name: name, path: path) } .sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } @@ -99,7 +123,7 @@ enum DavClient { """ - static func listFolders(account: SharedAccount, path: String) async throws -> [DavFolder] { + static func listFolders(account: SharedAccount, path: String, hidden: HiddenFilter) async throws -> [DavFolder] { guard let url = WebDAV.fileURL(serverUrl: account.serverUrl, username: account.username, remotePath: path) else { throw TransferError(message: "Invalid server address.") } var request = URLRequest(url: url) @@ -115,6 +139,6 @@ enum DavClient { guard status == 207 else { throw TransferError(message: status == 401 ? "Signed out - open Noo to sign in again." : "Server returned \(status).") } - return DavFolderParser.folders(from: data, excluding: path) + return DavFolderParser.folders(from: data, excluding: path, hidden: hidden) } } diff --git a/ios/Shared/SharedAccount.swift b/ios/Shared/SharedAccount.swift index 939268e..ed5672f 100644 --- a/ios/Shared/SharedAccount.swift +++ b/ios/Shared/SharedAccount.swift @@ -1,27 +1,57 @@ import Foundation import Security -/// The signed-in account, as much of it as the Share Extension needs to list -/// folders and upload: the app writes it when an account becomes active and -/// clears it on sign-out. Compiled into both targets. -struct SharedAccount: Codable, Equatable { +/// One signed-in account, as much of it as the Share Extension needs to list +/// folders and upload. Compiled into both targets. +struct SharedAccount: Codable, Equatable, Identifiable { + let id: String let serverUrl: String let username: String /// `Basic ...` - the app password never leaves the Keychain as plain text. let authHeader: String /// Shown in the picker, e.g. "alice@cloud.example.com". let displayName: String + /// The app's Files "hidden files" filter for this account - `hide` (the + /// default), `only` or `include` (see `HiddenFilter`). The share sheet + /// follows it instead of having a setting of its own. + let hiddenFilter: String } -/// Keeps the [SharedAccount] in a Keychain access group both the app and the +/// Everything the app publishes for the extension: every account that can +/// upload, which one is active in the app, and the app-lock settings the +/// extension has to honour. The app rewrites it whenever any of that changes. +struct SharedAccounts: Codable, Equatable { + var accounts: [SharedAccount] + var activeId: String? + /// Settings -> Security: the master "login lock" and its two sub-toggles. + /// The sub-toggles only count while the master one is on (as in the app). + var loginLockEnabled: Bool + var lockAccountSwitching: Bool + var lockHiddenFiles: Bool + + /// The account the app is currently using, else the first one. + var active: SharedAccount? { + accounts.first { $0.id == activeId } ?? accounts.first + } + + /// Uploading to an account other than the active one is "switching" in + /// the app's terms, so it needs the same unlock. + var needsUnlockToSwitchAccount: Bool { loginLockEnabled && lockAccountSwitching } + + /// Showing hidden folders needs the same unlock the app asks for when you + /// turn hidden files on. + var needsUnlockForHidden: Bool { loginLockEnabled && lockHiddenFiles } +} + +/// Keeps the [SharedAccounts] in a Keychain access group both the app and the /// extension are entitled to (`keychain-access-groups`). The group's full id /// carries the signing team's prefix, so it's injected into each target's /// Info.plist as `NooKeychainAccessGroup` (= `$(AppIdentifierPrefix)` + /// `dev.ayushya.noo.shared`) instead of being hard-coded - both processes /// then always agree on it, with or without a team. enum SharedAccountStore { - private static let service = "dev.ayushya.noo.shared-account" - private static let account = "active" + private static let service = "dev.ayushya.noo.shared-accounts" + private static let account = "all" static var accessGroup: String? { Bundle.main.object(forInfoDictionaryKey: "NooKeychainAccessGroup") as? String @@ -39,7 +69,7 @@ enum SharedAccountStore { return query } - static func save(_ value: SharedAccount) throws { + static func save(_ value: SharedAccounts) throws { let data = try JSONEncoder().encode(value) clear() var query = baseQuery() @@ -51,15 +81,17 @@ enum SharedAccountStore { } } - static func load() -> SharedAccount? { + static func load() -> SharedAccounts? { var query = baseQuery() query[kSecReturnData as String] = true query[kSecMatchLimit as String] = kSecMatchLimitOne var result: AnyObject? guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess, - let data = result as? Data + let data = result as? Data, + let decoded = try? JSONDecoder().decode(SharedAccounts.self, from: data), + !decoded.accounts.isEmpty else { return nil } - return try? JSONDecoder().decode(SharedAccount.self, from: data) + return decoded } static func clear() { diff --git a/lib/main.dart b/lib/main.dart index d042399..6ed8da5 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -61,11 +61,9 @@ void main() { ChangeNotifierProvider( create: (context) { final session = SessionController(context.read()); - // iOS's Share Extension has no Flutter engine; it picks its - // folders/uploads as whichever account was last published here. - session.addAccountReadyListener( - () => ShareAccountService.publish(session), - ); + // iOS's Share Extension has no Flutter engine; sign-out must wipe + // what it was given. (Publishing happens in the shell - see + // ShareAccountSync - once there's a FilesController to read.) session.addAccountClearedListener(ShareAccountService.clear); return session; }, @@ -215,6 +213,7 @@ class _MainShellViewState extends State { late final Map _scrollControllers; StreamSubscription>? _shareSub; StreamSubscription? _pickSub; + ShareAccountSync? _shareAccountSync; @override void initState() { @@ -236,6 +235,13 @@ class _MainShellViewState extends State { ShareIntentService.getInitialShare().then(_handleSharedFiles); _shareSub = ShareIntentService.onNewShare.listen(_handleSharedFiles); + // iOS's Share Extension picks accounts/folders on its own, so it's kept + // supplied with the accounts, lock settings and hidden-files filters. + _shareAccountSync = ShareAccountSync( + context.read(), + context.read(), + )..start(); + // Same cold-start-vs-already-running split as the share intent above: // another app may have launched Noo as its GET_CONTENT picker. PickIntentService.getPickRequest().then((request) { @@ -320,6 +326,7 @@ class _MainShellViewState extends State { } _shareSub?.cancel(); _pickSub?.cancel(); + _shareAccountSync?.dispose(); super.dispose(); } diff --git a/lib/providers/files_controller.dart b/lib/providers/files_controller.dart index 1ed4b8b..552ae90 100644 --- a/lib/providers/files_controller.dart +++ b/lib/providers/files_controller.dart @@ -677,6 +677,19 @@ class FilesController extends ChangeNotifier ); } + /// The hidden-files filter saved for [accountId] - not just the active + /// account's, which is all [hiddenFilter] holds. For iOS's Share Extension, + /// which follows each account's own setting. + static HiddenFilesFilter savedHiddenFilter( + SharedPreferences prefs, + String Function(String accountId, String baseKey) accountPrefKey, + String accountId, + ) => loadHiddenFilter( + prefs, + accountPrefKey(accountId, _prefHiddenFilter), + accountPrefKey(accountId, _prefShowHiddenFiles), + ); + /// Reads a persisted [HiddenFilesFilter], falling back to the old /// show-hidden bool (`true` meant everything incl. hidden) so a pref /// saved before the three-way filter keeps its meaning. [key]s are diff --git a/lib/services/share_account_service.dart b/lib/services/share_account_service.dart index df2efdc..6b39003 100644 --- a/lib/services/share_account_service.dart +++ b/lib/services/share_account_service.dart @@ -1,30 +1,175 @@ +import 'dart:async'; +import 'dart:convert'; import 'package:flutter/services.dart'; +import '../providers/files_controller.dart'; import '../providers/session_controller.dart'; import 'native_channel.dart'; -/// Tells the native side which account is active, so iOS's Share Extension - -/// a separate process with no Flutter engine - can list folders and upload as -/// it without opening the app (`ios/Runner/Native/NativeServices.swift`'s +/// One account as the iOS Share Extension needs it - plain data, so the +/// payload builder is testable without a session. +class ShareAccountEntry { + final String id; + final String serverUrl; + final String username; + final String password; + + /// The account's Files "hidden files" filter: `hide`, `only` or `include`. + final String hiddenFilter; + + const ShareAccountEntry({ + required this.id, + required this.serverUrl, + required this.username, + required this.password, + required this.hiddenFilter, + }); +} + +/// Tells the native side which accounts can upload, which one is active, and +/// the app-lock settings, so iOS's Share Extension - a separate process with +/// no Flutter engine - can offer an account picker, list folders and upload +/// as them without opening the app (`ios/Runner/Native/NativeServices.swift`'s /// `share_account`, stored in a Keychain group shared with the extension). -/// Android has no equivalent (its share intent opens the app itself), so -/// there the channel simply doesn't exist and both calls are no-ops. +/// Android has no equivalent (its share intent opens the app itself), so there +/// the channel simply doesn't exist and everything here is a no-op. class ShareAccountService { static const _channel = MethodChannel('dev.ayushya.noo/share_account'); - /// Publishes the active account (call whenever one becomes active). - static Future publish(SessionController session) async { - final authHeader = session.service?.authHeaders['Authorization']; - if (authHeader == null || session.serverUrl.isEmpty) return; - final host = Uri.tryParse(session.serverUrl)?.host ?? session.serverUrl; - await invokeIfAvailable(_channel, 'setAccount', { - 'serverUrl': session.serverUrl, - 'username': session.username, - 'authHeader': authHeader, - 'displayName': '${session.username}@$host', + static String basicAuth(String username, String password) => + 'Basic ${base64Encode(utf8.encode('$username:$password'))}'; + + /// The JSON `SharedAccounts` (`ios/Shared/SharedAccount.swift`) decodes. + static String buildPayload({ + required List accounts, + required String? activeId, + required bool loginLockEnabled, + required bool lockAccountSwitching, + required bool lockHiddenFiles, + }) { + return jsonEncode({ + 'accounts': [ + for (final a in accounts) + { + 'id': a.id, + 'serverUrl': a.serverUrl, + 'username': a.username, + 'authHeader': basicAuth(a.username, a.password), + 'displayName': + '${a.username}@${Uri.tryParse(a.serverUrl)?.host ?? a.serverUrl}', + 'hiddenFilter': a.hiddenFilter, + }, + ], + 'activeId': activeId, + 'loginLockEnabled': loginLockEnabled, + 'lockAccountSwitching': lockAccountSwitching, + 'lockHiddenFiles': lockHiddenFiles, }); } - /// Forgets it (sign-out / switching away) so the extension stops offering - /// uploads to an account that's no longer signed in. + /// Publishes every saved account that can actually upload (has a stored + /// password and isn't signed out), with the active one's hidden-files + /// filter taken live from [files] - its saved pref is written + /// asynchronously, so it can lag a change that just happened. + static Future publish( + SessionController session, + FilesController files, + ) async { + final activeId = session.activeAccountId; + if (activeId == null) return clear(); + + final prefs = await session.prefsFuture; + final store = session.accountStore; + final signedOut = store.loadSignedOut(prefs); + final entries = []; + for (final account in session.accounts) { + if (signedOut.contains(account.id)) continue; + final password = await store.readPassword(account.id); + if (password == null) continue; + final filter = account.id == activeId + ? files.hiddenFilter + : FilesController.savedHiddenFilter( + prefs, + store.accountPrefKey, + account.id, + ); + entries.add( + ShareAccountEntry( + id: account.id, + serverUrl: account.serverUrl, + username: account.username, + password: password, + hiddenFilter: filter.name, + ), + ); + } + if (entries.isEmpty) return clear(); + + await invokeIfAvailable(_channel, 'setAccounts', { + 'accounts': buildPayload( + accounts: entries, + activeId: activeId, + loginLockEnabled: session.loginLockEnabled, + lockAccountSwitching: session.lockAccountSwitching, + lockHiddenFiles: session.lockHiddenFiles, + ), + }); + } + + /// Forgets everything (sign-out) so the extension stops offering uploads. static Future clear() => invokeIfAvailable(_channel, 'clearAccount'); } + +/// Keeps [ShareAccountService]'s published data current while the app is +/// running: republishes when an account becomes ready or when anything the +/// extension depends on changes - the account list or active account, the +/// lock settings, or the active account's hidden-files filter. +class ShareAccountSync { + final SessionController session; + final FilesController files; + + String? _lastSignature; + bool _disposed = false; + Future _queue = Future.value(); + + ShareAccountSync(this.session, this.files); + + void start() { + session.addAccountReadyListener(_publish); + session.addListener(_onChange); + files.addListener(_onChange); + } + + void dispose() { + _disposed = true; + session.removeListener(_onChange); + files.removeListener(_onChange); + } + + String _signature() => [ + session.activeAccountId, + session.accounts.map((a) => a.id).join(','), + session.loginLockEnabled, + session.lockAccountSwitching, + session.lockHiddenFiles, + files.hiddenFilter.name, + ].join('|'); + + void _onChange() { + if (_signature() != _lastSignature) _publish(); + } + + /// Runs one at a time, in order, so a slow publish can't land after a + /// newer one and leave the extension with stale data. + void _publish() { + if (_disposed) return; + _lastSignature = _signature(); + _queue = _queue.then((_) async { + if (_disposed) return; + try { + await ShareAccountService.publish(session, files); + } catch (_) { + // Best effort: the extension just keeps the previous data. + } + }); + } +} diff --git a/test/services/share_account_service_test.dart b/test/services/share_account_service_test.dart new file mode 100644 index 0000000..813306e --- /dev/null +++ b/test/services/share_account_service_test.dart @@ -0,0 +1,73 @@ +import 'dart:convert'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:noo/services/share_account_service.dart'; + +/// What the iOS Share Extension is given - the contract with +/// `ios/Shared/SharedAccount.swift`'s `SharedAccounts`. +void main() { + const alice = ShareAccountEntry( + id: 'cloud_example_com__alice', + serverUrl: 'https://cloud.example.com', + username: 'alice', + password: 's3cret', + hiddenFilter: 'include', + ); + const bob = ShareAccountEntry( + id: 'nc_home_lan__bob', + serverUrl: 'https://nc.home.lan:8443/nextcloud', + username: 'bob', + password: 'pw', + hiddenFilter: 'hide', + ); + + Map build({ + List accounts = const [alice, bob], + String? activeId = 'nc_home_lan__bob', + bool login = true, + bool switching = true, + bool hidden = false, + }) => jsonDecode( + ShareAccountService.buildPayload( + accounts: accounts, + activeId: activeId, + loginLockEnabled: login, + lockAccountSwitching: switching, + lockHiddenFiles: hidden, + ), + ); + + test('basic auth header matches what the app sends', () { + expect( + ShareAccountService.basicAuth('alice', 's3cret'), + 'Basic ${base64Encode(utf8.encode('alice:s3cret'))}', + ); + }); + + test('every account is listed with its own header, name and filter', () { + final accounts = (build()['accounts'] as List).cast>(); + expect(accounts.map((a) => a['id']), [alice.id, bob.id]); + expect( + accounts[0]['authHeader'], + ShareAccountService.basicAuth('alice', 's3cret'), + ); + expect(accounts[0]['displayName'], 'alice@cloud.example.com'); + expect(accounts[0]['hiddenFilter'], 'include'); + // The display name uses the host only - not the port or sub-path. + expect(accounts[1]['displayName'], 'bob@nc.home.lan'); + expect(accounts[1]['serverUrl'], 'https://nc.home.lan:8443/nextcloud'); + expect(accounts[1]['hiddenFilter'], 'hide'); + }); + + test('the active account and the lock settings are passed through', () { + final payload = build(login: true, switching: false, hidden: true); + expect(payload['activeId'], 'nc_home_lan__bob'); + expect(payload['loginLockEnabled'], true); + expect(payload['lockAccountSwitching'], false); + expect(payload['lockHiddenFiles'], true); + }); + + test('no active account is a null activeId, not a missing key', () { + expect(build(activeId: null).containsKey('activeId'), true); + expect(build(activeId: null)['activeId'], isNull); + }); +} From a33b5c422fd3948affba0a7e92de17a2a3586897 Mon Sep 17 00:00:00 2001 From: Ayushya Amitabh Date: Tue, 6 Oct 2026 19:59:38 -0400 Subject: [PATCH 17/31] Share Extension: remove the legacy single-account keychain item on clear Co-Authored-By: Claude Sonnet 5.5 --- ios/Shared/SharedAccount.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/ios/Shared/SharedAccount.swift b/ios/Shared/SharedAccount.swift index ed5672f..bf98259 100644 --- a/ios/Shared/SharedAccount.swift +++ b/ios/Shared/SharedAccount.swift @@ -96,5 +96,11 @@ enum SharedAccountStore { static func clear() { SecItemDelete(baseQuery() as CFDictionary) + // The first version kept a single account under its own service name; + // don't leave that credential behind. + var legacy = baseQuery() + legacy[kSecAttrService as String] = "dev.ayushya.noo.shared-account" + legacy[kSecAttrAccount as String] = "active" + SecItemDelete(legacy as CFDictionary) } } From c9a3036e078044f48c5b2e4d532aa670be19af58 Mon Sep 17 00:00:00 2001 From: Ayushya Amitabh Date: Tue, 6 Oct 2026 20:07:28 -0400 Subject: [PATCH 18/31] Security locks work independently; other-account avatars flush right in the avatar menu Login lock, lock account switching and lock hidden files no longer depend on each other: passGate prompts on its own flag, the two sub-locks can be set with login lock off, and disabling login lock leaves them alone. Turning any lock on or off asks for device auth (on also checks the device can). The iOS Share Extension's unlock rules follow. The avatar dropdown's additional accounts lose the 44px spacer so their avatars sit at the right edge. Co-Authored-By: Claude Sonnet 5.5 --- .claude/context/architecture.md | 4 +- .claude/context/server.md | 23 ++- ios/RunnerTests/RunnerTests.swift | 11 +- ios/Shared/SharedAccount.swift | 9 +- lib/providers/session_controller.dart | 67 ++++++--- lib/services/app_lock_service.dart | 10 ++ lib/widgets/avatar_menu.dart | 1 - lib/widgets/settings/settings_security.dart | 51 +++++-- test/providers/security_locks_test.dart | 156 ++++++++++++++++++++ test/widgets/avatar_menu_test.dart | 46 ++++++ 10 files changed, 323 insertions(+), 55 deletions(-) create mode 100644 test/providers/security_locks_test.dart diff --git a/.claude/context/architecture.md b/.claude/context/architecture.md index cc65fa1..9fee13d 100644 --- a/.claude/context/architecture.md +++ b/.claude/context/architecture.md @@ -57,8 +57,8 @@ new provider instance): saved-accounts list, which one is active, `sessionGeneration` — see below), auth/login-flow state (`isLoggedIn`, `isRestoringSession`, `loginFlowStatus`), the active `NextcloudService` instance, and login lock - (`loginLockEnabled`/`lockAccountSwitching`/`lockHiddenFiles`/ - `needsUnlock`/`passGate`). Exposes `addAccountClearedListener`/ + (`loginLockEnabled`/`lockAccountSwitching`/`lockHiddenFiles` - three + independent locks - plus `needsUnlock`/`passGate`). Exposes `addAccountClearedListener`/ `addAccountActivatedListener` (plain `List`) so sibling controllers — constructed after `SessionController` and unable to hold a forward reference to it — can react to login/logout/account-switch diff --git a/.claude/context/server.md b/.claude/context/server.md index ddac4dc..74f0e99 100644 --- a/.claude/context/server.md +++ b/.claude/context/server.md @@ -798,6 +798,19 @@ plugin's own manifest via merge, but kept explicit here too). own requirement) via `maxOf(24, flutter.minSdkVersion)` rather than trusting Flutter's own default to already be high enough. +**The three locks are independent** (Settings -> Security; +`SessionController`): `loginLockEnabled` (unlock to open the app - the only one +`needsUnlock`/the lock screen look at), `lockAccountSwitching` (unlock to +switch accounts) and `lockHiddenFiles` (unlock to turn on showing hidden +files). Each gate works with the others off - `passGate(gate, reason)` +prompts whenever its own `gate` is on, whether or not login lock is. Turning +any of them on **or off** needs a successful `AppLockService.authenticate` +(on also needs `isDeviceSupported`, so a gate nobody can pass can't be set; +off needs it so someone with a momentarily unlocked phone can't remove it), +and `disableLoginLock` leaves the other two untouched. The old master/sub-toggle +dependency is gone. Tests replace the prompt through +`AppLockService.debugAuthenticate`/`debugIsDeviceSupported`. + `isRestoringSession` still gates the splash screen until the above resolves — see `standards.md` for why widget tests must mock both storage channels rather than relying on this async path throwing naturally. @@ -903,16 +916,16 @@ Reminders/Notes - the destination is picked *inside* the sheet: setting of their own. `hide` (default) drops dot-folders, `only` lists just them, `include` lists everything; a folder is hidden when it *or any ancestor* starts with a dot (`HiddenFilter`, same rule as the app). - When the filter isn't `hide` and the app has login lock + "lock hidden - files" on, the sheet asks for Face ID/passcode first (`DeviceAuth`, the + When the filter isn't `hide` and the app's "lock hidden files" is on, + the sheet asks for Face ID/passcode first (`DeviceAuth`, the `.deviceOwnerAuthentication` policy - biometrics with passcode fallback, like `local_auth` with `biometricOnly: false`); cancelling falls back to hiding them, with a note. - **Account switching**: choosing any account other than the app's - active one asks for the same unlock when login lock + "lock account - switching" are on. One successful unlock covers the rest of that sheet. + active one asks for the same unlock when "lock account switching" is on. One successful unlock covers the rest of that sheet. - Opening the sheet itself is not gated - only these two actions are - (as in the app, where login lock guards launch and these toggles). + (as in the app, where login lock guards launch and these are separate + locks). 3. **Upload** calls `ShareUpload.enqueue`: one `PUT` per file on a *background* `URLSession` (`dev.ayushya.noo.transfers.share`, with `sharedContainerIdentifier`) that outlives the extension, and posts diff --git a/ios/RunnerTests/RunnerTests.swift b/ios/RunnerTests/RunnerTests.swift index ff1b899..f390cdd 100644 --- a/ios/RunnerTests/RunnerTests.swift +++ b/ios/RunnerTests/RunnerTests.swift @@ -204,18 +204,17 @@ class RunnerTests: XCTestCase { XCTAssertEqual(shared.active?.id, "alice") } - func testUnlockRulesNeedTheMasterLockAndTheirOwnToggle() { + func testUnlockRulesAreIndependentOfLoginLock() { func rules(master: Bool, switching: Bool, hidden: Bool) -> (Bool, Bool) { let shared = SharedAccounts( accounts: [account("a")], activeId: "a", loginLockEnabled: master, lockAccountSwitching: switching, lockHiddenFiles: hidden) return (shared.needsUnlockToSwitchAccount, shared.needsUnlockForHidden) } - XCTAssertTrue(rules(master: true, switching: true, hidden: false) == (true, false)) - XCTAssertTrue(rules(master: true, switching: false, hidden: true) == (false, true)) - XCTAssertTrue( - rules(master: false, switching: true, hidden: true) == (false, false), - "the sub-toggles mean nothing while the login lock is off, as in the app") + XCTAssertTrue(rules(master: false, switching: true, hidden: false) == (true, false)) + XCTAssertTrue(rules(master: false, switching: false, hidden: true) == (false, true)) + XCTAssertTrue(rules(master: true, switching: false, hidden: false) == (false, false)) + XCTAssertTrue(rules(master: true, switching: true, hidden: true) == (true, true)) } // MARK: - TransferBatchStore diff --git a/ios/Shared/SharedAccount.swift b/ios/Shared/SharedAccount.swift index bf98259..2423ff8 100644 --- a/ios/Shared/SharedAccount.swift +++ b/ios/Shared/SharedAccount.swift @@ -23,8 +23,9 @@ struct SharedAccount: Codable, Equatable, Identifiable { struct SharedAccounts: Codable, Equatable { var accounts: [SharedAccount] var activeId: String? - /// Settings -> Security: the master "login lock" and its two sub-toggles. - /// The sub-toggles only count while the master one is on (as in the app). + /// Settings -> Security: the three independent locks. `loginLockEnabled` + /// (unlock to open the app) is carried for completeness; the extension's own + /// gates are the other two. var loginLockEnabled: Bool var lockAccountSwitching: Bool var lockHiddenFiles: Bool @@ -36,11 +37,11 @@ struct SharedAccounts: Codable, Equatable { /// Uploading to an account other than the active one is "switching" in /// the app's terms, so it needs the same unlock. - var needsUnlockToSwitchAccount: Bool { loginLockEnabled && lockAccountSwitching } + var needsUnlockToSwitchAccount: Bool { lockAccountSwitching } /// Showing hidden folders needs the same unlock the app asks for when you /// turn hidden files on. - var needsUnlockForHidden: Bool { loginLockEnabled && lockHiddenFiles } + var needsUnlockForHidden: Bool { lockHiddenFiles } } /// Keeps the [SharedAccounts] in a Keychain access group both the app and the diff --git a/lib/providers/session_controller.dart b/lib/providers/session_controller.dart index 2504d80..42f8636 100644 --- a/lib/providers/session_controller.dart +++ b/lib/providers/session_controller.dart @@ -516,7 +516,7 @@ class SessionController extends ChangeNotifier with WidgetsBindingObserver { /// session-restore that failed, e.g. transient network trouble at cold /// start), this still retries rather than no-op, since that's exactly the /// case LoginView's "Continue as" tile exists to recover from. Gated - /// behind login lock when [lockAccountSwitching] is on. Returns whether + /// behind its own unlock when [lockAccountSwitching] is on. Returns whether /// the account ended up logged in, so callers (LoginView's saved-account /// tile) can surface a failure - e.g. a stored app password that no /// longer works and needs the account removed/re-added. @@ -640,10 +640,10 @@ class SessionController extends ChangeNotifier with WidgetsBindingObserver { return true; } - /// Turns login lock off, along with both of its sub-toggles (meaningless - /// once the base lock is gone). Requires a successful auth first, same as - /// turning it on - otherwise anyone with momentary access to an unlocked - /// phone could just switch it off. + /// Turns login lock off. Requires a successful auth first, same as turning + /// it on - otherwise anyone with momentary access to an unlocked phone + /// could just switch it off. Only this lock: the account-switching and + /// hidden-files locks are independent and stay as they are. Future disableLoginLock() async { if (!_loginLockEnabled) return true; final confirmed = await AppLockService.authenticate( @@ -651,30 +651,55 @@ class SessionController extends ChangeNotifier with WidgetsBindingObserver { ); if (!confirmed) return false; _loginLockEnabled = false; - _lockAccountSwitching = false; - _lockHiddenFiles = false; _isUnlocked = false; notifyListeners(); - prefsFuture.then((p) { - p.setBool(_prefLoginLockEnabled, false); - p.setBool(_prefLockAccountSwitching, false); - p.setBool(_prefLockHiddenFiles, false); - }); + prefsFuture.then((p) => p.setBool(_prefLoginLockEnabled, false)); return true; } - void setLockAccountSwitching(bool value) { - if (!_loginLockEnabled) return; + /// Turns the "unlock to switch accounts" gate on or off. Independent of + /// login lock. Both directions need a successful auth - enabling proves the + /// device can authenticate at all (a gate nobody can pass would lock the + /// user out of switching), disabling stops anyone with momentary access to + /// an unlocked phone from just removing it. Returns whether it changed. + Future setLockAccountSwitching(bool value) async { + if (value == _lockAccountSwitching) return true; + if (!await _confirmLockChange( + value, + 'Confirm to lock account switching', + 'Confirm to unlock account switching', + )) { + return false; + } _lockAccountSwitching = value; notifyListeners(); prefsFuture.then((p) => p.setBool(_prefLockAccountSwitching, value)); + return true; } - void setLockHiddenFiles(bool value) { - if (!_loginLockEnabled) return; + /// Same as [setLockAccountSwitching], for revealing hidden files. + Future setLockHiddenFiles(bool value) async { + if (value == _lockHiddenFiles) return true; + if (!await _confirmLockChange( + value, + 'Confirm to lock hidden files', + 'Confirm to unlock hidden files', + )) { + return false; + } _lockHiddenFiles = value; notifyListeners(); prefsFuture.then((p) => p.setBool(_prefLockHiddenFiles, value)); + return true; + } + + Future _confirmLockChange( + bool enabling, + String enableReason, + String disableReason, + ) async { + if (enabling && !await AppLockService.isDeviceSupported()) return false; + return AppLockService.authenticate(enabling ? enableReason : disableReason); } /// Called by the lock screen. Returns whether it actually unlocked. @@ -687,12 +712,12 @@ class SessionController extends ChangeNotifier with WidgetsBindingObserver { return success; } - /// Prompts for auth if [gate] is on and login lock is configured; - /// returns true immediately (no prompt) otherwise. Shared by the - /// account-switching gate above and [FilesController]'s/`PhotosController`'s - /// hidden-files gates. + /// Prompts for auth if [gate] is on; returns true immediately (no prompt) + /// otherwise. Each gate stands on its own - it doesn't depend on login lock + /// being on. Shared by the account-switching gate above and + /// [FilesController]'s/`PhotosController`'s hidden-files gates. Future passGate(bool gate, String reason) async { - if (!_loginLockEnabled || !gate) return true; + if (!gate) return true; return AppLockService.authenticate(reason); } diff --git a/lib/services/app_lock_service.dart b/lib/services/app_lock_service.dart index 8a205d7..7a0cda6 100644 --- a/lib/services/app_lock_service.dart +++ b/lib/services/app_lock_service.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:local_auth/local_auth.dart'; /// Thin wrapper around `local_auth`. Deliberately doesn't implement its own @@ -8,9 +9,17 @@ import 'package:local_auth/local_auth.dart'; class AppLockService { static final LocalAuthentication _auth = LocalAuthentication(); + /// Replace the platform prompt in tests (null = the real one). + @visibleForTesting + static Future Function(String reason)? debugAuthenticate; + + @visibleForTesting + static Future Function()? debugIsDeviceSupported; + /// Whether this device can do *some* form of local auth - biometric /// enrolled, or at minimum a device PIN/pattern/password set up. static Future isDeviceSupported() async { + if (debugIsDeviceSupported != null) return debugIsDeviceSupported!(); try { final canCheckBiometrics = await _auth.canCheckBiometrics; if (canCheckBiometrics) return true; @@ -24,6 +33,7 @@ class AppLockService { /// throws) on cancellation, failure, or any platform error, so callers /// can treat every non-true result the same way: stay locked/blocked. static Future authenticate(String reason) async { + if (debugAuthenticate != null) return debugAuthenticate!(reason); try { return await _auth.authenticate( localizedReason: reason, diff --git a/lib/widgets/avatar_menu.dart b/lib/widgets/avatar_menu.dart index 3c13dcc..898c88e 100644 --- a/lib/widgets/avatar_menu.dart +++ b/lib/widgets/avatar_menu.dart @@ -332,7 +332,6 @@ class _OtherAccountRow extends StatelessWidget { ), const SizedBox(width: 12), NooAvatar(initials: accountInitial(name), current: false, size: 40), - const SizedBox(width: 44), ], ), ), diff --git a/lib/widgets/settings/settings_security.dart b/lib/widgets/settings/settings_security.dart index 341ab93..e9de0de 100644 --- a/lib/widgets/settings/settings_security.dart +++ b/lib/widgets/settings/settings_security.dart @@ -26,26 +26,28 @@ String biometricLabel(TargetPlatform platform) { } } -/// Settings section 3: login lock, which gates opening the app, switching -/// accounts, and revealing hidden files behind the device's own PIN/ +/// Settings section 3: three independent locks - opening the app, switching +/// accounts, and revealing hidden files - each behind the device's own PIN/ /// biometric credential (see `AppLockService` - this app never stores or -/// handles a PIN itself). +/// handles a PIN itself). Turning one on or off asks for that credential, and +/// none of them requires or implies another. class SettingsSecuritySection extends StatelessWidget { const SettingsSecuritySection({super.key}); - Future _handleLoginLockChanged( - BuildContext context, - SessionController session, - bool value, - ) async { - final success = value ? await session.setupLoginLock() : await session.disableLoginLock(); + Future _handleLockChanged( + BuildContext context, { + required Future Function() change, + required bool turningOn, + required String name, + }) async { + final success = await change(); if (!success && context.mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( - value - ? "Could not set up login lock - make sure this device has a PIN, pattern, password, or biometric configured" - : 'Could not turn off login lock', + turningOn + ? "Could not turn on $name - make sure this device has a PIN, pattern, password, or biometric configured" + : 'Could not turn off $name', ), behavior: SnackBarBehavior.floating, ), @@ -64,10 +66,17 @@ class SettingsSecuritySection extends StatelessWidget { NooSettingsRow( icon: LucideIcons.lock, label: Text(biometricLabel(platform)), - subtitle: const Text("Require this device's PIN or biometric to open Noo"), + subtitle: const Text( + "Require this device's PIN or biometric to open Noo", + ), trailing: NooToggle( checked: session.loginLockEnabled, - onChanged: (value) => _handleLoginLockChanged(context, session, value), + onChanged: (value) => _handleLockChanged( + context, + change: value ? session.setupLoginLock : session.disableLoginLock, + turningOn: value, + name: 'login lock', + ), ), ), NooSettingsRow( @@ -76,7 +85,12 @@ class SettingsSecuritySection extends StatelessWidget { subtitle: const Text('Unlock to switch between saved accounts'), trailing: NooToggle( checked: session.lockAccountSwitching, - onChanged: session.loginLockEnabled ? session.setLockAccountSwitching : null, + onChanged: (value) => _handleLockChanged( + context, + change: () => session.setLockAccountSwitching(value), + turningOn: value, + name: 'account switching lock', + ), ), ), NooSettingsRow( @@ -85,7 +99,12 @@ class SettingsSecuritySection extends StatelessWidget { subtitle: const Text('Unlock to reveal hidden files and folders'), trailing: NooToggle( checked: session.lockHiddenFiles, - onChanged: session.loginLockEnabled ? session.setLockHiddenFiles : null, + onChanged: (value) => _handleLockChanged( + context, + change: () => session.setLockHiddenFiles(value), + turningOn: value, + name: 'hidden files lock', + ), ), ), ], diff --git a/test/providers/security_locks_test.dart b/test/providers/security_locks_test.dart new file mode 100644 index 0000000..be09cab --- /dev/null +++ b/test/providers/security_locks_test.dart @@ -0,0 +1,156 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:noo/providers/connectivity_controller.dart'; +import 'package:noo/providers/session_controller.dart'; +import 'package:noo/services/app_lock_service.dart'; + +/// The three Settings -> Security locks (open the app, switch accounts, reveal +/// hidden files) each work on their own - none needs or implies another. The +/// device prompt is replaced with a recorder so the gates can be exercised. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUpAll(() { + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + messenger.setMockMethodCallHandler( + const MethodChannel('plugins.it_nomads.com/flutter_secure_storage'), + (call) async => call.method == 'readAll' ? {} : null, + ); + messenger.setMockMethodCallHandler( + const MethodChannel('dev.fluttercommunity.plus/connectivity'), + (call) async => call.method == 'check' ? ['none'] : null, + ); + messenger.setMockStreamHandler( + const EventChannel('dev.fluttercommunity.plus/connectivity_status'), + MockStreamHandler.inline(onListen: (arguments, events) {}), + ); + }); + + late List prompts; + var authSucceeds = true; + var deviceSupported = true; + + setUp(() { + SharedPreferences.setMockInitialValues({}); + prompts = []; + authSucceeds = true; + deviceSupported = true; + AppLockService.debugAuthenticate = (reason) async { + prompts.add(reason); + return authSucceeds; + }; + AppLockService.debugIsDeviceSupported = () async => deviceSupported; + }); + + tearDown(() { + AppLockService.debugAuthenticate = null; + AppLockService.debugIsDeviceSupported = null; + }); + + SessionController build() => SessionController(ConnectivityController()); + + test( + 'hidden files and account switching can be locked without login lock', + () async { + final session = build(); + + expect(await session.setLockHiddenFiles(true), isTrue); + expect(await session.setLockAccountSwitching(true), isTrue); + + expect(session.lockHiddenFiles, isTrue); + expect(session.lockAccountSwitching, isTrue); + expect(session.loginLockEnabled, isFalse); + expect( + session.needsUnlock, + isFalse, + reason: 'opening the app stays open', + ); + }, + ); + + test('a gate prompts on its own, with login lock off', () async { + final session = build(); + + expect(await session.passGate(false, 'unused'), isTrue); + expect(prompts, isEmpty, reason: 'a gate that is off never prompts'); + + expect(await session.passGate(true, 'Unlock to show hidden files'), isTrue); + expect(prompts, ['Unlock to show hidden files']); + + authSucceeds = false; + expect(await session.passGate(true, 'again'), isFalse); + }); + + test( + 'turning a lock on needs a capable device and a successful auth', + () async { + final session = build(); + + deviceSupported = false; + expect(await session.setLockHiddenFiles(true), isFalse); + expect(session.lockHiddenFiles, isFalse); + + deviceSupported = true; + authSucceeds = false; + expect(await session.setLockHiddenFiles(true), isFalse); + expect(session.lockHiddenFiles, isFalse); + + authSucceeds = true; + expect(await session.setLockHiddenFiles(true), isTrue); + expect(session.lockHiddenFiles, isTrue); + }, + ); + + test( + 'turning a lock off needs auth, so it cannot just be flipped away', + () async { + final session = build(); + await session.setLockAccountSwitching(true); + prompts.clear(); + + authSucceeds = false; + expect(await session.setLockAccountSwitching(false), isFalse); + expect(session.lockAccountSwitching, isTrue); + + authSucceeds = true; + expect(await session.setLockAccountSwitching(false), isTrue); + expect(session.lockAccountSwitching, isFalse); + expect(prompts, hasLength(2)); + }, + ); + + test('setting a lock to its current value does not prompt', () async { + final session = build(); + expect(await session.setLockHiddenFiles(false), isTrue); + expect(prompts, isEmpty); + }); + + test('turning login lock off leaves the other two locks alone', () async { + final session = build(); + await session.setupLoginLock(); + await session.setLockAccountSwitching(true); + await session.setLockHiddenFiles(true); + expect(session.loginLockEnabled, isTrue); + + expect(await session.disableLoginLock(), isTrue); + + expect(session.loginLockEnabled, isFalse); + expect(session.lockAccountSwitching, isTrue); + expect(session.lockHiddenFiles, isTrue); + }); + + test('each lock is saved on its own and restored', () async { + final first = build(); + await first.setLockHiddenFiles(true); + // Let the fire-and-forget pref write land. + await Future.delayed(const Duration(milliseconds: 50)); + + final second = build(); + await Future.delayed(const Duration(milliseconds: 100)); + expect(second.lockHiddenFiles, isTrue); + expect(second.lockAccountSwitching, isFalse); + expect(second.loginLockEnabled, isFalse); + }); +} diff --git a/test/widgets/avatar_menu_test.dart b/test/widgets/avatar_menu_test.dart index 2d703fc..6fc1dd9 100644 --- a/test/widgets/avatar_menu_test.dart +++ b/test/widgets/avatar_menu_test.dart @@ -15,6 +15,8 @@ import 'package:noo/providers/settings_controller.dart'; import 'package:noo/providers/sync_status_controller.dart'; import 'package:noo/providers/trash_controller.dart'; import 'package:noo/theme/app_theme.dart'; +import 'package:noo/theme/design_tokens.dart'; +import 'package:noo/widgets/noo/core/noo_avatar.dart'; import 'package:noo/widgets/app_top_bar.dart'; import 'package:noo/widgets/noo/nav/noo_top_bar.dart'; @@ -174,4 +176,48 @@ void main() { expect(find.text('Add account'), findsOneWidget); }, ); + + testWidgets("other accounts' avatars sit flush right in the dropdown", ( + tester, + ) async { + SharedPreferences.setMockInitialValues({ + 'account_migration_v1_done': true, + 'accounts_list': jsonEncode([ + const SavedAccount( + id: accountId, + serverUrl: 'https://server.example.com', + username: 'alice', + ).toJson(), + const SavedAccount( + id: 'other_example_org__bob', + serverUrl: 'https://other.example.org', + username: 'bob', + ).toJson(), + ]), + 'active_account_id': accountId, + }); + await pumpTopBar(tester, navMenuStyle: NooNavMenuStyle.avatarMenu); + + await tester.tap(find.byTooltip('Menu')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + // Expand the account list from the header. + await tester.tap(find.text('alice')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + + final row = find.ancestor( + of: find.text('bob'), + matching: find.byType(InkWell), + ); + final avatar = find.descendant(of: row, matching: find.byType(NooAvatar)); + expect(avatar, findsOneWidget); + + // Only the row's own padding between the avatar and the card's edge - + // no extra gutter pushing it in from the right. + expect( + tester.getTopRight(row).dx - tester.getTopRight(avatar).dx, + NooSpace.md, + ); + }); } From c2f6e6166dfda3d34c90f6d805c7495ed08a1f79 Mon Sep 17 00:00:00 2001 From: Ayushya Amitabh Date: Tue, 6 Oct 2026 20:15:19 -0400 Subject: [PATCH 19/31] Share sheet: Hidden and Storage toggles, plus diagnostics for the missing accounts Two menu chips start at the account's app settings (hidden filter, storage scope - now published per account), hidden-on asks for the app's unlock, and external storage is detected via nc:mount-type incl. everything under a mount. Adds logging for which accounts get published and why one is skipped. Co-Authored-By: Claude Sonnet 5.5 --- .claude/context/server.md | 31 +++- ios/Runner/Native/NativeServices.swift | 1 + ios/RunnerTests/RunnerTests.swift | 40 ++++ ios/ShareExtension/SharePickerView.swift | 172 ++++++++++++++++-- ios/Shared/DavFolders.swift | 36 +++- ios/Shared/SharedAccount.swift | 31 ++++ lib/providers/files_controller.dart | 13 ++ lib/services/share_account_service.dart | 39 +++- test/services/share_account_service_test.dart | 4 + 9 files changed, 328 insertions(+), 39 deletions(-) diff --git a/.claude/context/server.md b/.claude/context/server.md index 74f0e99..df64637 100644 --- a/.claude/context/server.md +++ b/.claude/context/server.md @@ -908,19 +908,32 @@ Reminders/Notes - the destination is picked *inside* the sheet: `initState`) republishes - through the native `share_account` channel's `setAccounts` - when an account becomes ready or when its change signature moves: the account list/active account, the three lock - settings, or the active account's hidden-files filter; sign-out calls + settings, or the active account's hidden-files filter / storage scope; + sign-out calls `clearAccount`. It publishes every saved account that has a stored password and isn't signed out, each with its **own Files hidden-files filter** (`FilesController.savedHiddenFilter`). - - **Hidden folders** follow that app setting - there is no share-sheet - setting of their own. `hide` (default) drops dot-folders, `only` lists - just them, `include` lists everything; a folder is hidden when it *or - any ancestor* starts with a dot (`HiddenFilter`, same rule as the app). - When the filter isn't `hide` and the app's "lock hidden files" is on, - the sheet asks for Face ID/passcode first (`DeviceAuth`, the + - **Toggles**: the folder list has two menu chips, **Hidden** (hide / only + hidden / all) and **Storage** (cloud only / only external / all + + external), each starting at the chosen account's own app setting + (`hiddenFilter`, `storageScope` published per account) and changeable for + this share only - nothing is written back. The list is fetched once per + folder (`DavClient.listFolders` returns everything, hidden and external + included, with `nc:mount-type` requested) and filtered in memory + (`ShareModel.visibleFolders`), so flipping a toggle just returns to the + top. Hidden uses the app's rule (`HiddenFilter`): a folder is hidden when + it *or any ancestor* starts with a dot. External storage: only a mount's + *root* has `mount-type=external`, so `ShareModel` remembers the roots it + has seen and treats anything under one as external too (the app's own + filter doesn't, which empties its list inside a mount). "All" lists + external folders in their own "External storage" group. + - **Hidden unlock**: turning hidden folders *on* (from `hide`), or opening + the sheet with the account's filter already not `hide`, asks for + Face ID/passcode when "lock hidden files" is on (`DeviceAuth`, the `.deviceOwnerAuthentication` policy - biometrics with passcode fallback, - like `local_auth` with `biometricOnly: false`); cancelling falls back to - hiding them, with a note. + like `local_auth` with `biometricOnly: false`); cancelling keeps them + hidden, with a note. Switching `only` <-> `all`, or back to `hide`, never + asks. - **Account switching**: choosing any account other than the app's active one asks for the same unlock when "lock account switching" is on. One successful unlock covers the rest of that sheet. - Opening the sheet itself is not gated - only these two actions are diff --git a/ios/Runner/Native/NativeServices.swift b/ios/Runner/Native/NativeServices.swift index 954d235..8a770cd 100644 --- a/ios/Runner/Native/NativeServices.swift +++ b/ios/Runner/Native/NativeServices.swift @@ -95,6 +95,7 @@ enum NativeServices { let accounts = try? JSONDecoder().decode(SharedAccounts.self, from: data) else { throw TransferError(message: "Bad accounts payload.") } try SharedAccountStore.save(accounts) + NSLog("[ShareAccounts] saved %d account(s), active=%@", accounts.accounts.count, accounts.activeId ?? "none") } case "clearAccount": SharedAccountStore.clear() diff --git a/ios/RunnerTests/RunnerTests.swift b/ios/RunnerTests/RunnerTests.swift index f390cdd..f324061 100644 --- a/ios/RunnerTests/RunnerTests.swift +++ b/ios/RunnerTests/RunnerTests.swift @@ -247,4 +247,44 @@ class RunnerTests: XCTestCase { XCTAssertEqual(TransferNotifications.files(1), "1 file") XCTAssertEqual(TransferNotifications.files(3), "3 files") } + + // MARK: - External storage + tolerant decoding + + func testFoldersFlagExternalMountsAndStorageFilterAppliesToThem() { + let xml = Data( + """ + + /remote.php/dav/files/a/ + + /remote.php/dav/files/a/NAS/ + external + + /remote.php/dav/files/a/Docs/ + + /remote.php/dav/files/a/Shared/ + shared + + + """.utf8) + let folders = DavFolderParser.folders(from: xml, excluding: "/") + XCTAssertEqual(folders.map(\.name), ["Docs", "NAS", "Shared"]) + XCTAssertEqual(folders.map(\.isExternal), [false, true, false], "only mount-type=external counts") + + XCTAssertTrue(StorageFilter.cloud.shows(isExternal: false)) + XCTAssertFalse(StorageFilter.cloud.shows(isExternal: true)) + XCTAssertTrue(StorageFilter.external.shows(isExternal: true)) + XCTAssertFalse(StorageFilter.external.shows(isExternal: false)) + XCTAssertTrue(StorageFilter.all.shows(isExternal: true) && StorageFilter.all.shows(isExternal: false)) + XCTAssertEqual(StorageFilter(raw: "bogus"), .cloud) + } + + func testSharedAccountDecodesDataWrittenBeforeNewFieldsExisted() throws { + let old = Data( + """ + {"id":"a","serverUrl":"https://x","username":"u","authHeader":"Basic x","displayName":"u@x"} + """.utf8) + let decoded = try JSONDecoder().decode(SharedAccount.self, from: old) + XCTAssertEqual(decoded.hiddenFilter, "hide") + XCTAssertEqual(decoded.storageScope, "cloud") + } } diff --git a/ios/ShareExtension/SharePickerView.swift b/ios/ShareExtension/SharePickerView.swift index 8ab7de6..9b963da 100644 --- a/ios/ShareExtension/SharePickerView.swift +++ b/ios/ShareExtension/SharePickerView.swift @@ -15,7 +15,6 @@ final class ShareModel: ObservableObject { @Published var stage: Stage = .preparing @Published var items: [SharedItem] = [] @Published var path = "/" - @Published var folders: [DavFolder] = [] @Published var isLoadingFolders = false @Published var folderError: String? /// A short explanation shown under the list - e.g. hidden folders stayed @@ -23,11 +22,21 @@ final class ShareModel: ObservableObject { @Published var notice: String? @Published private(set) var selected: SharedAccount? + /// The sheet's two toggles. They start at the app's own setting for the + /// chosen account and can be changed for this share only. + @Published private(set) var hiddenFilter: HiddenFilter = .hide + @Published private(set) var storageFilter: StorageFilter = .cloud + + /// Every child folder of [path], unfiltered; the toggles filter it live. + @Published private var allFolders: [DavFolder] = [] + /// Mount points of external storage seen so far. Only the mount's root is + /// flagged by the server, so anything under one of these is external too. + private var externalRoots: Set = [] + let shared: SharedAccounts? /// One successful unlock covers every gate for the rest of this sheet. private var unlocked = false - private var hidden: HiddenFilter = .hide /// Set by the view controller: what each button actually does. var onUpload: () -> Void = {} @@ -50,6 +59,23 @@ final class ShareModel: ObservableObject { items.count == 1 ? "1 file" : "\(items.count) files" } + // MARK: - Folders, filtered by the toggles + + private func isExternal(_ folder: DavFolder) -> Bool { + folder.isExternal + || externalRoots.contains { folder.path == $0 || folder.path.hasPrefix($0 + "/") } + } + + private var visibleFolders: [DavFolder] { + allFolders.filter { hiddenFilter.shows(path: $0.path) && storageFilter.shows(isExternal: isExternal($0)) } + } + + /// Internal folders - and, for "All", the external ones are listed apart. + var internalFolders: [DavFolder] { visibleFolders.filter { !isExternal($0) } } + var externalFolders: [DavFolder] { visibleFolders.filter { isExternal($0) } } + + // MARK: - Accounts + /// With several accounts the list comes first; with one it goes straight /// to its folders. func start() async { @@ -57,6 +83,7 @@ final class ShareModel: ObservableObject { stage = .noAccount return } + NSLog("[ShareExtension] %d account(s), active=%@", shared.accounts.count, shared.active?.id ?? "none") if shared.accounts.count > 1 { stage = .chooseAccount } else { @@ -71,8 +98,8 @@ final class ShareModel: ObservableObject { /// Uploading to an account other than the one the app is on is "switching" /// in the app's terms, so it asks for the same unlock when the app does. - /// Hidden folders follow the account's own app setting, behind the app's - /// "lock hidden files" unlock. + /// The toggles start at that account's own app settings; hidden folders + /// are behind the app's "lock hidden files" unlock. func select(_ account: SharedAccount) async { guard let shared else { return } notice = nil @@ -83,11 +110,14 @@ final class ShareModel: ObservableObject { return } } + NSLog("[ShareExtension] selected %@ (hidden=%@ storage=%@)", account.id, account.hiddenFilter, account.storageScope) selected = account - hidden = HiddenFilter(raw: account.hiddenFilter) - if hidden != .hide, shared.needsUnlockForHidden { + externalRoots = [] + storageFilter = StorageFilter(raw: account.storageScope) + hiddenFilter = HiddenFilter(raw: account.hiddenFilter) + if hiddenFilter != .hide, shared.needsUnlockForHidden { if await !unlock("Unlock to show hidden folders") { - hidden = .hide + hiddenFilter = .hide notice = "Hidden folders are locked, so they're not shown." } } @@ -95,6 +125,31 @@ final class ShareModel: ObservableObject { await open("/") } + // MARK: - Toggles + + /// Turning hidden folders *on* is what the app locks; going back to + /// hiding them never exposes anything, and neither does switching + /// between the two revealing modes. + func setHiddenFilter(_ filter: HiddenFilter) async { + guard filter != hiddenFilter else { return } + if hiddenFilter == .hide, shared?.needsUnlockForHidden == true { + guard await unlock("Unlock to show hidden folders") else { + notice = "Hidden folders are locked, so they're not shown." + return + } + } + notice = nil + hiddenFilter = filter + await open("/") + } + + func setStorageFilter(_ filter: StorageFilter) async { + guard filter != storageFilter else { return } + notice = nil + storageFilter = filter + await open("/") + } + private func unlock(_ reason: String) async -> Bool { if unlocked { return true } let ok = await DeviceAuth.authenticate(reason: reason) @@ -102,6 +157,10 @@ final class ShareModel: ObservableObject { return ok } + // MARK: - Browsing + + /// A toggle can leave you inside a folder it now hides, so changing one + /// always returns to the top - same as [open] with "/". func open(_ path: String) async { guard let account = selected else { return } self.path = path @@ -109,9 +168,10 @@ final class ShareModel: ObservableObject { folderError = nil defer { isLoadingFolders = false } do { - folders = try await DavClient.listFolders(account: account, path: path, hidden: hidden) + allFolders = try await DavClient.listFolders(account: account, path: path) + externalRoots.formUnion(allFolders.filter(\.isExternal).map(\.path)) } catch { - folders = [] + allFolders = [] folderError = error.localizedDescription } } @@ -124,6 +184,7 @@ final class ShareModel: ObservableObject { struct SharePickerView: View { @ObservedObject var model: ShareModel + @State private var externalExpanded = true var body: some View { VStack(spacing: 0) { @@ -203,12 +264,12 @@ struct SharePickerView: View { .listStyle(.insetGrouped) } + // MARK: Folders + private var folderList: some View { List { - Section( - header: accountHeader, - footer: model.notice.map { Text($0) } - ) { + Section(header: accountHeader, footer: model.notice.map { Text($0) }) { + filterRow if model.path != "/" { Button { Task { await model.openParent() } @@ -220,15 +281,19 @@ struct SharePickerView: View { HStack { Spacer(); ProgressView(); Spacer() } } else if let error = model.folderError { Text(error).foregroundColor(.secondary) - } else if model.folders.isEmpty { + } else if model.internalFolders.isEmpty && model.externalFolders.isEmpty { Text("No folders here").foregroundColor(.secondary) } else { - ForEach(model.folders, id: \.path) { folder in - Button { - Task { await model.open(folder.path) } - } label: { - Label(folder.name, systemImage: "folder") - } + ForEach(model.internalFolders, id: \.path) { folderRow($0) } + } + } + // "All" keeps external storage in its own group, like the app's list. + if !model.isLoadingFolders, model.folderError == nil, !model.externalFolders.isEmpty { + Section { + DisclosureGroup(isExpanded: $externalExpanded) { + ForEach(model.externalFolders, id: \.path) { folderRow($0) } + } label: { + Label("External storage (\(model.externalFolders.count))", systemImage: "externaldrive") } } } @@ -236,6 +301,73 @@ struct SharePickerView: View { .listStyle(.insetGrouped) } + private func folderRow(_ folder: DavFolder) -> some View { + Button { + Task { await model.open(folder.path) } + } label: { + Label(folder.name, systemImage: folder.isExternal ? "externaldrive" : "folder") + } + } + + /// The two toggles, as menus - Hidden folders and External storage each + /// start at the app's setting and can be changed for this share. + private var filterRow: some View { + HStack(spacing: 8) { + Menu { + Picker("Hidden folders", selection: hiddenBinding) { + Text("Hide hidden folders").tag(HiddenFilter.hide) + Text("Only hidden folders").tag(HiddenFilter.only) + Text("Show all").tag(HiddenFilter.include) + } + } label: { + chip("eye", "Hidden", hiddenTitle) + } + Menu { + Picker("External storage", selection: storageBinding) { + Text("Cloud only").tag(StorageFilter.cloud) + Text("Only external storage").tag(StorageFilter.external) + Text("All + external storage").tag(StorageFilter.all) + } + } label: { + chip("externaldrive", "Storage", storageTitle) + } + Spacer() + } + .buttonStyle(.borderless) + } + + private func chip(_ icon: String, _ name: String, _ value: String) -> some View { + Label("\(name): \(value)", systemImage: icon) + .font(.footnote.weight(.medium)) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(Capsule().fill(Color(.secondarySystemFill))) + } + + private var hiddenBinding: Binding { + Binding(get: { model.hiddenFilter }, set: { value in Task { await model.setHiddenFilter(value) } }) + } + + private var storageBinding: Binding { + Binding(get: { model.storageFilter }, set: { value in Task { await model.setStorageFilter(value) } }) + } + + private var hiddenTitle: String { + switch model.hiddenFilter { + case .hide: return "Hide" + case .only: return "Only" + case .include: return "All" + } + } + + private var storageTitle: String { + switch model.storageFilter { + case .cloud: return "Cloud" + case .external: return "External" + case .all: return "All" + } + } + private var accountHeader: some View { HStack { Text(model.selected?.displayName ?? "") diff --git a/ios/Shared/DavFolders.swift b/ios/Shared/DavFolders.swift index a855b6e..bb502c6 100644 --- a/ios/Shared/DavFolders.swift +++ b/ios/Shared/DavFolders.swift @@ -2,9 +2,31 @@ import Foundation /// A folder in the account's WebDAV tree. [path] is relative to the user's /// root with a leading slash ("/Documents/Tax Forms"); "/" is the root. +/// [isExternal] is true for an external-storage mount point (Nextcloud's +/// `nc:mount-type` = `external`) - only the mount's root carries it, its +/// children don't, so callers track mount roots to know what's inside one. struct DavFolder: Equatable { let name: String let path: String + var isExternal = false +} + +/// The app's Files storage scope (`StorageScope` in Dart): internal ("cloud") +/// folders only (the default), only external-storage ones, or both. +enum StorageFilter: String { + case cloud, external, all + + init(raw: String) { + self = StorageFilter(rawValue: raw) ?? .cloud + } + + func shows(isExternal: Bool) -> Bool { + switch self { + case .cloud: return !isExternal + case .external: return isExternal + case .all: return true + } + } } /// The app's Files "hidden files" filter (`HiddenFilesFilter` in Dart): @@ -37,6 +59,7 @@ enum DavFolderParser { private struct Response { var href = "" var isCollection = false + var mountType = "" } private final class Delegate: NSObject, XMLParserDelegate { @@ -54,7 +77,7 @@ enum DavFolderParser { ) { switch local(elementName) { case "response": current = Response() - case "href": text = "" + case "href", "mount-type": text = "" case "collection": current?.isCollection = true default: break } @@ -70,6 +93,7 @@ enum DavFolderParser { ) { switch local(elementName) { case "href": current?.href = text.trimmingCharacters(in: .whitespacesAndNewlines) + case "mount-type": current?.mountType = text.trimmingCharacters(in: .whitespacesAndNewlines) case "response": if let current { responses.append(current) } current = nil @@ -109,7 +133,7 @@ enum DavFolderParser { guard let path = relativePath(fromHref: response.href), path != current else { return nil } let name = (path as NSString).lastPathComponent guard !name.isEmpty, hidden.shows(path: path) else { return nil } - return DavFolder(name: name, path: path) + return DavFolder(name: name, path: path, isExternal: response.mountType == "external") } .sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } } @@ -120,10 +144,12 @@ enum DavFolderParser { enum DavClient { private static let body = """ - + """ - static func listFolders(account: SharedAccount, path: String, hidden: HiddenFilter) async throws -> [DavFolder] { + /// Every child folder, hidden and external ones included - the sheet's + /// toggles filter that list in memory, so changing one doesn't refetch. + static func listFolders(account: SharedAccount, path: String) async throws -> [DavFolder] { guard let url = WebDAV.fileURL(serverUrl: account.serverUrl, username: account.username, remotePath: path) else { throw TransferError(message: "Invalid server address.") } var request = URLRequest(url: url) @@ -139,6 +165,6 @@ enum DavClient { guard status == 207 else { throw TransferError(message: status == 401 ? "Signed out - open Noo to sign in again." : "Server returned \(status).") } - return DavFolderParser.folders(from: data, excluding: path, hidden: hidden) + return DavFolderParser.folders(from: data, excluding: path, hidden: .include) } } diff --git a/ios/Shared/SharedAccount.swift b/ios/Shared/SharedAccount.swift index 2423ff8..aa9ff97 100644 --- a/ios/Shared/SharedAccount.swift +++ b/ios/Shared/SharedAccount.swift @@ -15,6 +15,37 @@ struct SharedAccount: Codable, Equatable, Identifiable { /// default), `only` or `include` (see `HiddenFilter`). The share sheet /// follows it instead of having a setting of its own. let hiddenFilter: String + /// The app's storage scope for this account - `cloud` (the default), + /// `external` or `all` (see `StorageFilter`). + let storageScope: String + + init( + id: String, serverUrl: String, username: String, authHeader: String, displayName: String, + hiddenFilter: String = "hide", storageScope: String = "cloud" + ) { + self.id = id + self.serverUrl = serverUrl + self.username = username + self.authHeader = authHeader + self.displayName = displayName + self.hiddenFilter = hiddenFilter + self.storageScope = storageScope + } + + /// Tolerates data written before a field existed instead of failing the + /// whole decode (which would look like "signed out" to the extension). + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + self.init( + id: try c.decode(String.self, forKey: .id), + serverUrl: try c.decode(String.self, forKey: .serverUrl), + username: try c.decode(String.self, forKey: .username), + authHeader: try c.decode(String.self, forKey: .authHeader), + displayName: try c.decode(String.self, forKey: .displayName), + hiddenFilter: try c.decodeIfPresent(String.self, forKey: .hiddenFilter) ?? "hide", + storageScope: try c.decodeIfPresent(String.self, forKey: .storageScope) ?? "cloud" + ) + } } /// Everything the app publishes for the extension: every account that can diff --git a/lib/providers/files_controller.dart b/lib/providers/files_controller.dart index 552ae90..486a1e1 100644 --- a/lib/providers/files_controller.dart +++ b/lib/providers/files_controller.dart @@ -677,6 +677,19 @@ class FilesController extends ChangeNotifier ); } + /// The storage scope saved for [accountId] (see [savedHiddenFilter]). + static StorageScope savedStorageScope( + SharedPreferences prefs, + String Function(String accountId, String baseKey) accountPrefKey, + String accountId, + ) { + final name = prefs.getString(accountPrefKey(accountId, _prefStorageScope)); + return StorageScope.values.firstWhere( + (s) => s.name == name, + orElse: () => StorageScope.cloud, + ); + } + /// The hidden-files filter saved for [accountId] - not just the active /// account's, which is all [hiddenFilter] holds. For iOS's Share Extension, /// which follows each account's own setting. diff --git a/lib/services/share_account_service.dart b/lib/services/share_account_service.dart index 6b39003..fccd277 100644 --- a/lib/services/share_account_service.dart +++ b/lib/services/share_account_service.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:convert'; +import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import '../providers/files_controller.dart'; import '../providers/session_controller.dart'; @@ -16,12 +17,16 @@ class ShareAccountEntry { /// The account's Files "hidden files" filter: `hide`, `only` or `include`. final String hiddenFilter; + /// The account's Files storage scope: `cloud`, `external` or `all`. + final String storageScope; + const ShareAccountEntry({ required this.id, required this.serverUrl, required this.username, required this.password, required this.hiddenFilter, + required this.storageScope, }); } @@ -57,6 +62,7 @@ class ShareAccountService { 'displayName': '${a.username}@${Uri.tryParse(a.serverUrl)?.host ?? a.serverUrl}', 'hiddenFilter': a.hiddenFilter, + 'storageScope': a.storageScope, }, ], 'activeId': activeId, @@ -68,8 +74,8 @@ class ShareAccountService { /// Publishes every saved account that can actually upload (has a stored /// password and isn't signed out), with the active one's hidden-files - /// filter taken live from [files] - its saved pref is written - /// asynchronously, so it can lag a change that just happened. + /// filter and storage scope taken live from [files] - the saved prefs are + /// written asynchronously, so they can lag a change that just happened. static Future publish( SessionController session, FilesController files, @@ -82,16 +88,32 @@ class ShareAccountService { final signedOut = store.loadSignedOut(prefs); final entries = []; for (final account in session.accounts) { - if (signedOut.contains(account.id)) continue; + if (signedOut.contains(account.id)) { + debugPrint('[ShareAccounts] skipping ${account.id}: signed out'); + continue; + } final password = await store.readPassword(account.id); - if (password == null) continue; - final filter = account.id == activeId + if (password == null) { + debugPrint( + '[ShareAccounts] skipping ${account.id}: no stored password', + ); + continue; + } + final isActive = account.id == activeId; + final filter = isActive ? files.hiddenFilter : FilesController.savedHiddenFilter( prefs, store.accountPrefKey, account.id, ); + final scope = isActive + ? files.storageScope + : FilesController.savedStorageScope( + prefs, + store.accountPrefKey, + account.id, + ); entries.add( ShareAccountEntry( id: account.id, @@ -99,9 +121,15 @@ class ShareAccountService { username: account.username, password: password, hiddenFilter: filter.name, + storageScope: scope.name, ), ); } + debugPrint( + '[ShareAccounts] publishing ${entries.length} of ' + '${session.accounts.length} saved accounts, active=$activeId ' + '(${signedOut.length} signed out)', + ); if (entries.isEmpty) return clear(); await invokeIfAvailable(_channel, 'setAccounts', { @@ -152,6 +180,7 @@ class ShareAccountSync { session.lockAccountSwitching, session.lockHiddenFiles, files.hiddenFilter.name, + files.storageScope.name, ].join('|'); void _onChange() { diff --git a/test/services/share_account_service_test.dart b/test/services/share_account_service_test.dart index 813306e..1f3c70c 100644 --- a/test/services/share_account_service_test.dart +++ b/test/services/share_account_service_test.dart @@ -11,6 +11,7 @@ void main() { username: 'alice', password: 's3cret', hiddenFilter: 'include', + storageScope: 'all', ); const bob = ShareAccountEntry( id: 'nc_home_lan__bob', @@ -18,6 +19,7 @@ void main() { username: 'bob', password: 'pw', hiddenFilter: 'hide', + storageScope: 'cloud', ); Map build({ @@ -52,10 +54,12 @@ void main() { ); expect(accounts[0]['displayName'], 'alice@cloud.example.com'); expect(accounts[0]['hiddenFilter'], 'include'); + expect(accounts[0]['storageScope'], 'all'); // The display name uses the host only - not the port or sub-path. expect(accounts[1]['displayName'], 'bob@nc.home.lan'); expect(accounts[1]['serverUrl'], 'https://nc.home.lan:8443/nextcloud'); expect(accounts[1]['hiddenFilter'], 'hide'); + expect(accounts[1]['storageScope'], 'cloud'); }); test('the active account and the lock settings are passed through', () { From 1542d53a0c5d0876de5d4d5a35a3c0c4cefb9312 Mon Sep 17 00:00:00 2001 From: Ayushya Amitabh Date: Tue, 6 Oct 2026 20:18:20 -0400 Subject: [PATCH 20/31] iOS: authenticate each request by its own header only (no cookies/credential cache) Two accounts can share one server; the Share Extension's folder listing and the upload sessions now ignore cookies and stored credentials so one account's session can never answer for another. Logs each PROPFIND (user, path, status). Co-Authored-By: Claude Sonnet 5.5 --- ios/Runner/Native/TransferManager.swift | 5 +++++ ios/Shared/DavFolders.swift | 17 ++++++++++++++++- ios/Shared/ShareUpload.swift | 4 ++++ 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/ios/Runner/Native/TransferManager.swift b/ios/Runner/Native/TransferManager.swift index dae9465..a303c07 100644 --- a/ios/Runner/Native/TransferManager.swift +++ b/ios/Runner/Native/TransferManager.swift @@ -34,6 +34,11 @@ final class TransferManager: NSObject { config.sessionSendsLaunchEvents = true config.isDiscretionary = false config.waitsForConnectivity = true + // Two accounts can share a server: authenticate by the request's own + // header only, never a cookie another account's request left behind. + config.httpShouldSetCookies = false + config.httpCookieAcceptPolicy = .never + config.urlCredentialStorage = nil return URLSession(configuration: config, delegate: self, delegateQueue: nil) }() diff --git a/ios/Shared/DavFolders.swift b/ios/Shared/DavFolders.swift index bb502c6..98f538a 100644 --- a/ios/Shared/DavFolders.swift +++ b/ios/Shared/DavFolders.swift @@ -149,6 +149,20 @@ enum DavClient { /// Every child folder, hidden and external ones included - the sheet's /// toggles filter that list in memory, so changing one doesn't refetch. + /// Two accounts can live on the same server, so a request must be + /// authenticated by its own `Authorization` header alone - never by a + /// session cookie or credential a previous account's request left behind + /// in the shared stores, or the second account could be answered as the + /// first. + private static let session: URLSession = { + let config = URLSessionConfiguration.ephemeral + config.httpShouldSetCookies = false + config.httpCookieAcceptPolicy = .never + config.urlCredentialStorage = nil + config.requestCachePolicy = .reloadIgnoringLocalCacheData + return URLSession(configuration: config) + }() + static func listFolders(account: SharedAccount, path: String) async throws -> [DavFolder] { guard let url = WebDAV.fileURL(serverUrl: account.serverUrl, username: account.username, remotePath: path) else { throw TransferError(message: "Invalid server address.") } @@ -160,8 +174,9 @@ enum DavClient { request.httpBody = body.data(using: .utf8) request.timeoutInterval = 20 - let (data, response) = try await URLSession.shared.data(for: request) + let (data, response) = try await session.data(for: request) let status = (response as? HTTPURLResponse)?.statusCode ?? 0 + NSLog("[DavClient] PROPFIND %@ as %@ -> %d", url.path, account.username, status) guard status == 207 else { throw TransferError(message: status == 401 ? "Signed out - open Noo to sign in again." : "Server returned \(status).") } diff --git a/ios/Shared/ShareUpload.swift b/ios/Shared/ShareUpload.swift index cb1a9a2..eb68ff1 100644 --- a/ios/Shared/ShareUpload.swift +++ b/ios/Shared/ShareUpload.swift @@ -16,6 +16,10 @@ enum ShareUpload { config.sessionSendsLaunchEvents = true config.isDiscretionary = false config.waitsForConnectivity = true + // Authenticate by the request's own header only (see DavClient.session). + config.httpShouldSetCookies = false + config.httpCookieAcceptPolicy = .never + config.urlCredentialStorage = nil return config } From b7ca714bbf84565dc809addb8807b19cd732c52e Mon Sep 17 00:00:00 2001 From: Ayushya Amitabh Date: Tue, 6 Oct 2026 20:24:20 -0400 Subject: [PATCH 21/31] Share sheet: ask to unlock on every gated action, like the app The sheet remembered one unlock for its whole lifetime, so later account picks and hidden toggles skipped Face ID. Each switch / each turning-on of hidden folders now prompts again; one action needing both is a single prompt. Co-Authored-By: Claude Sonnet 5.5 --- .claude/context/server.md | 10 ++++- ios/RunnerTests/RunnerTests.swift | 49 +++++++++++++++++++++++ ios/ShareExtension/SharePickerView.swift | 50 +++++++++++------------- ios/Shared/SharedAccount.swift | 23 +++++++++++ 4 files changed, 104 insertions(+), 28 deletions(-) diff --git a/.claude/context/server.md b/.claude/context/server.md index df64637..a63fa9e 100644 --- a/.claude/context/server.md +++ b/.claude/context/server.md @@ -935,7 +935,15 @@ Reminders/Notes - the destination is picked *inside* the sheet: hidden, with a note. Switching `only` <-> `all`, or back to `hide`, never asks. - **Account switching**: choosing any account other than the app's - active one asks for the same unlock when "lock account switching" is on. One successful unlock covers the rest of that sheet. + active one asks for the same unlock when "lock account switching" is on. + **Every gated action prompts every time**, exactly like the app - no + unlock is remembered for the sheet (an earlier version cached one, so + later account picks / hidden toggles skipped Face ID). The only + merging is within one action: picking another account whose own filter + already shows hidden folders is a single prompt covering both + (`SharedAccounts.unlockNeeds` / `SelectionUnlock`, unit-tested). + Declining the account unlock cancels the pick; declining only the + hidden unlock shows the account with hidden folders hidden. - Opening the sheet itself is not gated - only these two actions are (as in the app, where login lock guards launch and these are separate locks). diff --git a/ios/RunnerTests/RunnerTests.swift b/ios/RunnerTests/RunnerTests.swift index f324061..79e2c65 100644 --- a/ios/RunnerTests/RunnerTests.swift +++ b/ios/RunnerTests/RunnerTests.swift @@ -217,6 +217,55 @@ class RunnerTests: XCTestCase { XCTAssertTrue(rules(master: true, switching: true, hidden: true) == (true, true)) } + // Which actions in the share sheet ask to unlock - and how many prompts. + + private func lockedAccounts(switching: Bool, hidden: Bool) -> SharedAccounts { + SharedAccounts( + accounts: [account("alice"), account("bob")], activeId: "alice", + loginLockEnabled: false, lockAccountSwitching: switching, lockHiddenFiles: hidden) + } + + func testChoosingTheActiveAccountWithoutHiddenFoldersNeverPrompts() { + let shared = lockedAccounts(switching: true, hidden: true) + let needs = shared.unlockNeeds(choosing: account("alice"), showing: .hide) + XCTAssertEqual(needs, SelectionUnlock(switchesAccount: false, showsHidden: false)) + XCTAssertFalse(needs.needsPrompt) + } + + func testChoosingAnotherAccountPromptsEveryTime() { + let shared = lockedAccounts(switching: true, hidden: false) + // No unlock is remembered: the same choice asks again on every call. + for _ in 0..<3 { + XCTAssertTrue(shared.unlockNeeds(choosing: account("bob"), showing: .hide).needsPrompt) + } + XCTAssertFalse( + lockedAccounts(switching: false, hidden: false).unlockNeeds(choosing: account("bob"), showing: .hide).needsPrompt) + } + + func testSwitchPlusHiddenFoldersIsOnePromptThatCoversBoth() { + let needs = lockedAccounts(switching: true, hidden: true) + .unlockNeeds(choosing: account("bob"), showing: .include) + XCTAssertEqual(needs, SelectionUnlock(switchesAccount: true, showsHidden: true)) + XCTAssertTrue(needs.needsPrompt) + } + + func testHiddenFoldersAloneOnTheActiveAccountPromptsForHidden() { + let needs = lockedAccounts(switching: true, hidden: true) + .unlockNeeds(choosing: account("alice"), showing: .only) + XCTAssertEqual(needs, SelectionUnlock(switchesAccount: false, showsHidden: true)) + } + + func testOnlyTurningHiddenFoldersOnAsksToUnlock() { + let locked = lockedAccounts(switching: false, hidden: true) + XCTAssertTrue(locked.needsUnlockToChangeHidden(from: .hide, to: .only)) + XCTAssertTrue(locked.needsUnlockToChangeHidden(from: .hide, to: .include)) + XCTAssertFalse(locked.needsUnlockToChangeHidden(from: .include, to: .hide), "hiding again never asks") + XCTAssertFalse(locked.needsUnlockToChangeHidden(from: .only, to: .include), "only <-> all never asks") + XCTAssertFalse( + lockedAccounts(switching: false, hidden: false).needsUnlockToChangeHidden(from: .hide, to: .include), + "no lock set, no prompt") + } + // MARK: - TransferBatchStore func testBatchSummaryFiresOnlyOnTheLastFile() { diff --git a/ios/ShareExtension/SharePickerView.swift b/ios/ShareExtension/SharePickerView.swift index 9b963da..ff9cd1a 100644 --- a/ios/ShareExtension/SharePickerView.swift +++ b/ios/ShareExtension/SharePickerView.swift @@ -35,9 +35,6 @@ final class ShareModel: ObservableObject { let shared: SharedAccounts? - /// One successful unlock covers every gate for the rest of this sheet. - private var unlocked = false - /// Set by the view controller: what each button actually does. var onUpload: () -> Void = {} var onSaveForLater: () -> Void = {} @@ -103,24 +100,30 @@ final class ShareModel: ObservableObject { func select(_ account: SharedAccount) async { guard let shared else { return } notice = nil - if account.id != shared.active?.id, shared.needsUnlockToSwitchAccount { - guard await unlock("Unlock to upload to \(account.displayName)") else { - notice = "Unlock to upload to a different account." - stage = .chooseAccount - return - } - } - NSLog("[ShareExtension] selected %@ (hidden=%@ storage=%@)", account.id, account.hiddenFilter, account.storageScope) - selected = account - externalRoots = [] - storageFilter = StorageFilter(raw: account.storageScope) - hiddenFilter = HiddenFilter(raw: account.hiddenFilter) - if hiddenFilter != .hide, shared.needsUnlockForHidden { - if await !unlock("Unlock to show hidden folders") { - hiddenFilter = .hide + var wantedHidden = HiddenFilter(raw: account.hiddenFilter) + + // Every selection asks again, like the app does for every switch - no + // unlock is remembered. If one action needs both unlocks, one prompt + // covers both. + let needs = shared.unlockNeeds(choosing: account, showing: wantedHidden) + if needs.needsPrompt { + let reason = needs.switchesAccount + ? "Unlock to upload to \(account.displayName)" : "Unlock to show hidden folders" + if await !DeviceAuth.authenticate(reason: reason) { + guard !needs.switchesAccount else { + notice = "Unlock to upload to a different account." + stage = .chooseAccount + return + } + wantedHidden = .hide notice = "Hidden folders are locked, so they're not shown." } } + NSLog("[ShareExtension] selected %@ (hidden=%@ storage=%@)", account.id, wantedHidden.rawValue, account.storageScope) + selected = account + externalRoots = [] + storageFilter = StorageFilter(raw: account.storageScope) + hiddenFilter = wantedHidden stage = .picking await open("/") } @@ -132,8 +135,8 @@ final class ShareModel: ObservableObject { /// between the two revealing modes. func setHiddenFilter(_ filter: HiddenFilter) async { guard filter != hiddenFilter else { return } - if hiddenFilter == .hide, shared?.needsUnlockForHidden == true { - guard await unlock("Unlock to show hidden folders") else { + if shared?.needsUnlockToChangeHidden(from: hiddenFilter, to: filter) == true { + guard await DeviceAuth.authenticate(reason: "Unlock to show hidden folders") else { notice = "Hidden folders are locked, so they're not shown." return } @@ -150,13 +153,6 @@ final class ShareModel: ObservableObject { await open("/") } - private func unlock(_ reason: String) async -> Bool { - if unlocked { return true } - let ok = await DeviceAuth.authenticate(reason: reason) - if ok { unlocked = true } - return ok - } - // MARK: - Browsing /// A toggle can leave you inside a folder it now hides, so changing one diff --git a/ios/Shared/SharedAccount.swift b/ios/Shared/SharedAccount.swift index aa9ff97..031620d 100644 --- a/ios/Shared/SharedAccount.swift +++ b/ios/Shared/SharedAccount.swift @@ -73,6 +73,21 @@ struct SharedAccounts: Codable, Equatable { /// Showing hidden folders needs the same unlock the app asks for when you /// turn hidden files on. var needsUnlockForHidden: Bool { lockHiddenFiles } + + /// What choosing [account] (with [hidden] folders to show) asks the user + /// to unlock. One action, one prompt - when both the account switch and + /// the hidden folders need it, a single authentication covers both. + func unlockNeeds(choosing account: SharedAccount, showing hidden: HiddenFilter) -> SelectionUnlock { + SelectionUnlock( + switchesAccount: account.id != active?.id && needsUnlockToSwitchAccount, + showsHidden: hidden != .hide && needsUnlockForHidden) + } + + /// Turning hidden folders on (from `hide`) is what the app locks; going + /// back to `hide`, or between the two revealing modes, never asks. + func needsUnlockToChangeHidden(from old: HiddenFilter, to new: HiddenFilter) -> Bool { + old == .hide && new != .hide && needsUnlockForHidden + } } /// Keeps the [SharedAccounts] in a Keychain access group both the app and the @@ -136,3 +151,11 @@ enum SharedAccountStore { SecItemDelete(legacy as CFDictionary) } } + +/// What one selection needs unlocked - see `SharedAccounts.unlockNeeds`. +struct SelectionUnlock: Equatable { + let switchesAccount: Bool + let showsHidden: Bool + + var needsPrompt: Bool { switchesAccount || showsHidden } +} From ca75eb9c309d17a48a2c9b80b613be3b9b67ec29 Mon Sep 17 00:00:00 2001 From: Ayushya Amitabh Date: Tue, 6 Oct 2026 20:38:19 -0400 Subject: [PATCH 22/31] Media viewer: Edit/Preview and Save move to the top bar A TextPreviewController (owned by the viewer, bound by MediaTextPreview) mirrors the editing/dirty/saving state out so the top bar draws the buttons; Save shows a spinner while saving. Located and tested via a Codex consult (its end-to-end widget test, with the provider fixed and the saving state adapted); the controller design replaces its builder-wrapping patch. Co-Authored-By: Claude Sonnet 5.5 --- .claude/context/styling.md | 4 +- lib/views/file_viewer_screen.dart | 62 ++++++- lib/widgets/viewer/media_text_preview.dart | 142 +++++++++++---- test/views/file_viewer_screen_test.dart | 195 +++++++++++++++++++++ 4 files changed, 369 insertions(+), 34 deletions(-) create mode 100644 test/views/file_viewer_screen_test.dart diff --git a/.claude/context/styling.md b/.claude/context/styling.md index 58c3bf3..bd6ed4b 100644 --- a/.claude/context/styling.md +++ b/.claude/context/styling.md @@ -389,11 +389,11 @@ blocks are noted where they matter: ## Text/markdown viewer -`MediaTextPreview` is an editable monospace `TextField` padded clear of the status bar, top bar and action bar; a Save button appears when dirty (read-only for offline copies). `NooPersonAccessRow.trailing` replaces the owner label/permission pill (used by share-search results). Sheets whose close button lives in `showNooSheet` children must pop via a `Builder` context, not the caller's. +`MediaTextPreview` is an editable monospace `TextField` padded clear of the status bar, top bar and action bar; a Save icon button appears in the viewer's top bar when dirty (a spinner while it saves; read-only for offline copies). The preview owns the text, the editing mode and the save; a `TextPreviewController` (owned by `FileViewerScreen`, passed to `MediaTextPreview`) mirrors that state out so the screen's top bar can draw - and trigger - the buttons, which keeps the preview's widget tree unchanged. `NooPersonAccessRow.trailing` replaces the owner label/permission pill (used by share-search results). Sheets whose close button lives in `showNooSheet` children must pop via a `Builder` context, not the caller's. `showNooSheet` insets its body by the keyboard (`viewInsets.bottom`) so focused fields stay visible; the body's widget structure must not change when the keyboard opens, or the sheet content is rebuilt and loses focus. -Markdown files (`.md`/`.markdown`) in `MediaTextPreview` open rendered via `flutter_markdown_plus` (`Markdown`, styled from Noo tokens in `_markdownStyle`), with a top-right Edit/Preview toggle (hidden for read-only offline copies). Other text files go straight to the editor. +Markdown files (`.md`/`.markdown`) in `MediaTextPreview` open rendered via `flutter_markdown_plus` (`Markdown`, styled from Noo tokens in `_markdownStyle`), with an Edit/Preview icon toggle in the viewer's top bar (hidden for read-only offline copies). Other text files go straight to the editor. `ShareSheet`: focusing the people search field does not scroll; once the user types, `_revealPeopleSection` animates the "Share with people" section (keyed by `_peopleKey` on the `NooShareSection`, not the inner column) to just below the sheet's top edge with a small gap. diff --git a/lib/views/file_viewer_screen.dart b/lib/views/file_viewer_screen.dart index 32e22f0..69f11b3 100644 --- a/lib/views/file_viewer_screen.dart +++ b/lib/views/file_viewer_screen.dart @@ -178,6 +178,10 @@ class _FileViewerScreenState extends State { final _panelKey = GlobalKey(); + /// Edit/Preview + Save for a text/markdown file: the preview owns the + /// editing, this just lets the top bar draw (and trigger) its buttons. + final _textActions = TextPreviewController(); + void _openDetails() { if (NooLayout.isDesktop(context)) { DetailsSheet.show(context, _currentItem); @@ -229,6 +233,7 @@ class _FileViewerScreenState extends State { @override void dispose() { + _textActions.dispose(); _pageController.dispose(); super.dispose(); } @@ -532,7 +537,14 @@ class _FileViewerScreenState extends State { ), ), ), - const SizedBox(width: 10), + // Edit/Preview and Save for text/markdown files - + // empty (zero-width) for every other file type. + ListenableBuilder( + listenable: _textActions, + builder: (context, _) => + _TextActions(controller: _textActions), + ), + const SizedBox(width: 4), ], ), ), @@ -731,6 +743,7 @@ class _FileViewerScreenState extends State { return MediaTextPreview( item: widget.item, session: session, + controller: _textActions, localPathResolver: widget.localPathResolver, ); } @@ -743,3 +756,50 @@ class _FileViewerScreenState extends State { } } } + +/// The text viewer's top-bar buttons: Edit/Preview (markdown only) and Save +/// (once the content changed; a spinner while it saves). Same +/// [NooTopBarButton] look as the back button next to it. +class _TextActions extends StatelessWidget { + final TextPreviewController controller; + + const _TextActions({required this.controller}); + + @override + Widget build(BuildContext context) { + final colors = context.nooColors; + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (controller.canToggleEditing) + NooTopBarButton( + icon: controller.editing ? LucideIcons.eye : LucideIcons.pencil, + tooltip: controller.editing ? 'Preview' : 'Edit', + onTap: controller.toggleEditing, + ), + if (controller.saving) + Semantics( + label: 'Saving', + child: SizedBox.square( + dimension: 48, + child: Center( + child: SizedBox.square( + dimension: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: colors.fg1, + ), + ), + ), + ), + ) + else if (controller.showSave) + NooTopBarButton( + icon: LucideIcons.save, + tooltip: 'Save', + onTap: controller.save, + ), + ], + ); + } +} diff --git a/lib/widgets/viewer/media_text_preview.dart b/lib/widgets/viewer/media_text_preview.dart index 8dde92d..af0640d 100644 --- a/lib/widgets/viewer/media_text_preview.dart +++ b/lib/widgets/viewer/media_text_preview.dart @@ -2,11 +2,9 @@ import 'dart:convert'; import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; -import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../models/nextcloud_item.dart'; import '../../providers/session_controller.dart'; import '../../theme/design_tokens.dart'; -import '../noo/core/noo_button.dart'; /// Space the viewer's floating top bar and bottom action bar cover, so the /// text sits clear of them (and the status bar) instead of scrolling @@ -14,16 +12,96 @@ import '../noo/core/noo_button.dart'; const double _kTopBarClearance = 72; const double _kActionBarClearance = 112; +/// What the viewer's top bar needs from [MediaTextPreview]: whether to show +/// the Edit/Preview toggle and the Save button, their current state, and a way +/// to trigger them. The preview owns the text, the editing mode and the save; +/// this just mirrors that out so `FileViewerScreen` can draw the buttons in +/// its own top bar (they used to float inside the preview). +class TextPreviewController extends ChangeNotifier { + bool _canToggleEditing = false; + bool _editing = false; + bool _dirty = false; + bool _saving = false; + bool _disposed = false; + VoidCallback? _onToggleEditing; + Future Function()? _onSave; + + /// Markdown, writable (not the read-only Offline copy) and loaded - the + /// Edit/Preview toggle only exists then. + bool get canToggleEditing => _canToggleEditing; + + /// Markdown is currently showing its raw text (the toggle then offers + /// "Preview"); false while rendered (it offers "Edit"). + bool get editing => _editing; + + /// The text differs from what's saved on the server. + bool get dirty => _dirty; + bool get saving => _saving; + + /// The Save button is shown once the content has changed, and stays (as a + /// spinner) while the save is in flight. + bool get showSave => _dirty || _saving; + + void toggleEditing() => _onToggleEditing?.call(); + Future save() async => _onSave?.call(); + + void _bind(VoidCallback toggleEditing, Future Function() save) { + _onToggleEditing = toggleEditing; + _onSave = save; + } + + /// The preview went away: nothing to toggle or save any more. Quiet (no + /// notification) - it happens while the tree is being torn down. + void _unbind() { + _onToggleEditing = null; + _onSave = null; + _canToggleEditing = false; + _editing = false; + _dirty = false; + _saving = false; + } + + void _update({ + required bool canToggleEditing, + required bool editing, + required bool dirty, + required bool saving, + }) { + if (_canToggleEditing == canToggleEditing && + _editing == editing && + _dirty == dirty && + _saving == saving) { + return; + } + _canToggleEditing = canToggleEditing; + _editing = editing; + _dirty = dirty; + _saving = saving; + if (!_disposed) notifyListeners(); + } + + @override + void dispose() { + _disposed = true; + super.dispose(); + } +} + /// Text/markdown file preview and editor for [FileViewerScreen]'s static /// (non-swipeable) path (`_textPreviewExtensions`). Content is always -/// editable when online; a Save button appears once it has changed. Markdown -/// files open rendered, with an Edit/Preview toggle. Opened -/// from the Offline tab ([localPathResolver] set) it is read-only, since the -/// local copy has no server to write back to. +/// editable when online; Save appears once it has changed. Markdown files open +/// rendered, with an Edit/Preview toggle. Both controls live in the viewer's +/// top bar, driven through [controller]. Opened from the Offline tab +/// ([localPathResolver] set) it is read-only, since the local copy has no +/// server to write back to. class MediaTextPreview extends StatefulWidget { final NextcloudItem item; final SessionController session; + /// Receives this preview's editing/save state and is how the top bar's + /// buttons reach it. Optional so the preview also works on its own. + final TextPreviewController? controller; + /// When set, read bytes from the local path it resolves to instead of /// fetching from the server - see /// `FileViewerScreen.localPathResolver`'s doc comment. @@ -33,6 +111,7 @@ class MediaTextPreview extends StatefulWidget { super.key, required this.item, required this.session, + this.controller, this.localPathResolver, }); @@ -60,16 +139,38 @@ class _MediaTextPreviewState extends State { @override void initState() { super.initState(); - _controller.addListener(() => setState(() {})); + widget.controller?._bind(_toggleEditing, _save); + _controller.addListener(() { + setState(() {}); + _syncToolbar(); + }); _load(); } @override void dispose() { + widget.controller?._unbind(); _controller.dispose(); super.dispose(); } + /// Mirrors the editing/save state out to the viewer's top bar. Only called + /// from event handlers and async completions - never from build or + /// initState, where notifying the bar's listeners would be illegal. + void _syncToolbar() { + widget.controller?._update( + canToggleEditing: _saved != null && _isMarkdown && !_readOnly, + editing: _editing, + dirty: _dirty, + saving: _isSaving, + ); + } + + void _toggleEditing() { + setState(() => _editing = !_editing); + _syncToolbar(); + } + Future _load() async { try { final List bytes; @@ -89,6 +190,7 @@ class _MediaTextPreviewState extends State { final text = utf8.decode(bytes, allowMalformed: true); _controller.text = text; setState(() => _saved = text); + _syncToolbar(); } } catch (e) { if (mounted) setState(() => _error = e.toString()); @@ -99,6 +201,7 @@ class _MediaTextPreviewState extends State { final messenger = ScaffoldMessenger.of(context); final text = _controller.text; setState(() => _isSaving = true); + _syncToolbar(); var ok = false; try { ok = await widget.session.service!.putBytes( @@ -111,6 +214,7 @@ class _MediaTextPreviewState extends State { _isSaving = false; if (ok) _saved = text; }); + _syncToolbar(); messenger.showSnackBar( SnackBar( content: Text(ok ? 'Saved ${widget.item.name}' : 'Could not save file'), @@ -171,30 +275,6 @@ class _MediaTextPreviewState extends State { ), ), ), - if (_isMarkdown && !_readOnly) - Positioned( - right: 20, - top: safe.top + _kTopBarClearance - 8, - child: NooButton( - variant: NooButtonVariant.tonal, - size: NooButtonSize.compact, - icon: _editing ? LucideIcons.eye : LucideIcons.pencil, - onTap: () => setState(() => _editing = !_editing), - child: Text(_editing ? 'Preview' : 'Edit'), - ), - ), - if (_dirty) - Positioned( - right: 20, - bottom: safe.bottom + _kActionBarClearance, - child: NooButton( - size: NooButtonSize.card, - icon: LucideIcons.save, - disabled: _isSaving, - onTap: _isSaving ? null : _save, - child: Text(_isSaving ? 'Saving…' : 'Save'), - ), - ), ], ), ); diff --git a/test/views/file_viewer_screen_test.dart b/test/views/file_viewer_screen_test.dart new file mode 100644 index 0000000..9e083a2 --- /dev/null +++ b/test/views/file_viewer_screen_test.dart @@ -0,0 +1,195 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:noo/models/nextcloud_item.dart'; +import 'package:noo/providers/item_operations.dart'; +import 'package:noo/providers/session_controller.dart'; +import 'package:noo/services/nextcloud_service.dart'; +import 'package:noo/views/file_viewer_screen.dart'; +import 'package:noo/widgets/noo/nav/noo_top_bar.dart'; +import 'package:noo/widgets/viewer/media_action_bar.dart'; +import 'package:provider/provider.dart'; + +import '../widgets/noo/noo_test_utils.dart'; + +class _Service extends NextcloudService { + _Service() : super(serverUrl: '', username: '', password: ''); + + final loaded = Completer>(); + Completer? saving; + String? written; + + @override + Future> fetchBytes(String itemPath) => loaded.future; + + @override + Future putBytes(String itemPath, List bytes) { + written = utf8.decode(bytes); + saving = Completer(); + return saving!.future; + } +} + +// These stand-ins avoid starting session restoration and platform channels. +class _Session extends ChangeNotifier implements SessionController { + _Session(this.service); + + @override + final NextcloudService service; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _Operations implements ItemOperations { + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +Finder _button(String tooltip) => find.byWidgetPredicate( + (widget) => widget is NooTopBarButton && widget.tooltip == tooltip, +); + +void main() { + setUpNooTests(); + + Future<_Service> mount( + WidgetTester tester, + Size size, { + String name = 'notes.md', + bool offline = false, + }) async { + final service = _Service(); + final session = _Session(service); + addTearDown(session.dispose); + await tester.binding.setSurfaceSize(size); + addTearDown(() => tester.binding.setSurfaceSize(null)); + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: session), + Provider.value(value: _Operations()), + ], + child: MaterialApp( + theme: nooTheme(Brightness.light), + home: FileViewerScreen( + item: NextcloudItem( + id: '1', + name: name, + path: '/$name', + type: NextcloudItemType.file, + size: 12, + lastModified: DateTime(2026), + ), + localPathResolver: offline ? (_) async => null : null, + ), + ), + ), + ); + return service; + } + + for (final width in [390.0, 1200.0]) { + testWidgets('text actions use the top bar at width $width', (tester) async { + final service = await mount(tester, Size(width, 800)); + expect(_button('Edit'), findsNothing); + expect(_button('Save'), findsNothing); + service.loaded.complete(utf8.encode('# Original')); + await tester.pumpAndSettle(); + + void inHeader(String label) { + final row = find + .ancestor(of: _button('Back'), matching: find.byType(Row)) + .first; + expect( + find.descendant(of: row, matching: _button(label)), + findsOneWidget, + ); + expect( + find.descendant( + of: find.byType(MediaActionBar), + matching: _button(label), + ), + findsNothing, + ); + } + + inHeader('Edit'); + expect(find.byType(Markdown), findsOneWidget); + await tester.tap(_button('Edit')); + await tester.pumpAndSettle(); + inHeader('Preview'); + await tester.enterText(find.byType(TextField), '# Changed'); + await tester.pump(); + inHeader('Save'); + await tester.tap(_button('Preview')); + await tester.pumpAndSettle(); + expect(tester.widget(find.byType(Markdown)).data, '# Changed'); + inHeader('Save'); + await tester.tap(_button('Save')); + await tester.pump(); + expect(service.written, '# Changed'); + // While the save is in flight the Save button gives way to a spinner, + // so it can't be tapped twice. + expect(_button('Save'), findsNothing); + expect( + find.byWidgetPredicate( + (w) => w is Semantics && w.properties.label == 'Saving', + ), + findsOneWidget, + ); + service.saving!.complete(false); + await tester.pumpAndSettle(); + inHeader('Save'); + expect(find.text('Could not save file'), findsOneWidget); + await tester.tap(_button('Save')); + await tester.pump(); + service.saving!.complete(true); + await tester.pumpAndSettle(); + expect(_button('Save'), findsNothing); + expect(_button('Edit'), findsOneWidget); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox.shrink()); + }); + } + + testWidgets('plain text has Save without the markdown toggle', ( + tester, + ) async { + final service = await mount( + tester, + const Size(390, 800), + name: 'notes.txt', + ); + service.loaded.complete(utf8.encode('Original')); + await tester.pumpAndSettle(); + expect(_button('Edit'), findsNothing); + expect(_button('Preview'), findsNothing); + await tester.enterText(find.byType(TextField), 'Changed'); + await tester.pump(); + expect(_button('Save'), findsOneWidget); + await tester.pumpWidget(const SizedBox.shrink()); + }); + + for (final offline in [false, true]) { + testWidgets('no text actions for offline=$offline unsupported files', ( + tester, + ) async { + await mount( + tester, + const Size(390, 800), + name: offline ? 'notes.md' : 'data.bin', + offline: offline, + ); + await tester.pumpAndSettle(); + expect(_button('Edit'), findsNothing); + expect(_button('Preview'), findsNothing); + expect(_button('Save'), findsNothing); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox.shrink()); + }); + } +} From e176152d56dcf7e491d41b823e465b08af1b60cf Mon Sep 17 00:00:00 2001 From: Ayushya Amitabh Date: Tue, 6 Oct 2026 20:50:26 -0400 Subject: [PATCH 23/31] Viewer: round icon buttons with backgrounds; plain text follows Edit -> Save too Edit/Preview and Save are now compact (36px) icon-only NooButtons - primary when alone, Edit/Preview secondary beside a primary Save, a spinner circle while saving. Plain text opens read-only like Markdown and gets the same toggle. NooButton gains an opt-in iconOnly mode. Co-Authored-By: Claude Sonnet 5.5 --- .claude/context/styling.md | 4 +- lib/views/file_viewer_screen.dart | 95 +++++++++++++++++----- lib/widgets/noo/core/noo_button.dart | 33 ++++++-- lib/widgets/viewer/media_text_preview.dart | 22 ++++- test/views/file_viewer_screen_test.dart | 55 +++++++++++-- 5 files changed, 169 insertions(+), 40 deletions(-) diff --git a/.claude/context/styling.md b/.claude/context/styling.md index bd6ed4b..1d494d4 100644 --- a/.claude/context/styling.md +++ b/.claude/context/styling.md @@ -389,11 +389,11 @@ blocks are noted where they matter: ## Text/markdown viewer -`MediaTextPreview` is an editable monospace `TextField` padded clear of the status bar, top bar and action bar; a Save icon button appears in the viewer's top bar when dirty (a spinner while it saves; read-only for offline copies). The preview owns the text, the editing mode and the save; a `TextPreviewController` (owned by `FileViewerScreen`, passed to `MediaTextPreview`) mirrors that state out so the screen's top bar can draw - and trigger - the buttons, which keeps the preview's widget tree unchanged. `NooPersonAccessRow.trailing` replaces the owner label/permission pill (used by share-search results). Sheets whose close button lives in `showNooSheet` children must pop via a `Builder` context, not the caller's. +`MediaTextPreview` is an editable monospace `TextField` padded clear of the status bar, top bar and action bar; every text file opens read-only (plain text as-is, Markdown rendered) and follows Edit -> Save: an Edit/Preview toggle and, once dirty, a Save button live in the viewer's top bar as round icon-only `NooButton`s (`iconOnly`, 36px; a lone button is primary, with two Edit/Preview is secondary and Save primary; Save becomes a primary circle with a spinner while it saves). Offline copies are read-only with no buttons. The preview owns the text, the editing mode and the save; a `TextPreviewController` (owned by `FileViewerScreen`, passed to `MediaTextPreview`) mirrors that state out so the screen's top bar can draw - and trigger - the buttons, which keeps the preview's widget tree unchanged. `NooPersonAccessRow.trailing` replaces the owner label/permission pill (used by share-search results). Sheets whose close button lives in `showNooSheet` children must pop via a `Builder` context, not the caller's. `showNooSheet` insets its body by the keyboard (`viewInsets.bottom`) so focused fields stay visible; the body's widget structure must not change when the keyboard opens, or the sheet content is rebuilt and loses focus. -Markdown files (`.md`/`.markdown`) in `MediaTextPreview` open rendered via `flutter_markdown_plus` (`Markdown`, styled from Noo tokens in `_markdownStyle`), with an Edit/Preview icon toggle in the viewer's top bar (hidden for read-only offline copies). Other text files go straight to the editor. +Markdown files (`.md`/`.markdown`) in `MediaTextPreview` open rendered via `flutter_markdown_plus` (`Markdown`, styled from Noo tokens in `_markdownStyle`), with the same Edit/Preview toggle in the viewer's top bar (hidden for read-only offline copies). Other text files go straight to the editor. `ShareSheet`: focusing the people search field does not scroll; once the user types, `_revealPeopleSection` animates the "Share with people" section (keyed by `_peopleKey` on the `NooShareSection`, not the inner column) to just below the sheet's top edge with a small gap. diff --git a/lib/views/file_viewer_screen.dart b/lib/views/file_viewer_screen.dart index 69f11b3..5025e9b 100644 --- a/lib/views/file_viewer_screen.dart +++ b/lib/views/file_viewer_screen.dart @@ -759,7 +759,7 @@ class _FileViewerScreenState extends State { /// The text viewer's top-bar buttons: Edit/Preview (markdown only) and Save /// (once the content changed; a spinner while it saves). Same -/// [NooTopBarButton] look as the back button next to it. +/// round [NooButton] look (primary for the main action, secondary beside it). class _TextActions extends StatelessWidget { final TextPreviewController controller; @@ -767,39 +767,92 @@ class _TextActions extends StatelessWidget { @override Widget build(BuildContext context) { - final colors = context.nooColors; + final toggle = controller.canToggleEditing; + final save = controller.saving || controller.showSave; + // One button is the primary action; with two, Save is and Edit/Preview + // steps back to secondary. + final both = toggle && save; return Row( mainAxisSize: MainAxisSize.min, children: [ - if (controller.canToggleEditing) - NooTopBarButton( + if (toggle) + _ActionButton( icon: controller.editing ? LucideIcons.eye : LucideIcons.pencil, tooltip: controller.editing ? 'Preview' : 'Edit', + variant: both + ? NooButtonVariant.secondary + : NooButtonVariant.primary, onTap: controller.toggleEditing, ), + if (both) const SizedBox(width: 8), if (controller.saving) - Semantics( - label: 'Saving', - child: SizedBox.square( - dimension: 48, - child: Center( - child: SizedBox.square( - dimension: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - color: colors.fg1, - ), - ), - ), - ), - ) - else if (controller.showSave) - NooTopBarButton( + const _SavingIndicator() + else if (save) + _ActionButton( icon: LucideIcons.save, tooltip: 'Save', + variant: NooButtonVariant.primary, onTap: controller.save, ), ], ); } } + +/// A round icon-only [NooButton] with the tooltip and semantic label an +/// icon-only button needs. +class _ActionButton extends StatelessWidget { + final IconData icon; + final String tooltip; + final NooButtonVariant variant; + final VoidCallback onTap; + + const _ActionButton({ + required this.icon, + required this.tooltip, + required this.variant, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return Tooltip( + message: tooltip, + child: Semantics( + button: true, + label: tooltip, + excludeSemantics: true, + child: NooButton( + icon: icon, + iconOnly: true, + variant: variant, + onTap: onTap, + ), + ), + ); + } +} + +/// Stands in for the Save button while it saves: the same primary circle, +/// with a spinner, so it can't be tapped twice. +class _SavingIndicator extends StatelessWidget { + const _SavingIndicator(); + + @override + Widget build(BuildContext context) { + final colors = context.nooColors; + return Semantics( + label: 'Saving', + child: Container( + width: 36, + height: 36, + alignment: Alignment.center, + decoration: BoxDecoration(color: colors.accent, shape: BoxShape.circle), + child: const SizedBox.square( + dimension: 16, + child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white), + ), + ), + ); + } +} diff --git a/lib/widgets/noo/core/noo_button.dart b/lib/widgets/noo/core/noo_button.dart index 53a19d2..473443e 100644 --- a/lib/widgets/noo/core/noo_button.dart +++ b/lib/widgets/noo/core/noo_button.dart @@ -1,7 +1,15 @@ import 'package:flutter/material.dart'; import '../../../theme/design_tokens.dart'; -enum NooButtonVariant { primary, tonal, secondary, danger, outline, text, textDanger } +enum NooButtonVariant { + primary, + tonal, + secondary, + danger, + outline, + text, + textDanger, +} enum NooButtonSize { cta, card, field, toolbar, compact, xs } @@ -37,6 +45,11 @@ class NooButton extends StatefulWidget { final bool disabled; final VoidCallback? onTap; + /// A round, icon-only button (no label) - [icon] centred in a circle as + /// wide as the size's height, instead of the pill's side padding. Callers + /// are responsible for a tooltip/semantic label, since there's no text. + final bool iconOnly; + const NooButton({ super.key, this.variant = NooButtonVariant.primary, @@ -46,6 +59,7 @@ class NooButton extends StatefulWidget { this.fullWidth = false, this.disabled = false, this.onTap, + this.iconOnly = false, }); @override @@ -87,7 +101,8 @@ class _NooButtonState extends State { final colors = context.nooColors; final s = _sizes[widget.size]!; final (bg, fg, border) = _colors(colors); - final hasLeadingIcon = widget.icon != null && widget.child != null && !_isTextVariant; + final hasLeadingIcon = + widget.icon != null && widget.child != null && !_isTextVariant; final content = Row( mainAxisSize: MainAxisSize.min, @@ -107,7 +122,9 @@ class _NooButtonState extends State { child: GestureDetector( onTapDown: widget.disabled ? null : (_) => setState(() => _down = true), onTapUp: widget.disabled ? null : (_) => setState(() => _down = false), - onTapCancel: widget.disabled ? null : () => setState(() => _down = false), + onTapCancel: widget.disabled + ? null + : () => setState(() => _down = false), onTap: widget.disabled ? null : widget.onTap, child: AnimatedScale( scale: _down && !widget.disabled ? NooMotion.pressScale : 1, @@ -115,8 +132,10 @@ class _NooButtonState extends State { curve: NooMotion.ease, child: Container( height: s.height, - width: widget.fullWidth ? double.infinity : null, - padding: _isTextVariant + width: widget.iconOnly + ? s.height + : (widget.fullWidth ? double.infinity : null), + padding: _isTextVariant || widget.iconOnly ? EdgeInsets.zero : EdgeInsets.only( left: hasLeadingIcon ? s.px - 4 : s.px, @@ -128,7 +147,9 @@ class _NooButtonState extends State { // of shrink-wrapping to it, which is what stretched this button // edge-to-edge instead of sizing to its content (see NooFab's // doc comment for the same bug). - alignment: widget.fullWidth ? Alignment.center : null, + alignment: widget.fullWidth || widget.iconOnly + ? Alignment.center + : null, decoration: BoxDecoration( color: bg, borderRadius: BorderRadius.circular(NooRadii.pill), diff --git a/lib/widgets/viewer/media_text_preview.dart b/lib/widgets/viewer/media_text_preview.dart index af0640d..7d280df 100644 --- a/lib/widgets/viewer/media_text_preview.dart +++ b/lib/widgets/viewer/media_text_preview.dart @@ -89,8 +89,9 @@ class TextPreviewController extends ChangeNotifier { /// Text/markdown file preview and editor for [FileViewerScreen]'s static /// (non-swipeable) path (`_textPreviewExtensions`). Content is always -/// editable when online; Save appears once it has changed. Markdown files open -/// rendered, with an Edit/Preview toggle. Both controls live in the viewer's +/// editable when online - after tapping Edit; every file opens read-only +/// (Markdown rendered) with an Edit/Preview toggle - and Save appears once it +/// has changed. Both controls live in the viewer's /// top bar, driven through [controller]. Opened from the Offline tab /// ([localPathResolver] set) it is read-only, since the local copy has no /// server to write back to. @@ -125,12 +126,15 @@ class _MediaTextPreviewState extends State { String? _error; bool _isSaving = false; bool _editing = false; + final _focus = FocusNode(); bool get _isMarkdown { final name = widget.item.name.toLowerCase(); return name.endsWith('.md') || name.endsWith('.markdown'); } + /// Markdown shows rendered until the user taps Edit. Plain text has no + /// rendered form, so it just shows its (read-only) text until then. bool get _showRendered => _isMarkdown && !_editing; bool get _readOnly => widget.localPathResolver != null; @@ -150,6 +154,7 @@ class _MediaTextPreviewState extends State { @override void dispose() { widget.controller?._unbind(); + _focus.dispose(); _controller.dispose(); super.dispose(); } @@ -159,7 +164,7 @@ class _MediaTextPreviewState extends State { /// initState, where notifying the bar's listeners would be illegal. void _syncToolbar() { widget.controller?._update( - canToggleEditing: _saved != null && _isMarkdown && !_readOnly, + canToggleEditing: _saved != null && !_readOnly, editing: _editing, dirty: _dirty, saving: _isSaving, @@ -169,6 +174,12 @@ class _MediaTextPreviewState extends State { void _toggleEditing() { setState(() => _editing = !_editing); _syncToolbar(); + // Tapping Edit should put the cursor in the text, not just unlock it. + if (_editing) { + WidgetsBinding.instance.addPostFrameCallback( + (_) => _focus.requestFocus(), + ); + } } Future _load() async { @@ -262,7 +273,10 @@ class _MediaTextPreviewState extends State { padding: padding, child: TextField( controller: _controller, - readOnly: _readOnly, + focusNode: _focus, + // Read-only until Edit is tapped (always, for the + // Offline copy). + readOnly: _readOnly || !_editing, maxLines: null, keyboardType: TextInputType.multiline, style: style, diff --git a/test/views/file_viewer_screen_test.dart b/test/views/file_viewer_screen_test.dart index 9e083a2..4a47fed 100644 --- a/test/views/file_viewer_screen_test.dart +++ b/test/views/file_viewer_screen_test.dart @@ -9,7 +9,7 @@ import 'package:noo/providers/item_operations.dart'; import 'package:noo/providers/session_controller.dart'; import 'package:noo/services/nextcloud_service.dart'; import 'package:noo/views/file_viewer_screen.dart'; -import 'package:noo/widgets/noo/nav/noo_top_bar.dart'; +import 'package:noo/widgets/noo/core/noo_button.dart'; import 'package:noo/widgets/viewer/media_action_bar.dart'; import 'package:provider/provider.dart'; @@ -49,9 +49,11 @@ class _Operations implements ItemOperations { dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); } -Finder _button(String tooltip) => find.byWidgetPredicate( - (widget) => widget is NooTopBarButton && widget.tooltip == tooltip, -); +Finder _button(String tooltip) => find.byTooltip(tooltip); + +/// The [NooButton] behind the top-bar action tagged [tooltip]. +Finder _noo(String tooltip) => + find.descendant(of: _button(tooltip), matching: find.byType(NooButton)); void main() { setUpNooTests(); @@ -118,6 +120,12 @@ void main() { } inHeader('Edit'); + // A lone action is the primary one, and a compact round icon button. + expect( + tester.widget(_noo('Edit')).variant, + NooButtonVariant.primary, + ); + expect(tester.getSize(_noo('Edit')), const Size(36, 36)); expect(find.byType(Markdown), findsOneWidget); await tester.tap(_button('Edit')); await tester.pumpAndSettle(); @@ -125,6 +133,15 @@ void main() { await tester.enterText(find.byType(TextField), '# Changed'); await tester.pump(); inHeader('Save'); + // With two, Save is primary and the toggle steps back to secondary. + expect( + tester.widget(_noo('Save')).variant, + NooButtonVariant.primary, + ); + expect( + tester.widget(_noo('Preview')).variant, + NooButtonVariant.secondary, + ); await tester.tap(_button('Preview')); await tester.pumpAndSettle(); expect(tester.widget(find.byType(Markdown)).data, '# Changed'); @@ -156,7 +173,7 @@ void main() { }); } - testWidgets('plain text has Save without the markdown toggle', ( + testWidgets('plain text opens read-only and follows Edit -> Save too', ( tester, ) async { final service = await mount( @@ -166,11 +183,35 @@ void main() { ); service.loaded.complete(utf8.encode('Original')); await tester.pumpAndSettle(); - expect(_button('Edit'), findsNothing); - expect(_button('Preview'), findsNothing); + + bool readOnly() => + tester.widget(find.byType(TextField)).readOnly; + expect(readOnly(), isTrue, reason: 'not directly editable'); + expect(_button('Edit'), findsOneWidget); + expect(_button('Save'), findsNothing); + expect( + tester.widget(_noo('Edit')).variant, + NooButtonVariant.primary, + ); + + await tester.tap(_button('Edit')); + await tester.pumpAndSettle(); + expect(readOnly(), isFalse); + expect(_button('Preview'), findsOneWidget); await tester.enterText(find.byType(TextField), 'Changed'); await tester.pump(); expect(_button('Save'), findsOneWidget); + expect( + tester.widget(_noo('Save')).variant, + NooButtonVariant.primary, + ); + + // Preview goes back to the read-only view, keeping the change unsaved. + await tester.tap(_button('Preview')); + await tester.pumpAndSettle(); + expect(readOnly(), isTrue); + expect(find.text('Changed'), findsOneWidget); + expect(_button('Save'), findsOneWidget); await tester.pumpWidget(const SizedBox.shrink()); }); From e67e08d571541e4a52605cfb35ded57ef34b8e65 Mon Sep 17 00:00:00 2001 From: Ayushya Amitabh Date: Tue, 6 Oct 2026 21:01:08 -0400 Subject: [PATCH 24/31] iOS: device sync (Swift port of the Android engine) behind the sync_service channel PROPFIND walk / diff / GET / PUT mirror with conflicts, per-account state, BGTaskScheduler background runs, and conflict notifications with Keep local / Use server. Reviewed by Codex against the Kotlin original; its data-loss findings are fixed and each has a regression test (fake-server end-to-end tests, 68 Swift tests total): truncated/unusable PROPFIND answers are never a manifest, unreadable state aborts the run, server paths can't leave the mirror, case/Unicode collisions are skipped, atomic downloads, a locally edited file deleted on the server is kept, '/' scope matching, serialised removal, tracked/cancellable background runs, per-account conflicts, and sign-out vs background-off credentials (cancel gains a 'forget' flag). Co-Authored-By: Claude Sonnet 5.5 --- .claude/context/server.md | 78 +- ios/Runner.xcodeproj/project.pbxproj | 41 + ios/Runner/AppDelegate.swift | 14 + ios/Runner/Info.plist | 10 + ios/Runner/Native/NativeServices.swift | 109 +++ .../Native/SyncEngine/DavSyncClient.swift | 294 ++++++ .../Native/SyncEngine/SyncConfigStore.swift | 120 +++ .../Native/SyncEngine/SyncCoordinator.swift | 448 ++++++++++ ios/Runner/Native/SyncEngine/SyncDiff.swift | 195 ++++ ios/Runner/Native/SyncEngine/SyncModels.swift | 99 ++ ios/Runner/Native/SyncEngine/SyncRunner.swift | 392 ++++++++ ios/Runner/Native/SyncEngine/SyncStore.swift | 175 ++++ ios/RunnerTests/SyncEngineTests.swift | 845 ++++++++++++++++++ lib/services/sync_service.dart | 24 +- 14 files changed, 2834 insertions(+), 10 deletions(-) create mode 100644 ios/Runner/Native/SyncEngine/DavSyncClient.swift create mode 100644 ios/Runner/Native/SyncEngine/SyncConfigStore.swift create mode 100644 ios/Runner/Native/SyncEngine/SyncCoordinator.swift create mode 100644 ios/Runner/Native/SyncEngine/SyncDiff.swift create mode 100644 ios/Runner/Native/SyncEngine/SyncModels.swift create mode 100644 ios/Runner/Native/SyncEngine/SyncRunner.swift create mode 100644 ios/Runner/Native/SyncEngine/SyncStore.swift create mode 100644 ios/RunnerTests/SyncEngineTests.swift diff --git a/.claude/context/server.md b/.claude/context/server.md index a63fa9e..2b955cf 100644 --- a/.claude/context/server.md +++ b/.claude/context/server.md @@ -831,9 +831,9 @@ The five `dev.ayushya.noo/*` channels (`share_intent`, `pick_intent`, `upload_service`, `download_service`, `sync_service`, plus their status `EventChannel`s) were written for Android in Kotlin. iOS implements `upload_service` (+ its status stream), `download_service` and -`share_intent` so far (`ios/Runner/Native/` + `ios/ShareExtension/`, see -below); for `pick_intent` and `sync_service` there's no handler, so calls -throw `MissingPluginException`. `lib/services/native_channel.dart` +`share_intent` and `sync_service` so far (`ios/Runner/Native/` + +`ios/ShareExtension/`, see below); for `pick_intent` there's no handler, so +calls throw `MissingPluginException`. `lib/services/native_channel.dart` makes that safe until each one is built in Swift: - `invokeIfAvailable` - a missing implementation returns null. For cold-start @@ -987,5 +987,73 @@ mismatch); it's embedded by an "Embed Foundation Extensions" phase placed cycle). The simulator honours the App Group and keychain group without a signing team; a real device needs a team that owns both ids. -Not built on iOS yet: the picker (File Provider) and sync -(`BGTaskScheduler`) - see the iOS handoff notes. +### iOS device sync (`ios/Runner/Native/SyncEngine/`) + +A Swift port of Android's `SyncEngine.kt`/`SyncWorker.kt`/ +`ConflictResolveWorker.kt`/`SyncStatusBus.kt`, behind the same `sync_service` +channel (+ `/status` event stream) so the Dart side is unchanged apart from +`cancelAccount`'s `forget` flag (below). Same rules: only paths the user +synced are mirrored; brand-new local files are never uploaded (only edits to +already-synced files are); a file edited on both sides is a conflict (neither +copy is touched, "Keep local"/"Use server" resolves it); a local deletion is +repaired by re-downloading; the mirror lives at +`/sync//...` (what Dart's +`SyncService.baseDirectory()` reads for the Offline tab, excluded from +backup) with state in `<...>/sync-state//*.json`. + +- `SyncDiff` (pure decisions, mirrors `diffFolder`), `DavSyncClient` + (PROPFIND/GET/PUT on a cookie-less ephemeral session), `SyncRunner` (one + account's pass), `SyncStore`, `SyncConfigStore` (Keychain), `SyncCoordinator` + (runs, `BGTaskScheduler`, notifications), and the `registerSync` channel + handlers in `NativeServices.swift`. +- **Safeguards (each has a regression test in `SyncEngineTests`, which drives + the real runner against an in-memory fake Nextcloud):** an unreachable + server, a non-207, or a PROPFIND answer that is truncated, not a + `multistatus`, empty, or has an href outside the account root / with dot + components throws `SyncRemoteUnavailable` and the path is skipped - never + diffed as empty or partial (which would delete local files); unreadable + recorded state makes the run do nothing (`SyncStateUnreadable`) instead of + treating every file as untracked and overwriting local edits; every + server-supplied path is resolved through `LocalFS.resolve` (no escaping the + mirror) and entries that would land on the same local file (case or Unicode + spellings) are skipped, not downloaded over each other; downloads go to a + temp file and replace atomically, never replacing a folder; empty-folder + pruning uses `rmdir` and only on a successful empty read; a root's etag + shortcut marker is only written when the path synced cleanly *and* the state + was saved. +- **Deliberate differences from Android:** a file deleted on the server but + edited here is **kept** (untracked) rather than deleted - the edit is the + only copy; after our own upload the file's etag is re-read so it isn't + downloaded back; state is flushed every 20 transfers; conflicts are kept + per account on the status bus; downloads write to a temp file first. No + ongoing progress notification (a run posts a summary, and conflict + notifications with Keep local / Use server actions). +- **Running:** every run - "Sync now", the in-app refresh, and background - + goes through `SyncCoordinator.startRun`: one tracked run per account + (`force` supersedes it, a quiet check leaves it alone), serialised by one + cancellable `AsyncMutex`, tagged with a per-account *generation* so a run + that was only queued never starts for an account that was cancelled or had a + path removed meanwhile. `removeLocalSync` cancels the run and takes the same + mutex, so a finishing run can't write stale state back over it. +- **Background:** `BGAppRefreshTask` + `BGProcessingTask` + (`dev.ayushya.noo.sync.refresh`/`.processing`, `UIBackgroundModes` fetch + + processing, `BGTaskSchedulerPermittedIdentifiers` in Info.plist; handlers + registered before launch finishes). `intervalMinutes` (floored at 15, like + Android) is only the *earliest* start - iOS picks when - and Wi-Fi-only is + checked with `NWPathMonitor` (`isExpensive`) since a BGTask can't require + it. An expiring task cancels its runs (they save state as they go) and is + completed exactly once (`OnceGate`). **Not testable in the simulator** + (needs a real device and the Xcode `_simulateLaunchForTaskWithIdentifier` + debugger command). +- **Credentials:** `SyncConfigStore` keeps each account's server, auth header, + paths, interval and notify flag as one Keychain item + (`AfterFirstUnlock`, so a background run works with the phone locked). + Conflict notification actions look the account up there - nothing secret is + in a notification. `sync_service.cancel` takes `forget` (default true: + signed out/removed -> credentials dropped; false: only background sync was + turned off -> kept for manual runs and conflict actions). Dart sends + `forget: false` only for "paths configured but no background interval". +- **Gotcha:** `ios/.gitignore` has `**/*sync/`, which (case-insensitively on + macOS) swallows any directory named `Sync` - hence `SyncEngine/`. + +Not built on iOS yet: the picker (File Provider) - see the iOS handoff notes. diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 65e4e79..eca4693 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -7,9 +7,11 @@ objects = { /* Begin PBXBuildFile section */ + 0EA689E7D32E498936A37CDC /* DavSyncClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 308A73EC19D196226AB395DC /* DavSyncClient.swift */; }; 134157C4C8F53C9997FF265C /* TransferTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20B0C3085FA6467C1A4A2947 /* TransferTypes.swift */; }; 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; 167672C5B1FBD70656C2FEED /* TransferTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20B0C3085FA6467C1A4A2947 /* TransferTypes.swift */; }; + 187CEAB4C9995B379F33409F /* SyncCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57FF20A8DFA5A1A9F75A5F5A /* SyncCoordinator.swift */; }; 223784E5DAE5A653F0F61771 /* ShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 405A5408F4071B4D12F08B21 /* ShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; 33B3C3BED4C1F26439DD6825 /* SharedInbox.swift in Sources */ = {isa = PBXBuildFile; fileRef = 836D37F2376AFCE0EFDD30A7 /* SharedInbox.swift */; }; @@ -18,14 +20,19 @@ 3DEDE94F2A4C921407EF4382 /* ShareUpload.swift in Sources */ = {isa = PBXBuildFile; fileRef = B93DB1F595F44E2F2057BB2A /* ShareUpload.swift */; }; 43DB9AB631DD359AEC613A1D /* WebDAV.swift in Sources */ = {isa = PBXBuildFile; fileRef = 871EDB5791D5B3DA0ECF3D4E /* WebDAV.swift */; }; 44078FC9474337A0E102B524 /* TransferNotifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3247AAA4B65FE0F1A7279FA1 /* TransferNotifications.swift */; }; + 4618DB104BDFF73311A5DA6E /* SyncStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A39E02713460B6C7875F3278 /* SyncStore.swift */; }; + 47EF34D26CA64490D2118B0D /* SyncRunner.swift in Sources */ = {isa = PBXBuildFile; fileRef = C028B2E4E2874BB74498EABD /* SyncRunner.swift */; }; 5694337ECF8D3ADD3695CBCD /* NativeServices.swift in Sources */ = {isa = PBXBuildFile; fileRef = 901C0CDB4FB819302E2A7563 /* NativeServices.swift */; }; 62028E64976F15B4FDEDA9F1 /* ShareUpload.swift in Sources */ = {isa = PBXBuildFile; fileRef = B93DB1F595F44E2F2057BB2A /* ShareUpload.swift */; }; 63229CACE4281880037D33E6 /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27BDF61F5197F9B8F648E8AE /* ShareViewController.swift */; }; 66AFC246E251554A80B2476F /* SharePickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BE7B8C31FF78E212BD71DFC /* SharePickerView.swift */; }; + 721B9C528F23F144966483E0 /* SyncModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF9CFAFB2387EB4E34A992BC /* SyncModels.swift */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; }; 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; + 804B3C9878B9A8BB30E09340 /* SyncEngineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2947D6E441BA30E10B1F8C17 /* SyncEngineTests.swift */; }; 852C0C8EBA96CB6389DF6917 /* SharedAccount.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F2C956C98F58A97B5595307 /* SharedAccount.swift */; }; + 87DD0AED8048F7F7FF908FDB /* SyncDiff.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0933A96CE0B65A9A6AB2D098 /* SyncDiff.swift */; }; 8C73B4CB445AEF383B7640E1 /* DeviceAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97ACCF43E1EC13AA2538E42C /* DeviceAuth.swift */; }; 91C1658DC7467CACB2AAC7FF /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0AE6F681A3100A461B4A5318 /* Foundation.framework */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; @@ -33,6 +40,7 @@ 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; A16680889B3A9D8672FA29C1 /* TransferNotifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3247AAA4B65FE0F1A7279FA1 /* TransferNotifications.swift */; }; A3B3AB501C29516E20455F23 /* DavFolders.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51604DEE43E937A5FDCA7A58 /* DavFolders.swift */; }; + B749ED5B4464A1950E1A4B18 /* SyncConfigStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18691F7A45AD55A2246075DA /* SyncConfigStore.swift */; }; E295E45196E3973A5F7E1D8E /* SharedAccount.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F2C956C98F58A97B5595307 /* SharedAccount.swift */; }; F598B112F4D33953984FEFF4 /* WebDAV.swift in Sources */ = {isa = PBXBuildFile; fileRef = 871EDB5791D5B3DA0ECF3D4E /* WebDAV.swift */; }; F8D51B4045340275FBD12D16 /* SharedInbox.swift in Sources */ = {isa = PBXBuildFile; fileRef = 836D37F2376AFCE0EFDD30A7 /* SharedInbox.swift */; }; @@ -81,12 +89,16 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 0933A96CE0B65A9A6AB2D098 /* SyncDiff.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SyncDiff.swift; sourceTree = ""; }; 0AE6F681A3100A461B4A5318 /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; }; 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 18691F7A45AD55A2246075DA /* SyncConfigStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SyncConfigStore.swift; sourceTree = ""; }; 1F2C956C98F58A97B5595307 /* SharedAccount.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SharedAccount.swift; sourceTree = ""; }; 20B0C3085FA6467C1A4A2947 /* TransferTypes.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TransferTypes.swift; sourceTree = ""; }; 27BDF61F5197F9B8F648E8AE /* ShareViewController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = ""; }; + 2947D6E441BA30E10B1F8C17 /* SyncEngineTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SyncEngineTests.swift; sourceTree = ""; }; + 308A73EC19D196226AB395DC /* DavSyncClient.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DavSyncClient.swift; sourceTree = ""; }; 3247AAA4B65FE0F1A7279FA1 /* TransferNotifications.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TransferNotifications.swift; sourceTree = ""; }; 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -94,6 +106,7 @@ 405A5408F4071B4D12F08B21 /* ShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = ShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 49213989F0FA1B83B0B8A523 /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 51604DEE43E937A5FDCA7A58 /* DavFolders.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DavFolders.swift; sourceTree = ""; }; + 57FF20A8DFA5A1A9F75A5F5A /* SyncCoordinator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SyncCoordinator.swift; sourceTree = ""; }; 5BE7B8C31FF78E212BD71DFC /* SharePickerView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SharePickerView.swift; sourceTree = ""; }; 65F57A1EB0C62774E561860D /* ShareExtension.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = ShareExtension.entitlements; sourceTree = ""; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; @@ -114,7 +127,10 @@ 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + A39E02713460B6C7875F3278 /* SyncStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SyncStore.swift; sourceTree = ""; }; + AF9CFAFB2387EB4E34A992BC /* SyncModels.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SyncModels.swift; sourceTree = ""; }; B93DB1F595F44E2F2057BB2A /* ShareUpload.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ShareUpload.swift; sourceTree = ""; }; + C028B2E4E2874BB74498EABD /* SyncRunner.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SyncRunner.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -149,6 +165,7 @@ isa = PBXGroup; children = ( 331C807B294A618700263BE5 /* RunnerTests.swift */, + 2947D6E441BA30E10B1F8C17 /* SyncEngineTests.swift */, ); path = RunnerTests; sourceTree = ""; @@ -171,6 +188,7 @@ children = ( 8064CC1652B8F0A44E371638 /* TransferManager.swift */, 901C0CDB4FB819302E2A7563 /* NativeServices.swift */, + DDC89BA8A42212F55851CBA1 /* SyncEngine */, ); name = Native; path = Native; @@ -252,6 +270,21 @@ name = Frameworks; sourceTree = ""; }; + DDC89BA8A42212F55851CBA1 /* SyncEngine */ = { + isa = PBXGroup; + children = ( + AF9CFAFB2387EB4E34A992BC /* SyncModels.swift */, + A39E02713460B6C7875F3278 /* SyncStore.swift */, + 308A73EC19D196226AB395DC /* DavSyncClient.swift */, + 0933A96CE0B65A9A6AB2D098 /* SyncDiff.swift */, + C028B2E4E2874BB74498EABD /* SyncRunner.swift */, + 18691F7A45AD55A2246075DA /* SyncConfigStore.swift */, + 57FF20A8DFA5A1A9F75A5F5A /* SyncCoordinator.swift */, + ); + name = SyncEngine; + path = SyncEngine; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -425,6 +458,7 @@ buildActionMask = 2147483647; files = ( 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, + 804B3C9878B9A8BB30E09340 /* SyncEngineTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -461,6 +495,13 @@ 62028E64976F15B4FDEDA9F1 /* ShareUpload.swift in Sources */, A3B3AB501C29516E20455F23 /* DavFolders.swift in Sources */, 44078FC9474337A0E102B524 /* TransferNotifications.swift in Sources */, + 721B9C528F23F144966483E0 /* SyncModels.swift in Sources */, + 4618DB104BDFF73311A5DA6E /* SyncStore.swift in Sources */, + 0EA689E7D32E498936A37CDC /* DavSyncClient.swift in Sources */, + 87DD0AED8048F7F7FF908FDB /* SyncDiff.swift in Sources */, + 47EF34D26CA64490D2118B0D /* SyncRunner.swift in Sources */, + B749ED5B4464A1950E1A4B18 /* SyncConfigStore.swift in Sources */, + 187CEAB4C9995B379F33409F /* SyncCoordinator.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index ae86176..afc9315 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -9,11 +9,25 @@ import UserNotifications didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { TransferManager.shared.reconnect() + // BGTask handlers have to be registered before launch finishes. + SyncCoordinator.shared.registerBackgroundTasks() + SyncCoordinator.shared.registerNotificationCategories() let launched = super.application(application, didFinishLaunchingWithOptions: launchOptions) UNUserNotificationCenter.current().delegate = self + SyncCoordinator.shared.scheduleBackgroundWork() return launched } + /// Taps on a sync-conflict notification's "Keep local" / "Use server". + override func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void + ) { + if SyncCoordinator.shared.handleNotificationResponse(response, completion: completionHandler) { return } + super.userNotificationCenter(center, didReceive: response, withCompletionHandler: completionHandler) + } + /// A transfer summary ("Downloaded 1 file") usually finishes while the app /// is open; without this iOS delivers it silently to Notification Center /// instead of showing the banner. diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 5d33b03..eabd0ab 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -59,6 +59,16 @@ NooKeychainAccessGroup $(AppIdentifierPrefix)dev.ayushya.noo.shared + BGTaskSchedulerPermittedIdentifiers + + dev.ayushya.noo.sync.refresh + dev.ayushya.noo.sync.processing + + UIBackgroundModes + + fetch + processing + NSFaceIDUsageDescription Noo uses Face ID to unlock the app and to protect hidden files. NSPhotoLibraryUsageDescription diff --git a/ios/Runner/Native/NativeServices.swift b/ios/Runner/Native/NativeServices.swift index 8a770cd..762d74d 100644 --- a/ios/Runner/Native/NativeServices.swift +++ b/ios/Runner/Native/NativeServices.swift @@ -76,6 +76,115 @@ enum NativeServices { TransferManager.shared.reconnect() registerShare(messenger: messenger) registerShareAccount(messenger: messenger) + registerSync(messenger: messenger) + } + + // MARK: - Device sync + + /// Sends the current sync status the moment Dart starts listening, then + /// every change - same as Android's `SyncStatusBus.subscribe`. + private final class SyncStatusStream: NSObject, FlutterStreamHandler { + var sink: FlutterEventSink? + + func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) + -> FlutterError? + { + sink = events + let coordinator = SyncCoordinator.shared + events(coordinator.statusMap(coordinator.bus.snapshot())) + return nil + } + + func onCancel(withArguments arguments: Any?) -> FlutterError? { + sink = nil + return nil + } + } + + private static let syncStatusStream = SyncStatusStream() + /// Serial, so status events reach Dart in the order they happened even + /// though building each one reads the state from disk. + private static let syncStatusQueue = DispatchQueue(label: "dev.ayushya.noo.sync-status", qos: .utility) + + /// `sync_service`: the same methods Android's `MainActivity.kt` serves, + /// backed by `SyncCoordinator`/`SyncRunner`. + private static func registerSync(messenger: FlutterBinaryMessenger) { + let coordinator = SyncCoordinator.shared + + FlutterMethodChannel(name: "dev.ayushya.noo/sync_service", binaryMessenger: messenger) + .setMethodCallHandler { call, result in + let args = call.arguments as? [String: Any] ?? [:] + switch call.method { + case "reschedule": + handle(call, result) { _ in coordinator.reschedule(try syncConfig(args)) } + case "cancel": + // `forget` (default true) = signed out / removed; false = only the + // background job is off - credentials stay for manual runs and + // conflict actions. + handle(call, result) { _ in + coordinator.cancel( + accountId: try string(args, "accountId"), forget: args["forget"] as? Bool ?? true) + } + case "syncNow": + handle(call, result) { _ in + let force = args["force"] as? Bool ?? true + coordinator.syncNow(try syncConfig(args), force: force, wifiOnly: args["wifiOnly"] as? Bool) + } + case "getSyncStatus": + let accountId = args["accountId"] as? String + syncStatusQueue.async { + let map = coordinator.statusMap(coordinator.bus.snapshot(), accountIdOverride: accountId) + DispatchQueue.main.async { result(map) } + } + case "resolveConflict": + handle(call, result) { _ in + let conflict = SyncConflict( + accountId: try string(args, "accountId"), fileId: try string(args, "fileId"), + remotePath: try string(args, "remotePath"), relPath: try string(args, "relPath"), + name: ((args["relPath"] as? String ?? "") as NSString).lastPathComponent) + let creds = SyncCredentials( + serverUrl: try string(args, "serverUrl"), username: try string(args, "username"), + authHeader: try string(args, "authHeader")) + let resolution = try string(args, "resolution") + // Fire and forget, like Android: the status stream reports the result. + Task { _ = await coordinator.resolveConflict(conflict, resolution: resolution, creds: creds) } + } + case "removeLocalSync": + guard let accountId = args["accountId"] as? String, let path = args["path"] as? String else { + return result(FlutterError(code: "bad_args", message: "Missing required arguments", details: nil)) + } + Task { + await coordinator.removeLocalSync(accountId: accountId, path: path) + await MainActor.run { result(nil) } + } + default: + result(FlutterMethodNotImplemented) + } + } + + FlutterEventChannel(name: "dev.ayushya.noo/sync_service/status", binaryMessenger: messenger) + .setStreamHandler(syncStatusStream) + coordinator.bus.onChange = { status in + syncStatusQueue.async { + let map = coordinator.statusMap(status) + DispatchQueue.main.async { syncStatusStream.sink?(map) } + } + } + } + + /// The account credentials + settings every sync call carries, as a config. + private static func syncConfig(_ args: [String: Any]) throws -> SyncAccountConfig { + var folders: [String] = [] + if let json = args["folders"] as? String, let data = json.data(using: .utf8), + let list = try? JSONSerialization.jsonObject(with: data) as? [String] + { + folders = list + } + return SyncAccountConfig( + accountId: try string(args, "accountId"), serverUrl: try string(args, "serverUrl"), + username: try string(args, "username"), authHeader: try string(args, "authHeader"), + folders: folders, wifiOnly: args["wifiOnly"] as? Bool ?? true, + intervalMinutes: args["intervalMinutes"] as? Int, notify: args["notify"] as? Bool ?? true) } // MARK: - Account for the Share Extension diff --git a/ios/Runner/Native/SyncEngine/DavSyncClient.swift b/ios/Runner/Native/SyncEngine/DavSyncClient.swift new file mode 100644 index 0000000..5927967 --- /dev/null +++ b/ios/Runner/Native/SyncEngine/DavSyncClient.swift @@ -0,0 +1,294 @@ +import Foundation + +/// Credentials for one account's WebDAV root. +struct SyncCredentials { + let serverUrl: String + let username: String + let authHeader: String +} + +/// The WebDAV calls sync needs: PROPFIND (a folder's children, or one item), +/// a recursive walk, and GET/PUT of a single file. Foreground +/// `URLSession` work - sync runs inside the app or a `BGTask`, not on a +/// background session, so a run can walk, diff and decide in one go. +/// +/// Authenticates by each request's own `Authorization` header only (no cookies, +/// no credential cache): two accounts can share a server, and one account's +/// session must never answer for another. +final class DavSyncClient { + private let session: URLSession + + init(session: URLSession = DavSyncClient.makeSession()) { + self.session = session + } + + static func makeSession() -> URLSession { + let config = URLSessionConfiguration.ephemeral + config.httpShouldSetCookies = false + config.httpCookieAcceptPolicy = .never + config.urlCredentialStorage = nil + config.requestCachePolicy = .reloadIgnoringLocalCacheData + config.timeoutIntervalForRequest = 30 + config.timeoutIntervalForResource = 15 * 60 + return URLSession(configuration: config) + } + + // MARK: - PROPFIND + + private static let propfindBody = """ + + + + + + + + + + + """ + + /// Depth-1 PROPFIND of [path]: its direct children only. A clean 404 (the + /// folder is genuinely gone) is an empty list; anything else that goes wrong + /// throws `SyncRemoteUnavailable`. + func propfindChildren(_ creds: SyncCredentials, path: String) async throws -> [SyncRemoteEntry] { + try await propfind(creds, path: path, depth: "1", skipSelf: true) + } + + /// Depth-0 PROPFIND of [path] itself - nil on a clean 404. + func propfindSelf(_ creds: SyncCredentials, path: String) async throws -> SyncRemoteEntry? { + try await propfind(creds, path: path, depth: "0", skipSelf: false).first + } + + private func propfind( + _ creds: SyncCredentials, path: String, depth: String, skipSelf: Bool + ) async throws -> [SyncRemoteEntry] { + var clean = path.trimmingCharacters(in: .whitespaces) + if !clean.hasPrefix("/") { clean = "/" + clean } + guard var url = WebDAV.fileURL(serverUrl: creds.serverUrl, username: creds.username, remotePath: clean) + else { throw SyncRemoteUnavailable(message: "Invalid server address.") } + // Collections are addressed with a trailing slash. + if depth == "1", let slashed = URL(string: url.absoluteString + "/") { url = slashed } + + var request = URLRequest(url: url) + request.httpMethod = "PROPFIND" + request.setValue(depth, forHTTPHeaderField: "Depth") + request.setValue("application/xml", forHTTPHeaderField: "Content-Type") + request.setValue(creds.authHeader, forHTTPHeaderField: "Authorization") + request.httpBody = Self.propfindBody.data(using: .utf8) + + let data: Data + let status: Int + do { + let (body, response) = try await session.data(for: request) + data = body + status = (response as? HTTPURLResponse)?.statusCode ?? 0 + } catch { + try Self.rethrowIfCancelled(error) + throw SyncRemoteUnavailable(message: "PROPFIND \(url.path) failed: \(error.localizedDescription)") + } + if status == 404 { return [] } // genuinely gone + guard status == 207 else { + throw SyncRemoteUnavailable(message: "PROPFIND \(url.path) -> \(status)") + } + return try DavSyncParser.entries(from: data, username: creds.username, requestedPath: clean, skipSelf: skipSelf) + } + + /// Recursively walks [root] (Depth-1 PROPFINDs, breadth-first) into a flat + /// manifest. Throws if *any* level fails - a partial manifest would look + /// like the missing part had been deleted on the server. + func walk(_ creds: SyncCredentials, root: String) async throws -> [SyncRemoteEntry] { + var result: [SyncRemoteEntry] = [] + var queue = [root] + var index = 0 + while index < queue.count { + try Task.checkCancellation() + let children = try await propfindChildren(creds, path: queue[index]) + index += 1 + for child in children { + result.append(child) + if child.isFolder { queue.append(child.path) } + } + } + return result + } + + // MARK: - GET / PUT + + /// Downloads [remotePath] to [destination]. Written to a temp file first and + /// moved into place, so an interrupted download never leaves a truncated + /// file where a good one used to be. False on any failure. + func download(_ creds: SyncCredentials, remotePath: String, to destination: URL) async throws -> Bool { + guard let url = WebDAV.fileURL(serverUrl: creds.serverUrl, username: creds.username, remotePath: remotePath) + else { return false } + var request = URLRequest(url: url) + request.setValue(creds.authHeader, forHTTPHeaderField: "Authorization") + do { + let (temp, response) = try await session.download(for: request) + guard let status = (response as? HTTPURLResponse)?.statusCode, (200..<300).contains(status) else { + try? FileManager.default.removeItem(at: temp) + return false + } + try Task.checkCancellation() + let fm = FileManager.default + try fm.createDirectory(at: destination.deletingLastPathComponent(), withIntermediateDirectories: true) + var isDirectory: ObjCBool = false + if fm.fileExists(atPath: destination.path, isDirectory: &isDirectory) { + // Never replace a folder with a file, and replace a file atomically: + // the old copy stays until the new one is fully in place. + guard !isDirectory.boolValue else { + try? fm.removeItem(at: temp) + return false + } + _ = try fm.replaceItemAt(destination, withItemAt: temp) + } else { + try fm.moveItem(at: temp, to: destination) + } + return true + } catch { + try Self.rethrowIfCancelled(error) + return false + } + } + + /// PUTs [source] to [remotePath]. False on any failure. + func upload(_ creds: SyncCredentials, remotePath: String, from source: URL) async throws -> Bool { + guard let url = WebDAV.fileURL(serverUrl: creds.serverUrl, username: creds.username, remotePath: remotePath) + else { return false } + var request = URLRequest(url: url) + request.httpMethod = "PUT" + request.setValue(creds.authHeader, forHTTPHeaderField: "Authorization") + do { + let (_, response) = try await session.upload(for: request, fromFile: source) + guard let status = (response as? HTTPURLResponse)?.statusCode else { return false } + return (200..<300).contains(status) + } catch { + try Self.rethrowIfCancelled(error) + return false + } + } + + /// A cancelled run must stop, not be mistaken for a flaky network. + private static func rethrowIfCancelled(_ error: Error) throws { + if error is CancellationError || (error as? URLError)?.code == .cancelled || Task.isCancelled { + throw CancellationError() + } + } +} + +/// Parses a PROPFIND multistatus into [SyncRemoteEntry]s. Pure, so it's +/// unit-tested. +enum DavSyncParser { + private struct Raw { + var href = "" + var etag = "" + var modified = "" + var length = "" + var fileId = "" + var isCollection = false + } + + private final class Delegate: NSObject, XMLParserDelegate { + var responses: [Raw] = [] + var sawMultistatus = false + private var current: Raw? + private var text = "" + + func parser( + _ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?, + qualifiedName qName: String?, attributes attributeDict: [String: String] = [:] + ) { + text = "" + if elementName == "multistatus" { sawMultistatus = true } + if elementName == "response" { current = Raw() } + if elementName == "collection" { current?.isCollection = true } + } + + func parser(_ parser: XMLParser, foundCharacters string: String) { + text += string + } + + func parser( + _ parser: XMLParser, didEndElement elementName: String, namespaceURI: String?, + qualifiedName qName: String? + ) { + let value = text.trimmingCharacters(in: .whitespacesAndNewlines) + switch elementName { + case "href": current?.href = value + case "getetag": current?.etag = value + case "getlastmodified": current?.modified = value + case "getcontentlength": current?.length = value + case "fileid": current?.fileId = value + case "response": + if let current { responses.append(current) } + current = nil + default: break + } + text = "" + } + } + + private static let httpDate: DateFormatter = { + let f = DateFormatter() + f.locale = Locale(identifier: "en_US_POSIX") + f.timeZone = TimeZone(secondsFromGMT: 0) + f.dateFormat = "EEE, dd MMM yyyy HH:mm:ss zzz" + return f + }() + + /// Parses a PROPFIND multistatus. Throws `SyncRemoteUnavailable` for + /// anything that isn't a complete, plausible answer - malformed or + /// truncated XML, no `multistatus` envelope, no responses at all (a 207 + /// always has at least the requested resource), or an href that isn't + /// under this account's files root or tries to climb out of it. A partial + /// manifest would look like the missing part was deleted on the server + /// (and an empty one like the whole path was), so the caller must treat + /// those as "couldn't reach the server", never as data. + static func entries( + from xml: Data, username: String, requestedPath: String, skipSelf: Bool + ) throws -> [SyncRemoteEntry] { + let delegate = Delegate() + let parser = XMLParser(data: xml) + parser.shouldProcessNamespaces = true + parser.delegate = delegate + guard parser.parse(), delegate.sawMultistatus, !delegate.responses.isEmpty else { + throw SyncRemoteUnavailable(message: "Unusable PROPFIND answer") + } + + let marker = "/remote.php/dav/files/\(username)" + let selfPath = trimTrailingSlashes(requestedPath) + + var entries: [SyncRemoteEntry] = [] + for raw in delegate.responses { + let decoded = raw.href.removingPercentEncoding ?? raw.href + guard let range = decoded.range(of: marker) else { + throw SyncRemoteUnavailable(message: "PROPFIND href outside the account's files root") + } + let hrefPath = String(decoded[range.upperBound...]) + // A well-formed path continues the marker at a component boundary and + // never contains a dot component. + guard hrefPath.isEmpty || hrefPath.hasPrefix("/"), + !hrefPath.split(separator: "/").contains(where: { $0 == ".." || $0 == "." }) + else { + throw SyncRemoteUnavailable(message: "PROPFIND href has an unexpected path") + } + let normalized = trimTrailingSlashes(hrefPath.isEmpty ? "/" : hrefPath) + if skipSelf && normalized == selfPath { continue } // the folder itself, not a child + entries.append( + SyncRemoteEntry( + path: normalized.isEmpty ? "/" : normalized, + fileId: raw.fileId.isEmpty ? (normalized.isEmpty ? "/" : normalized) : raw.fileId, + etag: raw.etag.trimmingCharacters(in: CharacterSet(charactersIn: "\"")), + lastModified: httpDate.date(from: raw.modified).map { Int64($0.timeIntervalSince1970 * 1000) } ?? 0, + size: Int64(raw.length) ?? 0, + isFolder: raw.isCollection)) + } + return entries + } + + private static func trimTrailingSlashes(_ path: String) -> String { + var p = path + while p.hasSuffix("/") { p.removeLast() } + return p + } +} diff --git a/ios/Runner/Native/SyncEngine/SyncConfigStore.swift b/ios/Runner/Native/SyncEngine/SyncConfigStore.swift new file mode 100644 index 0000000..bb5b0ae --- /dev/null +++ b/ios/Runner/Native/SyncEngine/SyncConfigStore.swift @@ -0,0 +1,120 @@ +import Foundation +import Security + +/// Every account's sync settings and credentials, kept in the Keychain (the +/// auth header is a secret) as one JSON item. A background run - which may +/// start while the app isn't running - reads these to know what to sync and +/// as whom, and a conflict notification's action looks the account up here +/// instead of carrying credentials in the notification itself. +/// +/// `kSecAttrAccessibleAfterFirstUnlock`, not "when unlocked": a background +/// task can fire with the phone locked. +/// +/// All accounts live in one item, so a write must never be built on a +/// failed read: an unreadable item makes `upsert`/`remove` do nothing (and +/// say so) rather than save a map holding only one account, and an update +/// replaces the item in place instead of deleting it first. +final class SyncConfigStore { + private struct KeychainError: Error { let status: OSStatus } + + private let service: String + private let account = "configs" + private let lock = NSLock() + + init(service: String = "dev.ayushya.noo.sync-configs") { + self.service = service + } + + /// Empty if there are none - or if the item can't be read, in which case + /// nothing runs in the background until it can. + func all() -> [SyncAccountConfig] { + lock.lock() + defer { lock.unlock() } + return Array(((try? load()) ?? [:]).values).sorted { $0.accountId < $1.accountId } + } + + func config(for accountId: String) -> SyncAccountConfig? { + lock.lock() + defer { lock.unlock() } + return (try? load())?[accountId] + } + + /// False if it couldn't be stored (the previous contents are untouched). + @discardableResult + func upsert(_ config: SyncAccountConfig) -> Bool { + lock.lock() + defer { lock.unlock() } + do { + var configs = try load() + configs[config.accountId] = config + try save(configs) + return true + } catch { + NSLog("[Sync] couldn't store config for %@: %@", config.accountId, String(describing: error)) + return false + } + } + + @discardableResult + func remove(accountId: String) -> Bool { + lock.lock() + defer { lock.unlock() } + do { + var configs = try load() + guard configs.removeValue(forKey: accountId) != nil else { return true } + try save(configs) + return true + } catch { + NSLog("[Sync] couldn't remove config for %@: %@", accountId, String(describing: error)) + return false + } + } + + func removeAll() { + lock.lock() + defer { lock.unlock() } + SecItemDelete(baseQuery() as CFDictionary) + } + + // MARK: - Keychain + + private func baseQuery() -> [String: Any] { + [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + ] + } + + /// `[:]` only when nothing is stored yet; any other failure throws. + private func load() throws -> [String: SyncAccountConfig] { + var query = baseQuery() + query[kSecReturnData as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + if status == errSecItemNotFound { return [:] } + guard status == errSecSuccess, let data = result as? Data else { throw KeychainError(status: status) } + return try JSONDecoder().decode([String: SyncAccountConfig].self, from: data) + } + + private func save(_ configs: [String: SyncAccountConfig]) throws { + guard !configs.isEmpty else { + let status = SecItemDelete(baseQuery() as CFDictionary) + if status != errSecSuccess && status != errSecItemNotFound { throw KeychainError(status: status) } + return + } + let data = try JSONEncoder().encode(configs) // before touching the item + let status = SecItemUpdate( + baseQuery() as CFDictionary, [kSecValueData as String: data] as CFDictionary) + if status == errSecItemNotFound { + var query = baseQuery() + query[kSecValueData as String] = data + query[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock + let added = SecItemAdd(query as CFDictionary, nil) + if added != errSecSuccess { throw KeychainError(status: added) } + } else if status != errSecSuccess { + throw KeychainError(status: status) + } + } +} diff --git a/ios/Runner/Native/SyncEngine/SyncCoordinator.swift b/ios/Runner/Native/SyncEngine/SyncCoordinator.swift new file mode 100644 index 0000000..2f06664 --- /dev/null +++ b/ios/Runner/Native/SyncEngine/SyncCoordinator.swift @@ -0,0 +1,448 @@ +import BackgroundTasks +import Network +import UIKit +import UserNotifications + +/// Whether the network currently allows a sync run: any connection normally, +/// but with "Wi-Fi only" nothing metered (cellular, a phone's hotspot) - +/// iOS's equivalent of Android's `NetworkType.UNMETERED` work constraint, +/// which a `BGTask` doesn't offer. +enum NetworkGate { + static func allows(wifiOnly: Bool) async -> Bool { + let monitor = NWPathMonitor() + let queue = DispatchQueue(label: "dev.ayushya.noo.network-gate") + return await withCheckedContinuation { continuation in + var answered = false // only touched on `queue` + monitor.pathUpdateHandler = { path in + guard !answered else { return } + answered = true + monitor.cancel() + let connected = path.status == .satisfied + continuation.resume(returning: connected && (!wifiOnly || !path.isExpensive)) + } + monitor.start(queue: queue) + } + } +} + +/// Runs an action at most once - for completing a `BGTask`, which must be +/// told it's done exactly one time whether the work finished or the system's +/// time ran out first. +final class OnceGate { + private let lock = NSLock() + private var done = false + private let action: (Bool) -> Void + + init(_ action: @escaping (Bool) -> Void) { + self.action = action + } + + func finish(_ success: Bool) { + lock.lock() + if done { + lock.unlock() + return + } + done = true + lock.unlock() + action(success) + } +} + +/// Runs sync for the app: the "Sync now" / in-app refresh runs the Dart side +/// asks for, the periodic background runs iOS grants through +/// `BGTaskScheduler`, conflict resolution, and the notifications around them. +/// The iOS counterpart of Android's `SyncWorker` scheduling + `MainActivity`'s +/// `sync_service` handlers; the actual walk/diff/transfer is `SyncRunner`. +/// +/// Every run - foreground or background - goes through `startRun`, so one +/// per-account record knows what's running, "Sync now" can supersede it and a +/// quiet check can leave it alone, and signing out or removing a path can +/// cancel it. Runs are also serialised against each other (and against +/// removal) by one mutex, and carry the account's *generation*: cancelling or +/// removing bumps it, so a run that was only queued behind another never +/// starts for an account that's gone in the meantime. +/// +/// iOS decides *when* background work runs (typically while charging, on +/// Wi-Fi, learned from how you use the phone) - `intervalMinutes` is only the +/// earliest it may start, not a schedule, and a run is cut off when the +/// system's time budget ends (the runner saves its state as it goes). +final class SyncCoordinator { + static let shared = SyncCoordinator() + + static let refreshTaskId = "dev.ayushya.noo.sync.refresh" + static let processingTaskId = "dev.ayushya.noo.sync.processing" + + static let conflictCategory = "dev.ayushya.noo.sync.conflict" + static let keepLocalAction = "dev.ayushya.noo.sync.keepLocal" + static let useServerAction = "dev.ayushya.noo.sync.useServer" + + let store: SyncStore + let bus: SyncStatusBus + let configs: SyncConfigStore + private let runner: SyncRunner + + /// One run at a time, app-wide (see `AsyncMutex`). + private let mutex = AsyncMutex() + private let lock = NSLock() + private var running: [String: (token: UUID, task: Task)] = [:] + private var generations: [String: Int] = [:] + + init( + store: SyncStore = .shared, bus: SyncStatusBus = SyncStatusBus(), + configs: SyncConfigStore = SyncConfigStore(), client: DavSyncClient = DavSyncClient() + ) { + self.store = store + self.bus = bus + self.configs = configs + self.runner = SyncRunner(client: client, store: store, bus: bus) + } + + // MARK: - Settings from Dart + + /// Brings the background job in line with [config] (called whenever the + /// synced paths, account or network setting changes). A config with no + /// folders means this account has nothing to sync. + func reschedule(_ config: SyncAccountConfig) { + if config.folders.isEmpty { + cancel(accountId: config.accountId, forget: true) + return + } + configs.upsert(config) + scheduleBackgroundWork() + } + + /// [forget] true: the account was removed or signed out - stop its run and + /// drop its settings and credentials. [forget] false: only background sync + /// was turned off (manual refresh is still possible) - the credentials stay, + /// since a conflict notification's actions and the next "Sync now" need + /// them, and a run in progress is left to finish. + func cancel(accountId: String, forget: Bool = true) { + if forget { + cancelRun(accountId: accountId, invalidateQueued: true) + configs.remove(accountId: accountId) + } else if var config = configs.config(for: accountId) { + config.intervalMinutes = nil + configs.upsert(config) + } + scheduleBackgroundWork() + } + + /// A one-off run. [force] is an explicit "Sync now" / newly added path: + /// a full walk, a summary notification, and it supersedes any run already + /// going for this account; without it (pull-to-refresh, foreground timer, + /// app resume) it's a quiet check that never interrupts a run in progress. + /// [wifiOnly] non-nil means "respect the Wi-Fi-only setting". + func syncNow(_ config: SyncAccountConfig, force: Bool, wifiOnly: Bool?) { + if config.folders.isEmpty { return } + // Keep the background settings this call doesn't carry. + var stored = config + if let existing = configs.config(for: config.accountId) { + stored.intervalMinutes = existing.intervalMinutes + stored.wifiOnly = existing.wifiOnly + } + configs.upsert(stored) + startRun(stored.withNotify(config.notify), force: force, wifiOnly: wifiOnly) + } + + /// Stops [accountId]'s run, if any. [invalidateQueued] also stops runs that + /// are only waiting their turn from ever starting. + private func cancelRun(accountId: String, invalidateQueued: Bool) { + lock.lock() + if invalidateQueued { generations[accountId, default: 0] += 1 } + running[accountId]?.task.cancel() + lock.unlock() + } + + private func generation(of accountId: String) -> Int { + lock.lock() + defer { lock.unlock() } + return generations[accountId] ?? 0 + } + + /// Starts a tracked run, or - for a non-forced one when this account is + /// already running - returns nil and leaves the current run alone. + @discardableResult + private func startRun(_ config: SyncAccountConfig, force: Bool, wifiOnly: Bool?) -> Task? { + lock.lock() + if let existing = running[config.accountId] { + if force { + existing.task.cancel() + } else { + lock.unlock() + return nil + } + } + let token = UUID() + let generation = generations[config.accountId] ?? 0 + let task = Task { [weak self] in + await self?.execute(config, force: force, wifiOnly: wifiOnly, generation: generation) + self?.finished(accountId: config.accountId, token: token) + } + running[config.accountId] = (token, task) + lock.unlock() + return task + } + + private func finished(accountId: String, token: UUID) { + lock.lock() + if running[accountId]?.token == token { running[accountId] = nil } + lock.unlock() + } + + /// Turning sync off for [path]: stop this account's run first and do the + /// removal under the same mutex as runs - a run still holding the old + /// state would otherwise write it back over the removal (and a transfer + /// finishing after it would leave a file the state doesn't know about). + func removeLocalSync(accountId: String, path: String) async { + cancelRun(accountId: accountId, invalidateQueued: true) + _ = try? await mutex.withLock { + store.removeLocalSync(accountId: accountId, path: path) + } + } + + /// The map Dart's `SyncStatusSnapshot.fromMap` reads. [accountIdOverride] + /// is the account Dart is asking about: the bus only learns an account once + /// a run has happened in this process, so on a fresh start the durable + /// per-file synced state would otherwise be reported as empty. + func statusMap(_ status: SyncStatusBus.Status, accountIdOverride: String? = nil) -> [String: Any] { + let accountId = accountIdOverride ?? status.accountId + // Live syncing state belongs to whichever account last synced. + let busMatches = status.accountId == nil || status.accountId == accountId + let synced = accountId.map { Array(((try? store.loadState(accountId: $0)) ?? [:]).keys) } ?? [] + let missing = accountId.map { Array(store.loadMissingRoots(accountId: $0)) } ?? [] + return [ + "accountId": accountId as Any, + "syncing": busMatches && status.syncing, + "syncingFileIds": busMatches ? Array(status.syncingFileIds) : [], + "syncedFileIds": synced, + "missingRoots": missing, + // Conflicts are per account (a file id is only unique within one). + "conflicts": status.conflicts.filter { $0.accountId == accountId }.map { + [ + "accountId": $0.accountId, "fileId": $0.fileId, "remotePath": $0.remotePath, + "relPath": $0.relPath, "name": $0.name, + ] + }, + ] + } + + // MARK: - Running + + /// One account's pass, serialised against every other run. + private func execute( + _ config: SyncAccountConfig, force: Bool, wifiOnly: Bool?, generation: Int + ) async { + // A few extra seconds if the app is backgrounded mid-run; if even those + // run out, stop this account's run so it saves and ends cleanly. + let box = BackgroundTaskBox() + box.id = await MainActor.run { + UIApplication.shared.beginBackgroundTask(withName: "noo.sync") { [weak self] in + self?.cancelRun(accountId: config.accountId, invalidateQueued: false) + UIApplication.shared.endBackgroundTask(box.id) + } + } + defer { Task { @MainActor in UIApplication.shared.endBackgroundTask(box.id) } } + + do { + try await mutex.withLock { + try Task.checkCancellation() + // Gone (signed out / removed) while this was queued? + guard self.generation(of: config.accountId) == generation, + self.configs.config(for: config.accountId) != nil + else { return } + if let wifiOnly, await !NetworkGate.allows(wifiOnly: wifiOnly) { return } + let summary = await runner.run(config, force: force) + if summary.changedAnything && (force || config.notify) { + SyncNotifications.summary(summary, username: config.username, accountId: config.accountId) + } + SyncNotifications.conflicts(summary.conflicts, username: config.username) + } + } catch { + // Cancelled while waiting for its turn - nothing ran. + } + } + + /// Resolves a conflict from a notification action, using the stored + /// credentials for its account. + func resolveConflict(_ conflict: SyncConflict, resolution: String, creds: SyncCredentials) async -> Bool { + (try? await mutex.withLock { + await runner.resolveConflict(conflict, resolution: resolution, creds: creds) + }) ?? false + } + + // MARK: - Background tasks + + /// Registers the two `BGTask` handlers. Must run before the app finishes + /// launching (see `AppDelegate`). + func registerBackgroundTasks() { + for id in [Self.refreshTaskId, Self.processingTaskId] { + BGTaskScheduler.shared.register(forTaskWithIdentifier: id, using: nil) { [weak self] task in + self?.handleBackground(task) + } + } + } + + /// Asks iOS for the next background run: a short app-refresh slot and a + /// longer processing slot (the system picks when, e.g. overnight on power). + /// Nothing is requested when no account has background sync turned on. + func scheduleBackgroundWork() { + let scheduler = BGTaskScheduler.shared + let due = backgroundConfigs() + guard let minutes = due.compactMap(\.intervalMinutes).min() else { + scheduler.cancel(taskRequestWithIdentifier: Self.refreshTaskId) + scheduler.cancel(taskRequestWithIdentifier: Self.processingTaskId) + return + } + // Same floor as Android's periodic work. + let earliest = Date(timeIntervalSinceNow: TimeInterval(max(minutes, 15) * 60)) + + let refresh = BGAppRefreshTaskRequest(identifier: Self.refreshTaskId) + refresh.earliestBeginDate = earliest + let processing = BGProcessingTaskRequest(identifier: Self.processingTaskId) + processing.earliestBeginDate = earliest + processing.requiresNetworkConnectivity = true + processing.requiresExternalPower = false + for request in [refresh, processing] as [BGTaskRequest] { + do { try scheduler.submit(request) } catch { + NSLog("[Sync] couldn't schedule %@: %@", request.identifier, String(describing: error)) + } + } + } + + private func backgroundConfigs() -> [SyncAccountConfig] { + configs.all().filter { $0.intervalMinutes != nil && !$0.folders.isEmpty } + } + + /// A background slot: runs every account that has background sync on, each + /// through the normal tracked path (so it can be cancelled by sign-out, and + /// a run already going is left alone). The task is reported complete + /// exactly once - when the work ends or when the system's time does. + private func handleBackground(_ task: BGTask) { + scheduleBackgroundWork() // line up the next one before this one runs + let gate = OnceGate { task.setTaskCompleted(success: $0) } + let accountIds = backgroundConfigs().map(\.accountId) + let work = Task { [weak self] in + guard let self else { return gate.finish(false) } + for config in self.backgroundConfigs() { + if Task.isCancelled { break } + await self.startRun(config, force: false, wifiOnly: config.wifiOnly)?.value + } + gate.finish(!Task.isCancelled) + } + // The system's time is up: stop the work and the runs it started (each + // saves what it has) and report. + task.expirationHandler = { [weak self] in + work.cancel() + for id in accountIds { self?.cancelRun(accountId: id, invalidateQueued: false) } + gate.finish(false) + } + } + + // MARK: - Notifications + + /// Registers the conflict notification's actions. Call once at launch. + func registerNotificationCategories() { + let keep = UNNotificationAction(identifier: Self.keepLocalAction, title: "Keep local", options: []) + let server = UNNotificationAction(identifier: Self.useServerAction, title: "Use server", options: []) + let category = UNNotificationCategory( + identifier: Self.conflictCategory, actions: [keep, server], intentIdentifiers: [], options: []) + UNUserNotificationCenter.current().setNotificationCategories([category]) + } + + /// Handles a tap on a conflict notification's action. Returns whether the + /// response was ours (so `AppDelegate` knows not to pass it on). + @MainActor + func handleNotificationResponse( + _ response: UNNotificationResponse, completion: @escaping () -> Void + ) -> Bool { + let content = response.notification.request.content + guard content.categoryIdentifier == Self.conflictCategory else { return false } + let resolution: String + switch response.actionIdentifier { + case Self.keepLocalAction: resolution = "local" + case Self.useServerAction: resolution = "server" + default: + completion() // plain tap: just opens the app + return true + } + let info = content.userInfo + guard let accountId = info["accountId"] as? String, + let fileId = info["fileId"] as? String, + let remotePath = info["remotePath"] as? String, + let relPath = info["relPath"] as? String, + let config = configs.config(for: accountId) + else { + completion() + return true + } + let conflict = SyncConflict( + accountId: accountId, fileId: fileId, remotePath: remotePath, relPath: relPath, + name: (relPath as NSString).lastPathComponent) + let creds = SyncCredentials(serverUrl: config.serverUrl, username: config.username, authHeader: config.authHeader) + let box = BackgroundTaskBox() + box.id = UIApplication.shared.beginBackgroundTask(withName: "noo.sync.resolve") { + completion() + UIApplication.shared.endBackgroundTask(box.id) + } + Task { + let ok = await self.resolveConflict(conflict, resolution: resolution, creds: creds) + if ok { + UNUserNotificationCenter.current().removeDeliveredNotifications( + withIdentifiers: [response.notification.request.identifier]) + } + completion() + await MainActor.run { UIApplication.shared.endBackgroundTask(box.id) } + } + return true + } +} + +/// Holds a `UIBackgroundTaskIdentifier` so an expiration handler (created +/// before the identifier exists) can end the task it belongs to. +final class BackgroundTaskBox { + var id: UIBackgroundTaskIdentifier = .invalid +} + +extension SyncAccountConfig { + func withNotify(_ notify: Bool) -> SyncAccountConfig { + var copy = self + copy.notify = notify + return copy + } +} + +/// The local notifications sync posts: a summary of what changed, and one per +/// conflict (which always posts - it needs a decision) with Keep local / Use +/// server actions. +enum SyncNotifications { + static func summary(_ summary: SyncRunSummary, username: String, accountId: String) { + var parts: [String] = [] + if summary.downloaded > 0 { parts.append("\(summary.downloaded) updated") } + if summary.uploaded > 0 { parts.append("\(summary.uploaded) uploaded") } + if summary.deleted > 0 { parts.append("\(summary.deleted) removed") } + let content = UNMutableNotificationContent() + content.title = "Noo sync · \(username)" + content.body = parts.joined(separator: ", ") + // One per account, so a newer summary replaces an older one in place. + post(content, id: "sync.summary.\(accountId)") + } + + static func conflicts(_ conflicts: [SyncConflict], username: String) { + for conflict in conflicts { + let content = UNMutableNotificationContent() + content.title = "Sync conflict: \(conflict.name)" + content.body = "\(username) - changed both on this device and on the server." + content.categoryIdentifier = SyncCoordinator.conflictCategory + content.userInfo = [ + "accountId": conflict.accountId, "fileId": conflict.fileId, + "remotePath": conflict.remotePath, "relPath": conflict.relPath, + ] + post(content, id: "sync.conflict.\(conflict.accountId).\(conflict.relPath)") + } + } + + private static func post(_ content: UNMutableNotificationContent, id: String) { + UNUserNotificationCenter.current().add(UNNotificationRequest(identifier: id, content: content, trigger: nil)) + } +} diff --git a/ios/Runner/Native/SyncEngine/SyncDiff.swift b/ios/Runner/Native/SyncEngine/SyncDiff.swift new file mode 100644 index 0000000..de4bb60 --- /dev/null +++ b/ios/Runner/Native/SyncEngine/SyncDiff.swift @@ -0,0 +1,195 @@ +import Foundation + +/// A local file's size and modification time (ms), the pair sync compares +/// against what it recorded. +struct LocalStat: Equatable { + let size: Int64 + let mtimeMs: Int64 +} + +enum LocalFS { + /// nil if [url] doesn't exist. + static func stat(_ url: URL) -> LocalStat? { + guard let attrs = try? FileManager.default.attributesOfItem(atPath: url.path) else { return nil } + let size = (attrs[.size] as? NSNumber)?.int64Value ?? 0 + let mtime = (attrs[.modificationDate] as? Date).map { Int64($0.timeIntervalSince1970 * 1000) } ?? 0 + return LocalStat(size: size, mtimeMs: mtime) + } + + static func relative(_ path: String) -> String { + path.hasPrefix("/") ? String(path.dropFirst()) : path + } + + static func trimTrailingSlashes(_ path: String) -> String { + var p = path + while p.hasSuffix("/") { p.removeLast() } + return p + } + + /// [rel] under [root], or nil if it isn't safely inside it: empty, with a + /// `.`/`..` component, or resolving outside [root]. Every path that came + /// from the server goes through this before anything is read, written or + /// deleted, so a hostile or buggy server can't point sync at other files. + static func resolve(_ rel: String, in root: URL) -> URL? { + let parts = rel.split(separator: "/", omittingEmptySubsequences: true) + guard !parts.isEmpty, !parts.contains(where: { $0 == ".." || $0 == "." }) else { return nil } + let url = root.appendingPathComponent(rel) + let rootPath = root.standardizedFileURL.path + guard url.standardizedFileURL.path.hasPrefix(rootPath + "/") else { return nil } + return url + } +} + +/// The decisions of a sync pass, kept free of networking so they can be +/// unit-tested. A direct port of Android's `SyncEngine.diffFolder` and its +/// helpers - the same rules, so a device behaves the same on either platform. +enum SyncDiff { + /// Diffs one synced path's remote manifest against the recorded state. + /// Callers own actually doing the GET/PUT/delete and updating state. + /// + /// - A file with no recorded state is simply pulled. + /// - Server changed and device changed -> conflict (never silently overwrite). + /// - Only the server changed -> download. Only the device changed -> upload. + /// - Recorded but missing on the device -> pull it back (it's inside a path + /// the user asked to sync, so a vanished copy is repaired, not propagated). + /// - Recorded for this path but absent from a *successful* listing -> + /// deleted on the server, so the local copy goes too. (An unreachable + /// server never gets here - the caller skips the path instead.) + static func diffFolder( + entries: [SyncRemoteEntry], + state: [String: SyncFileState], + priorFolderFileIds: Set, + syncRoot: URL + ) -> [SyncAction] { + var actions: [SyncAction] = [] + var seen = Set() + + for entry in entries where !entry.isFolder { + seen.insert(entry.fileId) + let rel = LocalFS.relative(entry.path) + let local = LocalFS.stat(syncRoot.appendingPathComponent(rel)) + + guard let prior = state[entry.fileId] else { + actions.append(.download(entry)) + continue + } + let serverChanged = entry.etag != prior.etag + let localChanged = local.map { $0.mtimeMs != prior.localMTime || $0.size != prior.size } ?? false + + if local == nil { + actions.append(.download(entry)) + } else if serverChanged && localChanged { + actions.append(.conflict(entry, relPath: rel)) + } else if serverChanged { + actions.append(.download(entry)) + } else if localChanged { + actions.append(.upload(relPath: rel, fileId: entry.fileId)) + } + } + + for fileId in priorFolderFileIds where !seen.contains(fileId) { + guard let prior = state[fileId] else { continue } + // Deleted on the server. If it was also edited here since the last sync + // that edit is the only copy left - keep it (untracked) instead of + // deleting it along with the rest. + if let local = LocalFS.stat(syncRoot.appendingPathComponent(prior.relPath)), + local.mtimeMs != prior.localMTime || local.size != prior.size + { + actions.append(.orphan(relPath: prior.relPath, fileId: fileId)) + } else { + actions.append(.delete(relPath: prior.relPath, fileId: fileId)) + } + } + return actions + } + + /// The recorded files that belong to the synced [path]: everything for the + /// root ("/"), else the file itself or anything under it - matched on a + /// path-component boundary, so "/Docs" never claims "/Docs2" or + /// "/Documents". + static func priorFileIds(state: [String: SyncFileState], path: String) -> Set { + let rootRel = LocalFS.relative(LocalFS.trimTrailingSlashes(path)) + return Set( + state.filter { + rootRel.isEmpty || $0.value.relPath == rootRel || $0.value.relPath.hasPrefix(rootRel + "/") + }.keys) + } + + /// Local paths (as spelled by the server) that more than one remote entry + /// would land on: "a.txt" and "A.txt" on a case-insensitive volume, or two + /// canonically-equivalent Unicode spellings. Downloading either would + /// overwrite the other, so callers skip them. + static func collidingRelPaths(_ entries: [SyncRemoteEntry]) -> Set { + var byKey: [String: [String]] = [:] + for entry in entries { + let rel = LocalFS.relative(entry.path) + byKey[rel.precomposedStringWithCanonicalMapping.lowercased(), default: []].append(rel) + } + // Compared as raw bytes: Swift's own `String` equality already treats + // canonically equivalent spellings as the same string, which is exactly + // the distinction the server preserves and the disk may not. + return Set(byKey.values.filter { Set($0.map { Array($0.utf8) }).count > 1 }.flatMap { $0 }) + } + + /// True if every file in [fileIds] is still on disk exactly as recorded + /// (same size and mtime): nothing local to upload and nothing missing to + /// re-download. Pure local stat calls, no network. + static func localMatchesState( + state: [String: SyncFileState], fileIds: Set, syncRoot: URL + ) -> Bool { + fileIds.allSatisfy { id in + guard let s = state[id], let local = LocalFS.stat(syncRoot.appendingPathComponent(s.relPath)) else { + return false + } + return local.size == s.size && local.mtimeMs == s.localMTime + } + } + + /// Creates a local directory for every remote folder, so empty folders + /// exist on the device too. + static func mirrorFolders( + entries: [SyncRemoteEntry], syncRoot: URL, rootRel: String, collisions: Set = [] + ) { + let fm = FileManager.default + if !rootRel.isEmpty, let dir = LocalFS.resolve(rootRel, in: syncRoot) { + try? fm.createDirectory(at: dir, withIntermediateDirectories: true) + } + for entry in entries where entry.isFolder { + let rel = entry.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + // Skip anything unsafe or that would share a local folder with another + // remote folder (a/A on a case-insensitive volume). + guard !collisions.contains(rel), let dir = LocalFS.resolve(rel, in: syncRoot) else { continue } + try? fm.createDirectory(at: dir, withIntermediateDirectories: true) + } + } + + /// Removes local directories under [rootRel] that no longer exist on the + /// server ([remoteFolders], relative paths). Only *empty* directories go: + /// by the time this runs the diff has already deleted the files that were + /// inside a deleted server folder, while a directory that still holds + /// something (a file this device created that hasn't synced) is left alone. + /// Never removes the sync root itself. + static func pruneRemovedFolders(syncRoot: URL, rootRel: String, remoteFolders: Set) { + let fm = FileManager.default + let top = rootRel.isEmpty ? syncRoot : syncRoot.appendingPathComponent(rootRel) + var isDir: ObjCBool = false + guard fm.fileExists(atPath: top.path, isDirectory: &isDir), isDir.boolValue else { return } + + func prune(_ dir: URL, rel: String) { + let children = (try? fm.contentsOfDirectory(at: dir, includingPropertiesForKeys: [.isDirectoryKey])) ?? [] + for child in children { + let childIsDir = (try? child.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false + if childIsDir { + prune(child, rel: rel.isEmpty ? child.lastPathComponent : rel + "/" + child.lastPathComponent) + } + } + guard !rel.isEmpty, !remoteFolders.contains(rel), + let remaining = try? fm.contentsOfDirectory(atPath: dir.path), remaining.isEmpty + else { return } + // rmdir, not removeItem: it refuses a directory that isn't empty, so a + // file that appeared since the check survives. + rmdir(dir.path) + } + prune(top, rel: rootRel) + } +} diff --git a/ios/Runner/Native/SyncEngine/SyncModels.swift b/ios/Runner/Native/SyncEngine/SyncModels.swift new file mode 100644 index 0000000..19a83e6 --- /dev/null +++ b/ios/Runner/Native/SyncEngine/SyncModels.swift @@ -0,0 +1,99 @@ +import Foundation + +// The iOS port of Android's `SyncEngine.kt`/`SyncWorker.kt` data model. Times +// are milliseconds since the epoch, like the Kotlin side, so the persisted +// state means the same thing on both platforms. + +/// One file or folder the server reported in a PROPFIND. +struct SyncRemoteEntry: Equatable { + /// Path relative to the user's files root, no trailing slash ("/Docs/a.txt"). + let path: String + let fileId: String + let etag: String + let lastModified: Int64 + let size: Int64 + let isFolder: Bool +} + +/// What the last successful sync recorded for a file - the baseline a later +/// pass diffs the server and the device against. +struct SyncFileState: Codable, Equatable { + let relPath: String + let etag: String + let lastModified: Int64 + let size: Int64 + let localMTime: Int64 +} + +/// A configured sync root's etag at the end of its last clean, complete walk. +/// Nextcloud propagates a change to any descendant up through every ancestor's +/// etag, so an unchanged root etag means nothing beneath it changed. +struct SyncRootMarker: Codable, Equatable { + let etag: String + let fullWalkAt: Int64 +} + +enum SyncAction: Equatable { + case download(SyncRemoteEntry) + case upload(relPath: String, fileId: String) + case delete(relPath: String, fileId: String) + case conflict(SyncRemoteEntry, relPath: String) + /// Deleted on the server, but edited on this device since the last sync: + /// the edited copy is the only one left, so it's kept (and simply no longer + /// tracked) instead of being deleted with the rest. Android deletes it. + case orphan(relPath: String, fileId: String) +} + +/// A file changed both on the device and on the server since the last sync. +struct SyncConflict: Codable, Equatable { + let accountId: String + let fileId: String + let remotePath: String + let relPath: String + let name: String +} + +/// Everything a sync run (foreground or background) needs for one account. +/// Persisted in the Keychain - it carries the auth header. +struct SyncAccountConfig: Codable, Equatable { + let accountId: String + let serverUrl: String + let username: String + let authHeader: String + /// Remote paths (files or folders) to mirror. + var folders: [String] + var wifiOnly: Bool + /// Minutes between background syncs; nil = no background sync (manual and + /// in-app refresh only). + var intervalMinutes: Int? + /// Whether automatic runs may post a summary notification. + var notify: Bool +} + +struct SyncRunSummary: Equatable { + var downloaded = 0 + var uploaded = 0 + var deleted = 0 + var conflicts: [SyncConflict] = [] + + var changedAnything: Bool { downloaded > 0 || uploaded > 0 || deleted > 0 } +} + +/// The server couldn't be reached or answered with an error (anything but a +/// clean 404). Distinct from "the path is genuinely gone": treating a network +/// blip as an empty listing made every previously synced file look deleted +/// server-side, and the diff then deleted the local copies. Callers skip the +/// affected path for this run instead. +struct SyncRemoteUnavailable: Error, CustomStringConvertible { + let message: String + var description: String { message } +} + +/// The recorded sync state exists but couldn't be read (corrupt, or an I/O +/// error). Treating that as "nothing recorded yet" would make every local +/// file look untracked and overwrite local edits with the server's copy, so +/// a run that hits it does nothing at all. +struct SyncStateUnreadable: Error, CustomStringConvertible { + let accountId: String + var description: String { "Sync state for \(accountId) is unreadable" } +} diff --git a/ios/Runner/Native/SyncEngine/SyncRunner.swift b/ios/Runner/Native/SyncEngine/SyncRunner.swift new file mode 100644 index 0000000..7cbee73 --- /dev/null +++ b/ios/Runner/Native/SyncEngine/SyncRunner.swift @@ -0,0 +1,392 @@ +import Foundation + +/// A mutex for `async` code: one holder at a time, waiters resume in order. +/// (An `actor` isn't enough - it lets other calls in at every `await`.) Held +/// for the whole of any run that reads-then-rewrites a sync state map, which +/// is also what keeps `SyncStatusBus` - it tracks one account at a time - +/// coherent when several accounts are due. +/// +/// Waiting is cancellable: a task that's cancelled while queued (a background +/// run whose time budget expired, a superseded "Sync now") leaves the queue +/// at once with `CancellationError` instead of sitting behind a long run. +final class AsyncMutex { + private let lock = NSLock() + private var locked = false + private var waiters: [(id: UUID, continuation: CheckedContinuation)] = [] + + func withLock(_ body: () async throws -> T) async throws -> T { + try await acquire() + defer { release() } + return try await body() + } + + private func acquire() async throws { + let id = UUID() + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + lock.lock() + if Task.isCancelled { + lock.unlock() + continuation.resume(throwing: CancellationError()) + } else if !locked { + locked = true + lock.unlock() + continuation.resume() + } else { + waiters.append((id, continuation)) + lock.unlock() + } + } + } onCancel: { + lock.lock() + if let index = waiters.firstIndex(where: { $0.id == id }) { + let waiter = waiters.remove(at: index) + lock.unlock() + waiter.continuation.resume(throwing: CancellationError()) + } else { + lock.unlock() + } + } + } + + private func release() { + lock.lock() + if waiters.isEmpty { + locked = false + lock.unlock() + } else { + let next = waiters.removeFirst() + lock.unlock() + next.continuation.resume() + } + } +} + +/// Live sync status for the app: whether a run is going, which files are +/// transferring, and unresolved conflicts. In-memory only - what actually +/// answers "is this file synced" is the on-disk state; this carries just the +/// transient parts. The iOS twin of Android's `SyncStatusBus`, except that +/// conflicts are kept per account (a file id is only unique within one). +final class SyncStatusBus { + struct Status: Equatable { + var accountId: String? + var syncing = false + var syncingFileIds: Set = [] + /// Every account's unresolved conflicts - readers filter by account. + var conflicts: [SyncConflict] = [] + } + + private let lock = NSLock() + private var current = Status() + + /// Called (on whatever thread changed it) after every update. + var onChange: ((Status) -> Void)? + + func snapshot() -> Status { + lock.lock() + defer { lock.unlock() } + return current + } + + func setSyncing(accountId: String, _ syncing: Bool) { + update { + $0.accountId = accountId + $0.syncing = syncing + if !syncing { $0.syncingFileIds = [] } + } + } + + func markFileSyncing(accountId: String, fileId: String, _ syncing: Bool) { + update { + $0.accountId = accountId + if syncing { $0.syncingFileIds.insert(fileId) } else { $0.syncingFileIds.remove(fileId) } + } + } + + func addConflicts(accountId: String, _ new: [SyncConflict]) { + guard !new.isEmpty else { return } + update { + $0.accountId = accountId + for conflict in new + where !$0.conflicts.contains(where: { $0.accountId == conflict.accountId && $0.fileId == conflict.fileId }) { + $0.conflicts.append(conflict) + } + } + } + + func removeConflict(accountId: String, fileId: String) { + update { + $0.accountId = accountId + $0.conflicts.removeAll { $0.accountId == accountId && $0.fileId == fileId } + } + } + + private func update(_ mutate: (inout Status) -> Void) { + lock.lock() + mutate(¤t) + let snapshot = current + lock.unlock() + onChange?(snapshot) + } +} + +/// One account's sync pass: walk each configured path, diff it against the +/// recorded state, transfer, and record the result. The iOS port of Android's +/// `SyncWorker.performSync` (and `ConflictResolveWorker`), with the same +/// safeguards - and a few more, found in review: +/// +/// - If the server can't be reached for a path - or answers with something +/// that isn't a complete, plausible listing - that path is skipped for this +/// run, never diffed against an empty or partial listing, which would look +/// like files were deleted and wipe the local copies. +/// - If the recorded state can't be read the run does nothing at all (it +/// would otherwise treat every local file as untracked and overwrite local +/// edits with the server's copies). +/// - Paths the server sends are checked to stay inside the mirror, and two +/// remote files that would land on the same local file are both skipped. +/// - A file deleted on the server but edited here is kept, not deleted. +/// - A root's etag is only remembered once its whole path synced cleanly, and +/// only if the state it relies on was actually saved. +struct SyncRunner { + let client: DavSyncClient + let store: SyncStore + let bus: SyncStatusBus + var now: () -> Int64 = { Int64(Date().timeIntervalSince1970 * 1000) } + + /// Even when a root's etag hasn't changed, walk it in full at least this + /// often: etag propagation isn't reliable everywhere (external storage + /// mounts in particular), so this bounds how stale a mirror can get. + static let fullWalkMaxAgeMs: Int64 = 6 * 60 * 60 * 1000 + + /// State is flushed to disk after this many transfers. + static let saveEvery = 20 + + /// [force] is an explicit "Sync now" / newly added path: always a full + /// walk, never trusting the root-etag shortcut. + func run(_ config: SyncAccountConfig, force: Bool) async -> SyncRunSummary { + var summary = SyncRunSummary() + guard !config.folders.isEmpty else { return summary } + let accountId = config.accountId + let creds = SyncCredentials(serverUrl: config.serverUrl, username: config.username, authHeader: config.authHeader) + let syncRoot = store.syncRoot(accountId: accountId) + + var state: [String: SyncFileState] + do { + state = try store.loadState(accountId: accountId) + } catch { + NSLog("[Sync] %@ - skipping this run", String(describing: error)) + return summary + } + var markers = store.loadRootMarkers(accountId: accountId) + var sinceSave = 0 + + bus.setSyncing(accountId: accountId, true) + defer { + // State first, then the markers that depend on it, then announce. + if store.saveState(accountId: accountId, state) { + markers = markers.filter { config.folders.contains($0.key) } + store.saveRootMarkers(accountId: accountId, markers) + } else { + NSLog("[Sync] couldn't save state for %@; not recording this run's markers", accountId) + } + bus.setSyncing(accountId: accountId, false) + } + + func transferred() { + sinceSave += 1 + if sinceSave >= Self.saveEvery { + if !store.saveState(accountId: accountId, state) { NSLog("[Sync] periodic state save failed") } + sinceSave = 0 + } + } + + for path in config.folders { + if Task.isCancelled { break } + do { + // A configured path can be a file or a folder - check which before + // deciding whether to walk it or just diff the single item. + let selfEntry = try await client.propfindSelf(creds, path: path) + let priorIds = SyncDiff.priorFileIds(state: state, path: path) + + // Cheap "did anything change?" check: an unchanged root etag means + // nothing beneath it changed on the server, and if every local file + // is also still as recorded there's nothing to upload or re-download + // either - skip the walk (one Depth-0 PROPFIND instead of one per + // subfolder). + if !force, let root = selfEntry, root.isFolder, let marker = markers[path], + marker.etag == root.etag, + now() - marker.fullWalkAt < Self.fullWalkMaxAgeMs, + !priorIds.isEmpty, + SyncDiff.localMatchesState(state: state, fileIds: priorIds, syncRoot: syncRoot) + { + continue + } + + // The server says this synced path no longer exists (a clean 404, not + // a network error): flag it so the app drops it from its synced list. + store.setRootMissing(accountId: accountId, path: path, missing: selfEntry == nil) + + var entries: [SyncRemoteEntry] = [] + if let root = selfEntry { + if root.isFolder { + entries = try await client.walk(creds, root: path) + } else { + entries = [root] + } + } + var pathClean = true + let collisions = SyncDiff.collidingRelPaths(entries) + + let actions = SyncDiff.diffFolder( + entries: entries, state: state, priorFolderFileIds: priorIds, syncRoot: syncRoot) + for action in actions { + try Task.checkCancellation() + switch action { + case .download(let entry): + let rel = LocalFS.relative(entry.path) + guard !collisions.contains(rel), let dest = LocalFS.resolve(rel, in: syncRoot) else { + NSLog("[Sync] not downloading %@: unsafe or colliding local path", rel) + pathClean = false + continue + } + bus.markFileSyncing(accountId: accountId, fileId: entry.fileId, true) + let ok = try await client.download(creds, remotePath: entry.path, to: dest) + bus.markFileSyncing(accountId: accountId, fileId: entry.fileId, false) + if ok { + let local = LocalFS.stat(dest) + state[entry.fileId] = SyncFileState( + relPath: rel, etag: entry.etag, lastModified: entry.lastModified, + size: local?.size ?? entry.size, localMTime: local?.mtimeMs ?? 0) + summary.downloaded += 1 + transferred() + } else { + pathClean = false + } + + case .upload(let rel, let fileId): + guard let local = LocalFS.resolve(rel, in: syncRoot) else { + pathClean = false + continue + } + bus.markFileSyncing(accountId: accountId, fileId: fileId, true) + var ok = false + if FileManager.default.fileExists(atPath: local.path) { + ok = try await client.upload(creds, remotePath: "/" + rel, from: local) + } + bus.markFileSyncing(accountId: accountId, fileId: fileId, false) + if ok, let stat = LocalFS.stat(local) { + // Our own PUT changed the file's etag; record the new one, or + // the next pass would see "server changed" and re-download it. + // If it can't be read back, keep the old one and let the root + // be walked for real next time. + let fresh = (try? await client.propfindSelf(creds, path: "/" + rel)) ?? nil + if fresh == nil { pathClean = false } + state[fileId] = SyncFileState( + relPath: rel, etag: fresh?.etag ?? state[fileId]?.etag ?? "", + lastModified: stat.mtimeMs, size: stat.size, localMTime: stat.mtimeMs) + summary.uploaded += 1 + transferred() + } else { + pathClean = false + } + + case .delete(let rel, let fileId): + // Only counts as a removal if a local copy existed and went; + // dropping stale state for a file never on disk isn't news. + if let local = LocalFS.resolve(rel, in: syncRoot), + FileManager.default.fileExists(atPath: local.path), + (try? FileManager.default.removeItem(at: local)) != nil + { + summary.deleted += 1 + } + state[fileId] = nil + + case .orphan(_, let fileId): + // Deleted on the server but edited here: the edit stays on the + // device, no longer tracked. + state[fileId] = nil + + case .conflict(let entry, let rel): + summary.conflicts.append( + SyncConflict( + accountId: accountId, fileId: entry.fileId, remotePath: entry.path, relPath: rel, + name: (rel as NSString).lastPathComponent)) + pathClean = false + } + } + + // Mirror the folder structure itself: empty folders created on the + // server appear on the device, and folders deleted on the server + // (whose files the diff has already removed) don't linger. + if selfEntry == nil || selfEntry?.isFolder == true { + let rootRel = path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + if selfEntry != nil { + SyncDiff.mirrorFolders(entries: entries, syncRoot: syncRoot, rootRel: rootRel, collisions: collisions) + } + var remoteFolders = Set( + entries.filter(\.isFolder).map { $0.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) }) + if selfEntry != nil { remoteFolders.insert(rootRel) } + SyncDiff.pruneRemovedFolders(syncRoot: syncRoot, rootRel: rootRel, remoteFolders: remoteFolders) + } + + if let root = selfEntry, root.isFolder, pathClean { + markers[path] = SyncRootMarker(etag: root.etag, fullWalkAt: now()) + } else { + markers[path] = nil + } + } catch is CancellationError { + markers[path] = nil // cut short: make the next run walk it for real + break + } catch { + // Couldn't reach the server for this path (or it answered with + // nothing usable): leave its state and marker alone and try again + // next run. + NSLog("[Sync] skipping %@ this run: %@", path, String(describing: error)) + } + } + + if !summary.conflicts.isEmpty { + bus.addConflicts(accountId: accountId, summary.conflicts) + } + return summary + } + + /// Applies the user's choice for one conflicted file - `local` uploads the + /// device's copy over the server's, `server` downloads the server's over + /// the device's - then refreshes the recorded etag so the next pass doesn't + /// immediately re-flag it. Returns whether it worked. + func resolveConflict( + _ conflict: SyncConflict, resolution: String, creds: SyncCredentials + ) async -> Bool { + // An unreadable baseline must not be overwritten with a one-file one. + guard var state = try? store.loadState(accountId: conflict.accountId), + let local = LocalFS.resolve(conflict.relPath, in: store.syncRoot(accountId: conflict.accountId)) + else { return false } + + var ok = false + do { + switch resolution { + case "local": + if FileManager.default.fileExists(atPath: local.path) { + ok = try await client.upload(creds, remotePath: conflict.remotePath, from: local) + } + case "server": + ok = try await client.download(creds, remotePath: conflict.remotePath, to: local) + default: + break + } + } catch { + return false + } + guard ok else { return false } + + let fresh = (try? await client.propfindSelf(creds, path: conflict.remotePath)) ?? nil + let stat = LocalFS.stat(local) + state[conflict.fileId] = SyncFileState( + relPath: conflict.relPath, etag: fresh?.etag ?? "", lastModified: fresh?.lastModified ?? 0, + size: stat?.size ?? 0, localMTime: stat?.mtimeMs ?? 0) + store.saveState(accountId: conflict.accountId, state) + bus.removeConflict(accountId: conflict.accountId, fileId: conflict.fileId) + return true + } +} diff --git a/ios/Runner/Native/SyncEngine/SyncStore.swift b/ios/Runner/Native/SyncEngine/SyncStore.swift new file mode 100644 index 0000000..0f5d626 --- /dev/null +++ b/ios/Runner/Native/SyncEngine/SyncStore.swift @@ -0,0 +1,175 @@ +import Foundation + +/// The on-device side of sync: the mirrored files and the per-account sync +/// state. Laid out under one base directory (Application Support, which is +/// also where Dart's `SyncService.baseDirectory()` looks for the mirror): +/// +/// /sync//... the mirrored files +/// /sync-state//*.json state, root markers, missing roots +/// +/// Plain JSON files rather than `UserDefaults`: the state map grows with the +/// number of synced files. Not thread-safe by itself - the sync runner holds +/// one lock around anything that reads-then-rewrites it (see `AsyncMutex`). +final class SyncStore { + /// Bumped whenever a walk starts doing something new (e.g. mirroring empty + /// folders) so markers recorded by an older version - which would make the + /// next run skip the walk - are ignored once. Same value as Android. + static let rootsVersion = 2 + + static let shared = SyncStore(base: defaultBase) + + static var defaultBase: URL { + FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + } + + let base: URL + private let fileManager = FileManager.default + + init(base: URL) { + self.base = base + } + + // MARK: - Locations + + /// The account's mirror folder, created on demand. Marked excluded from + /// iCloud/device backups: it's a re-downloadable copy of server data. + func syncRoot(accountId: String) -> URL { + let top = base.appendingPathComponent("sync", isDirectory: true) + let root = top.appendingPathComponent(accountId, isDirectory: true) + try? fileManager.createDirectory(at: root, withIntermediateDirectories: true) + var values = URLResourceValues() + values.isExcludedFromBackup = true + var topMutable = top + try? topMutable.setResourceValues(values) + return root + } + + private func stateDirectory(accountId: String) -> URL { + let dir = base.appendingPathComponent("sync-state", isDirectory: true) + .appendingPathComponent(accountId, isDirectory: true) + try? fileManager.createDirectory(at: dir, withIntermediateDirectories: true) + return dir + } + + private func file(_ accountId: String, _ name: String) -> URL { + stateDirectory(accountId: accountId).appendingPathComponent(name) + } + + // MARK: - Per-file state + + /// The recorded baseline - empty if nothing has been recorded yet, and + /// *throws* if there is a file that can't be read or decoded (see + /// `SyncStateUnreadable`). + func loadState(accountId: String) throws -> [String: SyncFileState] { + let url = file(accountId, "state.json") + guard fileManager.fileExists(atPath: url.path) else { return [:] } + do { + return try JSONDecoder().decode([String: SyncFileState].self, from: Data(contentsOf: url)) + } catch { + throw SyncStateUnreadable(accountId: accountId) + } + } + + /// False if the state couldn't be written - callers must not then record + /// anything that assumes it was. + @discardableResult + func saveState(accountId: String, _ state: [String: SyncFileState]) -> Bool { + write(state, to: file(accountId, "state.json")) + } + + // MARK: - Root markers + + private struct MarkersFile: Codable { + let version: Int + let markers: [String: SyncRootMarker] + } + + func loadRootMarkers(accountId: String) -> [String: SyncRootMarker] { + guard let stored = read(MarkersFile.self, from: file(accountId, "roots.json")), + stored.version == Self.rootsVersion + else { return [:] } + return stored.markers + } + + @discardableResult + func saveRootMarkers(accountId: String, _ markers: [String: SyncRootMarker]) -> Bool { + write(MarkersFile(version: Self.rootsVersion, markers: markers), to: file(accountId, "roots.json")) + } + + // MARK: - Roots the server says are gone + + func loadMissingRoots(accountId: String) -> Set { + Set(read([String].self, from: file(accountId, "missing.json")) ?? []) + } + + func setRootMissing(accountId: String, path: String, missing: Bool) { + var current = loadMissingRoots(accountId: accountId) + let changed = missing ? current.insert(path).inserted : (current.remove(path) != nil) + if changed { _ = write(Array(current).sorted(), to: file(accountId, "missing.json")) } + } + + // MARK: - Removing a path's mirror + + /// Deletes [path]'s local mirror (a file, or a whole folder's worth) and + /// forgets its entries - called when the user turns sync off for that path. + /// Without clearing the state too, a later re-add would see the (now + /// missing) local file as "deleted, server unchanged" and not pull it back. + func removeLocalSync(accountId: String, path: String) { + var cleanPath = path.trimmingCharacters(in: .whitespaces) + if cleanPath.hasPrefix("/") { cleanPath.removeFirst() } + while cleanPath.hasSuffix("/") { cleanPath.removeLast() } + let prefix = cleanPath.isEmpty ? "" : cleanPath + "/" + + // An unreadable baseline is left alone rather than overwritten with a + // partial one; the next run reports it and does nothing. + if var state = try? loadState(accountId: accountId) { + // The sync root itself ("/") covers every recorded file. + for (fileId, entry) in state + where cleanPath.isEmpty || entry.relPath == cleanPath || entry.relPath.hasPrefix(prefix) { + state[fileId] = nil + } + saveState(accountId: accountId, state) + } + + setRootMissing(accountId: accountId, path: "/" + cleanPath, missing: false) + setRootMissing(accountId: accountId, path: cleanPath, missing: false) + + // A root marker for this path (or an ancestor/descendant that also covers + // some of what was just deleted) would make the next background pass + // think nothing changed and skip re-downloading. + let removedPath = "/" + cleanPath + var markers = loadRootMarkers(accountId: accountId) + let stale = markers.keys.filter { key in + var k = key + while k.hasSuffix("/") { k.removeLast() } + return k.isEmpty || removedPath == "/" || k == removedPath || k.hasPrefix(removedPath + "/") + || removedPath.hasPrefix(k + "/") + } + if !stale.isEmpty { + stale.forEach { markers[$0] = nil } + saveRootMarkers(accountId: accountId, markers) + } + + let root = syncRoot(accountId: accountId) + let target = cleanPath.isEmpty ? root : root.appendingPathComponent(cleanPath) + if fileManager.fileExists(atPath: target.path) { try? fileManager.removeItem(at: target) } + if cleanPath.isEmpty { _ = syncRoot(accountId: accountId) } + } + + // MARK: - Helpers + + private func read(_ type: T.Type, from url: URL) -> T? { + guard let data = try? Data(contentsOf: url) else { return nil } + return try? JSONDecoder().decode(T.self, from: data) + } + + private func write(_ value: T, to url: URL) -> Bool { + guard let data = try? JSONEncoder().encode(value) else { return false } + do { + try data.write(to: url, options: .atomic) + return true + } catch { + return false + } + } +} diff --git a/ios/RunnerTests/SyncEngineTests.swift b/ios/RunnerTests/SyncEngineTests.swift new file mode 100644 index 0000000..395956e --- /dev/null +++ b/ios/RunnerTests/SyncEngineTests.swift @@ -0,0 +1,845 @@ +import XCTest + +@testable import Runner + +/// An in-memory Nextcloud that answers just enough WebDAV (PROPFIND depth 0/1, +/// GET, PUT) for the sync engine to run end to end against - with ancestor +/// etags bumping on every change, like the real thing. +final class FakeDav: URLProtocol { + struct Node { + var isFolder: Bool + var data = Data() + var etag: String + var fileId: String + var mtime = Date(timeIntervalSince1970: 1_700_000_000) + } + + static var nodes: [String: Node] = [:] + static var failAll = false + /// A folder whose PROPFIND answers 500 while everything else works - for + /// proving a half-built manifest is never trusted. + static var failPropfindPath: String? + /// Answer PROPFIND with a 207 whose XML is cut off mid-way. + static var truncatePropfind = false + static var log: [String] = [] + private static var counter = 0 + static let user = "alice" + private static let base = "/remote.php/dav/files/alice" + + static func reset() { + nodes = ["/": Node(isFolder: true, etag: nextTag(), fileId: "root")] + failAll = false + failPropfindPath = nil + truncatePropfind = false + log = [] + counter = 0 + } + + private static func nextTag() -> String { + counter += 1 + return "tag\(counter)" + } + + private static func parent(of path: String) -> String { + let p = (path as NSString).deletingLastPathComponent + return p.isEmpty ? "/" : p + } + + /// Bumps [path]'s etag and every ancestor's. + private static func touch(_ path: String) { + var p = path + while true { + nodes[p]?.etag = nextTag() + if p == "/" { break } + p = parent(of: p) + } + } + + // MARK: Test-side helpers + + static func mkdir(_ path: String) { + var parts: [String] = [] + for comp in path.split(separator: "/") { + parts.append(String(comp)) + let p = "/" + parts.joined(separator: "/") + if nodes[p] == nil { + nodes[p] = Node(isFolder: true, etag: nextTag(), fileId: "id\(counter)") + touch(parent(of: p)) + } + } + } + + static func put(_ path: String, _ text: String) { + mkdir(parent(of: path)) + var node = nodes[path] ?? Node(isFolder: false, etag: "", fileId: "id\(nextTag())") + node.data = Data(text.utf8) + nodes[path] = node + touch(path) + } + + static func remove(_ path: String) { + for key in nodes.keys where key == path || key.hasPrefix(path + "/") { nodes[key] = nil } + touch(parent(of: path)) + } + + static func text(_ path: String) -> String? { + nodes[path].flatMap { String(data: $0.data, encoding: .utf8) } + } + + // MARK: URLProtocol + + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + override func stopLoading() {} + + override func startLoading() { + let request = self.request + let method = request.httpMethod ?? "GET" + let depth = request.value(forHTTPHeaderField: "Depth") ?? "-" + var path = (request.url?.path ?? "").removingPercentEncoding ?? "" + if path.hasPrefix(Self.base) { path = String(path.dropFirst(Self.base.count)) } + while path.count > 1 && path.hasSuffix("/") { path.removeLast() } + if path.isEmpty { path = "/" } + Self.log.append("\(method) \(depth) \(path)") + + func send(_ status: Int, _ body: Data = Data()) { + let response = HTTPURLResponse( + url: request.url!, statusCode: status, httpVersion: "HTTP/1.1", headerFields: nil)! + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: body) + client?.urlProtocolDidFinishLoading(self) + } + + if Self.failAll { return send(503) } + + switch method { + case "PROPFIND": + if path == Self.failPropfindPath { return send(500) } + guard Self.nodes[path] != nil else { return send(404) } + var paths = [path] + if depth == "1" { + paths += Self.nodes.keys.filter { $0 != path && Self.parent(of: $0) == path }.sorted() + } + var body = Self.multistatus(paths) + // Only folder listings: a cut-off *listing* still holds a few complete + // responses, which is the dangerous case (a partial manifest). + if Self.truncatePropfind && depth == "1" { body = String(body.prefix(body.count * 2 / 3)) } + send(207, Data(body.utf8)) + case "GET": + guard let node = Self.nodes[path], !node.isFolder else { return send(404) } + send(200, node.data) + case "PUT": + var data = Data() + if let stream = request.httpBodyStream { + stream.open() + var buffer = [UInt8](repeating: 0, count: 4096) + while stream.hasBytesAvailable { + let n = stream.read(&buffer, maxLength: buffer.count) + if n <= 0 { break } + data.append(buffer, count: n) + } + stream.close() + } else if let body = request.httpBody { + data = body + } + Self.mkdir(Self.parent(of: path)) + var node = Self.nodes[path] ?? Node(isFolder: false, etag: "", fileId: "id\(Self.nextTag())") + node.data = data + Self.nodes[path] = node + Self.touch(path) + send(201) + default: + send(405) + } + } + + private static let httpDate: DateFormatter = { + let f = DateFormatter() + f.locale = Locale(identifier: "en_US_POSIX") + f.timeZone = TimeZone(secondsFromGMT: 0) + f.dateFormat = "EEE, dd MMM yyyy HH:mm:ss 'GMT'" + return f + }() + + private static func multistatus(_ paths: [String]) -> String { + let responses = paths.map { p -> String in + let node = nodes[p]! + let encoded = p.split(separator: "/").map { WebDAV.encodeSegment(String($0)) }.joined(separator: "/") + let href = base + (encoded.isEmpty ? "" : "/" + encoded) + (node.isFolder ? "/" : "") + return """ + \(href)\ + \(httpDate.string(from: node.mtime))\ + \(node.data.count)\ + \(node.isFolder ? "" : "")\ + "\(node.etag)"\(node.fileId)\ + HTTP/1.1 200 OK + """ + }.joined() + return """ + \(responses) + """ + } +} + +final class SyncEngineTests: XCTestCase { + private var store: SyncStore! + private var bus: SyncStatusBus! + private var runner: SyncRunner! + private var fakeSession: URLSession! + private var clock: Int64 = 1_700_000_000_000 + private let config = SyncAccountConfig( + accountId: "acct", serverUrl: "https://dav.test", username: "alice", authHeader: "Basic x", + folders: ["/Docs"], wifiOnly: false, intervalMinutes: nil, notify: false) + private var creds: SyncCredentials { + SyncCredentials(serverUrl: config.serverUrl, username: config.username, authHeader: config.authHeader) + } + + override func setUp() { + super.setUp() + FakeDav.reset() + let base = FileManager.default.temporaryDirectory + .appendingPathComponent("noo-sync-\(UUID().uuidString)", isDirectory: true) + try? FileManager.default.createDirectory(at: base, withIntermediateDirectories: true) + addTeardownBlock { try? FileManager.default.removeItem(at: base) } + store = SyncStore(base: base) + bus = SyncStatusBus() + + let sessionConfig = URLSessionConfiguration.ephemeral + sessionConfig.protocolClasses = [FakeDav.self] + fakeSession = URLSession(configuration: sessionConfig) + runner = SyncRunner( + client: DavSyncClient(session: fakeSession), + store: store, bus: bus, now: { [unowned self] in self.clock }) + } + + private func state() -> [String: SyncFileState] { + (try? store.loadState(accountId: "acct")) ?? [:] + } + + private var mirror: URL { store.syncRoot(accountId: "acct") } + private func local(_ rel: String) -> String? { + try? String(contentsOf: mirror.appendingPathComponent(rel), encoding: .utf8) + } + private func exists(_ rel: String) -> Bool { + FileManager.default.fileExists(atPath: mirror.appendingPathComponent(rel).path) + } + private func editLocally(_ rel: String, _ text: String) throws { + let url = mirror.appendingPathComponent(rel) + try text.write(to: url, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.modificationDate: Date().addingTimeInterval(60)], ofItemAtPath: url.path) + } + private func depth1Requests() -> Int { FakeDav.log.filter { $0.hasPrefix("PROPFIND 1") }.count } + + private func seedServer() { + FakeDav.put("/Docs/a.txt", "alpha") + FakeDav.put("/Docs/sub/b.txt", "bravo") + } + + // MARK: - The happy path + + func testFirstSyncDownloadsEverythingAndMirrorsFolders() async { + seedServer() + FakeDav.mkdir("/Docs/empty") + let summary = await runner.run(config, force: false) + + XCTAssertEqual(summary.downloaded, 2) + XCTAssertEqual(local("Docs/a.txt"), "alpha") + XCTAssertEqual(local("Docs/sub/b.txt"), "bravo") + XCTAssertTrue(exists("Docs/empty"), "empty server folders are mirrored") + XCTAssertEqual(state().count, 2) + XCTAssertNotNil(store.loadRootMarkers(accountId: "acct")["/Docs"]) + XCTAssertFalse(bus.snapshot().syncing, "announced as finished") + } + + func testUnchangedServerIsOneCheapRequestNotAWalk() async { + seedServer() + _ = await runner.run(config, force: false) + let walks = depth1Requests() + + FakeDav.log = [] + let summary = await runner.run(config, force: false) + XCTAssertFalse(summary.changedAnything) + XCTAssertEqual(depth1Requests(), 0, "the root etag shortcut skipped the walk (was \(walks) before)") + XCTAssertEqual(FakeDav.log, ["PROPFIND 0 /Docs"]) + } + + func testForceAlwaysWalks() async { + seedServer() + _ = await runner.run(config, force: false) + FakeDav.log = [] + _ = await runner.run(config, force: true) + XCTAssertGreaterThan(depth1Requests(), 0) + } + + func testShortcutExpiresAfterTheMaxAge() async { + seedServer() + _ = await runner.run(config, force: false) + clock += SyncRunner.fullWalkMaxAgeMs + 1 + FakeDav.log = [] + _ = await runner.run(config, force: false) + XCTAssertGreaterThan(depth1Requests(), 0, "etags aren't trusted forever") + } + + func testServerChangeIsDownloaded() async { + seedServer() + _ = await runner.run(config, force: false) + FakeDav.put("/Docs/a.txt", "alpha v2") + FakeDav.put("/Docs/new.txt", "fresh") + + let summary = await runner.run(config, force: false) + XCTAssertEqual(summary.downloaded, 2) + XCTAssertEqual(local("Docs/a.txt"), "alpha v2") + XCTAssertEqual(local("Docs/new.txt"), "fresh") + } + + func testLocalEditIsUploadedOnceAndNotReDownloaded() async throws { + seedServer() + _ = await runner.run(config, force: false) + try editLocally("Docs/a.txt", "alpha edited on phone") + + let summary = await runner.run(config, force: false) + XCTAssertEqual(summary.uploaded, 1) + XCTAssertEqual(FakeDav.text("/Docs/a.txt"), "alpha edited on phone") + + let again = await runner.run(config, force: false) + XCTAssertFalse(again.changedAnything, "our own upload must not come back as a server change") + XCTAssertEqual(local("Docs/a.txt"), "alpha edited on phone") + } + + func testFileDeletedLocallyIsPulledBack() async throws { + seedServer() + _ = await runner.run(config, force: false) + try FileManager.default.removeItem(at: mirror.appendingPathComponent("Docs/a.txt")) + + let summary = await runner.run(config, force: false) + XCTAssertEqual(summary.downloaded, 1) + XCTAssertEqual(local("Docs/a.txt"), "alpha") + XCTAssertNotNil(FakeDav.text("/Docs/a.txt"), "a local delete never deletes on the server") + } + + // MARK: - Conflicts + + func testChangedOnBothSidesIsAConflictAndNothingIsOverwritten() async throws { + seedServer() + _ = await runner.run(config, force: false) + FakeDav.put("/Docs/a.txt", "server edit") + try editLocally("Docs/a.txt", "phone edit") + + let summary = await runner.run(config, force: false) + XCTAssertEqual(summary.conflicts.map(\.relPath), ["Docs/a.txt"]) + XCTAssertEqual(summary.conflicts.first?.name, "a.txt") + XCTAssertEqual(local("Docs/a.txt"), "phone edit", "the device copy is untouched") + XCTAssertEqual(FakeDav.text("/Docs/a.txt"), "server edit", "the server copy is untouched") + XCTAssertEqual(bus.snapshot().conflicts.count, 1) + XCTAssertNil(store.loadRootMarkers(accountId: "acct")["/Docs"], "an unresolved conflict forces a real walk next time") + } + + func testResolveWithServerCopyThenNoConflictOnTheNextPass() async throws { + seedServer() + _ = await runner.run(config, force: false) + FakeDav.put("/Docs/a.txt", "server edit") + try editLocally("Docs/a.txt", "phone edit") + let conflict = await runner.run(config, force: false).conflicts[0] + + let ok = await runner.resolveConflict(conflict, resolution: "server", creds: creds) + XCTAssertTrue(ok) + XCTAssertEqual(local("Docs/a.txt"), "server edit") + XCTAssertTrue(bus.snapshot().conflicts.isEmpty) + + let next = await runner.run(config, force: false) + XCTAssertTrue(next.conflicts.isEmpty) + XCTAssertFalse(next.changedAnything) + } + + func testResolveWithLocalCopyUploadsIt() async throws { + seedServer() + _ = await runner.run(config, force: false) + FakeDav.put("/Docs/a.txt", "server edit") + try editLocally("Docs/a.txt", "phone edit") + let conflict = await runner.run(config, force: false).conflicts[0] + + let ok = await runner.resolveConflict(conflict, resolution: "local", creds: creds) + XCTAssertTrue(ok) + XCTAssertEqual(FakeDav.text("/Docs/a.txt"), "phone edit") + let next = await runner.run(config, force: false) + XCTAssertTrue(next.conflicts.isEmpty && !next.changedAnything) + } + + func testAnUnknownResolutionDoesNothing() async throws { + seedServer() + _ = await runner.run(config, force: false) + FakeDav.put("/Docs/a.txt", "server edit") + try editLocally("Docs/a.txt", "phone edit") + let conflict = await runner.run(config, force: false).conflicts[0] + + let ok = await runner.resolveConflict(conflict, resolution: "bogus", creds: creds) + XCTAssertFalse(ok) + XCTAssertEqual(local("Docs/a.txt"), "phone edit") + XCTAssertEqual(FakeDav.text("/Docs/a.txt"), "server edit") + } + + // MARK: - Deletes (the dangerous part) + + func testFileDeletedOnTheServerIsRemovedLocally() async { + seedServer() + _ = await runner.run(config, force: false) + FakeDav.remove("/Docs/a.txt") + + let summary = await runner.run(config, force: false) + XCTAssertEqual(summary.deleted, 1) + XCTAssertFalse(exists("Docs/a.txt")) + XCTAssertTrue(exists("Docs/sub/b.txt"), "only the deleted file goes") + } + + func testFolderDeletedOnTheServerRemovesItsFilesAndTheEmptyDirectory() async { + seedServer() + _ = await runner.run(config, force: false) + FakeDav.remove("/Docs/sub") + + let summary = await runner.run(config, force: false) + XCTAssertEqual(summary.deleted, 1) + XCTAssertFalse(exists("Docs/sub/b.txt")) + XCTAssertFalse(exists("Docs/sub"), "the now-empty directory is pruned") + XCTAssertTrue(exists("Docs/a.txt")) + } + + func testAnUnreachableServerNeverDeletesAnything() async { + seedServer() + _ = await runner.run(config, force: false) + let before = state() + + FakeDav.failAll = true + let summary = await runner.run(config, force: true) + XCTAssertFalse(summary.changedAnything, "a 503 is not an empty folder") + XCTAssertEqual(local("Docs/a.txt"), "alpha") + XCTAssertEqual(local("Docs/sub/b.txt"), "bravo") + XCTAssertEqual(state(), before) + XCTAssertTrue(store.loadMissingRoots(accountId: "acct").isEmpty, "unreachable is not 'gone'") + + FakeDav.failAll = false + let recovered = await runner.run(config, force: true) + XCTAssertFalse(recovered.changedAnything) + XCTAssertEqual(local("Docs/a.txt"), "alpha") + } + + func testAPartiallyFailingWalkDoesNotLookLikeAMassDeletion() async { + seedServer() + _ = await runner.run(config, force: false) + // Only the subfolder listing breaks: a half-built manifest must not make + // sub/b.txt look deleted. + FakeDav.nodes["/Docs/sub"]?.etag = "changed" + FakeDav.nodes["/Docs"]?.etag = "changed" + FakeDav.failPropfindPath = "/Docs/sub" + + let summary = await runner.run(config, force: true) + XCTAssertEqual(summary.deleted, 0) + XCTAssertEqual(local("Docs/sub/b.txt"), "bravo") + XCTAssertEqual(local("Docs/a.txt"), "alpha") + } + + func testAPathDeletedOnTheServerIsFlaggedAndItsMirrorRemoved() async { + seedServer() + _ = await runner.run(config, force: false) + FakeDav.remove("/Docs") + + let summary = await runner.run(config, force: false) + XCTAssertEqual(summary.deleted, 2) + XCTAssertEqual(store.loadMissingRoots(accountId: "acct"), ["/Docs"]) + XCTAssertFalse(exists("Docs/a.txt")) + } + + // MARK: - Single files, scoping, housekeeping + + func testASyncedSingleFilePath() async { + FakeDav.put("/Notes/todo.txt", "buy milk") + var single = config + single.folders = ["/Notes/todo.txt"] + + let summary = await runner.run(single, force: false) + XCTAssertEqual(summary.downloaded, 1) + XCTAssertEqual(local("Notes/todo.txt"), "buy milk") + FakeDav.put("/Notes/todo.txt", "buy milk and eggs") + _ = await runner.run(single, force: false) + XCTAssertEqual(local("Notes/todo.txt"), "buy milk and eggs") + } + + func testSyncOnlyTouchesTheConfiguredPaths() async { + seedServer() + FakeDav.put("/Other/x.txt", "not synced") + _ = await runner.run(config, force: false) + XCTAssertFalse(exists("Other/x.txt")) + } + + func testNothingConfiguredDoesNothing() async { + seedServer() + var empty = config + empty.folders = [] + let summary = await runner.run(empty, force: true) + XCTAssertEqual(summary, SyncRunSummary()) + XCTAssertTrue(FakeDav.log.isEmpty) + } + + func testRemoveLocalSyncForgetsThePathSoItComesBackOnReAdd() async { + seedServer() + _ = await runner.run(config, force: false) + store.removeLocalSync(accountId: "acct", path: "/Docs/sub") + + XCTAssertFalse(exists("Docs/sub/b.txt")) + XCTAssertTrue(exists("Docs/a.txt")) + XCTAssertEqual(state().count, 1) + XCTAssertNil(store.loadRootMarkers(accountId: "acct")["/Docs"], "an ancestor's marker would skip the re-download") + + let summary = await runner.run(config, force: false) + XCTAssertEqual(summary.downloaded, 1) + XCTAssertEqual(local("Docs/sub/b.txt"), "bravo") + } + + func testStatusMarksFilesAsSyncingDuringTheRun() async { + seedServer() + var seen = Set() + bus.onChange = { seen.formUnion($0.syncingFileIds) } + _ = await runner.run(config, force: false) + XCTAssertEqual(seen.count, 2, "each transferring file was announced") + XCTAssertTrue(bus.snapshot().syncingFileIds.isEmpty) + } + + func testStaleMarkersForRemovedPathsAreDropped() async { + seedServer() + _ = await runner.run(config, force: false) + var other = config + other.folders = ["/Other"] + FakeDav.mkdir("/Other") + _ = await runner.run(other, force: false) + XCTAssertNil(store.loadRootMarkers(accountId: "acct")["/Docs"]) + } + + // MARK: - Parsing + + func testParserReadsEtagFileIdSizeAndDates() { + let xml = Data( + """ + + /nextcloud/remote.php/dav/files/alice/Docs/ + "abc123" + 7 + /nextcloud/remote.php/dav/files/alice/Docs/Tax%20Forms.pdf + Tue, 14 Nov 2023 22:13:20 GMT + 2048 + "e1"9 + + """.utf8) + let entries = try! DavSyncParser.entries(from: xml, username: "alice", requestedPath: "/Docs", skipSelf: true) + XCTAssertEqual(entries.count, 1, "the folder itself is skipped") + XCTAssertEqual(entries[0].path, "/Docs/Tax Forms.pdf") + XCTAssertEqual(entries[0].etag, "e1", "quotes are stripped") + XCTAssertEqual(entries[0].fileId, "9") + XCTAssertEqual(entries[0].size, 2048) + XCTAssertEqual(entries[0].lastModified, 1_700_000_000_000) + XCTAssertFalse(entries[0].isFolder) + + let all = try! DavSyncParser.entries(from: xml, username: "alice", requestedPath: "/Docs", skipSelf: false) + XCTAssertEqual(all.map(\.path), ["/Docs", "/Docs/Tax Forms.pdf"]) + XCTAssertTrue(all[0].isFolder) + } + + func testParserRefusesAnythingThatIsNotACompleteAnswer() { + func entries(_ xml: String) throws -> [SyncRemoteEntry] { + try DavSyncParser.entries(from: Data(xml.utf8), username: "alice", requestedPath: "/Docs", skipSelf: false) + } + XCTAssertThrowsError(try entries("nope"), "not XML") + XCTAssertThrowsError(try entries("/remote.php"), "truncated") + XCTAssertThrowsError( + try entries( + "/remote.php/dav/files/alice/Docs/a.txt" + + "/remote.php/dav/files/alice/Docs/b"), + "one complete response, then cut off: a partial manifest") + XCTAssertThrowsError(try entries("Maintenance"), "well-formed but not a multistatus") + XCTAssertThrowsError(try entries(""), "a 207 always has the resource itself") + } + + func testParserRefusesHrefsOutsideTheAccountRootOrClimbingOut() { + func parse(_ href: String) throws -> [SyncRemoteEntry] { + let xml = "\(href)" + return try DavSyncParser.entries(from: Data(xml.utf8), username: "alice", requestedPath: "/", skipSelf: false) + } + XCTAssertNoThrow(try parse("/remote.php/dav/files/alice/Docs/a.txt")) + XCTAssertThrowsError(try parse("/remote.php/dav/files/alice/../bob/secret.txt"), "dot-dot component") + XCTAssertThrowsError(try parse("/remote.php/dav/files/alice/Docs/%2E%2E/%2E%2E/x"), "encoded dot-dot") + XCTAssertThrowsError(try parse("/somewhere/else/a.txt"), "not under the files root") + XCTAssertThrowsError(try parse("/remote.php/dav/files/alicia/a.txt"), "another user's root that merely starts the same") + } + + // MARK: - Review findings: each of these used to lose or misplace data + + func testATruncatedAnswerIsNotAManifest() async { + seedServer() + _ = await runner.run(config, force: false) + FakeDav.truncatePropfind = true + + let summary = await runner.run(config, force: true) + XCTAssertFalse(summary.changedAnything, "half a listing must not look like deletions") + XCTAssertEqual(local("Docs/a.txt"), "alpha") + XCTAssertEqual(local("Docs/sub/b.txt"), "bravo") + XCTAssertTrue(store.loadMissingRoots(accountId: "acct").isEmpty, "nor like the folder being gone") + } + + func testDeletedOnTheServerButEditedHereIsKept() async throws { + seedServer() + _ = await runner.run(config, force: false) + try editLocally("Docs/a.txt", "my only copy of this edit") + FakeDav.remove("/Docs/a.txt") + + let summary = await runner.run(config, force: false) + XCTAssertEqual(summary.deleted, 0) + XCTAssertEqual(local("Docs/a.txt"), "my only copy of this edit") + XCTAssertNil(state().first { $0.value.relPath == "Docs/a.txt" }, "no longer tracked") + + let again = await runner.run(config, force: false) + XCTAssertEqual(local("Docs/a.txt"), "my only copy of this edit", "and not touched later either") + XCTAssertFalse(again.changedAnything) + } + + func testSyncingTheWholeAccountNoticesServerDeletions() async { + FakeDav.put("/a.txt", "top") + FakeDav.put("/sub/b.txt", "nested") + var everything = config + everything.folders = ["/"] + + _ = await runner.run(everything, force: false) + XCTAssertEqual(local("a.txt"), "top") + FakeDav.log = [] + _ = await runner.run(everything, force: false) + XCTAssertEqual(depth1Requests(), 0, "the root shortcut works for '/' too") + + FakeDav.remove("/a.txt") + let summary = await runner.run(everything, force: false) + XCTAssertEqual(summary.deleted, 1) + XCTAssertFalse(exists("a.txt")) + XCTAssertTrue(exists("sub/b.txt")) + } + + func testAPathNeverClaimsASiblingWithTheSamePrefix() { + let entry = { (rel: String) in SyncFileState(relPath: rel, etag: "e", lastModified: 0, size: 1, localMTime: 1) } + let state = ["1": entry("Docs/a.txt"), "2": entry("Docs2/b.txt"), "3": entry("Documents/c.txt"), "4": entry("Docs")] + XCTAssertEqual(SyncDiff.priorFileIds(state: state, path: "/Docs"), ["1", "4"]) + XCTAssertEqual(SyncDiff.priorFileIds(state: state, path: "/Docs/"), ["1", "4"]) + XCTAssertEqual(SyncDiff.priorFileIds(state: state, path: "/"), ["1", "2", "3", "4"], "the root covers everything") + } + + func testTwoRemoteNamesForOneLocalFileAreNeitherDownloaded() async { + FakeDav.put("/Docs/a.txt", "lower") + FakeDav.put("/Docs/A.txt", "upper") + FakeDav.put("/Docs/fine.txt", "ok") + + let summary = await runner.run(config, force: false) + XCTAssertEqual(summary.downloaded, 1, "only the file with no collision") + XCTAssertEqual(local("Docs/fine.txt"), "ok") + XCTAssertNil(local("Docs/a.txt"), "neither of the colliding pair overwrote the other") + XCTAssertNil(store.loadRootMarkers(accountId: "acct")["/Docs"], "and the path isn't called clean") + let unicode = SyncDiff.collidingRelPaths([ + SyncRemoteEntry(path: "/x/\u{C4}.txt", fileId: "1", etag: "", lastModified: 0, size: 0, isFolder: false), + SyncRemoteEntry(path: "/x/A\u{308}.txt", fileId: "2", etag: "", lastModified: 0, size: 0, isFolder: false), + ]) + XCTAssertTrue(unicode.contains("x/\u{C4}.txt") && unicode.contains("x/A\u{308}.txt"), + "canonically equivalent Unicode spellings collide too") + XCTAssertTrue( + SyncDiff.collidingRelPaths([ + SyncRemoteEntry(path: "/x/same.txt", fileId: "1", etag: "", lastModified: 0, size: 0, isFolder: false) + ]).isEmpty, "one spelling is never a collision") + } + + func testServerPathsCanNeverLeaveTheMirror() { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("noo-root-\(UUID().uuidString)") + XCTAssertNotNil(LocalFS.resolve("Docs/a.txt", in: root)) + XCTAssertNil(LocalFS.resolve("../outside.txt", in: root)) + XCTAssertNil(LocalFS.resolve("Docs/../../outside.txt", in: root)) + XCTAssertNil(LocalFS.resolve("./x", in: root)) + XCTAssertNil(LocalFS.resolve("", in: root)) + XCTAssertNil(LocalFS.resolve("/", in: root)) + } + + func testAnUnreadableStateMakesTheRunDoNothing() async throws { + seedServer() + _ = await runner.run(config, force: false) + try editLocally("Docs/a.txt", "unsynced local edit") + let stateFile = store.base.appendingPathComponent("sync-state/acct/state.json") + try Data("{ this is not json".utf8).write(to: stateFile) + FakeDav.log = [] + + let summary = await runner.run(config, force: true) + XCTAssertEqual(summary, SyncRunSummary()) + XCTAssertEqual(local("Docs/a.txt"), "unsynced local edit", "an untracked-looking file must not be overwritten") + XCTAssertTrue(FakeDav.log.isEmpty, "it didn't even talk to the server") + XCTAssertThrowsError(try store.loadState(accountId: "acct")) + XCTAssertEqual(try Data(contentsOf: stateFile), Data("{ this is not json".utf8), "the corrupt file isn't overwritten") + } + + func testNoStateYetIsJustAFirstSyncNotAnError() throws { + XCTAssertEqual(try store.loadState(accountId: "brand-new"), [:]) + } + + func testADownloadNeverReplacesAFolderOrDestroysTheOldCopyOnFailure() async throws { + FakeDav.put("/Docs/a.txt", "server") + let client = DavSyncClient(session: fakeSession) + let folder = mirror.appendingPathComponent("Docs/a.txt") + try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) + try "inside".write(to: folder.appendingPathComponent("keep.txt"), atomically: true, encoding: .utf8) + + let ok = try await client.download(creds, remotePath: "/Docs/a.txt", to: folder) + XCTAssertFalse(ok, "a folder is not replaced by a file") + XCTAssertEqual(local("Docs/a.txt/keep.txt"), "inside") + + let file = mirror.appendingPathComponent("Docs/b.txt") + try "old".write(to: file, atomically: true, encoding: .utf8) + FakeDav.put("/Docs/b.txt", "new") + let replaced = try await client.download(creds, remotePath: "/Docs/b.txt", to: file) + XCTAssertTrue(replaced) + XCTAssertEqual(local("Docs/b.txt"), "new") + FakeDav.failAll = true + let failed = try await client.download(creds, remotePath: "/Docs/b.txt", to: file) + XCTAssertFalse(failed) + XCTAssertEqual(local("Docs/b.txt"), "new", "a failed download leaves what was there") + } + + func testRemovingTheWholeRootForgetsEveryFile() async { + seedServer() + _ = await runner.run(config, force: false) + store.removeLocalSync(accountId: "acct", path: "/") + XCTAssertTrue(state().isEmpty, "'/' covers everything recorded") + XCTAssertFalse(exists("Docs/a.txt")) + } + + func testPruningNeverRemovesAFolderThatHoldsSomething() throws { + let root = mirror + try FileManager.default.createDirectory( + at: root.appendingPathComponent("Docs/stale"), withIntermediateDirectories: true) + try "mine".write(to: root.appendingPathComponent("Docs/stale/note.txt"), atomically: true, encoding: .utf8) + try FileManager.default.createDirectory( + at: root.appendingPathComponent("Docs/empty-stale"), withIntermediateDirectories: true) + + SyncDiff.pruneRemovedFolders(syncRoot: root, rootRel: "Docs", remoteFolders: ["Docs"]) + XCTAssertEqual(local("Docs/stale/note.txt"), "mine") + XCTAssertFalse(exists("Docs/empty-stale"), "an empty folder the server no longer has goes") + } + + // MARK: - Status, coordinator, config store, mutex + + func testConflictsAreKeptPerAccount() { + let a = SyncConflict(accountId: "A", fileId: "7", remotePath: "/x", relPath: "x", name: "x") + let b = SyncConflict(accountId: "B", fileId: "7", remotePath: "/y", relPath: "y", name: "y") + bus.addConflicts(accountId: "A", [a]) + bus.addConflicts(accountId: "B", [b]) + XCTAssertEqual(bus.snapshot().conflicts.count, 2, "same file id, different accounts: both kept") + + let coordinator = SyncCoordinator( + store: store, bus: bus, configs: SyncConfigStore(service: "test-\(UUID().uuidString)"), client: DavSyncClient()) + let forB = coordinator.statusMap(bus.snapshot(), accountIdOverride: "B")["conflicts"] as? [[String: String]] + XCTAssertEqual(forB?.map { $0["relPath"] }, ["y"], "B never sees A's conflicts") + + bus.removeConflict(accountId: "A", fileId: "7") + XCTAssertEqual(bus.snapshot().conflicts, [b], "removing A's leaves B's") + } + + func testSignOutForgetsCredentialsButTurningBackgroundOffKeepsThem() { + let service = "test-\(UUID().uuidString)" + let configs = SyncConfigStore(service: service) + addTeardownBlock { configs.removeAll() } + let coordinator = SyncCoordinator(store: store, bus: bus, configs: configs, client: DavSyncClient()) + var withBackground = config + withBackground.intervalMinutes = 30 + + coordinator.reschedule(withBackground) + XCTAssertEqual(configs.config(for: "acct")?.intervalMinutes, 30) + + coordinator.cancel(accountId: "acct", forget: false) + XCTAssertNotNil(configs.config(for: "acct"), "conflict actions and Sync now still need the credentials") + XCTAssertNil(configs.config(for: "acct")?.intervalMinutes, "but nothing is scheduled") + + coordinator.cancel(accountId: "acct", forget: true) + XCTAssertNil(configs.config(for: "acct"), "signing out forgets them") + } + + func testConfigStoreHoldsSeveralAccountsAndUpdatesInPlace() { + let configs = SyncConfigStore(service: "test-\(UUID().uuidString)") + addTeardownBlock { configs.removeAll() } + let second = SyncAccountConfig( + accountId: "other", serverUrl: "https://o", username: "bob", authHeader: "Basic y", + folders: ["/x"], wifiOnly: true, intervalMinutes: 60, notify: true) + + XCTAssertTrue(configs.upsert(config)) + XCTAssertTrue(configs.upsert(second)) + XCTAssertEqual(configs.all().map(\.accountId), ["acct", "other"]) + + var changed = config + changed.folders = ["/Docs", "/More"] + XCTAssertTrue(configs.upsert(changed)) + XCTAssertEqual(configs.config(for: "acct")?.folders, ["/Docs", "/More"]) + XCTAssertEqual(configs.config(for: "other"), second, "updating one leaves the others") + + XCTAssertTrue(configs.remove(accountId: "acct")) + XCTAssertEqual(configs.all().map(\.accountId), ["other"]) + XCTAssertTrue(configs.remove(accountId: "never-existed")) + } + + func testAQueuedWaiterThatIsCancelledLeavesTheQueueAtOnce() async throws { + let mutex = AsyncMutex() + let holderStarted = expectation(description: "holder has the lock") + let holder = Task { + try await mutex.withLock { + holderStarted.fulfill() + try await Task.sleep(nanoseconds: 1_500_000_000) + } + } + await fulfillment(of: [holderStarted], timeout: 5) + + let waiter = Task { try await mutex.withLock { "ran" } } + try await Task.sleep(nanoseconds: 100_000_000) + let cancelledAt = Date() + waiter.cancel() + do { + _ = try await waiter.value + XCTFail("a cancelled waiter must not run") + } catch { + XCTAssertTrue(error is CancellationError) + XCTAssertLessThan(Date().timeIntervalSince(cancelledAt), 0.8, "it didn't wait for the 1.5s holder") + } + + try await holder.value + let next = try await mutex.withLock { "next" } + XCTAssertEqual(next, "next", "the lock is free for the next one") + } + + private final class Counter: @unchecked Sendable { + var running = 0 + var maxRunning = 0 + var finished = 0 + } + + func testAMutexNeverRunsTwoBodiesAtOnce() async throws { + let mutex = AsyncMutex() + let counter = Counter() + await withTaskGroup(of: Void.self) { group in + for _ in 0..<6 { + group.addTask { + _ = try? await mutex.withLock { + counter.running += 1 + counter.maxRunning = max(counter.maxRunning, counter.running) + try? await Task.sleep(nanoseconds: 10_000_000) + counter.running -= 1 + counter.finished += 1 + } + } + } + } + XCTAssertEqual(counter.finished, 6) + XCTAssertEqual(counter.maxRunning, 1) + } + + func testOnceGateCompletesOnlyOnce() { + var calls: [Bool] = [] + let gate = OnceGate { calls.append($0) } + gate.finish(false) + gate.finish(true) + XCTAssertEqual(calls, [false]) + } +} diff --git a/lib/services/sync_service.dart b/lib/services/sync_service.dart index 7c15213..8c334b6 100644 --- a/lib/services/sync_service.dart +++ b/lib/services/sync_service.dart @@ -105,12 +105,17 @@ class SyncService { ) : _SyncConfig.fromPrefs(prefs, store.accountPrefKey, account.id); final password = await store.readPassword(account.id); - if (password == null || - config.paths.isEmpty || - config.intervalMinutes == null) { + if (password == null || config.paths.isEmpty) { await cancelAccount(account.id); continue; } + if (config.intervalMinutes == null) { + // Background sync is off, but this account still has paths to sync + // by hand: keep its credentials (native uses them for "Sync now" + // and conflict notification actions). + await cancelAccount(account.id, forget: false); + continue; + } await invokeIfAvailable(_channel, 'reschedule', { 'accountId': account.id, 'serverUrl': account.serverUrl, @@ -132,8 +137,17 @@ class SyncService { /// Stops [accountId]'s periodic job (the account was removed, or has /// nothing left to sync). - static Future cancelAccount(String accountId) async { - await invokeIfAvailable(_channel, 'cancel', {'accountId': accountId}); + /// + /// [forget] (the default) is for an account that was signed out or removed: + /// native also drops its stored credentials. Pass false when only the + /// background job is being turned off - the account's paths are still + /// synced by hand ("Sync now"), and a conflict notification's actions + /// still need its credentials. + static Future cancelAccount(String accountId, {bool forget = true}) async { + await invokeIfAvailable(_channel, 'cancel', { + 'accountId': accountId, + 'forget': forget, + }); } /// Runs a one-off sync pass immediately, independent of the periodic From 2e5fc4723a1f4291e399df1a8aafba10cbfe5f40 Mon Sep 17 00:00:00 2001 From: Ayushya Amitabh Date: Tue, 6 Oct 2026 21:02:21 -0400 Subject: [PATCH 25/31] Avatar menu: same side inset as the Files list rows The card was inset 16px from each screen edge; the list rows are 12px, so it read as narrower. Now NooSpace.sm, with a test on the account row's width. Co-Authored-By: Claude Sonnet 5.5 --- lib/widgets/avatar_menu.dart | 4 +++- test/widgets/avatar_menu_test.dart | 4 ++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/lib/widgets/avatar_menu.dart b/lib/widgets/avatar_menu.dart index 898c88e..d20af01 100644 --- a/lib/widgets/avatar_menu.dart +++ b/lib/widgets/avatar_menu.dart @@ -79,7 +79,9 @@ class _AvatarMenuContentState extends State<_AvatarMenuContent> { child: SafeArea( bottom: false, child: Padding( - padding: const EdgeInsets.symmetric(horizontal: NooSpace.md), + // Same side inset as the Files list's rows (`NooSpace.sm`), so the + // card is as wide as the list beneath it. + padding: const EdgeInsets.symmetric(horizontal: NooSpace.sm), child: SizedBox( width: double.infinity, child: Container( diff --git a/test/widgets/avatar_menu_test.dart b/test/widgets/avatar_menu_test.dart index 6fc1dd9..f99a902 100644 --- a/test/widgets/avatar_menu_test.dart +++ b/test/widgets/avatar_menu_test.dart @@ -219,5 +219,9 @@ void main() { tester.getTopRight(row).dx - tester.getTopRight(avatar).dx, NooSpace.md, ); + // The card is as wide as the Files list's rows: inset NooSpace.sm from + // each screen edge, less its 1px border and 1px inner padding per side. + final screenWidth = tester.getSize(find.byType(MaterialApp)).width; + expect(tester.getSize(row).width, screenWidth - 2 * NooSpace.sm - 4); }); } From 51636813af04922eb016e80fb891c3a82c7ebb4b Mon Sep 17 00:00:00 2001 From: Ayushya Amitabh Date: Tue, 6 Oct 2026 23:18:50 -0400 Subject: [PATCH 26/31] Refine iOS navigation, media controls, and background sync --- .claude/context/architecture.md | 22 +- .claude/context/server.md | 17 +- .claude/context/styling.md | 107 ++- .../Native/SyncEngine/SyncCoordinator.swift | 10 +- ios/Runner/Native/SyncEngine/SyncDiff.swift | 11 +- ios/Runner/Native/SyncEngine/SyncModels.swift | 4 - ios/Runner/Native/SyncEngine/SyncRunner.swift | 7 +- ios/RunnerTests/SyncEngineTests.swift | 38 +- lib/main.dart | 84 +- lib/providers/settings_controller.dart | 38 +- lib/theme/design_tokens.dart | 4 +- lib/views/favorites_view.dart | 82 +- lib/views/file_viewer_screen.dart | 4 +- lib/views/files_view.dart | 118 ++- lib/views/photos_view.dart | 132 ++- lib/widgets/app_tab_view_builder.dart | 15 +- lib/widgets/app_top_bar.dart | 94 +- lib/widgets/avatar_menu.dart | 862 +++++++++++++----- lib/widgets/bottom_nav_bar.dart | 23 +- lib/widgets/frosted_glass_container.dart | 62 +- lib/widgets/noo/core/noo_button.dart | 37 +- lib/widgets/noo/lists/noo_selection_bar.dart | 5 +- lib/widgets/noo/lists/noo_settings_row.dart | 22 +- lib/widgets/noo/nav/noo_bottom_bar.dart | 209 ++++- lib/widgets/noo/nav/noo_nav_style.dart | 4 +- lib/widgets/noo/nav/noo_top_bar.dart | 51 ++ lib/widgets/selection_bar_overlay.dart | 30 + lib/widgets/settings/settings_action_bar.dart | 45 +- lib/widgets/settings/settings_appearance.dart | 76 +- .../settings/settings_files_cache.dart | 10 +- lib/widgets/sticky_header_delegate.dart | 13 +- lib/widgets/viewer/media_action_bar.dart | 10 +- lib/widgets/viewer/media_video_preview.dart | 69 +- .../settings_selection_position_test.dart | 54 ++ test/widgets/avatar_menu_test.dart | 391 +++++++- .../widgets/frosted_glass_container_test.dart | 59 ++ test/widgets/ios_seek_bar_test.dart | 74 ++ test/widgets/noo/nav_test.dart | 4 +- test/widgets/selection_bar_overlay_test.dart | 59 ++ 39 files changed, 2309 insertions(+), 647 deletions(-) create mode 100644 lib/widgets/selection_bar_overlay.dart create mode 100644 test/providers/settings_selection_position_test.dart create mode 100644 test/widgets/frosted_glass_container_test.dart create mode 100644 test/widgets/ios_seek_bar_test.dart create mode 100644 test/widgets/selection_bar_overlay_test.dart diff --git a/.claude/context/architecture.md b/.claude/context/architecture.md index 9fee13d..77e69cb 100644 --- a/.claude/context/architecture.md +++ b/.claude/context/architecture.md @@ -65,7 +65,8 @@ new provider instance): without a circular dependency. - [`SettingsController`](../../lib/providers/settings_controller.dart) — global UI prefs independent of login state: theme mode/seed color/dynamic - color, bottom-bar opacity/blur, tap-tab-to-scroll-top, seek bar style, tab + color, bottom-bar opacity/blur, avatar and selection action-bar positions, + tap-tab-to-scroll-top, seek bar style, tab order/visibility/default (`requestedTab`/`requestTab`/ `consumeRequestedTab`), swipe actions. - [`FilesController`](../../lib/providers/files_controller.dart) — the @@ -282,11 +283,13 @@ the sidebar): - **Mobile:** `AppTopBar` (`widgets/app_top_bar.dart`) wraps `NooTopBar` for *every* tab (previously only Files had shell-level top chrome, with the rest building their own via `SyncedHeaderScaffold`) - iOS gets a - large title, an inline search field, a `plus` action on Files only (no - other tab has a create/upload flow), and the account avatar; Android + large title with Upload and the top account avatar on the same row, + and an inline search field below; Android gets a compact title row with `search`/avatar actions, relying on an - extended `NooFab` ("Upload", Files/Photos only) instead of a top-bar - icon for upload. Rather than one shared instance in `Scaffold.appBar`, + extended `NooFab` instead of a top-bar upload button. Both use the same + size preference: Auto expands on Files/Photos and collapses elsewhere, + Mini always collapses, and Expanded always shows Upload. Rather than one + shared instance in `Scaffold.appBar`, `MainShellView` builds a separate `AppTopBar` per tab (labelled for that tab) and each tab plants its own as the first sliver in its own `CustomScrollView` (`topBarSliver` in `widgets/tabs/tab_state_slivers.dart`, @@ -312,8 +315,13 @@ the sidebar): section yet). `SettingsController.navMenuStyle` (Settings → Appearance → "Navigation menu") offers an alternative to the hamburger/drawer pair: the avatar button opens `showAvatarMenu` (`widgets/avatar_menu.dart`) - instead, a dropdown holding the same hidden-tabs + Settings content - - see `styling.md`'s Gotchas for the wiring. + instead. `AvatarNavigationHost` wraps the mobile scaffold to animate a + card from the top or bottom avatar; `AvatarNavigationBody` moves only + content for the top popup. The bottom popup overlays content. Floating + mode moves the avatar into the popup and hands its satellite slot to + Search; attached mode keeps the avatar and Search in the full-width bar + and expands the popup upward from it. Position is a global setting. + See `styling.md` (Gotchas) for the wiring, motion and dismissal behavior. - **Wide tablet:** a `NooSidebar` (account card, pinned tabs, divider, remaining tabs, storage meter, Settings) sits beside a `NooToolbar` (tab title, search, an "Upload" action on Files/Photos) over the same diff --git a/.claude/context/server.md b/.claude/context/server.md index 2b955cf..d777aee 100644 --- a/.claude/context/server.md +++ b/.claude/context/server.md @@ -1021,10 +1021,10 @@ backup) with state in `<...>/sync-state//*.json`. pruning uses `rmdir` and only on a successful empty read; a root's etag shortcut marker is only written when the path synced cleanly *and* the state was saved. -- **Deliberate differences from Android:** a file deleted on the server but - edited here is **kept** (untracked) rather than deleted - the edit is the - only copy; after our own upload the file's etag is re-read so it isn't - downloaded back; state is flushed every 20 transfers; conflicts are kept +- **Server deletions:** remove the local copy even if edited locally, matching + Android. +- **Deliberate differences from Android:** after our own upload the file's + etag is re-read so it isn't downloaded back; state is flushed every 20 transfers; conflicts are kept per account on the status bus; downloads write to a temp file first. No ongoing progress notification (a run posts a summary, and conflict notifications with Keep local / Use server actions). @@ -1039,12 +1039,19 @@ backup) with state in `<...>/sync-state//*.json`. (`dev.ayushya.noo.sync.refresh`/`.processing`, `UIBackgroundModes` fetch + processing, `BGTaskSchedulerPermittedIdentifiers` in Info.plist; handlers registered before launch finishes). `intervalMinutes` (floored at 15, like - Android) is only the *earliest* start - iOS picks when - and Wi-Fi-only is + Android) is only the *earliest* start - iOS picks when. The Files cache + settings explain this on iOS; the 15-minute Android warning appears only + on Android for intervals below 15 minutes. Wi-Fi-only is checked with `NWPathMonitor` (`isExpensive`) since a BGTask can't require it. An expiring task cancels its runs (they save state as they go) and is completed exactly once (`OnceGate`). **Not testable in the simulator** (needs a real device and the Xcode `_simulateLaunchForTaskWithIdentifier` debugger command). +- **Simulator verification:** a temporary debug control ran the native + background slot and confirmed files update for a non-active saved account. + The control and its channel handler were removed after verification. + `startBackgroundSlot` retains a regression test for periodic eligibility. + OS wake-up delivery and background execution time limits remain unverified. - **Credentials:** `SyncConfigStore` keeps each account's server, auth header, paths, interval and notify flag as one Keychain item (`AfterFirstUnlock`, so a background run works with the phone locked). diff --git a/.claude/context/styling.md b/.claude/context/styling.md index 1d494d4..fb7cef3 100644 --- a/.claude/context/styling.md +++ b/.claude/context/styling.md @@ -121,7 +121,8 @@ Gotchas: toggle, Shares' scope switcher), but pass `onSurface: true` for one placed inside a sheet or dialog (already `colors.surface`), or its pill track blends invisibly into the sheet instead of reading as a grouped - control (Files'/Photos' sort and type-filter sheets do this). + control (Files'/Photos' filter sheets and Settings controls on surface + rows use the same `surface2` track). - Any `RefreshIndicator` needs `physics: const AlwaysScrollableScrollPhysics()` on its scrollable child, or pull-to-refresh silently can't be triggered once the list is short enough to fit the viewport (empty, or one item) - @@ -140,6 +141,19 @@ blocks are noted where they matter: `NooChip`/`NooSegmentedControl`; still the sort/hidden/scope/type-filter/ view-mode row shared by Files and Offline (`showStorageScope: false` for Offline), and reused as-is by Favorites. +- Selection action bars in Files, Photos, and Favorites have a global + Settings → Action bar → Position choice: Top (default) or Bottom, + persisted as `ui_selection_bar_position`. Top stays pinned in the content + header; Bottom uses `SelectionBarOverlay` above navigation (or the content + pane's bottom on desktop), with extra list clearance. The overlay uses + Scaffold's MediaQuery bottom padding so attached/floating/frosted navigation + and safe areas are accounted for without counting their height twice. + The shell hides Android's Upload FAB during selection via view callbacks. + The mobile Action bar settings use one grouped card for Position and + the reorder list, with no nested rounded corners or repeated page title. + Their header backdrop is transparent, so content scrolls behind the inset + `surface` card. A `line` border and `nooDialogShadow` match the floating + bottom bar's solid treatment. - Hidden files and external storage are each a three-way segmented row (`HiddenFilesFilterRow`/`StorageScopeRow`, `lib/widgets/filter_mode_rows.dart`, in both the Files and Photos filter sheets), not toggles: @@ -147,7 +161,8 @@ blocks are noted where they matter: cloud (default) / external / all. Files and Photos each persist their own hidden filter (`ui_hidden_filter`, `ui_hidden_filter_photos`; the old `ui_show_hidden*` bools are still read as a fallback, `true` → include); - `StorageScope` stays shared. Leaving `hide` is still behind the + `StorageScope` stays shared. Photos uses the same segmented pill for + Favorites (All / Favorites only). Leaving `hide` is still behind the hidden-files lock gate. With `StorageScope.all`, Files splits the listing: regular items first, then a collapsible "External storage" section (`_ExternalStorageHeader`, local `_externalExpanded` state, default open) @@ -246,7 +261,7 @@ blocks are noted where they matter: instead of a corner badge. `NooBottomBar` later gained its own, unrelated `NooBottomBarStyle.floating` (Settings → Appearance → "Bottom bar") - don't confuse the two: this one is still non-blurred, just inset with a - `line` border and `nooDialogShadow` (the app's one other shadow user - see + `line` border and `nooDialogShadow` (also used by floating selection bars - see that constant's doc comment) instead of edge to edge (no opacity/blur knob either). `floating` ignores `NooNavStyle` and always uses the Android row (icon-only idle tabs, sliding pill, 64px), so iOS matches Android there; @@ -279,44 +294,50 @@ blocks are noted where they matter: `onSearchTap`) and lowers `SettingsController.maxVisibleTabs` by one - use that getter, not `defaultMaxVisibleTabs` from `models/app_tab.dart`, anywhere that needs the *current* cap on regular tabs. -- `SettingsController.navMenuStyle` (`NooNavMenuStyle.drawer`/`avatarMenu`, - Settings → Appearance → "Navigation menu") picks what opens hidden tabs + - Settings on mobile: the original hamburger-opens-`AppDrawer` pattern, or - the avatar button opens [`showAvatarMenu`](../../lib/widgets/avatar_menu.dart) - instead. Wired through `AppTopBar`→`NooTopBar.onMenu` (null in `avatarMenu` - mode - no menu icon renders at all, see `NooTopBar`'s `lead` logic) and - `ShellAvatarButton`'s new `onTap`/`label` overrides (`shell_common.dart`) - - a caller passing a custom `onTap` *must* also pass a matching `label`, or - the tooltip/semantics still say "Accounts" for a button that no longer - opens the account switcher. In `avatarMenu` mode `main.dart` also sets - `Scaffold.drawerEnableOpenDragGesture: false` so the edge swipe can't open - the drawer. The menu header has a chevron that expands an account section - (other saved accounts to switch to, "Add Account", "Manage Accounts") - above the hidden tabs/Settings. `showAvatarMenu` is this app's first use of - `showGeneralDialog` directly (`barrierColor: Colors.transparent` + - `barrierDismissible: true` for a non-dimming click-outside-to-close menu, - not a modal flow) - there's no existing anchored-popup primitive here - (`PopupMenuButton`'s own width doesn't stretch to a full content column), - so don't reach for `showNooSheet`/`showNooDialog` for something shaped - like this. It's positioned just past the status bar (`SafeArea`'s own - inset, not the top bar's full height on top of that) so it covers the - top bar - including the tab title - rather than sitting below it, and - its card carries two stacked `boxShadow`s rather than just - `nooDialogShadow` alone: that one shadow's blur is wide and soft enough - to read as basically invisible on a small card over a dark theme's - near-black `bg` (a dark, diffuse shadow needs real density close to the - edge to be visible against an already-dark backdrop), so a second, - tighter, more opaque contact shadow underneath it gives real elevation - in both themes. The card's border can go missing wherever an opaque row - sits against it too (every corner but the header's, which has no - full-bleed fill of its own) if a `Container` combines `border` with its - own `clipBehavior` - that paints the border as part of the *outer* - decoration, then the clipped child on top right up to the same boundary, - with no gap for the border's own stroke to show through. Fixed the same - way any bordered-and-clipped `Container` should be: no `clipBehavior` on - the bordered `Container` itself, and a 1px-inset `ClipRRect` (radius - reduced by that same 1px) around the filled, clipped content instead, so - it never paints over the border. Also added +- `SettingsController.navMenuStyle` chooses hamburger/drawer or avatar popup + navigation (Settings → Appearance → Navigation menu). Avatar mode exposes + a persisted Avatar position choice (`ui_avatar_position`, Top by default). + `AvatarNavigationHost` owns one 420ms open / 320ms close animation using + `easeInOutCubicEmphasized`. The card expands from the real avatar anchor; + its controls fade in after the expansion starts. Top moves the content pane + down through `AvatarNavigationBody`, keeping bottom navigation stationary. + Bottom moves the avatar upward into the card's bottom-right corner and + overlays the list immediately above navigation. Both use the Files gutter. + The floating rail beside a bottom avatar shows text only on the selected + tab. Attached iOS also shows text only for the selected tab and keeps the full-width bar, + with the avatar inside the trailing end. Its popup expands upward with + full screen width, rounded top corners and no gap above the bar; the + avatar stays in the bar, and Search keeps its inline position. + The attached popup has no outer border or shadow, blending into the bar. + Attached popup order is navigation (including Settings), current account, + then expanded secondary accounts and Add/Manage Account controls. + The iOS shell places Upload and the top avatar on the large-title row. + Upload uses the shared `ui_fab_style` preference: Auto expands on Files + and Photos and collapses elsewhere; Mini is icon-only; Expanded always + shows the plus-and-Upload button. Upload uses the folder icon's tonal + `accentSoft` background and `accentText` foreground. Search stays below. + Upload uses the compact 32px size to match the visible top avatar. + On iOS the accent picker hides the unsupported wallpaper color option; + the accent subtitle, swatch and selection always reflect the seed color. + Upload's width, padding and label opacity interpolate over `NooMotion.base`. + A shared shell animation keeps separately mounted tab headers in sync + during Auto tab changes; reduced motion skips the transition. + Avatar navigation and its bottom satellite share the bottom bar's frost + toggle, blur and opacity, including throughout the popup animation. + The setting is labelled "Frosted glass" and also controls both media + viewer panels through `FrostedGlassContainer`. Off uses an opaque surface + without a backdrop filter; On uses the shared blur and tint opacity. + Search starts inside the rail when a bottom avatar occupies the satellite; + its row slot smoothly collapses as Search fades into that satellite while + the avatar moves into the popup. Closing reverses the same path. The card + retains hidden tabs, Settings, account switching, Add Account, and Manage + Accounts. Account controls scroll on short screens. Tap outside, tap the + popup avatar, or system Back to close. Reduced motion opens/closes without + the transition. Drawer gestures are disabled in avatar mode. Standalone + top bars outside the shell retain `showGeneralDialog` as a fallback. + `ShellAvatarButton` keeps its vertical-swipe account shortcuts in either + position. Its onTap override must have a matching tooltip/semantics label. + Also added `NooTopBar.androidTitleTrailing`: Android has no large title to put a second search row under the way iOS's `search:` slot does, so an inline search bar (`AppTopBar` passes a plain `ShellSearchLauncher()` when @@ -346,11 +367,13 @@ blocks are noted where they matter: `FileBreadcrumbRow` proved the same trail is still wanted elsewhere. - [`SeekBarPainter`/`SeekBarPreview`](../../lib/widgets/seek_bar_painter.dart) — the four `MediaProgressBarStyle` presets (Default/Wavy/Slim/Squiggly) - for the video player's seek bar, plus a perpetually-animated + for the non-iOS video player's seek bar, plus a perpetually-animated `SeekBarPreview` wrapper used by the Settings style picker so every preview always matches the real widget exactly (same painter, just fed demo `progress`/`phase` values). Add new seek-bar presets here, not by forking the painter. + iOS uses `IosSeekBar` with `CupertinoSlider` instead and hides the style + picker. Scrubbing pauses playback temporarily and restores its prior state. - Chrome inside the media viewer (`file_viewer_screen.dart` — the top bar's back button + filename, the bottom action bar, the video transport controls) all share one small hand-rolled icon-button pattern diff --git a/ios/Runner/Native/SyncEngine/SyncCoordinator.swift b/ios/Runner/Native/SyncEngine/SyncCoordinator.swift index 2f06664..26cb52d 100644 --- a/ios/Runner/Native/SyncEngine/SyncCoordinator.swift +++ b/ios/Runner/Native/SyncEngine/SyncCoordinator.swift @@ -320,7 +320,13 @@ final class SyncCoordinator { /// exactly once - when the work ends or when the system's time does. private func handleBackground(_ task: BGTask) { scheduleBackgroundWork() // line up the next one before this one runs - let gate = OnceGate { task.setTaskCompleted(success: $0) } + task.expirationHandler = startBackgroundSlot { task.setTaskCompleted(success: $0) } + } + + /// Runs a background slot and returns its expiration handler. + @discardableResult + func startBackgroundSlot(completion: @escaping (Bool) -> Void) -> () -> Void { + let gate = OnceGate(completion) let accountIds = backgroundConfigs().map(\.accountId) let work = Task { [weak self] in guard let self else { return gate.finish(false) } @@ -332,7 +338,7 @@ final class SyncCoordinator { } // The system's time is up: stop the work and the runs it started (each // saves what it has) and report. - task.expirationHandler = { [weak self] in + return { [weak self] in work.cancel() for id in accountIds { self?.cancelRun(accountId: id, invalidateQueued: false) } gate.finish(false) diff --git a/ios/Runner/Native/SyncEngine/SyncDiff.swift b/ios/Runner/Native/SyncEngine/SyncDiff.swift index de4bb60..3ae8621 100644 --- a/ios/Runner/Native/SyncEngine/SyncDiff.swift +++ b/ios/Runner/Native/SyncEngine/SyncDiff.swift @@ -89,16 +89,7 @@ enum SyncDiff { for fileId in priorFolderFileIds where !seen.contains(fileId) { guard let prior = state[fileId] else { continue } - // Deleted on the server. If it was also edited here since the last sync - // that edit is the only copy left - keep it (untracked) instead of - // deleting it along with the rest. - if let local = LocalFS.stat(syncRoot.appendingPathComponent(prior.relPath)), - local.mtimeMs != prior.localMTime || local.size != prior.size - { - actions.append(.orphan(relPath: prior.relPath, fileId: fileId)) - } else { - actions.append(.delete(relPath: prior.relPath, fileId: fileId)) - } + actions.append(.delete(relPath: prior.relPath, fileId: fileId)) } return actions } diff --git a/ios/Runner/Native/SyncEngine/SyncModels.swift b/ios/Runner/Native/SyncEngine/SyncModels.swift index 19a83e6..c5becde 100644 --- a/ios/Runner/Native/SyncEngine/SyncModels.swift +++ b/ios/Runner/Native/SyncEngine/SyncModels.swift @@ -38,10 +38,6 @@ enum SyncAction: Equatable { case upload(relPath: String, fileId: String) case delete(relPath: String, fileId: String) case conflict(SyncRemoteEntry, relPath: String) - /// Deleted on the server, but edited on this device since the last sync: - /// the edited copy is the only one left, so it's kept (and simply no longer - /// tracked) instead of being deleted with the rest. Android deletes it. - case orphan(relPath: String, fileId: String) } /// A file changed both on the device and on the server since the last sync. diff --git a/ios/Runner/Native/SyncEngine/SyncRunner.swift b/ios/Runner/Native/SyncEngine/SyncRunner.swift index 7cbee73..45547f2 100644 --- a/ios/Runner/Native/SyncEngine/SyncRunner.swift +++ b/ios/Runner/Native/SyncEngine/SyncRunner.swift @@ -144,7 +144,7 @@ final class SyncStatusBus { /// edits with the server's copies). /// - Paths the server sends are checked to stay inside the mirror, and two /// remote files that would land on the same local file are both skipped. -/// - A file deleted on the server but edited here is kept, not deleted. +/// - Server deletions remove the local copy, including local edits, like Android. /// - A root's etag is only remembered once its whole path synced cleanly, and /// only if the state it relies on was actually saved. struct SyncRunner { @@ -301,11 +301,6 @@ struct SyncRunner { } state[fileId] = nil - case .orphan(_, let fileId): - // Deleted on the server but edited here: the edit stays on the - // device, no longer tracked. - state[fileId] = nil - case .conflict(let entry, let rel): summary.conflicts.append( SyncConflict( diff --git a/ios/RunnerTests/SyncEngineTests.swift b/ios/RunnerTests/SyncEngineTests.swift index 395956e..f1804dc 100644 --- a/ios/RunnerTests/SyncEngineTests.swift +++ b/ios/RunnerTests/SyncEngineTests.swift @@ -583,19 +583,19 @@ final class SyncEngineTests: XCTestCase { XCTAssertTrue(store.loadMissingRoots(accountId: "acct").isEmpty, "nor like the folder being gone") } - func testDeletedOnTheServerButEditedHereIsKept() async throws { + func testDeletedOnTheServerButEditedHereIsDeletedLikeAndroid() async throws { seedServer() _ = await runner.run(config, force: false) try editLocally("Docs/a.txt", "my only copy of this edit") FakeDav.remove("/Docs/a.txt") let summary = await runner.run(config, force: false) - XCTAssertEqual(summary.deleted, 0) - XCTAssertEqual(local("Docs/a.txt"), "my only copy of this edit") + XCTAssertEqual(summary.deleted, 1) + XCTAssertFalse(exists("Docs/a.txt")) XCTAssertNil(state().first { $0.value.relPath == "Docs/a.txt" }, "no longer tracked") let again = await runner.run(config, force: false) - XCTAssertEqual(local("Docs/a.txt"), "my only copy of this edit", "and not touched later either") + XCTAssertFalse(exists("Docs/a.txt"), "not restored on a later sync") XCTAssertFalse(again.changedAnything) } @@ -740,6 +740,36 @@ final class SyncEngineTests: XCTestCase { XCTAssertEqual(bus.snapshot().conflicts, [b], "removing A's leaves B's") } + func testBackgroundSlotOnlyRunsAccountsWithPeriodicSyncEnabled() async { + seedServer() + let configs = SyncConfigStore(service: "test-\(UUID().uuidString)") + addTeardownBlock { configs.removeAll() } + let coordinator = SyncCoordinator(store: store, bus: bus, configs: configs, client: DavSyncClient(session: fakeSession)) + var scheduled = config + scheduled.intervalMinutes = nil + scheduled.wifiOnly = false + configs.upsert(scheduled) + + let skipped = expectation(description: "manual-only account skipped") + coordinator.startBackgroundSlot { success in + XCTAssertTrue(success) + skipped.fulfill() + } + await fulfillment(of: [skipped], timeout: 10) + XCTAssertFalse(exists("Docs/a.txt")) + + scheduled.intervalMinutes = 30 + configs.upsert(scheduled) + let completed = expectation(description: "periodic account synced") + coordinator.startBackgroundSlot { success in + XCTAssertTrue(success) + completed.fulfill() + } + await fulfillment(of: [completed], timeout: 15) + XCTAssertEqual(local("Docs/a.txt"), "alpha") + XCTAssertEqual(local("Docs/sub/b.txt"), "bravo") + } + func testSignOutForgetsCredentialsButTurningBackgroundOffKeepsThem() { let service = "test-\(UUID().uuidString)" let configs = SyncConfigStore(service: service) diff --git a/lib/main.dart b/lib/main.dart index 6ed8da5..a6126b0 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -32,6 +32,7 @@ import 'views/share_upload_view.dart'; import 'widgets/app_drawer.dart'; import 'widgets/app_tab_view_builder.dart'; import 'widgets/app_top_bar.dart'; +import 'widgets/avatar_menu.dart'; import 'widgets/bottom_nav_bar.dart'; import 'widgets/create_menu.dart'; import 'widgets/noo/core/noo_avatar.dart'; @@ -210,6 +211,7 @@ class MainShellView extends StatefulWidget { class _MainShellViewState extends State { late AppTab _currentTab; + final Set _selectionActiveTabs = {}; late final Map _scrollControllers; StreamSubscription>? _shareSub; StreamSubscription? _pickSub; @@ -430,24 +432,41 @@ class _MainShellViewState extends State { // null`). final tabStack = Stack( children: [ - IndexedStack( - index: selectedIndex, - children: displayTabs - .map( - (tab) => buildAppTabView( - tab, - _scrollControllers[tab]!, - topBar: isDesktop || pickRequest != null - ? null - : AppTopBar( - style: navStyle, - tab: tab, - searchInBottomBar: showBottomBarSearch, - navMenuStyle: settings.navMenuStyle, - ), - ), - ) - .toList(), + AvatarNavigationBody( + child: IndexedStack( + index: selectedIndex, + children: displayTabs + .map( + (tab) => buildAppTabView( + tab, + _scrollControllers[tab]!, + onSelectionChanged: (selecting) { + if (!mounted || + selecting == _selectionActiveTabs.contains(tab)) { + return; + } + setState(() { + if (selecting) { + _selectionActiveTabs.add(tab); + } else { + _selectionActiveTabs.remove(tab); + } + }); + }, + topBar: isDesktop || pickRequest != null + ? null + : AppTopBar( + style: navStyle, + tab: tab, + searchInBottomBar: showBottomBarSearch, + navMenuStyle: settings.navMenuStyle, + avatarPosition: settings.avatarPosition, + uploadButtonStyle: settings.fabStyle, + ), + ), + ) + .toList(), + ), ), if (pick.isDownloadingForPick) const _PickingProgressOverlay(), ], @@ -549,13 +568,15 @@ class _MainShellViewState extends State { bottomBarStyle, settings.bottomBarFrosted, ), - // Android-only extended Upload FAB - iOS uses the top bar's `plus` + // Android-only extended Upload FAB - iOS uses the top bar's Upload // instead (see AppTopBar). Stays mounted across every tab (picking // aside) and collapses to an icon-only circle off Files/Photos, // rather than the Scaffold popping it fully in/out on every tab // switch - see NooFab's [collapsed]. floatingActionButton: - pickRequest == null && navStyle == NooNavStyle.android + pickRequest == null && + navStyle == NooNavStyle.android && + !_selectionActiveTabs.contains(selectedTab) ? NooFab( collapsed: switch (settings.fabStyle) { FabStyle.auto => !canUpload, @@ -566,7 +587,22 @@ class _MainShellViewState extends State { onTap: () => showCreateMenu(context), ) : null, - body: tabStack, + body: TweenAnimationBuilder( + tween: Tween( + end: switch (settings.fabStyle) { + FabStyle.auto => canUpload ? 1.0 : 0.0, + FabStyle.mini => 0.0, + FabStyle.expanded => 1.0, + }, + ), + duration: MediaQuery.disableAnimationsOf(context) + ? Duration.zero + : NooMotion.base, + curve: NooMotion.ease, + child: tabStack, + builder: (context, progress, child) => + UploadButtonAnimation(progress: progress, child: child!), + ), bottomNavigationBar: BottomNavBar( style: navStyle, barStyle: bottomBarStyle, @@ -576,6 +612,10 @@ class _MainShellViewState extends State { tabs: pinnedTabs, selectedIndex: pinnedIndex, onSearchTap: showBottomBarSearch ? () => openSearch(context) : null, + avatarInBottomBar: + pickRequest == null && + settings.navMenuStyle == NooNavMenuStyle.avatarMenu && + settings.avatarPosition == AvatarPosition.bottom, onDestinationSelected: (index) { final tappedTab = pinnedTabs[index]; if (tappedTab == _currentTab) { @@ -615,7 +655,7 @@ class _MainShellViewState extends State { onPopInvokedWithResult: (didPop, result) { if (!didPop && pickRequest != null) pick.cancelPick(); }, - child: scaffold, + child: isDesktop ? scaffold : AvatarNavigationHost(child: scaffold), ); } } diff --git a/lib/providers/settings_controller.dart b/lib/providers/settings_controller.dart index 46f7f92..feb0851 100644 --- a/lib/providers/settings_controller.dart +++ b/lib/providers/settings_controller.dart @@ -9,11 +9,16 @@ import '../widgets/noo/nav/noo_nav_style.dart'; /// in Settings. enum SwipeAction { none, favorite, delete, share } -/// How the Android Upload FAB is sized: [auto] expands on Files/Photos and +/// How the mobile Upload button is sized: [auto] expands on Files/Photos and /// shrinks to an icon elsewhere, [mini] is always icon-only, [expanded] /// always shows the label. enum FabStyle { auto, mini, expanded } +/// Where bulk selection actions float within the content pane. +enum SelectionBarPosition { top, bottom } + +enum AvatarPosition { top, bottom } + /// Thumb/track presets for the video player's seek bar, matching the four /// combinations offered by other Material You media players: a Material 3 /// slider-style thumb ([classic]), an animated travelling wave with a round @@ -55,6 +60,7 @@ class SettingsController extends ChangeNotifier { static const _prefUseDynamicColor = 'ui_use_dynamic_color'; static const _prefSeedColor = 'ui_seed_color'; static const _prefTabOrder = 'ui_tab_order'; + static const _prefSelectionBarPosition = 'ui_selection_bar_position'; static const _prefSelectionActionOrder = 'ui_selection_action_order'; static const _prefHiddenTabs = 'ui_hidden_tabs'; static const _prefDefaultTab = 'ui_default_tab'; @@ -65,6 +71,7 @@ class SettingsController extends ChangeNotifier { static const _prefBottomBarFrosted = 'ui_bottom_bar_frosted'; static const _prefFrostedBlur = 'ui_bottom_bar_frosted_blur'; static const _prefFrostedOpacity = 'ui_bottom_bar_frosted_opacity'; + static const _prefAvatarPosition = 'ui_avatar_position'; static const _prefNavMenuStyle = 'ui_nav_menu_style'; static const _prefSearchInBottomBar = 'ui_search_in_bottom_bar'; static const _prefAmoledDark = 'ui_amoled_dark'; @@ -78,6 +85,7 @@ class SettingsController extends ChangeNotifier { ThemeMode _themeMode = ThemeMode.system; NooBottomBarStyle _bottomBarStyle = NooBottomBarStyle.attached; NooNavMenuStyle _navMenuStyle = NooNavMenuStyle.drawer; + AvatarPosition _avatarPosition = AvatarPosition.top; bool _bottomBarFrosted = false; double _frostedBlur = FrostedGlassPreset.standard.blur; double _frostedOpacity = FrostedGlassPreset.standard.opacity; @@ -87,6 +95,7 @@ class SettingsController extends ChangeNotifier { MediaProgressBarStyle _mediaProgressBarStyle = MediaProgressBarStyle.wavy; bool _tapTabToScrollTop = true; + SelectionBarPosition _selectionBarPosition = SelectionBarPosition.top; List _tabOrder = AppTab.values.toList(); Set _hiddenTabs = {}; @@ -134,12 +143,14 @@ class SettingsController extends ChangeNotifier { FrostedGlassPreset? get frostedPreset => FrostedGlassPreset.values .where((p) => p.blur == _frostedBlur && p.opacity == _frostedOpacity) .firstOrNull; + AvatarPosition get avatarPosition => _avatarPosition; NooNavMenuStyle get navMenuStyle => _navMenuStyle; bool get searchInBottomBar => _searchInBottomBar; bool get useDynamicColor => _useDynamicColor; bool get amoledDark => _amoledDark; MediaProgressBarStyle get mediaProgressBarStyle => _mediaProgressBarStyle; bool get tapTabToScrollTop => _tapTabToScrollTop; + SelectionBarPosition get selectionBarPosition => _selectionBarPosition; /// The cap a screen should actually enforce for the *regular*, user- /// reorderable tabs - one below [defaultMaxVisibleTabs] while @@ -180,6 +191,11 @@ class SettingsController extends ChangeNotifier { orElse: () => ThemeMode.system, ); } + _selectionBarPosition = SelectionBarPosition.values.firstWhere( + (position) => + position.name == prefs.getString(_prefSelectionBarPosition), + orElse: () => SelectionBarPosition.top, + ); final bottomBarStyleName = prefs.getString(_prefBottomBarStyle); if (bottomBarStyleName != null) { _bottomBarStyle = NooBottomBarStyle.values.firstWhere( @@ -187,6 +203,10 @@ class SettingsController extends ChangeNotifier { orElse: () => _bottomBarStyle, ); } + _avatarPosition = AvatarPosition.values.firstWhere( + (position) => position.name == prefs.getString(_prefAvatarPosition), + orElse: () => AvatarPosition.top, + ); final navMenuStyleName = prefs.getString(_prefNavMenuStyle); if (navMenuStyleName != null) { _navMenuStyle = NooNavMenuStyle.values.firstWhere( @@ -356,6 +376,22 @@ class SettingsController extends ChangeNotifier { _prefsFuture.then((p) => p.setString(_prefThemeMode, mode.name)); } + void setAvatarPosition(AvatarPosition position) { + if (_avatarPosition == position) return; + _avatarPosition = position; + notifyListeners(); + _prefsFuture.then((p) => p.setString(_prefAvatarPosition, position.name)); + } + + void setSelectionBarPosition(SelectionBarPosition position) { + if (_selectionBarPosition == position) return; + _selectionBarPosition = position; + notifyListeners(); + _prefsFuture.then( + (p) => p.setString(_prefSelectionBarPosition, position.name), + ); + } + void setBottomBarStyle(NooBottomBarStyle style) { if (_bottomBarStyle == style) return; _bottomBarStyle = style; diff --git a/lib/theme/design_tokens.dart b/lib/theme/design_tokens.dart index 672fbe4..dc27a09 100644 --- a/lib/theme/design_tokens.dart +++ b/lib/theme/design_tokens.dart @@ -366,8 +366,8 @@ class NooMotion { /// The one shadow the design system allows, from `tokens/spacing.css`'s /// `--shadow-dialog` - desktop dialogs, and [NooBottomBarStyle.floating]'s -/// bar/search satellite (the only other thing in the app that's genuinely -/// floating above other content). Nothing else should use a `BoxShadow`. +/// bar/search satellite, and the floating selection action bar. +/// Nothing else should use a `BoxShadow`. const nooDialogShadow = BoxShadow( color: Color(0x241E002F), // rgba(30,0,47,.14) blurRadius: 48, diff --git a/lib/views/favorites_view.dart b/lib/views/favorites_view.dart index 3cb6c4d..4ed8e9b 100644 --- a/lib/views/favorites_view.dart +++ b/lib/views/favorites_view.dart @@ -29,6 +29,7 @@ import '../widgets/noo/overlays/noo_sheet.dart'; import '../widgets/noo/core/noo_button.dart'; import '../widgets/share_sheet.dart'; import '../widgets/sticky_header_delegate.dart'; +import '../widgets/selection_bar_overlay.dart'; import '../widgets/tabs/tab_state_slivers.dart'; import '../widgets/synced_header_scaffold.dart' show formatBytes; import 'file_viewer_screen.dart'; @@ -54,8 +55,14 @@ class FavoritesView extends StatefulWidget { /// This tab's own shell top bar, planted as its first sliver - see /// `buildAppTabView`'s doc comment. Null on desktop and while picking. final PreferredSizeWidget? topBar; + final ValueChanged? onSelectionChanged; - const FavoritesView({super.key, required this.scrollController, this.topBar}); + const FavoritesView({ + super.key, + required this.scrollController, + this.topBar, + this.onSelectionChanged, + }); @override State createState() => _FavoritesViewState(); @@ -72,10 +79,12 @@ class _FavoritesViewState extends State { setState(() { if (!_selectedIds.remove(item.id)) _selectedIds.add(item.id); }); + widget.onSelectionChanged?.call(_isSelecting); } void _clearSelection() { setState(_selectedIds.clear); + widget.onSelectionChanged?.call(false); } /// A favorited folder switches to the Files tab, navigated there; a @@ -483,6 +492,10 @@ class _FavoritesViewState extends State { @override Widget build(BuildContext context) { + final selectionAtBottom = + _isSelecting && + context.watch().selectionBarPosition == + SelectionBarPosition.bottom; final colors = context.nooColors; final isDesktop = NooLayout.isDesktop(context); final favoritesController = context.watch(); @@ -513,15 +526,17 @@ class _FavoritesViewState extends State { final contentSlivers = [ if (widget.topBar != null) topBarSliver(widget.topBar!), - SliverPersistentHeader( - pinned: !_isSelecting, - delegate: StickyHeaderDelegate( - height: _isSelecting ? 56 : 64, - child: _isSelecting - ? _buildSelectionBar(context, selectedItems) - : controlsRow, + if (!selectionAtBottom) + SliverPersistentHeader( + pinned: true, + delegate: StickyHeaderDelegate( + floating: _isSelecting, + height: _isSelecting ? 56 : 64, + child: _isSelecting + ? _buildSelectionBar(context, selectedItems) + : controlsRow, + ), ), - ), if (favoritesController.isLoading && favorites.isEmpty) const SliverFillRemaining( hasScrollBody: false, @@ -646,7 +661,13 @@ class _FavoritesViewState extends State { }, childCount: favorites.length), ), ), - SliverToBoxAdapter(child: SizedBox(height: bottomBarClearance(context))), + SliverToBoxAdapter( + child: SizedBox( + height: + bottomBarClearance(context) + + (selectionAtBottom ? SelectionBarOverlay.clearance : 0), + ), + ), ]; return PopScope( @@ -654,24 +675,29 @@ class _FavoritesViewState extends State { onPopInvokedWithResult: (didPop, result) { if (!didPop && _isSelecting) _clearSelection(); }, - child: ColoredBox( - color: colors.bg, - child: RefreshIndicator( - color: colors.accent, - backgroundColor: colors.surface, - onRefresh: favoritesController.fetchAll, - // See files_view.dart's identical fix - without this, the sticky - // controls row rides up under the status bar once the floating - // top bar above it fully collapses. - child: SafeArea( - top: true, - bottom: false, - child: CustomScrollView( - controller: widget.scrollController, - // See files_view.dart's identical fix - without this, pull-to- - // refresh can't be triggered on an empty or single-item list. - physics: const AlwaysScrollableScrollPhysics(), - slivers: contentSlivers, + child: SelectionBarOverlay( + bar: selectionAtBottom + ? _buildSelectionBar(context, selectedItems) + : null, + child: ColoredBox( + color: colors.bg, + child: RefreshIndicator( + color: colors.accent, + backgroundColor: colors.surface, + onRefresh: favoritesController.fetchAll, + // See files_view.dart's identical fix - without this, the sticky + // controls row rides up under the status bar once the floating + // top bar above it fully collapses. + child: SafeArea( + top: true, + bottom: false, + child: CustomScrollView( + controller: widget.scrollController, + // See files_view.dart's identical fix - without this, pull-to- + // refresh can't be triggered on an empty or single-item list. + physics: const AlwaysScrollableScrollPhysics(), + slivers: contentSlivers, + ), ), ), ), diff --git a/lib/views/file_viewer_screen.dart b/lib/views/file_viewer_screen.dart index 5025e9b..5857e51 100644 --- a/lib/views/file_viewer_screen.dart +++ b/lib/views/file_viewer_screen.dart @@ -428,9 +428,7 @@ class _FileViewerScreenState extends State { // The stage follows the app's own theme (`bg`) rather than being pinned // to black - a black stage in light mode read as jarringly out of place // next to the rest of the light-themed app. The floating top/action - // bars now follow the theme too (FrostedGlassContainer's own `surface` - // default), at a higher blur opacity to stay legible over arbitrary - // photo/video brightness underneath. + // bars follow the theme and share the navigation frost preference. final stageColor = colors.bg; return Scaffold( diff --git a/lib/views/files_view.dart b/lib/views/files_view.dart index f0f7088..10e9ed6 100644 --- a/lib/views/files_view.dart +++ b/lib/views/files_view.dart @@ -44,6 +44,7 @@ import '../widgets/noo/noo_layout.dart'; import '../widgets/noo/overlays/noo_sheet.dart'; import '../widgets/share_sheet.dart'; import '../widgets/sticky_header_delegate.dart'; +import '../widgets/selection_bar_overlay.dart'; import '../widgets/tabs/tab_state_slivers.dart'; import '../widgets/synced_header_scaffold.dart' show formatBytes; import 'file_viewer_screen.dart'; @@ -64,12 +65,14 @@ class FilesView extends StatefulWidget { /// This tab's own shell top bar, planted as its first sliver - see /// `buildAppTabView`'s doc comment. Null on desktop and while picking. final PreferredSizeWidget? topBar; + final ValueChanged? onSelectionChanged; const FilesView({ super.key, required this.scrollController, this.offline = false, this.topBar, + this.onSelectionChanged, }); @override @@ -337,10 +340,12 @@ class _FilesViewState extends State { setState(() { if (!_selectedIds.remove(item.id)) _selectedIds.add(item.id); }); + widget.onSelectionChanged?.call(_isSelecting); } void _clearSelection() { setState(_selectedIds.clear); + widget.onSelectionChanged?.call(false); } /// Routes a tap on an item, in picking, selecting or plain-browsing mode @@ -652,6 +657,10 @@ class _FilesViewState extends State { @override Widget build(BuildContext context) { + final selectionAtBottom = + _isSelecting && + context.watch().selectionBarPosition == + SelectionBarPosition.bottom; final colors = context.nooColors; // Display prefs (grid/list, filters, sort) live on FilesController for // both tabs; `browser` is the tab's own folder listing. @@ -835,8 +844,7 @@ class _FilesViewState extends State { child: _ExternalStorageHeader( count: externalItems.length, expanded: _externalExpanded, - onTap: () => - setState(() => _externalExpanded = !_externalExpanded), + onTap: () => setState(() => _externalExpanded = !_externalExpanded), ), ), if (_externalExpanded) @@ -854,17 +862,19 @@ class _FilesViewState extends State { // (+ breadcrumbs), while selecting it's the selection bar (see // `topRow` above): either way it's the one thing that always stays // at the very top of the scroll view. - SliverPersistentHeader( - pinned: true, - delegate: StickyHeaderDelegate( - // 20 (topRow's own top+bottom padding) + 44 (FilesControlsRow's - // fixed height) [+ 10 gap + 32 breadcrumbs height, if present]. - // Getting this wrong overflows the sliver header by exactly the - // shortfall - a real bug this shipped with once already. - height: _isSelecting ? 56 : (hasBreadcrumbs ? 106 : 64), - child: topRow, + if (!selectionAtBottom) + SliverPersistentHeader( + pinned: true, + delegate: StickyHeaderDelegate( + floating: _isSelecting, + // 20 (topRow's own top+bottom padding) + 44 (FilesControlsRow's + // fixed height) [+ 10 gap + 32 breadcrumbs height, if present]. + // Getting this wrong overflows the sliver header by exactly the + // shortfall - a real bug this shipped with once already. + height: _isSelecting ? 56 : (hasBreadcrumbs ? 106 : 64), + child: topRow, + ), ), - ), if (_offline) SliverToBoxAdapter(child: _buildOfflineSummary(context, sync)), // Files List / Grid @@ -939,12 +949,17 @@ class _FilesViewState extends State { // draws the body behind the bar instead (as does a frosted bar), so it // needs the bar's own footprint added on top or the last row ends up // under it. - if (NooBottomBar.drawsBehindBody( - context.watch().bottomBarStyle, - context.watch().bottomBarFrosted, - )) + if (selectionAtBottom || + NooBottomBar.drawsBehindBody( + context.watch().bottomBarStyle, + context.watch().bottomBarFrosted, + )) SliverToBoxAdapter( - child: SizedBox(height: bottomBarClearance(context)), + child: SizedBox( + height: + bottomBarClearance(context) + + (selectionAtBottom ? SelectionBarOverlay.clearance : 0), + ), ), ]; @@ -958,38 +973,43 @@ class _FilesViewState extends State { browser.navigateUp(); } }, - child: ColoredBox( - color: colors.bg, - child: RefreshIndicator( - color: colors.accent, - backgroundColor: colors.surface, - onRefresh: () { - unawaited(sync.syncOnPull()); - return browser.reload(); - }, - // `topBarSliver`'s floating header can collapse all the way to - // zero height (fully scrolled away), at which point the sticky - // controls row right below it in `contentSlivers` would otherwise - // ride up underneath the status bar instead of stopping below it - // - the floating top bar used to be the only thing reserving that - // space (via its own internal `SafeArea`), and that reservation - // disappears along with it once it's fully hidden. Wrapping the - // whole scroll view keeps the inset outside the scrolling region - // entirely, so it's never implicated in the floating header's own - // collapse/reveal math - safe to apply unconditionally, since - // desktop's `MediaQuery.padding.top` is 0 anyway (no topBar / no - // status bar there). - child: SafeArea( - top: true, - bottom: false, - child: CustomScrollView( - controller: widget.scrollController, - // Pull-to-refresh needs a scroll physics that allows dragging - // past the edge even when content doesn't fill the viewport - - // an empty or single-item list otherwise can't be pulled at - // all under the platform default physics. - physics: const AlwaysScrollableScrollPhysics(), - slivers: contentSlivers, + child: SelectionBarOverlay( + bar: selectionAtBottom + ? _buildSelectionBar(context, selectedItems) + : null, + child: ColoredBox( + color: colors.bg, + child: RefreshIndicator( + color: colors.accent, + backgroundColor: colors.surface, + onRefresh: () { + unawaited(sync.syncOnPull()); + return browser.reload(); + }, + // `topBarSliver`'s floating header can collapse all the way to + // zero height (fully scrolled away), at which point the sticky + // controls row right below it in `contentSlivers` would otherwise + // ride up underneath the status bar instead of stopping below it + // - the floating top bar used to be the only thing reserving that + // space (via its own internal `SafeArea`), and that reservation + // disappears along with it once it's fully hidden. Wrapping the + // whole scroll view keeps the inset outside the scrolling region + // entirely, so it's never implicated in the floating header's own + // collapse/reveal math - safe to apply unconditionally, since + // desktop's `MediaQuery.padding.top` is 0 anyway (no topBar / no + // status bar there). + child: SafeArea( + top: true, + bottom: false, + child: CustomScrollView( + controller: widget.scrollController, + // Pull-to-refresh needs a scroll physics that allows dragging + // past the edge even when content doesn't fill the viewport - + // an empty or single-item list otherwise can't be pulled at + // all under the platform default physics. + physics: const AlwaysScrollableScrollPhysics(), + slivers: contentSlivers, + ), ), ), ), diff --git a/lib/views/photos_view.dart b/lib/views/photos_view.dart index e618056..39ac518 100644 --- a/lib/views/photos_view.dart +++ b/lib/views/photos_view.dart @@ -19,7 +19,6 @@ import '../widgets/filter_mode_rows.dart'; import '../widgets/noo/core/noo_button.dart'; import '../widgets/noo/core/noo_chip.dart'; import '../widgets/noo/core/noo_segmented_control.dart'; -import '../widgets/noo/core/noo_toggle.dart'; import '../widgets/noo/files/noo_file_kind.dart'; import '../widgets/noo/lists/noo_grouped_list.dart'; import '../widgets/noo/lists/noo_selection_bar.dart'; @@ -31,6 +30,7 @@ import '../widgets/noo/overlays/noo_sheet.dart'; import '../widgets/share_sheet.dart'; import '../widgets/sort_menu_button.dart' show sortFieldLabel; import '../widgets/sticky_header_delegate.dart'; +import '../widgets/selection_bar_overlay.dart'; import '../widgets/tabs/tab_state_slivers.dart'; import 'file_viewer_screen.dart'; import 'move_copy_destination_picker.dart'; @@ -58,8 +58,14 @@ class PhotosView extends StatefulWidget { /// This tab's own shell top bar, planted as its first sliver - see /// `buildAppTabView`'s doc comment. Null on desktop and while picking. final PreferredSizeWidget? topBar; + final ValueChanged? onSelectionChanged; - const PhotosView({super.key, required this.scrollController, this.topBar}); + const PhotosView({ + super.key, + required this.scrollController, + this.topBar, + this.onSelectionChanged, + }); @override State createState() => _PhotosViewState(); @@ -82,10 +88,12 @@ class _PhotosViewState extends State { setState(() { if (!_selectedIds.remove(item.id)) _selectedIds.add(item.id); }); + widget.onSelectionChanged?.call(_isSelecting); } void _clearSelection() { setState(_selectedIds.clear); + widget.onSelectionChanged?.call(false); } /// Mirrors `FilesView._handlePickTap` - Photos has no folders, so this is @@ -215,6 +223,10 @@ class _PhotosViewState extends State { @override Widget build(BuildContext context) { + final selectionAtBottom = + _isSelecting && + context.watch().selectionBarPosition == + SelectionBarPosition.bottom; final colors = context.nooColors; final isDesktop = NooLayout.isDesktop(context); final photosController = context.watch(); @@ -248,23 +260,25 @@ class _PhotosViewState extends State { if (widget.topBar != null) topBarSliver(widget.topBar!), // Sticky while browsing; once selecting, the selection bar takes over // the same slot instead. - SliverPersistentHeader( - pinned: !_isSelecting, - delegate: StickyHeaderDelegate( - height: _isSelecting ? 56 : 64, - child: _isSelecting - ? _buildSelectionBar(context, pick, selectedItems) - : Padding( - padding: const EdgeInsets.fromLTRB( - NooSpace.sm, - NooSpace.sm, - NooSpace.sm, - NooSpace.xs, + if (!selectionAtBottom) + SliverPersistentHeader( + pinned: true, + delegate: StickyHeaderDelegate( + floating: _isSelecting, + height: _isSelecting ? 56 : 64, + child: _isSelecting + ? _buildSelectionBar(context, pick, selectedItems) + : Padding( + padding: const EdgeInsets.fromLTRB( + NooSpace.sm, + NooSpace.sm, + NooSpace.sm, + NooSpace.xs, + ), + child: controlsRow, ), - child: controlsRow, - ), + ), ), - ), if (photosController.isLoading && allPhotos.isEmpty) const SliverFillRemaining( @@ -360,7 +374,13 @@ class _PhotosViewState extends State { // Clearance so the last row isn't hidden behind the nav bar, // regardless of grid length - see `bottomBarClearance`'s own doc // comment for why this has to be dynamic rather than a flat 100. - SliverToBoxAdapter(child: SizedBox(height: bottomBarClearance(context))), + SliverToBoxAdapter( + child: SizedBox( + height: + bottomBarClearance(context) + + (selectionAtBottom ? SelectionBarOverlay.clearance : 0), + ), + ), ]; return PopScope( @@ -368,24 +388,29 @@ class _PhotosViewState extends State { onPopInvokedWithResult: (didPop, result) { if (!didPop && _isSelecting) _clearSelection(); }, - child: ColoredBox( - color: colors.bg, - child: RefreshIndicator( - color: colors.accent, - backgroundColor: colors.surface, - onRefresh: photosController.fetchAllMedia, - // See files_view.dart's identical fix - without this, the sticky - // controls row rides up under the status bar once the floating - // top bar above it fully collapses. - child: SafeArea( - top: true, - bottom: false, - child: CustomScrollView( - controller: widget.scrollController, - // See files_view.dart's identical fix - without this, pull-to- - // refresh can't be triggered on an empty or single-item list. - physics: const AlwaysScrollableScrollPhysics(), - slivers: contentSlivers, + child: SelectionBarOverlay( + bar: selectionAtBottom + ? _buildSelectionBar(context, pick, selectedItems) + : null, + child: ColoredBox( + color: colors.bg, + child: RefreshIndicator( + color: colors.accent, + backgroundColor: colors.surface, + onRefresh: photosController.fetchAllMedia, + // See files_view.dart's identical fix - without this, the sticky + // controls row rides up under the status bar once the floating + // top bar above it fully collapses. + child: SafeArea( + top: true, + bottom: false, + child: CustomScrollView( + controller: widget.scrollController, + // See files_view.dart's identical fix - without this, pull-to- + // refresh can't be triggered on an empty or single-item list. + physics: const AlwaysScrollableScrollPhysics(), + slivers: contentSlivers, + ), ), ), ), @@ -554,17 +579,32 @@ class _PhotosViewState extends State { ), ), const SizedBox(height: 22), - NooGroupedList( - children: [ - NooSettingsRow( - icon: LucideIcons.heart, - label: const Text('Favorites only'), - trailing: NooToggle( - checked: photos.showFavoritesOnly, - onChanged: (_) => photos.toggleFavoritesFilter(), + FilterSection( + icon: LucideIcons.heart, + title: 'Favorites', + child: NooSegmentedControl( + fill: true, + onSurface: true, + labelOnlySelected: true, + value: photos.showFavoritesOnly, + onChanged: (value) { + if (value != photos.showFavoritesOnly) { + photos.toggleFavoritesFilter(); + } + }, + options: const [ + NooSegmentOption( + value: false, + icon: LucideIcons.layers, + label: 'All', ), - ), - ], + NooSegmentOption( + value: true, + icon: LucideIcons.heart, + label: 'Favorites only', + ), + ], + ), ), const SizedBox(height: 22), HiddenFilesFilterRow( diff --git a/lib/widgets/app_tab_view_builder.dart b/lib/widgets/app_tab_view_builder.dart index 9be3456..c9ff2d5 100644 --- a/lib/widgets/app_tab_view_builder.dart +++ b/lib/widgets/app_tab_view_builder.dart @@ -24,6 +24,7 @@ Widget buildAppTabView( AppTab tab, ScrollController controller, { PreferredSizeWidget? topBar, + ValueChanged? onSelectionChanged, }) { switch (tab) { case AppTab.files: @@ -33,11 +34,20 @@ Widget buildAppTabView( key: const ValueKey('files'), scrollController: controller, topBar: topBar, + onSelectionChanged: onSelectionChanged, ); case AppTab.photos: - return PhotosView(scrollController: controller, topBar: topBar); + return PhotosView( + scrollController: controller, + topBar: topBar, + onSelectionChanged: onSelectionChanged, + ); case AppTab.favorites: - return FavoritesView(scrollController: controller, topBar: topBar); + return FavoritesView( + scrollController: controller, + topBar: topBar, + onSelectionChanged: onSelectionChanged, + ); case AppTab.activity: return ActivityView(scrollController: controller, topBar: topBar); case AppTab.trash: @@ -52,6 +62,7 @@ Widget buildAppTabView( scrollController: controller, offline: true, topBar: topBar, + onSelectionChanged: onSelectionChanged, ); } } diff --git a/lib/widgets/app_top_bar.dart b/lib/widgets/app_top_bar.dart index 8fa27a4..7cc293c 100644 --- a/lib/widgets/app_top_bar.dart +++ b/lib/widgets/app_top_bar.dart @@ -1,9 +1,12 @@ import 'package:flutter/material.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../models/app_tab.dart'; +import '../providers/settings_controller.dart'; import 'avatar_menu.dart'; import 'create_menu.dart'; import 'noo/nav/noo_top_bar.dart'; +import 'noo/core/noo_button.dart'; +import '../theme/design_tokens.dart'; import 'shell/shell_common.dart'; /// The shell's mobile top bar (DESIGN_SYSTEM.md 3, "Mobile"): every regular @@ -11,9 +14,9 @@ import 'shell/shell_common.dart'; /// tabs built their own via `SyncedHeaderScaffold`, which the rework drops). /// Built on [NooTopBar]: /// -/// - iOS: `menu` leading, `plus` (Files only - no tab besides Files has a -/// create/upload flow) and the account avatar trailing, then the large -/// title, then an inline search field that opens [SearchView]. +/// - iOS: large title, Upload button and account avatar in one row, then +/// an inline search field that opens [SearchView]. Upload shares Android's +/// Auto/Collapsed/Expanded preference. /// - Android: `menu`, title, avatar actions. Upload has no top-bar icon /// here - it's the [NooFab] `MainShellView` shows instead. When search /// isn't in the bottom bar, an inline search-field-styled launcher @@ -37,6 +40,8 @@ class AppTopBar extends StatelessWidget implements PreferredSizeWidget { final AppTab tab; final bool searchInBottomBar; final NooNavMenuStyle navMenuStyle; + final AvatarPosition avatarPosition; + final FabStyle uploadButtonStyle; const AppTopBar({ super.key, @@ -44,15 +49,23 @@ class AppTopBar extends StatelessWidget implements PreferredSizeWidget { required this.tab, this.searchInBottomBar = false, this.navMenuStyle = NooNavMenuStyle.drawer, + this.avatarPosition = AvatarPosition.top, + this.uploadButtonStyle = FabStyle.auto, }); bool get _ios => style == NooNavStyle.ios; bool get _showSearch => !searchInBottomBar; + bool get _uploadCollapsed => switch (uploadButtonStyle) { + FabStyle.auto => tab != AppTab.files && tab != AppTab.photos, + FabStyle.mini => true, + FabStyle.expanded => false, + }; @override Size get preferredSize => NooTopBar( style: style, title: tab.label, + iosActionsInTitleRow: true, // A throwaway placeholder purely to make preferredSize's "is there a // search slot" check match what build() actually shows - never // inserted into the tree, so its content doesn't matter. @@ -64,6 +77,7 @@ class AppTopBar extends StatelessWidget implements PreferredSizeWidget { return NooTopBar( style: style, title: tab.label, + iosActionsInTitleRow: true, onMenu: navMenuStyle == NooNavMenuStyle.drawer ? () => Scaffold.of(context).openDrawer() : null, @@ -74,23 +88,69 @@ class AppTopBar extends StatelessWidget implements PreferredSizeWidget { ? const ShellSearchLauncher() : null, actions: [ - if (_ios && tab == AppTab.files) - NooTopBarButton( - icon: LucideIcons.plus, - tooltip: 'Create', - onTap: () => showCreateMenu(context), + if (_ios) + Tooltip( + message: 'Upload', + child: Semantics( + button: true, + label: 'Upload', + child: Builder( + builder: (context) { + Widget button(double progress) => NooButton( + variant: NooButtonVariant.tonal, + size: NooButtonSize.compact, + icon: LucideIcons.plus, + expansion: progress, + onTap: () => showCreateMenu(context), + child: const Text('Upload'), + ); + final scope = context + .dependOnInheritedWidgetOfExactType< + UploadButtonAnimation + >(); + if (scope != null) return button(scope.progress); + return TweenAnimationBuilder( + tween: Tween(end: _uploadCollapsed ? 0 : 1), + duration: MediaQuery.disableAnimationsOf(context) + ? Duration.zero + : NooMotion.base, + curve: NooMotion.ease, + builder: (context, progress, _) => button(progress), + ); + }, + ), + ), + ), + if (navMenuStyle != NooNavMenuStyle.avatarMenu || + avatarPosition == AvatarPosition.top) + AvatarNavigationAnchor( + child: Builder( + builder: (anchorContext) => ShellAvatarButton( + hitBox: _ios ? 44 : 48, + onTap: navMenuStyle == NooNavMenuStyle.avatarMenu + ? () => showAvatarMenu(anchorContext) + : null, + label: navMenuStyle == NooNavMenuStyle.avatarMenu + ? 'Menu' + : 'Accounts', + ), + ), ), - ShellAvatarButton( - hitBox: _ios ? 44 : 48, - onTap: navMenuStyle == NooNavMenuStyle.avatarMenu - ? () => showAvatarMenu(context) - : null, - label: navMenuStyle == NooNavMenuStyle.avatarMenu - ? 'Menu' - : 'Accounts', - ), ], search: _ios && _showSearch ? const ShellSearchLauncher(ios: true) : null, ); } } + +/// Shares one transition across the shell's separately mounted tab headers. +class UploadButtonAnimation extends InheritedWidget { + final double progress; + const UploadButtonAnimation({ + super.key, + required this.progress, + required super.child, + }); + @override + bool updateShouldNotify(UploadButtonAnimation oldWidget) => + progress != oldWidget.progress; +} diff --git a/lib/widgets/avatar_menu.dart b/lib/widgets/avatar_menu.dart index d20af01..b7d00c2 100644 --- a/lib/widgets/avatar_menu.dart +++ b/lib/widgets/avatar_menu.dart @@ -1,3 +1,4 @@ +import 'dart:ui' show ImageFilter, lerpDouble; import 'package:flutter/material.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:provider/provider.dart'; @@ -6,53 +7,83 @@ import '../providers/session_controller.dart'; import '../providers/settings_controller.dart'; import '../providers/trash_controller.dart'; import '../theme/design_tokens.dart'; -import 'noo/core/noo_avatar.dart'; import '../views/login_view.dart'; +import 'noo/core/noo_avatar.dart'; import 'noo/core/noo_badge.dart'; +import 'noo/nav/noo_bottom_bar.dart'; +import 'noo/noo_layout.dart'; import 'noo/lists/noo_settings_row.dart'; import 'shell/shell_common.dart'; -/// The dropdown [ShellAvatarButton] opens when -/// [SettingsController.navMenuStyle] is [NooNavMenuStyle.avatarMenu] - -/// everything [AppDrawer] holds beyond its storage bar (hidden tabs, -/// Settings), reached from the avatar instead of a hamburger-triggered -/// left `Drawer`. Built with `showGeneralDialog` rather than a `Drawer`/ -/// `showNooSheet`/`showNooDialog` - this needs a transparent (non-dimming) -/// barrier and a card anchored under the top bar rather than a modal -/// sheet/dialog, and there's no existing anchored-popup primitive in this -/// app to reuse (`PopupMenuButton`'s own width doesn't stretch to the full -/// content column the way this needs to). -Future showAvatarMenu(BuildContext context) { - return showGeneralDialog( +/// The shell supplies the expanding-card presentation. Standalone top bars +/// retain a dialog fallback (for screens outside the shell). +Future showAvatarMenu(BuildContext context) async { + final scope = AvatarNavigationScope.maybeOf(context); + if (scope != null) { + scope._host.openFrom(context); + return; + } + await showGeneralDialog( context: context, barrierColor: Colors.transparent, barrierDismissible: true, barrierLabel: 'Close menu', - transitionDuration: NooMotion.fast, - pageBuilder: (context, _, _) => const _AvatarMenuContent(), + transitionDuration: NooMotion.base, + pageBuilder: (context, _, _) => Align( + alignment: Alignment.topCenter, + child: SafeArea( + bottom: false, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: NooSpace.sm), + child: _StandaloneAvatarCard(), + ), + ), + ), transitionBuilder: (context, animation, _, child) => FadeTransition( opacity: animation, child: ScaleTransition( alignment: Alignment.topRight, - scale: Tween( - begin: 0.96, - end: 1, - ).animate(CurvedAnimation(parent: animation, curve: NooMotion.ease)), + scale: Tween(begin: .88, end: 1.0).animate( + CurvedAnimation(parent: animation, curve: Curves.easeOutCubic), + ), child: child, ), ), ); } -class _AvatarMenuContent extends StatefulWidget { - const _AvatarMenuContent(); - +class _StandaloneAvatarCard extends StatefulWidget { @override - State<_AvatarMenuContent> createState() => _AvatarMenuContentState(); + State<_StandaloneAvatarCard> createState() => _StandaloneAvatarCardState(); } -class _AvatarMenuContentState extends State<_AvatarMenuContent> { - bool _expanded = false; +class _StandaloneAvatarCardState extends State<_StandaloneAvatarCard> { + bool expanded = false; + @override + Widget build(BuildContext context) => AvatarMenuCard( + expanded: expanded, + onExpand: () => setState(() => expanded = !expanded), + onClose: () => Navigator.pop(context), + ); +} + +/// Account controls and hidden-tab navigation, shared by both card positions. +class AvatarMenuCard extends StatelessWidget { + final bool bottom; + final bool expanded; + final bool movingAvatar; + final bool showHeaderAvatar; + final VoidCallback onExpand; + final VoidCallback onClose; + const AvatarMenuCard({ + super.key, + this.bottom = false, + this.movingAvatar = false, + this.showHeaderAvatar = true, + required this.expanded, + required this.onExpand, + required this.onClose, + }); @override Widget build(BuildContext context) { @@ -60,226 +91,310 @@ class _AvatarMenuContentState extends State<_AvatarMenuContent> { final session = context.watch(); final settings = context.watch(); final trashCount = context.watch().items.length; - final hiddenTabs = settings.tabOrder - .where((t) => settings.hiddenTabs.contains(t)) - .toList(); - - void closeAndOpenSettings() { - Navigator.pop(context); + void settingsTap() { + onClose(); openSettings(context); } - return Align( - alignment: Alignment.topCenter, - // Just the status-bar inset, not the top bar's own height on top of - // it - the card covers the top bar (title included) rather than - // sitting below it, so opening it reads as the avatar growing into - // this instead of a separate element appearing underneath the row - // it came from. - child: SafeArea( - bottom: false, - child: Padding( - // Same side inset as the Files list's rows (`NooSpace.sm`), so the - // card is as wide as the list beneath it. - padding: const EdgeInsets.symmetric(horizontal: NooSpace.sm), - child: SizedBox( - width: double.infinity, - child: Container( - // No `color`/`clipBehavior` here - a `Container` with both a - // `border` and `clipBehavior` set paints the border as part - // of its *outer* decoration, then paints its (clipped) child - // on top right up to that same boundary with no gap for the - // border's own stroke width to show through. An opaque child - // touching that edge - every row below has its own full-bleed - // `Material` fill - then paints straight over the inner half - // of the border, which read as the border going missing - // specifically wherever an opaque row sits (every corner but - // the two by the header, which has no opaque fill of its - // own). The 1px `Padding` + inset `ClipRRect` below keeps the - // clipped, filled content entirely inside the border's own - // stroke instead of racing it for the same pixels. - decoration: BoxDecoration( - border: Border.all(color: colors.line), - borderRadius: BorderRadius.circular(NooRadii.card), - // `nooDialogShadow` alone is a wide, soft, fairly faint - // shadow - built for a desktop dialog with plenty of room - // to fall off into. On a small card over a dark theme's - // near-black `bg`, that falloff is too gradual to read as - // elevation at all (a dark shadow needs real density close - // to the edge to be visible against an already-dark - // backdrop). A second, tighter, more opaque contact shadow - // underneath it gives an immediate value-step right at the - // card's edge in both themes, with the soft one still - // doing the wider ambient falloff on top. - boxShadow: const [ - BoxShadow( - color: Color(0x40000000), - blurRadius: 12, - offset: Offset(0, 4), - ), - nooDialogShadow, - ], - ), - child: Padding( - padding: const EdgeInsets.all(1), - child: ClipRRect( - borderRadius: BorderRadius.circular(NooRadii.card - 1), - child: Material( - color: colors.surface, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - // Avatar on the right, name/host on the left - mirrors the - // top bar's own right-aligned avatar exactly, so this - // header reads as a continuation of the button that - // opened it rather than a disconnected card. - InkWell( - onTap: () => setState(() => _expanded = !_expanded), - child: Padding( - padding: const EdgeInsets.all(NooSpace.md), - child: Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - Text( - session.username, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: NooText.bodyL.copyWith( - fontWeight: FontWeight.w600, - color: colors.fg1, - ), - ), - const SizedBox(height: 2), - Text( - serverHost(session.serverUrl), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: NooText.meta.copyWith( - color: colors.fg3, - ), - ), - ], - ), - ), - const SizedBox(width: 12), - NooAvatar( - initials: accountInitial(session.username), - current: true, - size: 40, - ), - const SizedBox(width: 8), - Container( - width: 36, - height: 36, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: colors.fg1.withValues(alpha: 0.08), - ), - child: Icon( - _expanded - ? LucideIcons.chevronUp - : LucideIcons.chevronDown, - size: 20, - color: colors.fg2, - ), - ), - ], - ), - ), - ), - AnimatedSize( - duration: NooMotion.fast, - curve: NooMotion.ease, - alignment: Alignment.topCenter, - child: _expanded - ? Column( - children: [ - for (final account in session.accounts) - if (account.id != session.activeAccountId) - _OtherAccountRow( - name: account.username, - host: serverHost(account.serverUrl), - onTap: () { - Navigator.pop(context); - session.switchAccount(account.id); - }, - ), - Padding( - padding: const EdgeInsets.fromLTRB( - NooSpace.md, - 0, - NooSpace.md, - NooSpace.md, - ), - child: Row( - spacing: 8, - children: [ - Expanded( - child: _AccountButton( - icon: LucideIcons.userPlus, - label: 'Add Account', - onTap: () { - final nav = Navigator.of( - context, - ); - nav.pop(); - nav.push( - MaterialPageRoute( - builder: (_) => - const LoginView( - isAddingAccount: true, - ), - ), - ); - }, - ), - ), - Expanded( - child: _AccountButton( - icon: LucideIcons.users, - label: 'Manage Accounts', - onTap: closeAndOpenSettings, - ), - ), - ], - ), - ), - ], - ) - : const SizedBox(width: double.infinity), - ), - if (hiddenTabs.isNotEmpty) ...[ - Divider(height: 1, color: colors.line), - for (final tab in hiddenTabs) - NooSettingsRow( - icon: tab.icon, - label: Text(tab.label), - trailing: tab == AppTab.trash && trashCount > 0 - ? NooBadge( - tone: NooBadgeTone.accent, - child: Text('$trashCount'), - ) - : null, - onTap: () { - Navigator.pop(context); - settings.requestTab(tab); - }, - ), - ], - Divider(height: 1, color: colors.line), - NooSettingsRow( - icon: LucideIcons.settings, - label: const Text('Settings'), - onTap: closeAndOpenSettings, - ), - ], + final header = InkWell( + onTap: onExpand, + child: Padding( + padding: const EdgeInsets.all(NooSpace.md), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + session.username, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: NooText.bodyL.copyWith( + fontWeight: FontWeight.w600, + color: colors.fg1, ), ), + const SizedBox(height: 2), + Text( + serverHost(session.serverUrl), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: NooText.meta.copyWith(color: colors.fg3), + ), + ], + ), + ), + Icon( + expanded ? LucideIcons.chevronUp : LucideIcons.chevronDown, + size: 20, + color: colors.fg2, + ), + if (showHeaderAvatar) const SizedBox(width: 12), + if (showHeaderAvatar) + movingAvatar + ? const SizedBox.square(dimension: 32) + : NooAvatar( + initials: accountInitial(session.username), + current: true, + size: 32, + ), + ], + ), + ), + ); + final attached = bottom && !showHeaderAvatar; + final accountSection = AnimatedSize( + duration: NooMotion.base, + curve: Curves.easeOutCubic, + alignment: bottom ? Alignment.bottomCenter : Alignment.topCenter, + child: expanded + ? Column( + children: [ + for (final account in session.accounts) + if (account.id != session.activeAccountId) + _OtherAccountRow( + name: account.username, + host: serverHost(account.serverUrl), + onTap: () { + onClose(); + session.switchAccount(account.id); + }, + ), + Padding( + padding: const EdgeInsets.all(NooSpace.md), + child: Row( + spacing: 8, + children: [ + Expanded( + child: _AccountButton( + icon: LucideIcons.userPlus, + label: 'Add Account', + onTap: () { + final nav = Navigator.of(context); + onClose(); + nav.push( + MaterialPageRoute( + builder: (_) => + const LoginView(isAddingAccount: true), + ), + ); + }, + ), + ), + Expanded( + child: _AccountButton( + icon: LucideIcons.users, + label: 'Manage Accounts', + onTap: settingsTap, + ), + ), + ], + ), ), + ], + ) + : const SizedBox(width: double.infinity), + ); + final rows = Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (!attached) accountSection, + for (final tab in settings.tabOrder.where(settings.hiddenTabs.contains)) + NooSettingsRow( + backgroundColor: Colors.transparent, + icon: tab.icon, + label: Text(tab.label), + trailing: tab == AppTab.trash && trashCount > 0 + ? NooBadge( + tone: NooBadgeTone.accent, + child: Text('$trashCount'), + ) + : null, + onTap: () { + onClose(); + settings.requestTab(tab); + }, + ), + Divider(height: 1, color: colors.line), + NooSettingsRow( + backgroundColor: Colors.transparent, + icon: LucideIcons.settings, + label: const Text('Settings'), + onTap: settingsTap, + ), + if (attached) ...[header, accountSection], + ], + ); + final content = SizedBox( + width: double.infinity, + child: Padding( + padding: const EdgeInsets.all(1), + child: ClipRRect( + borderRadius: BorderRadius.circular(NooRadii.card - 1), + child: Material( + color: Colors.transparent, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (!bottom) header, + Flexible( + fit: FlexFit.loose, + child: SingleChildScrollView(child: rows), + ), + if (bottom && !attached) header, + ], + ), + ), + ), + ), + ); + return movingAvatar + ? content + : _AvatarSurface( + radius: BorderRadius.circular(NooRadii.card), + child: content, + ); + } +} + +class _AvatarSurface extends StatelessWidget { + final BorderRadius radius; + final Widget child; + final bool connected; + const _AvatarSurface({ + required this.radius, + required this.child, + this.connected = false, + }); + + @override + Widget build(BuildContext context) { + final settings = context.watch(); + final colors = context.nooColors; + final frosted = settings.bottomBarFrosted; + final surface = DecoratedBox( + decoration: BoxDecoration( + color: frosted + ? colors.surface.withValues(alpha: settings.bottomBarFrostedOpacity) + : colors.surface, + border: connected + ? null + : Border.all(color: colors.line), + borderRadius: radius, + ), + child: Padding(padding: const EdgeInsets.all(1), child: child), + ); + return DecoratedBox( + decoration: BoxDecoration( + borderRadius: radius, + boxShadow: frosted || connected ? null : const [nooDialogShadow], + ), + child: ClipRRect( + borderRadius: radius, + child: frosted + ? BackdropFilter( + filter: ImageFilter.blur( + sigmaX: settings.bottomBarFrostedBlur, + sigmaY: settings.bottomBarFrostedBlur, + ), + child: surface, + ) + : surface, + ), + ); + } +} + +class AvatarNavigationScope extends InheritedNotifier { + final _AvatarNavigationHostState _host; + AvatarNavigationScope._({ + required _AvatarNavigationHostState host, + required super.child, + }) : _host = host, + super(notifier: host.animation); + static AvatarNavigationScope? maybeOf(BuildContext context) => + context.dependOnInheritedWidgetOfExactType(); + double get progress => + Curves.easeInOutCubicEmphasized.transform(_host.animation.value); + bool get bottom => _host.bottom; + void close() => _host.close(); + @override + bool updateShouldNotify(AvatarNavigationScope oldWidget) => true; +} + +/// Keeps the top avatar's layout slot while the single animated avatar moves +/// into the card. Its own context gives us the real screen-space origin. +class AvatarNavigationAnchor extends StatelessWidget { + final Widget child; + const AvatarNavigationAnchor({super.key, required this.child}); + @override + Widget build(BuildContext context) { + final scope = AvatarNavigationScope.maybeOf(context); + return IgnorePointer( + ignoring: scope != null && scope.progress > 0, + child: ClipRect( + child: Align( + alignment: Alignment.centerRight, + widthFactor: 1 - (scope?.progress ?? 0), + child: Opacity( + opacity: scope != null && scope.progress > 0 ? 0 : 1, + child: child, + ), + ), + ), + ); + } +} + +/// Only the content pane moves; the bottom navigation stays anchored. +class AvatarNavigationBody extends StatelessWidget { + final Widget child; + const AvatarNavigationBody({super.key, required this.child}); + @override + Widget build(BuildContext context) { + final scope = AvatarNavigationScope.maybeOf(context); + return Padding( + padding: EdgeInsets.only( + top: scope == null || scope.bottom + ? 0 + : (scope._host.cardHeight + 12) * scope.progress, + ), + child: child, + ); + } +} + +class AvatarNavigationSatellite extends StatelessWidget { + final double size; + const AvatarNavigationSatellite({super.key, required this.size}); + @override + Widget build(BuildContext context) { + final scope = AvatarNavigationScope.maybeOf(context); + if (context.watch().bottomBarStyle == + NooBottomBarStyle.attached) { + return Builder( + builder: (anchorContext) => ShellAvatarButton( + hitBox: size, + label: 'Menu', + onTap: () => showAvatarMenu(anchorContext), + ), + ); + } + return IgnorePointer( + ignoring: scope != null && scope.progress > 0, + child: Opacity( + opacity: scope != null && scope.progress > 0 ? 0 : 1, + child: SizedBox( + width: size, + height: size, + child: _AvatarSurface( + radius: BorderRadius.circular(size / 2), + child: Builder( + builder: (anchorContext) => ShellAvatarButton( + hitBox: size, + label: 'Menu', + onTap: () => showAvatarMenu(anchorContext), ), ), ), @@ -289,6 +404,273 @@ class _AvatarMenuContentState extends State<_AvatarMenuContent> { } } +class AvatarNavigationHost extends StatefulWidget { + final Widget child; + const AvatarNavigationHost({super.key, required this.child}); + @override + State createState() => _AvatarNavigationHostState(); +} + +class _AvatarNavigationHostState extends State + with SingleTickerProviderStateMixin { + late final AnimationController animation = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 420), + reverseDuration: const Duration(milliseconds: 320), + ); + final GlobalKey measureKey = GlobalKey(); + Rect? origin; + bool bottom = false; + bool expanded = false; + double cardHeight = 150; + bool measuring = false; + void measure() { + if (measuring) return; + measuring = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + measuring = false; + if (!mounted) return; + final box = measureKey.currentContext?.findRenderObject() as RenderBox?; + if (box != null && (box.size.height - cardHeight).abs() > .5) { + setState(() => cardHeight = box.size.height); + } + }); + } + + void openFrom(BuildContext context) { + if (animation.value > 0) { + close(); + return; + } + final settings = context.read(); + final box = context.findRenderObject() as RenderBox?; + if (box == null) return; + final hostBox = this.context.findRenderObject() as RenderBox; + final center = hostBox.globalToLocal( + box.localToGlobal(box.size.center(Offset.zero)), + ); + setState(() { + bottom = settings.avatarPosition == AvatarPosition.bottom; + final diameter = bottom + ? NooBottomBar.rowHeight( + NooLayout.navStyle(context), + settings.bottomBarStyle, + ) + : 32.0; + origin = Rect.fromCenter( + center: center, + width: diameter, + height: diameter, + ); + expanded = false; + }); + animation.duration = MediaQuery.disableAnimationsOf(context) + ? Duration.zero + : const Duration(milliseconds: 420); + animation.forward(); + } + + void close() { + animation.reverseDuration = MediaQuery.disableAnimationsOf(context) + ? Duration.zero + : const Duration(milliseconds: 320); + animation.reverse(); + } + + @override + void dispose() { + animation.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final settings = context.watch(); + final session = context.watch(); + final enabled = settings.navMenuStyle == NooNavMenuStyle.avatarMenu; + return AvatarNavigationScope._( + host: this, + child: AnimatedBuilder( + animation: animation, + builder: (context, _) { + final progress = Curves.easeInOutCubicEmphasized.transform( + animation.value, + ); + return PopScope( + canPop: animation.value == 0, + onPopInvokedWithResult: (didPop, _) { + if (!didPop) close(); + }, + child: LayoutBuilder( + builder: (context, constraints) { + final width = constraints.maxWidth; + final height = constraints.maxHeight; + final safeTop = MediaQuery.viewPaddingOf(context).top; + final attached = + bottom && + settings.bottomBarStyle == NooBottomBarStyle.attached; + final bottomEdge = bottom && origin != null + ? origin!.center.dy - + NooBottomBar.rowHeight( + NooLayout.navStyle(context), + settings.bottomBarStyle, + ) / + 2 - + (attached ? 0 : 12) + : height; + final maxHeight = (bottomEdge - safeTop - 24).clamp( + 72.0, + height * .8, + ); + measure(); + Widget card() => ConstrainedBox( + constraints: BoxConstraints(maxHeight: maxHeight), + child: AvatarMenuCard( + bottom: bottom, + movingAvatar: true, + showHeaderAvatar: !attached, + expanded: expanded, + onExpand: () => setState(() => expanded = !expanded), + onClose: close, + ), + ); + final target = Rect.fromLTWH( + attached ? 0 : 12, + bottom ? bottomEdge - cardHeight : safeTop + 8, + attached ? width : width - 24, + cardHeight, + ); + final rect = Rect.lerp( + attached + ? Rect.fromLTWH(0, bottomEdge, width, 0) + : origin ?? target, + target, + progress, + )!; + final radius = attached + ? BorderRadius.vertical( + top: Radius.circular(NooRadii.card * progress), + ) + : BorderRadius.circular( + lerpDouble( + (origin?.width ?? 32) / 2, + NooRadii.card, + progress, + )!, + ); + final avatarTarget = Rect.fromLTWH( + target.right - 49, + bottom ? target.bottom - 49 : target.top + 17, + 32, + 32, + ); + final avatar = Rect.lerp( + origin == null + ? avatarTarget + : Rect.fromCenter( + center: origin!.center, + width: 32, + height: 32, + ), + avatarTarget, + progress, + )!; + return Stack( + children: [ + widget.child, + if (enabled) + Positioned( + left: attached ? 0 : 12, + right: attached ? 0 : 12, + top: safeTop, + child: Offstage( + child: + NotificationListener< + SizeChangedLayoutNotification + >( + onNotification: (_) { + measure(); + return false; + }, + child: SizeChangedLayoutNotifier( + child: KeyedSubtree( + key: measureKey, + child: card(), + ), + ), + ), + ), + ), + if (enabled && progress > 0 && origin != null) ...[ + Positioned.fill( + bottom: bottom ? height - bottomEdge : 0, + child: Semantics( + label: 'Close menu', + button: true, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: close, + child: const ColoredBox(color: Colors.transparent), + ), + ), + ), + Positioned.fromRect( + rect: rect, + child: _AvatarSurface( + radius: radius, + connected: attached, + child: ClipRRect( + borderRadius: radius, + child: OverflowBox( + alignment: bottom + ? Alignment.bottomRight + : Alignment.topRight, + minWidth: target.width, + maxWidth: target.width, + minHeight: target.height, + maxHeight: target.height, + child: IgnorePointer( + ignoring: progress < .95, + child: Opacity( + opacity: ((progress - .3) / .7).clamp( + 0.0, + 1.0, + ), + child: card(), + ), + ), + ), + ), + ), + ), + if (!attached) + Positioned.fromRect( + rect: avatar, + child: Semantics( + label: 'Close menu', + button: true, + child: GestureDetector( + onTap: close, + child: NooAvatar( + initials: accountInitial(session.username), + current: true, + size: 32, + ), + ), + ), + ), + ], + ], + ); + }, + ), + ); + }, + ), + ); + } +} + class _OtherAccountRow extends StatelessWidget { final String name; final String host; @@ -333,7 +715,7 @@ class _OtherAccountRow extends StatelessWidget { ), ), const SizedBox(width: 12), - NooAvatar(initials: accountInitial(name), current: false, size: 40), + NooAvatar(initials: accountInitial(name), current: false, size: 32), ], ), ), diff --git a/lib/widgets/bottom_nav_bar.dart b/lib/widgets/bottom_nav_bar.dart index 15418a9..f920a1b 100644 --- a/lib/widgets/bottom_nav_bar.dart +++ b/lib/widgets/bottom_nav_bar.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../models/app_tab.dart'; import 'noo/nav/noo_bottom_bar.dart'; +import 'avatar_menu.dart'; /// Adapts the app's [AppTab] model onto [NooBottomBar]'s generic /// [NooNavDestination] list, so `MainShellView` only deals in tabs and @@ -23,6 +24,7 @@ class BottomNavBar extends StatelessWidget { final int selectedIndex; final ValueChanged onDestinationSelected; final VoidCallback? onSearchTap; + final bool avatarInBottomBar; const BottomNavBar({ super.key, @@ -35,6 +37,7 @@ class BottomNavBar extends StatelessWidget { required this.selectedIndex, required this.onDestinationSelected, this.onSearchTap, + this.avatarInBottomBar = false, }); @override @@ -50,11 +53,27 @@ class BottomNavBar extends StatelessWidget { NooNavDestination(icon: tab.icon, label: tab.label), ], selectedIndex: selectedIndex, - onSelected: onDestinationSelected, + onSelected: (index) { + AvatarNavigationScope.maybeOf(context)?.close(); + onDestinationSelected(index); + }, searchDestination: onSearchTap == null ? null : const NooNavDestination(icon: LucideIcons.search, label: 'Search'), - onSearchTap: onSearchTap, + onSearchTap: onSearchTap == null + ? null + : () { + AvatarNavigationScope.maybeOf(context)?.close(); + onSearchTap!(); + }, + avatarSatellite: avatarInBottomBar + ? AvatarNavigationSatellite( + size: NooBottomBar.rowHeight(style, barStyle), + ) + : null, + avatarMenuProgress: avatarInBottomBar + ? AvatarNavigationScope.maybeOf(context)?.progress ?? 0 + : 0, ); } } diff --git a/lib/widgets/frosted_glass_container.dart b/lib/widgets/frosted_glass_container.dart index 7b373fb..7de929b 100644 --- a/lib/widgets/frosted_glass_container.dart +++ b/lib/widgets/frosted_glass_container.dart @@ -1,18 +1,11 @@ import 'dart:ui'; import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../providers/settings_controller.dart'; import '../theme/design_tokens.dart'; -/// The blurred/translucent chrome for the media viewer's overlay bars -/// (`file_viewer_screen.dart`'s top bar, `MediaActionBar`, the video -/// transport row) - the one deliberate exception to the design system's -/// flat, no-shadow product UI (DESIGN_SYSTEM.md 1.4), since this chrome -/// floats over photo/video content rather than over the app's own -/// surfaces. No shadow, no outline: [color] defaults to the app's own -/// `surface` token (so this panel reads as light or dark to match the -/// active theme, like the rest of the media viewer, instead of a fixed -/// dark tone regardless of theme) at a higher-than-usual [opacity], since -/// a light tint needs denser coverage than a near-black one did to stay -/// legible over arbitrary photo/video brightness underneath. +/// Media viewer chrome that shares the navigation frost preference. +/// Turning frosting off produces an opaque themed surface without blur. class FrostedGlassContainer extends StatelessWidget { final Widget child; final double borderRadius; @@ -20,14 +13,12 @@ class FrostedGlassContainer extends StatelessWidget { /// Overrides [borderRadius] for non-uniform corners (e.g. top-only). final BorderRadius? radius; - /// Blur sigma for the backdrop filter. Defaults to a fixed value; pass an - /// explicit value (e.g. from user settings) to make it adjustable. - final double blurSigma; + /// Blur sigma for the backdrop filter. Defaults to the shared frost setting. + final double? blurSigma; - /// Opacity (0-1) of the tonal fill behind the blur. Defaults to a fixed - /// value; pass an explicit value (e.g. from user settings) to make it - /// adjustable. - final double opacity; + /// Opacity (0-1) of the tonal fill behind the blur. Defaults to the shared frost setting. + final double? opacity; + final bool? frosted; /// The tint under the blur. Defaults to [NooColors.surface] (resolved at /// build time, so it always matches the active theme) - pass an explicit @@ -39,8 +30,9 @@ class FrostedGlassContainer extends StatelessWidget { required this.child, this.borderRadius = 0, this.radius, - this.blurSigma = 20, - this.opacity = 0.8, + this.blurSigma, + this.opacity, + this.frosted, this.color, }); @@ -48,18 +40,28 @@ class FrostedGlassContainer extends StatelessWidget { Widget build(BuildContext context) { final shape = radius ?? BorderRadius.circular(borderRadius); final tint = color ?? context.nooColors.surface; + final settings = context.watch(); + final enabled = frosted ?? settings?.bottomBarFrosted ?? false; + final sigma = blurSigma ?? settings?.bottomBarFrostedBlur ?? 20; + final fill = Container( + decoration: BoxDecoration( + color: enabled + ? tint.withValues( + alpha: opacity ?? settings?.bottomBarFrostedOpacity ?? 0.72, + ) + : tint, + borderRadius: shape, + ), + child: child, + ); return ClipRRect( borderRadius: shape, - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: blurSigma, sigmaY: blurSigma), - child: Container( - decoration: BoxDecoration( - color: tint.withValues(alpha: opacity), - borderRadius: shape, - ), - child: child, - ), - ), + child: enabled + ? BackdropFilter( + filter: ImageFilter.blur(sigmaX: sigma, sigmaY: sigma), + child: fill, + ) + : fill, ); } } diff --git a/lib/widgets/noo/core/noo_button.dart b/lib/widgets/noo/core/noo_button.dart index 473443e..4c32105 100644 --- a/lib/widgets/noo/core/noo_button.dart +++ b/lib/widgets/noo/core/noo_button.dart @@ -50,6 +50,9 @@ class NooButton extends StatefulWidget { /// are responsible for a tooltip/semantic label, since there's no text. final bool iconOnly; + /// Continuous icon-only to labelled expansion for animated shell actions. + final double? expansion; + const NooButton({ super.key, this.variant = NooButtonVariant.primary, @@ -60,6 +63,7 @@ class NooButton extends StatefulWidget { this.disabled = false, this.onTap, this.iconOnly = false, + this.expansion, }); @override @@ -108,11 +112,22 @@ class _NooButtonState extends State { mainAxisSize: MainAxisSize.min, children: [ if (widget.icon != null) Icon(widget.icon, size: s.icon, color: fg), - if (widget.icon != null && widget.child != null) SizedBox(width: s.gap), - if (widget.child != null) - DefaultTextStyle( - style: NooText.buttonSm.copyWith(fontSize: s.font, color: fg), - child: widget.child!, + if (widget.icon != null && widget.child != null) + SizedBox(width: s.gap * (widget.expansion ?? 1)), + if (widget.child != null && widget.expansion != 0) + ClipRect( + child: Align( + alignment: Alignment.centerLeft, + widthFactor: widget.expansion ?? 1, + heightFactor: 1, + child: Opacity( + opacity: widget.expansion ?? 1, + child: DefaultTextStyle( + style: NooText.buttonSm.copyWith(fontSize: s.font, color: fg), + child: widget.child!, + ), + ), + ), ), ], ); @@ -135,7 +150,17 @@ class _NooButtonState extends State { width: widget.iconOnly ? s.height : (widget.fullWidth ? double.infinity : null), - padding: _isTextVariant || widget.iconOnly + padding: widget.expansion != null + ? EdgeInsets.only( + left: + (s.height - s.icon) / 2 + + (s.px - 4 - (s.height - s.icon) / 2) * + widget.expansion!, + right: + (s.height - s.icon) / 2 + + (s.px - (s.height - s.icon) / 2) * widget.expansion!, + ) + : _isTextVariant || widget.iconOnly ? EdgeInsets.zero : EdgeInsets.only( left: hasLeadingIcon ? s.px - 4 : s.px, diff --git a/lib/widgets/noo/lists/noo_selection_bar.dart b/lib/widgets/noo/lists/noo_selection_bar.dart index c782849..c271cc5 100644 --- a/lib/widgets/noo/lists/noo_selection_bar.dart +++ b/lib/widgets/noo/lists/noo_selection_bar.dart @@ -23,7 +23,8 @@ const int _kDesktopInlineActions = 4; /// Replaces the bare row of Material `IconButton`s each screen used to /// build ad hoc. /// -/// A `surface` card (radius 20) - selection reads as a distinct mode, not +/// A floating `surface` card (radius 20), with the bottom bar's border +/// and shadow - selection reads as a distinct mode, not /// just a row of buttons floating on `bg` - with a close circle, the /// count, and the bulk actions. This sits in the *content* pane's own /// pinned header slot on every platform (the shell's desktop toolbar, @@ -87,6 +88,8 @@ class NooSelectionBar extends StatelessWidget { decoration: BoxDecoration( color: colors.surface, borderRadius: BorderRadius.circular(NooRadii.card), + border: Border.all(color: colors.line), + boxShadow: const [nooDialogShadow], ), child: Row( children: [ diff --git a/lib/widgets/noo/lists/noo_settings_row.dart b/lib/widgets/noo/lists/noo_settings_row.dart index b5e3fd6..2906c8a 100644 --- a/lib/widgets/noo/lists/noo_settings_row.dart +++ b/lib/widgets/noo/lists/noo_settings_row.dart @@ -10,14 +10,17 @@ class NooSettingsRow extends StatelessWidget { final IconData? icon; final Widget label; final Widget? subtitle; + /// Renders as `value` text + a trailing chevron - mutually exclusive with /// [trailing] (a `NooToggle`, button, badge, etc.). final String? value; final Widget? trailing; final bool danger; + /// Accent-text action row, e.g. "Add account". final bool accent; final VoidCallback? onTap; + final Color? backgroundColor; const NooSettingsRow({ super.key, @@ -29,12 +32,15 @@ class NooSettingsRow extends StatelessWidget { this.danger = false, this.accent = false, this.onTap, + this.backgroundColor, }); @override Widget build(BuildContext context) { final colors = context.nooColors; - final color = danger ? colors.danger : (accent ? colors.accentText : colors.fg1); + final color = danger + ? colors.danger + : (accent ? colors.accentText : colors.fg1); final minHeight = subtitle != null ? 60.0 : 52.0; Widget? trail = trailing; @@ -44,7 +50,11 @@ class NooSettingsRow extends StatelessWidget { children: [ Text( value!, - style: NooText.body.copyWith(fontSize: 15, height: 1, color: colors.fg3), + style: NooText.body.copyWith( + fontSize: 15, + height: 1, + color: colors.fg3, + ), ), const SizedBox(width: 4), Icon(LucideIcons.chevronRight, size: 18, color: colors.fg3), @@ -53,7 +63,7 @@ class NooSettingsRow extends StatelessWidget { } return Material( - color: colors.surface, + color: backgroundColor ?? colors.surface, child: InkWell( onTap: onTap, child: ConstrainedBox( @@ -66,7 +76,11 @@ class NooSettingsRow extends StatelessWidget { child: Row( children: [ if (icon != null) ...[ - Icon(icon, size: 20, color: danger || accent ? color : colors.fg2), + Icon( + icon, + size: 20, + color: danger || accent ? color : colors.fg2, + ), const SizedBox(width: 14), ], Expanded( diff --git a/lib/widgets/noo/nav/noo_bottom_bar.dart b/lib/widgets/noo/nav/noo_bottom_bar.dart index 51a4f9e..8fd0ded 100644 --- a/lib/widgets/noo/nav/noo_bottom_bar.dart +++ b/lib/widgets/noo/nav/noo_bottom_bar.dart @@ -19,8 +19,8 @@ const double _kAndroidPillHeight = 32; /// what order, is the caller's business; this only draws [destinations]. /// /// - [NooNavStyle.ios]: surface fill, 1px top `line`, a 50px row (8px top -/// padding) of icon 24 over a 10px label. Active: accent-text, 600. -/// Idle: fg-3, 500. No ripple. +/// padding) of icon 24 over a 10px selected label. Active: accent-text, 600. +/// Idle: centered fg-3 icon, no label. No ripple. /// - [NooNavStyle.android]: surface fill, 80px. Every tab shows its 24px /// icon over a 12px label (label only visible - not removed from layout, /// so the row never resizes - once selected). The active icon sits in a @@ -78,6 +78,11 @@ class NooBottomBar extends StatelessWidget { final NooNavDestination? searchDestination; final VoidCallback? onSearchTap; + /// Bottom-avatar navigation occupies this slot while closed. Search moves + /// from the row into the slot as the avatar expands into its popup. + final Widget? avatarSatellite; + final double avatarMenuProgress; + const NooBottomBar({ super.key, required this.style, @@ -90,6 +95,8 @@ class NooBottomBar extends StatelessWidget { required this.onSelected, this.searchDestination, this.onSearchTap, + this.avatarSatellite, + this.avatarMenuProgress = 0, }); /// The row height [build] draws for [style]/[barStyle] - exposed so a @@ -126,7 +133,12 @@ class NooBottomBar extends StatelessWidget { // Attached folds Search into the row itself (last item); floating // gives it a separate satellite circle instead (built below), so the // row builders only ever see it as a trailing item in the former case. - final rowSearch = hasSearch && !floating ? searchDestination : null; + final rowSearch = + hasSearch && + (!floating || avatarSatellite != null) && + (avatarSatellite == null || !floating || avatarMenuProgress < 1) + ? searchDestination + : null; final row = ios ? _buildIosRow(trailingSearch: rowSearch) @@ -162,7 +174,9 @@ class NooBottomBar extends StatelessWidget { 16, NooBottomBar.floatingBottomMargin, ), - child: hasSearch + child: avatarSatellite != null + ? _withAvatar(pill, barHeight, hasSearch) + : hasSearch // A fixed-height SizedBox, not just a Row with // crossAxisAlignment.stretch - the bottomNavigationBar slot // gives this widget a *loose* (unbounded-max) height @@ -202,37 +216,126 @@ class NooBottomBar extends StatelessWidget { ), child: SafeArea( top: false, - child: SizedBox(height: barHeight, child: row), + child: SizedBox( + height: barHeight, + child: avatarSatellite == null + ? row + : Row( + children: [ + Expanded(child: row), + SizedBox( + width: barHeight, + height: barHeight, + child: avatarSatellite, + ), + const SizedBox(width: 8), + ], + ), + ), ), ); return frosted ? _blurred(bar, BorderRadius.zero, frostedBlur) : bar; } + Widget _withAvatar(Widget rail, double height, bool hasSearch) { + final satelliteWidth = hasSearch + ? height + : height * (1 - avatarMenuProgress); + return SizedBox( + height: height, + child: Row( + children: [ + Expanded(child: rail), + SizedBox(width: 8 * (hasSearch ? 1 : 1 - avatarMenuProgress)), + SizedBox( + width: satelliteWidth, + height: height, + child: ClipRect( + child: OverflowBox( + alignment: Alignment.centerRight, + minWidth: height, + maxWidth: height, + child: Stack( + children: [ + avatarSatellite!, + if (hasSearch && avatarMenuProgress > 0) + Opacity( + opacity: avatarMenuProgress, + child: _SearchSatellite( + destination: searchDestination!, + onTap: onSearchTap!, + size: height, + frosted: frosted, + frostedBlur: frostedBlur, + frostedOpacity: frostedOpacity, + ), + ), + ], + ), + ), + ), + ), + ], + ), + ); + } + Color _fill(NooColors colors) => frosted ? colors.surface.withValues(alpha: frostedOpacity) : colors.surface; + double get _rowSearchFraction => + avatarSatellite == null || barStyle == NooBottomBarStyle.attached + ? 1 + : 1 - avatarMenuProgress; + + Widget _searchRowItem(Widget child, double width) => SizedBox( + width: width * _rowSearchFraction, + child: IgnorePointer( + ignoring: _rowSearchFraction < .95, + child: ClipRect( + child: OverflowBox( + minWidth: width, + maxWidth: width, + child: Opacity(opacity: _rowSearchFraction, child: child), + ), + ), + ), + ); + Widget _buildIosRow({NooNavDestination? trailingSearch}) { - return Row( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - for (var i = 0; i < destinations.length; i++) - Expanded( - child: _IosItem( - destination: destinations[i], - selected: i == selectedIndex, - onTap: () => onSelected(i), - ), - ), - if (trailingSearch != null) - Expanded( - child: _IosItem( - destination: trailingSearch, - selected: false, - onTap: onSearchTap!, - ), - ), - ], + return LayoutBuilder( + builder: (context, constraints) { + final width = + constraints.maxWidth / + (destinations.length + + (trailingSearch == null ? 0 : _rowSearchFraction)); + return Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (var i = 0; i < destinations.length; i++) + SizedBox( + width: width, + child: _IosItem( + destination: destinations[i], + selected: i == selectedIndex, + selectedLabelOnly: true, + onTap: () => onSelected(i), + ), + ), + if (trailingSearch != null) + _searchRowItem( + _IosItem( + destination: trailingSearch, + selected: false, + selectedLabelOnly: true, + onTap: onSearchTap!, + ), + width, + ), + ], + ); + }, ); } @@ -242,7 +345,8 @@ class NooBottomBar extends StatelessWidget { NooNavDestination? trailingSearch, }) { final pillTop = floating ? _kFloatingPillTop : _kAttachedPillTop; - final itemCount = destinations.length + (trailingSearch != null ? 1 : 0); + final itemCount = + destinations.length + (trailingSearch != null ? _rowSearchFraction : 0); return LayoutBuilder( builder: (context, constraints) { final itemWidth = constraints.maxWidth / itemCount; @@ -268,7 +372,8 @@ class NooBottomBar extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ for (var i = 0; i < destinations.length; i++) - Expanded( + SizedBox( + width: itemWidth, child: _AndroidItem( destination: destinations[i], selected: i == selectedIndex, @@ -277,13 +382,14 @@ class NooBottomBar extends StatelessWidget { ), ), if (trailingSearch != null) - Expanded( - child: _AndroidItem( + _searchRowItem( + _AndroidItem( destination: trailingSearch, selected: false, floating: floating, onTap: onSearchTap!, ), + itemWidth, ), ], ), @@ -362,11 +468,13 @@ class _SearchSatellite extends StatelessWidget { class _IosItem extends StatelessWidget { final NooNavDestination destination; final bool selected; + final bool selectedLabelOnly; final VoidCallback onTap; const _IosItem({ required this.destination, required this.selected, + this.selectedLabelOnly = false, required this.onTap, }); @@ -377,30 +485,35 @@ class _IosItem extends StatelessWidget { return Semantics( button: true, selected: selected, + label: selectedLabelOnly && !selected ? destination.label : null, child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: onTap, - child: Padding( - padding: const EdgeInsets.only(top: 8), - child: Column( - mainAxisAlignment: MainAxisAlignment.start, - children: [ - Icon(destination.icon, size: 24, color: fg), - const SizedBox(height: 4), - Text( - destination.label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: NooText.navLabel.copyWith( - fontSize: 10, - height: 1, - fontWeight: selected ? FontWeight.w600 : FontWeight.w500, - color: fg, + child: selectedLabelOnly && !selected + ? Center(child: Icon(destination.icon, size: 24, color: fg)) + : Padding( + padding: const EdgeInsets.only(top: 8), + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Icon(destination.icon, size: 24, color: fg), + const SizedBox(height: 4), + Text( + destination.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: NooText.navLabel.copyWith( + fontSize: 10, + height: 1, + fontWeight: selected + ? FontWeight.w600 + : FontWeight.w500, + color: fg, + ), + ), + ], ), ), - ], - ), - ), ), ); } diff --git a/lib/widgets/noo/nav/noo_nav_style.dart b/lib/widgets/noo/nav/noo_nav_style.dart index dbffcb7..f1c9d84 100644 --- a/lib/widgets/noo/nav/noo_nav_style.dart +++ b/lib/widgets/noo/nav/noo_nav_style.dart @@ -37,8 +37,8 @@ enum NooBottomBarStyle { attached, floating } /// as [NooBottomBarStyle]. [drawer] is the original pattern: a `menu` /// icon, top-left of the top bar, opens a left-edge `Drawer`. [avatarMenu] /// drops that icon entirely and repurposes the avatar button - already -/// sitting top-right, already a tap target every user already knows - as -/// the one entry point instead, opening a dropdown anchored below it (see +/// available at the top or bottom by user preference - as the one entry +/// point instead, expanding into an anchored navigation card (see /// `showAvatarMenu` in `widgets/avatar_menu.dart`) rather than a drawer /// sliding from the opposite edge. Applies on both platforms, since /// [NooTopBar]'s `onMenu`/avatar wiring is shared chrome, not diff --git a/lib/widgets/noo/nav/noo_top_bar.dart b/lib/widgets/noo/nav/noo_top_bar.dart index b43caae..cd10903 100644 --- a/lib/widgets/noo/nav/noo_top_bar.dart +++ b/lib/widgets/noo/nav/noo_top_bar.dart @@ -52,6 +52,7 @@ class NooTopBar extends StatelessWidget implements PreferredSizeWidget { /// Icons inherit the bar's icon color (accent-text on iOS, fg-1 on /// Android) through [IconTheme]. final List actions; + final bool iosActionsInTitleRow; /// iOS only: the search field shown below the large title. final Widget? search; @@ -76,6 +77,7 @@ class NooTopBar extends StatelessWidget implements PreferredSizeWidget { this.leading, this.onMenu, this.actions = const [], + this.iosActionsInTitleRow = false, this.search, this.androidTitleTrailing, this.backgroundColor, @@ -86,6 +88,11 @@ class NooTopBar extends StatelessWidget implements PreferredSizeWidget { @override Size get preferredSize { if (!_ios) return const Size.fromHeight(_androidRow); + if (iosActionsInTitleRow) { + return Size.fromHeight( + 64 + (search != null ? _iosSearch + _iosSearchBottom : 0), + ); + } return Size.fromHeight( _iosRow + _iosTitleTop + @@ -127,6 +134,50 @@ class NooTopBar extends StatelessWidget implements PreferredSizeWidget { } Widget _buildIos(NooColors colors, Widget? lead) { + if (iosActionsInTitleRow) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + height: 64, + child: Padding( + padding: EdgeInsetsDirectional.only( + start: lead == null ? 16 : 6, + end: 6, + ), + child: Row( + spacing: 4, + children: [ + ?lead, + Expanded( + child: Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: NooText.largeTitle.copyWith( + leadingDistribution: TextLeadingDistribution.even, + color: colors.fg1, + ), + ), + ), + ...actions, + ], + ), + ), + ), + if (search != null) + Padding( + padding: const EdgeInsets.fromLTRB( + NooSpace.md, + 0, + NooSpace.md, + _iosSearchBottom, + ), + child: SizedBox(height: _iosSearch, child: search), + ), + ], + ); + } return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, diff --git a/lib/widgets/selection_bar_overlay.dart b/lib/widgets/selection_bar_overlay.dart new file mode 100644 index 0000000..6711697 --- /dev/null +++ b/lib/widgets/selection_bar_overlay.dart @@ -0,0 +1,30 @@ +import 'package:flutter/material.dart'; + +/// Pins selection actions above the shell navigation. Scaffold supplies its +/// navigation footprint through MediaQuery padding when the body extends +/// behind navigation; otherwise the body already ends above it. +class SelectionBarOverlay extends StatelessWidget { + static const clearance = 64.0; + + final Widget child; + final Widget? bar; + + const SelectionBarOverlay({super.key, required this.child, this.bar}); + + @override + Widget build(BuildContext context) { + if (bar == null) return child; + return Stack( + fit: StackFit.expand, + children: [ + child, + Positioned( + left: 0, + right: 0, + bottom: MediaQuery.paddingOf(context).bottom + 8, + child: SizedBox(height: 56, child: bar), + ), + ], + ); + } +} diff --git a/lib/widgets/settings/settings_action_bar.dart b/lib/widgets/settings/settings_action_bar.dart index 0fef176..d58e3a8 100644 --- a/lib/widgets/settings/settings_action_bar.dart +++ b/lib/widgets/settings/settings_action_bar.dart @@ -4,8 +4,10 @@ import '../../models/selection_action.dart'; import '../../providers/settings_controller.dart'; import '../../theme/design_tokens.dart'; import '../noo/lists/noo_tab_order_row.dart'; +import '../noo/lists/noo_settings_row.dart'; +import '../noo/lists/noo_grouped_list.dart'; +import '../noo/core/noo_segmented_control.dart'; import '../noo/noo_layout.dart'; -import 'settings_section.dart'; /// Settings section: the priority order bulk actions (favorite, share, /// download, ...) show in on the Files/Photos/Favorites multi-select @@ -13,7 +15,7 @@ import 'settings_section.dart'; /// which ones land in `NooSelectionBar`'s fixed inline slots (3 mobile / 4 /// desktop, see its own doc comment) vs. behind its trailing "More" - /// there's no pin/hide concept here the way Tabs has, so this is just a -/// reorder list, not a whole section of its own option rows. +/// position choice followed by a reorder list. class SettingsActionBarSection extends StatelessWidget { const SettingsActionBarSection({super.key}); @@ -33,18 +35,30 @@ class SettingsActionBarSection extends StatelessWidget { final desktop = NooLayout.isDesktop(context); final orderList = _ActionOrderList( + rounded: desktop, order: settings.selectionActionOrder, onReorder: (oldIndex, newIndex) => _handleReorder(settings, oldIndex, newIndex), ); + final positionRow = NooSettingsRow( + label: const Text('Position'), + trailing: NooSegmentedControl( + onSurface: true, + value: settings.selectionBarPosition, + onChanged: settings.setSelectionBarPosition, + options: const [ + NooSegmentOption(value: SelectionBarPosition.top, label: 'Top'), + NooSegmentOption(value: SelectionBarPosition.bottom, label: 'Bottom'), + ], + ), + ); final colors = context.nooColors; if (!desktop) { - return SettingsSection( - title: 'Action bar', - subtitle: _subtitle, - children: [orderList], + return NooGroupedList( + footer: const Text(_subtitle), + children: [positionRow, orderList], ); } @@ -65,6 +79,8 @@ class SettingsActionBarSection extends StatelessWidget { const SizedBox(height: 4), Text(_subtitle, style: NooText.meta.copyWith(color: colors.fg3)), const SizedBox(height: NooSpace.md), + positionRow, + const SizedBox(height: NooSpace.md), orderList, ], ), @@ -79,8 +95,13 @@ class SettingsActionBarSection extends StatelessWidget { class _ActionOrderList extends StatelessWidget { final List order; final void Function(int oldIndex, int newIndex) onReorder; + final bool rounded; - const _ActionOrderList({required this.order, required this.onReorder}); + const _ActionOrderList({ + required this.order, + required this.onReorder, + required this.rounded, + }); @override Widget build(BuildContext context) { @@ -105,12 +126,14 @@ class _ActionOrderList extends StatelessWidget { ); }, ); + final rows = DecoratedBox( + decoration: BoxDecoration(color: colors.line), + child: list, + ); + if (!rounded) return rows; return ClipRRect( borderRadius: BorderRadius.circular(NooRadii.card), - child: DecoratedBox( - decoration: BoxDecoration(color: colors.line), - child: list, - ), + child: rows, ); } } diff --git a/lib/widgets/settings/settings_appearance.dart b/lib/widgets/settings/settings_appearance.dart index 50267eb..ca9f89c 100644 --- a/lib/widgets/settings/settings_appearance.dart +++ b/lib/widgets/settings/settings_appearance.dart @@ -56,9 +56,9 @@ class SettingsAppearanceSection extends StatelessWidget { _BottomBarStyleRow(settings: settings), NooSettingsRow( icon: LucideIcons.glassWater, - label: const Text('Frosted glass bottom bar'), + label: const Text('Frosted glass'), subtitle: const Text( - 'A translucent, blurred bottom bar - content scrolls behind it', + 'Blurred bottom navigation, avatar menus and media viewer panels', ), trailing: NooToggle( checked: settings.bottomBarFrosted, @@ -67,6 +67,23 @@ class SettingsAppearanceSection extends StatelessWidget { ), if (settings.bottomBarFrosted) _FrostedGlassRow(settings: settings), _NavMenuStyleRow(settings: settings), + if (settings.navMenuStyle == NooNavMenuStyle.avatarMenu) + NooSettingsRow( + icon: LucideIcons.userRound, + label: const Text('Avatar position'), + trailing: NooSegmentedControl( + onSurface: true, + value: settings.avatarPosition, + onChanged: settings.setAvatarPosition, + options: const [ + NooSegmentOption(value: AvatarPosition.top, label: 'Top'), + NooSegmentOption( + value: AvatarPosition.bottom, + label: 'Bottom', + ), + ], + ), + ), NooSettingsRow( icon: LucideIcons.search, label: const Text('Search in bottom bar'), @@ -96,14 +113,19 @@ class SettingsAppearanceSection extends StatelessWidget { icon: LucideIcons.palette, label: const Text('Accent color'), subtitle: Text( - settings.useDynamicColor + settings.useDynamicColor && + Theme.of(context).platform != TargetPlatform.iOS ? 'Matching your wallpaper' : settings.seedColor == AppTheme.defaultAccent ? 'Default' : 'Custom color', ), trailing: _AccentSwatchDot( - color: settings.useDynamicColor ? null : settings.seedColor, + color: + settings.useDynamicColor && + Theme.of(context).platform != TargetPlatform.iOS + ? null + : settings.seedColor, ), onTap: () => _openAccentPicker(context, settings), ), @@ -116,15 +138,16 @@ class SettingsAppearanceSection extends StatelessWidget { onChanged: settings.setAmoledDark, ), ), - NooSettingsRow( - icon: LucideIcons.waves, - label: const Text('Seek bar style'), - subtitle: const Text( - 'The progress bar style used when playing videos', + if (Theme.of(context).platform != TargetPlatform.iOS) + NooSettingsRow( + icon: LucideIcons.waves, + label: const Text('Seek bar style'), + subtitle: const Text( + 'The progress bar style used when playing videos', + ), + value: _seekBarStyleLabel(settings.mediaProgressBarStyle), + onTap: () => _openSeekBarPicker(context, settings), ), - value: _seekBarStyleLabel(settings.mediaProgressBarStyle), - onTap: () => _openSeekBarPicker(context, settings), - ), ], ), ], @@ -168,6 +191,7 @@ class _ThemeRow extends StatelessWidget { const SizedBox(height: 12), NooSegmentedControl( fill: true, + onSurface: true, value: settings.themeMode, onChanged: settings.setThemeMode, options: const [ @@ -231,6 +255,7 @@ class _BottomBarStyleRow extends StatelessWidget { const SizedBox(height: 12), NooSegmentedControl( fill: true, + onSurface: true, value: settings.bottomBarStyle, onChanged: settings.setBottomBarStyle, options: const [ @@ -287,6 +312,7 @@ class _FrostedGlassRow extends StatelessWidget { const SizedBox(height: 12), NooSegmentedControl( fill: true, + onSurface: true, // No segment selected once the sliders no longer match a preset. value: settings.frostedPreset, onChanged: (preset) { @@ -402,6 +428,7 @@ class _NavMenuStyleRow extends StatelessWidget { const SizedBox(height: 12), NooSegmentedControl( fill: true, + onSurface: true, value: settings.navMenuStyle, onChanged: settings.setNavMenuStyle, options: const [ @@ -455,6 +482,7 @@ class _FabStyleRow extends StatelessWidget { const SizedBox(height: 12), NooSegmentedControl( fill: true, + onSurface: true, value: settings.fabStyle, onChanged: settings.setFabStyle, options: const [ @@ -508,22 +536,26 @@ class _AccentSwatchDot extends StatelessWidget { } void _openAccentPicker(BuildContext context, SettingsController settings) { + final useDynamicColor = + settings.useDynamicColor && + Theme.of(context).platform != TargetPlatform.iOS; Widget grid(NooColors colors) => Wrap( spacing: 14, runSpacing: 14, children: [ - _AccentSwatch( - isSelected: settings.useDynamicColor, - onTap: () { - settings.setUseDynamicColor(true); - Navigator.pop(context); - }, - background: colors.surface3, - child: Icon(LucideIcons.wallpaper, color: colors.fg2, size: 20), - ), + if (Theme.of(context).platform != TargetPlatform.iOS) + _AccentSwatch( + isSelected: settings.useDynamicColor, + onTap: () { + settings.setUseDynamicColor(true); + Navigator.pop(context); + }, + background: colors.surface3, + child: Icon(LucideIcons.wallpaper, color: colors.fg2, size: 20), + ), for (final color in AppTheme.seedColors) _AccentSwatch( - isSelected: !settings.useDynamicColor && settings.seedColor == color, + isSelected: !useDynamicColor && settings.seedColor == color, onTap: () { settings.setSeedColor(color); Navigator.pop(context); diff --git a/lib/widgets/settings/settings_files_cache.dart b/lib/widgets/settings/settings_files_cache.dart index 41c40e9..825ab19 100644 --- a/lib/widgets/settings/settings_files_cache.dart +++ b/lib/widgets/settings/settings_files_cache.dart @@ -125,12 +125,16 @@ class _CacheIntervalRow extends StatelessWidget { onChanged: (value) => onChanged(value.round()), ), ), - if (minutes < 15) + if (Theme.of(context).platform == TargetPlatform.iOS || + (Theme.of(context).platform == TargetPlatform.android && minutes < 15)) Padding( padding: const EdgeInsets.only(bottom: 8), child: Text( - 'While the app is closed, synced files refresh at most every ' - '15 min (an Android limit).', + Theme.of(context).platform == TargetPlatform.iOS + ? 'iOS decides when background sync runs; the refresh ' + 'interval is a request, not a guaranteed schedule.' + : 'While the app is closed, synced files refresh at most every ' + '15 min (an Android limit).', style: NooText.meta.copyWith(color: colors.fg3), ), ), diff --git a/lib/widgets/sticky_header_delegate.dart b/lib/widgets/sticky_header_delegate.dart index 342386e..61ce26f 100644 --- a/lib/widgets/sticky_header_delegate.dart +++ b/lib/widgets/sticky_header_delegate.dart @@ -10,8 +10,13 @@ import '../theme/design_tokens.dart'; class StickyHeaderDelegate extends SliverPersistentHeaderDelegate { final double height; final Widget child; + final bool floating; - const StickyHeaderDelegate({required this.height, required this.child}); + const StickyHeaderDelegate({ + required this.height, + required this.child, + this.floating = false, + }); @override double get minExtent => height; @@ -38,7 +43,7 @@ class StickyHeaderDelegate extends SliverPersistentHeaderDelegate { // Colors.transparent` guards against the same M3 elevation-tint // behavior even if this ever gets a non-zero elevation. return Material( - color: context.nooColors.bg, + color: floating ? Colors.transparent : context.nooColors.bg, surfaceTintColor: Colors.transparent, child: child, ); @@ -46,6 +51,8 @@ class StickyHeaderDelegate extends SliverPersistentHeaderDelegate { @override bool shouldRebuild(covariant StickyHeaderDelegate oldDelegate) { - return oldDelegate.height != height || oldDelegate.child != child; + return oldDelegate.height != height || + oldDelegate.child != child || + oldDelegate.floating != floating; } } diff --git a/lib/widgets/viewer/media_action_bar.dart b/lib/widgets/viewer/media_action_bar.dart index 7c30c0c..19cf736 100644 --- a/lib/widgets/viewer/media_action_bar.dart +++ b/lib/widgets/viewer/media_action_bar.dart @@ -4,14 +4,8 @@ import '../../theme/design_tokens.dart'; import '../frosted_glass_container.dart'; import 'viewer_icon_button.dart'; -/// The bottom panel overlaid on the media viewer: an optional transport row -/// ([transportControls], video only) above the action row -/// (share/favorite/open/download/delete/details) - one continuous flush, -/// full-width `FrostedGlassContainer` panel, not two separate floating -/// pills. Kept on that blurred chrome as a deliberate exception to the -/// design system's flat product UI (see `file_viewer_screen.dart`'s -/// build() comment for why), unlike the now-flat bottom nav bar -/// (`bottom_nav_bar.dart`) this screen sits above. +/// A full-width media panel containing transport, actions and details. +/// Its solid/frosted surface follows the shared appearance preference. class MediaActionBar extends StatelessWidget { final bool isFavorite; final bool isBusy; diff --git a/lib/widgets/viewer/media_video_preview.dart b/lib/widgets/viewer/media_video_preview.dart index 879bd97..7644d24 100644 --- a/lib/widgets/viewer/media_video_preview.dart +++ b/lib/widgets/viewer/media_video_preview.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'dart:math' as math; import 'package:flutter/material.dart'; +import 'package:flutter/cupertino.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:provider/provider.dart'; import 'package:video_player/video_player.dart'; @@ -198,12 +199,14 @@ class VideoTransportControls extends StatelessWidget { ), Padding( padding: const EdgeInsets.fromLTRB(12, 0, 12, 8), - child: WavySeekBar( - controller: controller, - playedColor: fg, - trackColor: fg.withValues(alpha: 0.3), - style: progressBarStyle, - ), + child: Theme.of(context).platform == TargetPlatform.iOS + ? IosSeekBar(controller: controller, color: fg) + : WavySeekBar( + controller: controller, + playedColor: fg, + trackColor: fg.withValues(alpha: 0.3), + style: progressBarStyle, + ), ), ], ), @@ -213,6 +216,60 @@ class VideoTransportControls extends StatelessWidget { } } +class IosSeekBar extends StatefulWidget { + final VideoPlayerController controller; + final Color color; + const IosSeekBar({super.key, required this.controller, required this.color}); + + @override + State createState() => _IosSeekBarState(); +} + +class _IosSeekBarState extends State { + double? _dragRatio; + bool _resumeAfterScrubbing = false; + + @override + Widget build(BuildContext context) { + final value = widget.controller.value; + final durationMs = value.duration.inMilliseconds; + final ratio = + (_dragRatio ?? + (durationMs > 0 + ? value.position.inMilliseconds / durationMs + : 0.0)) + .clamp(0.0, 1.0); + return SizedBox( + width: double.infinity, + child: CupertinoSlider( + value: ratio, + activeColor: widget.color, + thumbColor: CupertinoColors.white, + onChangeStart: durationMs <= 0 + ? null + : (_) { + _resumeAfterScrubbing = value.isPlaying; + if (_resumeAfterScrubbing) widget.controller.pause(); + }, + onChanged: durationMs <= 0 + ? null + : (ratio) { + setState(() => _dragRatio = ratio); + widget.controller.seekTo(value.duration * ratio); + }, + onChangeEnd: durationMs <= 0 + ? null + : (ratio) async { + await widget.controller.seekTo(value.duration * ratio); + if (!mounted) return; + setState(() => _dragRatio = null); + if (_resumeAfterScrubbing) widget.controller.play(); + }, + ), + ); + } +} + /// A Material You "expressive" wavy seek bar: the played portion of the /// track animates as a travelling sine wave while playing and settles flat /// when paused, matching the Android 16+ media player style. Drag or tap diff --git a/test/providers/settings_selection_position_test.dart b/test/providers/settings_selection_position_test.dart new file mode 100644 index 0000000..1e7b429 --- /dev/null +++ b/test/providers/settings_selection_position_test.dart @@ -0,0 +1,54 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:noo/providers/settings_controller.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test( + 'defaults to top and safely handles an unknown saved position', + () async { + for (final saved in [null, 'unknown']) { + SharedPreferences.setMockInitialValues({ + 'ui_selection_bar_position': ?saved, + }); + final settings = SettingsController(); + await Future.delayed(Duration.zero); + expect(settings.selectionBarPosition, SelectionBarPosition.top); + settings.dispose(); + } + }, + ); + + test( + 'bottom position persists and restores across controller creation', + () async { + SharedPreferences.setMockInitialValues({}); + final settings = SettingsController(); + await Future.delayed(Duration.zero); + settings.setSelectionBarPosition(SelectionBarPosition.bottom); + await Future.delayed(Duration.zero); + final restored = SettingsController(); + await Future.delayed(Duration.zero); + expect(restored.selectionBarPosition, SelectionBarPosition.bottom); + settings.dispose(); + restored.dispose(); + }, + ); + test( + 'avatar position defaults to top and restores a saved bottom choice', + () async { + SharedPreferences.setMockInitialValues({}); + final settings = SettingsController(); + await Future.delayed(Duration.zero); + expect(settings.avatarPosition, AvatarPosition.top); + settings.setAvatarPosition(AvatarPosition.bottom); + await Future.delayed(Duration.zero); + final restored = SettingsController(); + await Future.delayed(Duration.zero); + expect(restored.avatarPosition, AvatarPosition.bottom); + settings.dispose(); + restored.dispose(); + }, + ); +} diff --git a/test/widgets/avatar_menu_test.dart b/test/widgets/avatar_menu_test.dart index f99a902..6223df9 100644 --- a/test/widgets/avatar_menu_test.dart +++ b/test/widgets/avatar_menu_test.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'dart:ui' show ImageFilter; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -17,7 +18,10 @@ import 'package:noo/providers/trash_controller.dart'; import 'package:noo/theme/app_theme.dart'; import 'package:noo/theme/design_tokens.dart'; import 'package:noo/widgets/noo/core/noo_avatar.dart'; +import 'package:noo/widgets/noo/core/noo_button.dart'; import 'package:noo/widgets/app_top_bar.dart'; +import 'package:noo/widgets/avatar_menu.dart'; +import 'package:noo/widgets/bottom_nav_bar.dart'; import 'package:noo/widgets/noo/nav/noo_top_bar.dart'; /// Covers `SettingsController.navMenuStyle`'s two options as wired through @@ -86,7 +90,24 @@ void main() { Future pumpTopBar( WidgetTester tester, { required NooNavMenuStyle navMenuStyle, + bool withHost = false, + AvatarPosition position = AvatarPosition.top, + bool search = false, + bool reducedMotion = false, + NooNavStyle style = NooNavStyle.android, + NooBottomBarStyle barStyle = NooBottomBarStyle.floating, + AppTab tab = AppTab.files, + FabStyle uploadStyle = FabStyle.auto, + bool frosted = false, + int settleFrames = 30, }) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('ui_nav_menu_style', navMenuStyle.name); + await prefs.setString('ui_avatar_position', position.name); + await prefs.setString('ui_bottom_bar_style', barStyle.name); + await prefs.setBool('ui_bottom_bar_frosted', frosted); + await prefs.setDouble('ui_bottom_bar_frosted_blur', 19); + await prefs.setDouble('ui_bottom_bar_frosted_opacity', 0.45); await tester.binding.setSurfaceSize(const Size(400, 800)); addTearDown(() => tester.binding.setSurfaceSize(null)); @@ -110,15 +131,49 @@ void main() { ), ], child: MaterialApp( - theme: AppTheme.light(AppTheme.defaultAccent, useDynamicColor: false), - home: Scaffold( - drawer: const Drawer(child: Text('drawer open')), - appBar: AppTopBar( - style: NooNavStyle.android, - tab: AppTab.files, - navMenuStyle: navMenuStyle, - ), - body: const SizedBox(), + theme: AppTheme.light(AppTheme.defaultAccent, useDynamicColor: false) + .copyWith( + platform: style == NooNavStyle.ios + ? TargetPlatform.iOS + : TargetPlatform.android, + ), + home: Builder( + builder: (context) { + final scaffold = Scaffold( + drawer: const Drawer(child: Text('drawer open')), + appBar: AppTopBar( + style: style, + tab: tab, + uploadButtonStyle: uploadStyle, + navMenuStyle: navMenuStyle, + avatarPosition: position, + searchInBottomBar: search, + ), + body: const AvatarNavigationBody( + child: SizedBox.expand(key: ValueKey('page content')), + ), + bottomNavigationBar: withHost + ? BottomNavBar( + style: style, + barStyle: barStyle, + tabs: const [AppTab.files, AppTab.photos], + selectedIndex: 0, + onDestinationSelected: (_) {}, + avatarInBottomBar: position == AvatarPosition.bottom, + onSearchTap: search ? () {} : null, + ) + : null, + ); + final child = withHost + ? AvatarNavigationHost(child: scaffold) + : scaffold; + return MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(disableAnimations: reducedMotion), + child: child, + ); + }, ), ), ), @@ -127,7 +182,7 @@ void main() { // Not `pumpAndSettle` - the same reasoning as `account_view_test.dart`: // the active account's dependent controllers start periodic // timers/futures that would spin it forever. - for (var i = 0; i < 30; i++) { + for (var i = 0; i < settleFrames; i++) { await tester.pump(const Duration(milliseconds: 10)); } } @@ -224,4 +279,320 @@ void main() { final screenWidth = tester.getSize(find.byType(MaterialApp)).width; expect(tester.getSize(row).width, screenWidth - 2 * NooSpace.sm - 4); }); + testWidgets( + 'top avatar expands and pushes content down; closing restores it', + (tester) async { + await pumpTopBar( + tester, + navMenuStyle: NooNavMenuStyle.avatarMenu, + withHost: true, + ); + final page = find.byKey(const ValueKey('page content')); + final before = tester.getTopLeft(page).dy; + await tester.tap(find.byTooltip('Menu')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 120)); + final during = tester.getTopLeft(page).dy; + expect(during, greaterThan(before)); + await tester.pump(const Duration(milliseconds: 500)); + expect(tester.getTopLeft(page).dy, greaterThan(during)); + expect(find.text('Settings'), findsOneWidget); + await tester.tap(find.bySemanticsLabel('Close menu').last); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + expect(tester.getTopLeft(page).dy, before); + expect(find.byType(AvatarMenuCard), findsNothing); + expect(tester.takeException(), isNull); + }, + ); + + for (final search in [false, true]) { + testWidgets( + 'bottom avatar moves into card without shifting content (search=$search)', + (tester) async { + await pumpTopBar( + tester, + navMenuStyle: NooNavMenuStyle.avatarMenu, + withHost: true, + position: AvatarPosition.bottom, + search: search, + ); + final page = find.byKey(const ValueKey('page content')); + final before = tester.getTopLeft(page); + expect(find.byTooltip('Menu'), findsOneWidget); + final origin = tester.getCenter(find.byTooltip('Menu')); + await tester.tap(find.byTooltip('Menu')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 120)); + final moving = tester.getCenter( + find.bySemanticsLabel('Close menu').last, + ); + expect(moving.dy, lessThan(origin.dy)); + await tester.pump(const Duration(milliseconds: 500)); + expect(tester.getTopLeft(page), before); + final card = find.byType(AvatarMenuCard); + expect(card, findsOneWidget); + expect(tester.getBottomRight(card).dy, lessThan(origin.dy)); + expect(tester.getSize(card).width, 376); + if (search) expect(find.bySemanticsLabel('Search'), findsOneWidget); + await tester.tap(find.bySemanticsLabel('Close menu').last); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + expect(find.byType(AvatarMenuCard), findsNothing); + expect(tester.getCenter(find.byTooltip('Menu')), origin); + expect(tester.takeException(), isNull); + }, + ); + } + + testWidgets('reduced motion opens immediately and system back dismisses', ( + tester, + ) async { + await pumpTopBar( + tester, + navMenuStyle: NooNavMenuStyle.avatarMenu, + withHost: true, + reducedMotion: true, + ); + await tester.tap(find.byTooltip('Menu')); + await tester.pump(); + expect(find.text('Settings'), findsOneWidget); + await tester.binding.handlePopRoute(); + await tester.pump(); + expect(find.byType(AvatarMenuCard), findsNothing); + expect(tester.takeException(), isNull); + }); + for (final position in AvatarPosition.values) { + for (final frosted in [false, true]) { + testWidgets( + '$position popup shares frost toggle and strength ($frosted)', + (tester) async { + await pumpTopBar( + tester, + navMenuStyle: NooNavMenuStyle.avatarMenu, + withHost: true, + position: position, + frosted: frosted, + ); + await tester.tap(find.byTooltip('Menu')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + final card = find.byType(AvatarMenuCard); + final backdrop = find.ancestor( + of: card, + matching: find.byType(BackdropFilter), + ); + expect(backdrop, frosted ? findsOneWidget : findsNothing); + if (frosted) { + expect( + tester.widget(backdrop).filter, + ImageFilter.blur(sigmaX: 19, sigmaY: 19), + ); + final decorations = tester.widgetList( + find.ancestor(of: card, matching: find.byType(DecoratedBox)), + ); + expect( + decorations.any( + (box) => + box.decoration is BoxDecoration && + ((box.decoration as BoxDecoration).color?.a ?? 0) > 0.44 && + ((box.decoration as BoxDecoration).color?.a ?? 0) < 0.46, + ), + isTrue, + ); + final materials = tester.widgetList( + find.descendant(of: card, matching: find.byType(Material)), + ); + expect( + materials.every( + (material) => material.color == Colors.transparent, + ), + isTrue, + ); + } + expect(tester.takeException(), isNull); + }, + ); + } + } + + testWidgets('iOS upload width interpolates while its label fades', ( + tester, + ) async { + await pumpTopBar( + tester, + navMenuStyle: NooNavMenuStyle.avatarMenu, + style: NooNavStyle.ios, + uploadStyle: FabStyle.mini, + ); + final button = find.byType(NooButton); + final collapsedWidth = tester.getSize(button).width; + await pumpTopBar( + tester, + navMenuStyle: NooNavMenuStyle.avatarMenu, + style: NooNavStyle.ios, + uploadStyle: FabStyle.expanded, + settleFrames: 0, + ); + await tester.pump(const Duration(milliseconds: 100)); + final middleWidth = tester.getSize(button).width; + final labelOpacity = tester + .widget( + find + .ancestor(of: find.text('Upload'), matching: find.byType(Opacity)) + .first, + ) + .opacity; + expect(labelOpacity, greaterThan(0)); + expect(labelOpacity, lessThan(1)); + await tester.pump(const Duration(milliseconds: 300)); + final expandedWidth = tester.getSize(button).width; + expect(middleWidth, greaterThan(collapsedWidth)); + expect(middleWidth, lessThan(expandedWidth)); + expect(tester.getSize(button).height, 32); + expect(tester.takeException(), isNull); + }); + + for (final uploadStyle in FabStyle.values) { + for (final tab in [AppTab.files, AppTab.photos, AppTab.favorites]) { + testWidgets('iOS title row uses $uploadStyle upload control on $tab', ( + tester, + ) async { + await pumpTopBar( + tester, + navMenuStyle: NooNavMenuStyle.avatarMenu, + style: NooNavStyle.ios, + tab: tab, + uploadStyle: uploadStyle, + ); + final title = find.descendant( + of: find.byType(AppTopBar), + matching: find.text(tab.label), + ); + final titleY = tester.getCenter(title).dy; + expect( + tester.getCenter(find.byTooltip('Upload')).dy, + closeTo(titleY, 1), + ); + expect(tester.getCenter(find.byTooltip('Menu')).dy, closeTo(titleY, 1)); + final expanded = + uploadStyle == FabStyle.expanded || + (uploadStyle == FabStyle.auto && tab != AppTab.favorites); + expect(find.text('Upload'), expanded ? findsOneWidget : findsNothing); + expect(tester.takeException(), isNull); + }); + } + } + + testWidgets('iOS attached navigation keeps popup above its rail', ( + tester, + ) async { + final prefs = await SharedPreferences.getInstance(); + final accounts = jsonDecode(prefs.getString('accounts_list')!) as List; + accounts.add( + const SavedAccount( + id: 'other_example_org__bob', + serverUrl: 'https://other.example.org', + username: 'bob', + ).toJson(), + ); + await prefs.setString('accounts_list', jsonEncode(accounts)); + await pumpTopBar( + tester, + navMenuStyle: NooNavMenuStyle.avatarMenu, + withHost: true, + position: AvatarPosition.bottom, + search: true, + style: NooNavStyle.ios, + barStyle: NooBottomBarStyle.attached, + ); + final origin = tester.getCenter(find.byTooltip('Menu')); + expect( + find.descendant( + of: find.byType(BottomNavBar), + matching: find.text('Files'), + ), + findsOneWidget, + ); + expect(find.text('Photos'), findsNothing); + await tester.tap(find.byTooltip('Menu')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + expect( + tester.getBottomLeft(find.byType(AvatarMenuCard)).dy, + origin.dy - 25 - 1, + ); + expect(tester.getSize(find.byType(AvatarMenuCard)).width, 400); + expect(tester.getCenter(find.byTooltip('Menu')), origin); + expect(find.bySemanticsLabel('Search'), findsOneWidget); + await tester.tap(find.text('alice')); + await tester.pump(); + for (var i = 0; i < 8; i++) { + await tester.pump(const Duration(milliseconds: 50)); + } + expect( + tester.getCenter(find.text('Settings')).dy, + lessThan(tester.getCenter(find.text('alice')).dy), + ); + expect( + tester.getCenter(find.text('alice')).dy, + lessThan(tester.getCenter(find.text('bob')).dy), + ); + expect( + tester.getCenter(find.text('bob')).dy, + lessThan(tester.getCenter(find.text('Add Account')).dy), + ); + await tester.tap(find.byTooltip('Menu')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + expect(find.byType(AvatarMenuCard), findsNothing); + expect(tester.getCenter(find.byTooltip('Menu')), origin); + expect(tester.takeException(), isNull); + }); + + testWidgets('expanding accounts also grows the top content offset', ( + tester, + ) async { + await pumpTopBar( + tester, + navMenuStyle: NooNavMenuStyle.avatarMenu, + withHost: true, + ); + await tester.tap(find.byTooltip('Menu')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + final before = tester + .getTopLeft(find.byKey(const ValueKey('page content'))) + .dy; + await tester.tap(find.text('alice')); + await tester.pump(); + for (var i = 0; i < 8; i++) { + await tester.pump(const Duration(milliseconds: 50)); + } + expect(find.text('Add Account'), findsOneWidget); + expect( + tester.getTopLeft(find.byKey(const ValueKey('page content'))).dy, + greaterThan(before), + ); + expect(tester.takeException(), isNull); + }); + testWidgets('Search satellite closes the bottom navigation popup', ( + tester, + ) async { + await pumpTopBar( + tester, + navMenuStyle: NooNavMenuStyle.avatarMenu, + withHost: true, + position: AvatarPosition.bottom, + search: true, + ); + await tester.tap(find.byTooltip('Menu')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + await tester.tap(find.bySemanticsLabel('Search')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + expect(find.byType(AvatarMenuCard), findsNothing); + expect(tester.takeException(), isNull); + }); } diff --git a/test/widgets/frosted_glass_container_test.dart b/test/widgets/frosted_glass_container_test.dart new file mode 100644 index 0000000..6925cbe --- /dev/null +++ b/test/widgets/frosted_glass_container_test.dart @@ -0,0 +1,59 @@ +import 'dart:ui' show ImageFilter; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:noo/providers/settings_controller.dart'; +import 'package:noo/widgets/frosted_glass_container.dart'; +import 'noo/noo_test_utils.dart'; + +void main() { + setUpNooTests(); + testNooWidgets('viewer glass follows toggle, blur and opacity', ( + tester, + theme, + colors, + ) async { + SharedPreferences.setMockInitialValues({ + 'ui_bottom_bar_frosted': false, + 'ui_bottom_bar_frosted_blur': 19.0, + 'ui_bottom_bar_frosted_opacity': 0.45, + }); + final settings = SettingsController(); + await pumpNoo( + tester, + ChangeNotifierProvider.value( + value: settings, + child: const FrostedGlassContainer( + child: SizedBox(width: 200, height: 80), + ), + ), + theme: theme, + ); + await tester.pumpAndSettle(); + expect(find.byType(BackdropFilter), findsNothing); + final fill = find.descendant( + of: find.byType(FrostedGlassContainer), + matching: find.byType(Container), + ); + expect( + (tester.widget(fill).decoration as BoxDecoration).color, + colors.surface, + ); + settings.setBottomBarFrosted(true); + await tester.pump(); + expect( + tester.widget(find.byType(BackdropFilter)).filter, + ImageFilter.blur(sigmaX: 19, sigmaY: 19), + ); + expect( + (tester.widget(fill).decoration as BoxDecoration).color, + colors.surface.withValues(alpha: 0.45), + ); + settings.setBottomBarFrosted(false); + await tester.pump(); + expect(find.byType(BackdropFilter), findsNothing); + await tester.pumpWidget(const SizedBox()); + settings.dispose(); + }); +} diff --git a/test/widgets/ios_seek_bar_test.dart b/test/widgets/ios_seek_bar_test.dart new file mode 100644 index 0000000..ea73ad6 --- /dev/null +++ b/test/widgets/ios_seek_bar_test.dart @@ -0,0 +1,74 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:noo/widgets/viewer/media_video_preview.dart'; +import 'package:video_player/video_player.dart'; +import 'noo/noo_test_utils.dart'; + +class _Controller extends VideoPlayerController { + _Controller() : super.networkUrl(Uri.parse('https://example.com/video.mp4')); + Duration? sought; + @override + Future seekTo(Duration position) async { + sought = position; + value = value.copyWith(position: position); + } + + @override + Future pause() async { + value = value.copyWith(isPlaying: false); + } + + @override + Future play() async { + value = value.copyWith(isPlaying: true); + } +} + +void main() { + setUpNooTests(); + for (final playing in [false, true]) { + testWidgets('iOS scrubbing preserves playing=$playing', (tester) async { + final controller = _Controller(); + controller.value = VideoPlayerValue( + duration: const Duration(seconds: 60), + position: const Duration(seconds: 15), + isInitialized: true, + isPlaying: playing, + ); + await pumpNoo( + tester, + IosSeekBar(controller: controller, color: CupertinoColors.white), + ); + final slider = tester.widget( + find.byType(CupertinoSlider), + ); + expect(slider.value, 0.25); + slider.onChangeStart!(0.25); + expect(controller.value.isPlaying, false); + slider.onChanged!(0.5); + await tester.pump(); + expect(controller.sought, const Duration(seconds: 30)); + slider.onChangeEnd!(0.5); + await tester.pump(); + expect(controller.value.isPlaying, playing); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + await controller.dispose(); + }); + } + testWidgets('iOS scrubber disables seeking before duration is available', ( + tester, + ) async { + final controller = _Controller(); + await pumpNoo( + tester, + IosSeekBar(controller: controller, color: CupertinoColors.white), + ); + expect( + tester.widget(find.byType(CupertinoSlider)).onChanged, + isNull, + ); + await tester.pumpWidget(const SizedBox()); + await controller.dispose(); + }); +} diff --git a/test/widgets/noo/nav_test.dart b/test/widgets/noo/nav_test.dart index 84b1fd0..4018f01 100644 --- a/test/widgets/noo/nav_test.dart +++ b/test/widgets/noo/nav_test.dart @@ -215,7 +215,7 @@ void main() { expect(opacity.opacity, 0); }); - testNooWidgets('ios: unchanged icon-above-label layout, no indicator', ( + testNooWidgets('ios: only selected tab has a label, no indicator', ( tester, theme, c, @@ -238,6 +238,8 @@ void main() { final iconCenter = tester.getCenter(find.byIcon(LucideIcons.images)); final labelCenter = tester.getCenter(find.text('Photos')); expect(labelCenter.dy, greaterThan(iconCenter.dy)); + expect(find.text('Files'), findsNothing); + expect(find.text('Favorites'), findsNothing); }); testNooWidgets('floating: inset, rounded, bordered - not edge to edge', ( diff --git a/test/widgets/selection_bar_overlay_test.dart b/test/widgets/selection_bar_overlay_test.dart new file mode 100644 index 0000000..0917ea9 --- /dev/null +++ b/test/widgets/selection_bar_overlay_test.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:noo/widgets/selection_bar_overlay.dart'; + +void main() { + for (final extendsBody in [false, true]) { + for (final safeBottom in [0.0, 34.0]) { + testWidgets( + 'action bar clears navigation: extend=$extendsBody, inset=$safeBottom', + (tester) async { + tester.view.physicalSize = const Size(400, 800); + tester.view.devicePixelRatio = 1; + tester.view.padding = FakeViewPadding(bottom: safeBottom); + addTearDown(tester.view.reset); + var taps = 0; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + extendBody: extendsBody, + bottomNavigationBar: SafeArea( + top: false, + child: SizedBox( + key: const ValueKey('navigation'), + height: 64, + ), + ), + body: SelectionBarOverlay( + bar: GestureDetector( + onTap: () => taps++, + child: Container( + key: const ValueKey('actions'), + color: Colors.blue, + ), + ), + child: ListView.builder( + itemCount: 100, + itemBuilder: (_, i) => + SizedBox(height: 48, child: Text('Item $i')), + ), + ), + ), + ), + ); + final actions = find.byKey(const ValueKey('actions')); + final navTop = tester + .getTopLeft(find.byKey(const ValueKey('navigation'))) + .dy; + expect(tester.getBottomLeft(actions).dy, navTop - 8); + final before = tester.getTopLeft(actions); + await tester.drag(find.byType(ListView), const Offset(0, -350)); + await tester.pumpAndSettle(); + expect(tester.getTopLeft(actions), before); + await tester.tap(actions); + expect(taps, 1); + }, + ); + } + } +} From 1f19bb77f949e80da0d3646d8e200aed737b0a78 Mon Sep 17 00:00:00 2001 From: Ayushya Amitabh Date: Wed, 7 Oct 2026 03:14:14 -0400 Subject: [PATCH 27/31] Fix bottom avatar popup account ordering on Android --- .claude/context/styling.md | 2 +- lib/widgets/avatar_menu.dart | 53 +++++++++++++-------- test/widgets/avatar_menu_test.dart | 75 ++++++++++++++++++++++++++++++ 3 files changed, 110 insertions(+), 20 deletions(-) diff --git a/.claude/context/styling.md b/.claude/context/styling.md index fb7cef3..a149d7a 100644 --- a/.claude/context/styling.md +++ b/.claude/context/styling.md @@ -309,7 +309,7 @@ blocks are noted where they matter: full screen width, rounded top corners and no gap above the bar; the avatar stays in the bar, and Search keeps its inline position. The attached popup has no outer border or shadow, blending into the bar. - Attached popup order is navigation (including Settings), current account, + All bottom popups order navigation (including Settings), current account, then expanded secondary accounts and Add/Manage Account controls. The iOS shell places Upload and the top avatar on the large-title row. Upload uses the shared `ui_fab_style` preference: Auto expands on Files diff --git a/lib/widgets/avatar_menu.dart b/lib/widgets/avatar_menu.dart index b7d00c2..5778eb8 100644 --- a/lib/widgets/avatar_menu.dart +++ b/lib/widgets/avatar_menu.dart @@ -73,6 +73,7 @@ class AvatarMenuCard extends StatelessWidget { final bool expanded; final bool movingAvatar; final bool showHeaderAvatar; + final Key? headerAvatarKey; final VoidCallback onExpand; final VoidCallback onClose; const AvatarMenuCard({ @@ -80,6 +81,7 @@ class AvatarMenuCard extends StatelessWidget { this.bottom = false, this.movingAvatar = false, this.showHeaderAvatar = true, + this.headerAvatarKey, required this.expanded, required this.onExpand, required this.onClose, @@ -133,17 +135,20 @@ class AvatarMenuCard extends StatelessWidget { if (showHeaderAvatar) const SizedBox(width: 12), if (showHeaderAvatar) movingAvatar - ? const SizedBox.square(dimension: 32) - : NooAvatar( - initials: accountInitial(session.username), - current: true, - size: 32, + ? SizedBox.square(key: headerAvatarKey, dimension: 32) + : GestureDetector( + onTap: onClose, + child: NooAvatar( + key: headerAvatarKey, + initials: accountInitial(session.username), + current: true, + size: 32, + ), ), ], ), ), ); - final attached = bottom && !showHeaderAvatar; final accountSection = AnimatedSize( duration: NooMotion.base, curve: Curves.easeOutCubic, @@ -199,7 +204,7 @@ class AvatarMenuCard extends StatelessWidget { final rows = Column( mainAxisSize: MainAxisSize.min, children: [ - if (!attached) accountSection, + if (!bottom) accountSection, for (final tab in settings.tabOrder.where(settings.hiddenTabs.contains)) NooSettingsRow( backgroundColor: Colors.transparent, @@ -223,7 +228,7 @@ class AvatarMenuCard extends StatelessWidget { label: const Text('Settings'), onTap: settingsTap, ), - if (attached) ...[header, accountSection], + if (bottom) ...[header, accountSection], ], ); final content = SizedBox( @@ -242,7 +247,6 @@ class AvatarMenuCard extends StatelessWidget { fit: FlexFit.loose, child: SingleChildScrollView(child: rows), ), - if (bottom && !attached) header, ], ), ), @@ -278,9 +282,7 @@ class _AvatarSurface extends StatelessWidget { color: frosted ? colors.surface.withValues(alpha: settings.bottomBarFrostedOpacity) : colors.surface, - border: connected - ? null - : Border.all(color: colors.line), + border: connected ? null : Border.all(color: colors.line), borderRadius: radius, ), child: Padding(padding: const EdgeInsets.all(1), child: child), @@ -419,6 +421,8 @@ class _AvatarNavigationHostState extends State reverseDuration: const Duration(milliseconds: 320), ); final GlobalKey measureKey = GlobalKey(); + final GlobalKey measureAvatarKey = GlobalKey(); + Offset avatarOffset = const Offset(0, 17); Rect? origin; bool bottom = false; bool expanded = false; @@ -431,8 +435,18 @@ class _AvatarNavigationHostState extends State measuring = false; if (!mounted) return; final box = measureKey.currentContext?.findRenderObject() as RenderBox?; - if (box != null && (box.size.height - cardHeight).abs() > .5) { - setState(() => cardHeight = box.size.height); + final avatarBox = + measureAvatarKey.currentContext?.findRenderObject() as RenderBox?; + final offset = box != null && avatarBox != null + ? avatarBox.localToGlobal(Offset.zero, ancestor: box) + : avatarOffset; + if (box != null && + ((box.size.height - cardHeight).abs() > .5 || + (offset - avatarOffset).distance > .5)) { + setState(() { + cardHeight = box.size.height; + avatarOffset = offset; + }); } }); } @@ -523,11 +537,12 @@ class _AvatarNavigationHostState extends State height * .8, ); measure(); - Widget card() => ConstrainedBox( + Widget card({bool measurement = false}) => ConstrainedBox( constraints: BoxConstraints(maxHeight: maxHeight), child: AvatarMenuCard( bottom: bottom, - movingAvatar: true, + movingAvatar: measurement || progress < 1, + headerAvatarKey: measurement ? measureAvatarKey : null, showHeaderAvatar: !attached, expanded: expanded, onExpand: () => setState(() => expanded = !expanded), @@ -560,7 +575,7 @@ class _AvatarNavigationHostState extends State ); final avatarTarget = Rect.fromLTWH( target.right - 49, - bottom ? target.bottom - 49 : target.top + 17, + target.top + avatarOffset.dy, 32, 32, ); @@ -595,7 +610,7 @@ class _AvatarNavigationHostState extends State child: SizeChangedLayoutNotifier( child: KeyedSubtree( key: measureKey, - child: card(), + child: card(measurement: true), ), ), ), @@ -643,7 +658,7 @@ class _AvatarNavigationHostState extends State ), ), ), - if (!attached) + if (!attached && progress < 1) Positioned.fromRect( rect: avatar, child: Semantics( diff --git a/test/widgets/avatar_menu_test.dart b/test/widgets/avatar_menu_test.dart index 6223df9..41c296b 100644 --- a/test/widgets/avatar_menu_test.dart +++ b/test/widgets/avatar_menu_test.dart @@ -550,6 +550,81 @@ void main() { expect(tester.takeException(), isNull); }); + testWidgets('Android floating popup orders navigation before accounts', ( + tester, + ) async { + final prefs = await SharedPreferences.getInstance(); + final accounts = jsonDecode(prefs.getString('accounts_list')!) as List; + accounts.add( + const SavedAccount( + id: 'other_example_org__bob', + serverUrl: 'https://other.example.org', + username: 'bob', + ).toJson(), + ); + await prefs.setString('accounts_list', jsonEncode(accounts)); + await pumpTopBar( + tester, + navMenuStyle: NooNavMenuStyle.avatarMenu, + withHost: true, + position: AvatarPosition.bottom, + search: true, + style: NooNavStyle.android, + barStyle: NooBottomBarStyle.floating, + ); + final origin = tester.getCenter(find.byTooltip('Menu')); + expect( + find.descendant( + of: find.byType(BottomNavBar), + matching: find.text('Files'), + ), + findsOneWidget, + ); + expect(find.text('Photos'), findsNothing); + await tester.tap(find.byTooltip('Menu')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + expect( + tester.getBottomLeft(find.byType(AvatarMenuCard)).dy, + origin.dy - 32 - 12 - 1, + ); + expect(tester.getSize(find.byType(AvatarMenuCard)).width, 376); + expect(find.bySemanticsLabel('Search'), findsOneWidget); + await tester.tap(find.text('alice')); + await tester.pump(); + for (var i = 0; i < 8; i++) { + await tester.pump(const Duration(milliseconds: 50)); + } + expect( + tester.getCenter(find.text('Settings')).dy, + lessThan(tester.getCenter(find.text('alice')).dy), + ); + expect( + tester.getCenter(find.text('alice')).dy, + lessThan(tester.getCenter(find.text('bob')).dy), + ); + expect( + tester.getCenter(find.text('bob')).dy, + lessThan(tester.getCenter(find.text('Add Account')).dy), + ); + final currentAvatar = find + .descendant( + of: find.byType(AvatarMenuCard), + matching: find.byType(NooAvatar), + ) + .first; + expect( + tester.getCenter(currentAvatar).dy, + lessThan(tester.getCenter(find.text('bob')).dy), + ); + await tester.tap(currentAvatar); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + expect(find.byType(AvatarMenuCard), findsNothing); + expect(tester.getCenter(find.byTooltip('Menu')), origin); + expect(tester.takeException(), isNull); + }); + testWidgets('expanding accounts also grows the top content offset', ( tester, ) async { From e83358054cb07f5ad61d155127659b647dd8f800 Mon Sep 17 00:00:00 2001 From: Ayushya Amitabh Date: Wed, 7 Oct 2026 19:04:39 -0400 Subject: [PATCH 28/31] CI: signed iOS build and TestFlight upload on Release tags Co-Authored-By: Claude Sonnet 5.5 --- .gitea/scripts/ios_ci_signing.rb | 26 +++++++++ .gitea/workflows/ios.yml | 94 ++++++++++++++++++++++++++++++++ ios/ExportOptions.plist | 23 ++++++++ ios/Runner/Info.plist | 2 + 4 files changed, 145 insertions(+) create mode 100644 .gitea/scripts/ios_ci_signing.rb create mode 100644 .gitea/workflows/ios.yml create mode 100644 ios/ExportOptions.plist diff --git a/.gitea/scripts/ios_ci_signing.rb b/.gitea/scripts/ios_ci_signing.rb new file mode 100644 index 0000000..f79606b --- /dev/null +++ b/.gitea/scripts/ios_ci_signing.rb @@ -0,0 +1,26 @@ +# CI-only: switches the Release configuration of the Runner and ShareExtension +# targets to manual signing with the App Store profiles. The committed project +# stays on Automatic signing so local development is unaffected; this edit +# happens only inside the CI checkout. Run from the repo root: +# ruby .gitea/scripts/ios_ci_signing.rb +require "xcodeproj" + +TEAM = "Q3JLTAG9PV" +PROFILES = { + "Runner" => "NooProfile", + "ShareExtension" => "NooShareSheetProfile", +}.freeze + +project = Xcodeproj::Project.open("ios/Runner.xcodeproj") +PROFILES.each do |target_name, profile| + target = project.targets.find { |t| t.name == target_name } or abort("no target #{target_name}") + target.build_configurations.select { |c| c.name == "Release" }.each do |config| + settings = config.build_settings + settings["CODE_SIGN_STYLE"] = "Manual" + settings["DEVELOPMENT_TEAM"] = TEAM + settings["CODE_SIGN_IDENTITY"] = "Apple Distribution" + settings["CODE_SIGN_IDENTITY[sdk=iphoneos*]"] = "Apple Distribution" + settings["PROVISIONING_PROFILE_SPECIFIER"] = profile + end +end +project.save diff --git a/.gitea/workflows/ios.yml b/.gitea/workflows/ios.yml new file mode 100644 index 0000000..3caed23 --- /dev/null +++ b/.gitea/workflows/ios.yml @@ -0,0 +1,94 @@ +name: Build iOS + +# Same trigger as the Play Store bundle (release.yml): a real release tag. +# git tag Release-1.0.0 && git push origin Release-1.0.0 +# Builds a signed .ipa, attaches it to the Gitea release, and uploads it to +# TestFlight. Needs a Mac runner registered with the `macos` label (act_runner +# in host mode) with Xcode installed; Linux runners cannot build iOS. +on: + push: + tags: + - "Release-*" + +jobs: + build: + runs-on: macos + steps: + - name: Check out code + uses: actions/checkout@v4 + + - name: Set up Flutter + uses: subosito/flutter-action@v2 + with: + channel: stable + + - name: Check tag matches pubspec version + run: | + VERSION=$(grep '^version:' pubspec.yaml | sed 's/version: //' | cut -d'+' -f1) + if [ "${GITHUB_REF_NAME}" != "Release-${VERSION}" ]; then + echo "Tag ${GITHUB_REF_NAME} does not match pubspec version ${VERSION}" >&2 + exit 1 + fi + + - name: Install dependencies + run: flutter pub get + + - name: Analyze + run: flutter analyze + + - name: Install signing certificate and profiles + # A throwaway keychain keeps the distribution key off the runner's + # login keychain; it is deleted again in the cleanup step. + env: + CERT_P12_BASE64: ${{ secrets.IOS_DIST_CERT_P12_BASE64 }} + CERT_PASSWORD: ${{ secrets.IOS_DIST_CERT_PASSWORD }} + APP_PROFILE_BASE64: ${{ secrets.IOS_APP_PROFILE_BASE64 }} + EXT_PROFILE_BASE64: ${{ secrets.IOS_EXT_PROFILE_BASE64 }} + run: | + KEYCHAIN="${{ runner.temp }}/ci-signing.keychain-db" + KEYCHAIN_PASSWORD=$(uuidgen) + security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN" + security set-keychain-settings -lut 21600 "$KEYCHAIN" + security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN" + echo "$CERT_P12_BASE64" | base64 -d > "${{ runner.temp }}/dist.p12" + security import "${{ runner.temp }}/dist.p12" -P "$CERT_PASSWORD" -A -t cert -f pkcs12 -k "$KEYCHAIN" + security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN" + security list-keychains -d user -s "$KEYCHAIN" $(security list-keychains -d user | tr -d '"') + rm -f "${{ runner.temp }}/dist.p12" + + PROFILES_DIR="$HOME/Library/MobileDevice/Provisioning Profiles" + mkdir -p "$PROFILES_DIR" + echo "$APP_PROFILE_BASE64" | base64 -d > "$PROFILES_DIR/NooProfile.mobileprovision" + echo "$EXT_PROFILE_BASE64" | base64 -d > "$PROFILES_DIR/NooShareSheetProfile.mobileprovision" + + - name: Switch Release config to manual signing + run: ruby .gitea/scripts/ios_ci_signing.rb + + - name: Build signed IPA + run: flutter build ipa --release --export-options-plist=ios/ExportOptions.plist + + - name: Rename IPA to Noo-.ipa + run: | + VERSION=$(grep '^version:' pubspec.yaml | sed 's/version: //' | cut -d'+' -f1) + mkdir -p release + cp build/ios/ipa/*.ipa "release/Noo-${VERSION}.ipa" + echo "IPA_PATH=release/Noo-${VERSION}.ipa" >> "$GITHUB_ENV" + + - name: Upload to TestFlight + env: + ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }} + ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }} + ASC_KEY_P8_BASE64: ${{ secrets.ASC_KEY_P8_BASE64 }} + run: | + mkdir -p "$HOME/.appstoreconnect/private_keys" + echo "$ASC_KEY_P8_BASE64" | base64 -d > "$HOME/.appstoreconnect/private_keys/AuthKey_${ASC_KEY_ID}.p8" + xcrun altool --upload-app -f "$IPA_PATH" -t ios \ + --apiKey "$ASC_KEY_ID" --apiIssuer "$ASC_ISSUER_ID" + + - name: Cleanup signing material + if: always() + run: | + security delete-keychain "${{ runner.temp }}/ci-signing.keychain-db" || true + rm -f "$HOME/Library/MobileDevice/Provisioning Profiles/NooProfile.mobileprovision" \ + "$HOME/Library/MobileDevice/Provisioning Profiles/NooShareSheetProfile.mobileprovision" + rm -rf "$HOME/.appstoreconnect/private_keys" diff --git a/ios/ExportOptions.plist b/ios/ExportOptions.plist new file mode 100644 index 0000000..c01f601 --- /dev/null +++ b/ios/ExportOptions.plist @@ -0,0 +1,23 @@ + + + + + method + app-store-connect + teamID + Q3JLTAG9PV + signingStyle + manual + signingCertificate + Apple Distribution + provisioningProfiles + + dev.ayushya.noo + NooProfile + dev.ayushya.noo.ShareExtension + NooShareSheetProfile + + uploadSymbols + + + diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index eabd0ab..3d447d4 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -86,5 +86,7 @@ UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight + ITSAppUsesNonExemptEncryption + From ef4c1a2afa76269c51c9bdb014368c11d4e30435 Mon Sep 17 00:00:00 2001 From: Ayushya Amitabh Date: Wed, 7 Oct 2026 19:15:29 -0400 Subject: [PATCH 29/31] CI: split release tags - RC-Android-* (APK), RC-iOS-* (iOS), Release-* (both) Co-Authored-By: Claude Sonnet 5.5 --- .claude/context/standards.md | 8 +++++--- .gitea/workflows/build.yml | 11 ++++++----- .gitea/workflows/ios.yml | 16 +++++++++++----- .gitea/workflows/release.yml | 4 ++-- 4 files changed, 24 insertions(+), 15 deletions(-) diff --git a/.claude/context/standards.md b/.claude/context/standards.md index 18d3836..dc8b0b3 100644 --- a/.claude/context/standards.md +++ b/.claude/context/standards.md @@ -113,9 +113,11 @@ in this order: a local `android/key.properties` (gitignored — points at a gitignored keystore file, e.g. `android/app/release-keystore.jks`), then CI env vars (`RELEASE_KEYSTORE_PATH`/`_PASSWORD`, `RELEASE_KEY_ALIAS`/ `_PASSWORD`, set from the same repo secrets by both `.gitea/workflows/ -build.yml`, triggered by `RC*` tags and producing a sideloadable APK, and -`.gitea/workflows/release.yml`, triggered by `Release-*` tags and producing -the `.aab` Play Console wants), then falls back to the debug key if neither +build.yml`, triggered by `RC-Android-*` tags and producing a sideloadable +APK, and `.gitea/workflows/release.yml`, triggered by `Release-*` tags and +producing the `.aab` Play Console wants; `.gitea/workflows/ios.yml` builds +and uploads the signed iOS app to TestFlight on `RC-iOS-*` and `Release-*` +tags, on a self-hosted macOS runner), then falls back to the debug key if neither is configured. As long as the same dedicated release keystore backs both `key.properties` locally and the Gitea secrets, local release builds and CI-built release APKs share one signature, so `adb install -r` works diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index 8e7a8e8..e55836b 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -1,12 +1,13 @@ name: Build APK -# Deliberately not "on every push" - only when a release-candidate tag -# (RC1, RC2, RC2026.1, ...) is pushed. Tag the commit you want built: -# git tag RC1 && git push origin RC1 +# Deliberately not "on every push" - only when an Android release-candidate +# tag (RC-Android-1, RC-Android-2, ...) is pushed. Tag the commit you want built: +# git tag RC-Android-1 && git push origin RC-Android-1 +# iOS has its own RC-iOS-* tags (ios.yml); Release-* builds both platforms. on: push: tags: - - "RC*" + - "RC-Android-*" jobs: build: @@ -72,7 +73,7 @@ jobs: # talks to this same Gitea instance automatically via the # `github.token`/`github.server_url` context Gitea Actions # provides for compatibility. Release is named/tagged after - # whatever tag triggered this run (e.g. "RC1"). + # whatever tag triggered this run (e.g. "RC-Android-1"). uses: akkuman/gitea-release-action@v1 with: files: ${{ env.APK_PATH }} diff --git a/.gitea/workflows/ios.yml b/.gitea/workflows/ios.yml index 3caed23..d455de1 100644 --- a/.gitea/workflows/ios.yml +++ b/.gitea/workflows/ios.yml @@ -1,14 +1,18 @@ name: Build iOS -# Same trigger as the Play Store bundle (release.yml): a real release tag. -# git tag Release-1.0.0 && git push origin Release-1.0.0 -# Builds a signed .ipa, attaches it to the Gitea release, and uploads it to -# TestFlight. Needs a Mac runner registered with the `macos` label (act_runner -# in host mode) with Xcode installed; Linux runners cannot build iOS. +# Tag scheme (shared with the Android workflows): +# Release- -> release.yml (Play bundle) AND this workflow +# RC-Android- -> build.yml (APK) only +# RC-iOS- -> this workflow only +# git tag RC-iOS-1 && git push origin RC-iOS-1 +# Builds a signed .ipa and uploads it to TestFlight. Needs a Mac runner +# registered with the `macos` label (gitea-runner in host mode) with Xcode +# installed; Linux runners cannot build iOS. on: push: tags: - "Release-*" + - "RC-iOS-*" jobs: build: @@ -23,6 +27,8 @@ jobs: channel: stable - name: Check tag matches pubspec version + # Only Release-* tags carry a version; RC-iOS-* tags are free-form. + if: startsWith(github.ref_name, 'Release-') run: | VERSION=$(grep '^version:' pubspec.yaml | sed 's/version: //' | cut -d'+' -f1) if [ "${GITHUB_REF_NAME}" != "Release-${VERSION}" ]; then diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 12646e4..28e4799 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -3,7 +3,7 @@ name: Build App Bundle # Deliberately not "on every push" - only when a real release tag (Release-1.0.0, # Release-1.2.3, ...) is pushed. Tag the commit you want built: # git tag Release-1.0.0 && git push origin Release-1.0.0 -# Unlike the RC* flow (build.yml), this builds the .aab Play Console wants, +# Unlike the RC-Android-* flow (build.yml), this builds the .aab Play Console wants, # not a sideloadable .apk. on: push: @@ -56,7 +56,7 @@ jobs: run: flutter analyze - name: Decode release keystore - # Same key as the RC* APK builds, so a Play Store upload's signature + # Same key as the RC-Android-* APK builds, so a Play Store upload's signature # matches anything sideloaded from a release build. See # android/app/build.gradle.kts. run: echo "$RELEASE_KEYSTORE_BASE64" | base64 -d > "${{ runner.temp }}/release-keystore.jks" From 91ab8b4a07b58ecdbf7d57d7cc87c83cb527b872 Mon Sep 17 00:00:00 2001 From: Ayushya Amitabh Date: Wed, 7 Oct 2026 19:36:29 -0400 Subject: [PATCH 30/31] CI: pin iOS signing certificate by SHA-1 and log CI keychain identities Co-Authored-By: Claude Sonnet 5.5 --- .gitea/scripts/ios_ci_signing.rb | 9 +++++++-- .gitea/workflows/ios.yml | 3 +++ ios/ExportOptions.plist | 2 +- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/.gitea/scripts/ios_ci_signing.rb b/.gitea/scripts/ios_ci_signing.rb index f79606b..1a0ee18 100644 --- a/.gitea/scripts/ios_ci_signing.rb +++ b/.gitea/scripts/ios_ci_signing.rb @@ -6,6 +6,11 @@ require "xcodeproj" TEAM = "Q3JLTAG9PV" +# Pinned by SHA-1, not by the "Apple Distribution" name: a runner that also +# holds other same-named distribution certs (e.g. a dev Mac's login keychain) +# would otherwise let Xcode pick one the profiles don't include. Update this +# (and ios/ExportOptions.plist) when the certificate is renewed. +CERT_SHA1 = "2527806871D806E8E27221B15CA8A1938CF216F2" PROFILES = { "Runner" => "NooProfile", "ShareExtension" => "NooShareSheetProfile", @@ -18,8 +23,8 @@ PROFILES.each do |target_name, profile| settings = config.build_settings settings["CODE_SIGN_STYLE"] = "Manual" settings["DEVELOPMENT_TEAM"] = TEAM - settings["CODE_SIGN_IDENTITY"] = "Apple Distribution" - settings["CODE_SIGN_IDENTITY[sdk=iphoneos*]"] = "Apple Distribution" + settings["CODE_SIGN_IDENTITY"] = CERT_SHA1 + settings["CODE_SIGN_IDENTITY[sdk=iphoneos*]"] = CERT_SHA1 settings["PROVISIONING_PROFILE_SPECIFIER"] = profile end end diff --git a/.gitea/workflows/ios.yml b/.gitea/workflows/ios.yml index d455de1..4f4a5a2 100644 --- a/.gitea/workflows/ios.yml +++ b/.gitea/workflows/ios.yml @@ -61,6 +61,9 @@ jobs: security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN" security list-keychains -d user -s "$KEYCHAIN" $(security list-keychains -d user | tr -d '"') rm -f "${{ runner.temp }}/dist.p12" + # Must list 2527806871D8... (the cert the profiles embed); if it + # doesn't, IOS_DIST_CERT_P12_BASE64 holds the wrong certificate. + security find-identity -v -p codesigning "$KEYCHAIN" PROFILES_DIR="$HOME/Library/MobileDevice/Provisioning Profiles" mkdir -p "$PROFILES_DIR" diff --git a/ios/ExportOptions.plist b/ios/ExportOptions.plist index c01f601..346c2f2 100644 --- a/ios/ExportOptions.plist +++ b/ios/ExportOptions.plist @@ -9,7 +9,7 @@ signingStyle manual signingCertificate - Apple Distribution + 2527806871D806E8E27221B15CA8A1938CF216F2 provisioningProfiles dev.ayushya.noo From 2d244ce33503b3db9f1e4f76c94d8391e22ec77d Mon Sep 17 00:00:00 2001 From: Ayushya Amitabh Date: Wed, 7 Oct 2026 21:04:50 -0400 Subject: [PATCH 31/31] Add action bar preview and all swipe actions; rename App lock --- .../context/design-system/DESIGN_SYSTEM.md | 10 +- .claude/context/styling.md | 11 ++ lib/providers/settings_controller.dart | 19 +++- lib/views/files_view.dart | 46 ++++---- lib/widgets/noo/files/noo_swipe_action.dart | 19 ++-- lib/widgets/settings/settings_action_bar.dart | 58 +++++++++- lib/widgets/settings/settings_security.dart | 23 +--- lib/widgets/settings/settings_swipe.dart | 20 +--- test/widgets/noo/files_swipe_test.dart | 48 ++++++++ test/widgets/settings_action_bar_test.dart | 105 ++++++++++++++++++ 10 files changed, 277 insertions(+), 82 deletions(-) create mode 100644 test/widgets/settings_action_bar_test.dart diff --git a/.claude/context/design-system/DESIGN_SYSTEM.md b/.claude/context/design-system/DESIGN_SYSTEM.md index 8e1f6ca..8bf9811 100644 --- a/.claude/context/design-system/DESIGN_SYSTEM.md +++ b/.claude/context/design-system/DESIGN_SYSTEM.md @@ -168,10 +168,10 @@ Use **Lucide** (pinned to `lucide-static@0.460.0`) at a 1.8 stroke. Sizes are 14 - Shared: `users` in fg-3. **Swipe action** -- The row slides and uncovers a 96px action block. Delete is white on danger-fill; Favorite is white on accent. +- The row slides and uncovers a 96px action block. Delete is white on danger-fill; all other actions are white on accent. - The block has a 20px icon above a 12/600 label. - Releasing past half the block snaps it open; dragging further still (past 1.8× the block's width, a haptic tick marks the crossing and the icon bumps up 15%) arms the action, and releasing while armed fires it immediately - one swipe-through gesture instead of open-then-tap. The block grows to fill however far it's dragged, so there's never a gap behind it. -- Swipe actions are set in Settings → Swipe on a file. +- Swipe actions are set in Settings → Swipe on a file. Both directions offer None and every action-bar action: Favorite, Share, Download, Delete, Copy, Move, Rename, Sync to device, Details. They use the same single-item handlers; Delete still asks for confirmation. **Selection action bar** (Files, Photos, Favorites - anywhere with multi-select) @@ -289,7 +289,7 @@ Mobile always has **5 pinned tabs in the bottom bar**. The rest go in the drawer | Upload | `plus` in the nav bar | Extended FAB - fully round (pill) when the bottom bar is floating, rounded square (`card` radius) when it's attached | | Overflow icon | `ellipsis` | `ellipsis-vertical` | | Settings | Pushed screen with a "‹ Files" back button and a large title. No bottom bar. | Pushed screen with a back arrow and a title. No bottom bar. | -| Biometric label | "Lock with Face ID" | "Lock with fingerprint" | +| App lock label | "App lock" | "App lock" | ### Wide tablet @@ -359,7 +359,9 @@ Sidebar items are 38px tall with radius 12, an 18px icon and a 14/500 label. The priority order for the Selection action bar's bulk actions (favorite, share, download, delete, copy, move, rename, sync, details) - the first few (3 mobile / 4 wide tablet) land in the bar's fixed inline - slots, the rest sit behind "More". See §2 "Selection action bar". + slots, the rest sit behind "More". A live preview above the list uses + the actual bar and updates on reorder, including its More sheet. Preview + actions show feedback without touching files. See §2 "Selection action bar". 9. Swipe on a file Mobile is a two-level menu, the way native iOS/Android Settings apps diff --git a/.claude/context/styling.md b/.claude/context/styling.md index a149d7a..dc901fe 100644 --- a/.claude/context/styling.md +++ b/.claude/context/styling.md @@ -109,6 +109,17 @@ Gotchas: trigger it) but also fires it directly if the drag goes far enough (past 1.8x the block's width) before release - a full swipe-through does delete in one gesture, it's not tap-only anymore. +- Swipe settings offer every `SelectionActionKind` plus None. `SwipeAction` + persists the existing enum names and maps to that shared action catalog; + `FilesView` uses the single-item action-bar handlers, with the existing + delete confirmation retained. Labels/icons update for favorited and synced + items. Swipes remain disabled during selection, picking, and offline browsing. +- Settings → Action bar shows the real `NooSelectionBar` above its reorder + list. It updates immediately and supports the same More sheet; preview + actions only show feedback, never operate on files. The bar scales down + inside Settings' narrower column while keeping the mobile/tablet split. +- The Security setting is labelled **App lock** on every platform; its + subtitle still explains device PIN or biometric authentication. - Always read colors through `context.nooColors`, never `Theme.of(context).colorScheme` - the latter is Flutter's own Material 3 scheme, reseeded by the user's accent color choice (`AppTheme.light`/ diff --git a/lib/providers/settings_controller.dart b/lib/providers/settings_controller.dart index feb0851..83211d3 100644 --- a/lib/providers/settings_controller.dart +++ b/lib/providers/settings_controller.dart @@ -7,7 +7,24 @@ import '../widgets/noo/nav/noo_nav_style.dart'; /// What swiping a Files list-view item left/right does, user-configurable /// in Settings. -enum SwipeAction { none, favorite, delete, share } +enum SwipeAction { + none(null), + favorite(SelectionActionKind.favorite), + delete(SelectionActionKind.delete), + share(SelectionActionKind.share), + download(SelectionActionKind.download), + copy(SelectionActionKind.copy), + move(SelectionActionKind.move), + rename(SelectionActionKind.rename), + sync(SelectionActionKind.sync), + details(SelectionActionKind.details); + + const SwipeAction(this.selectionKind); + + final SelectionActionKind? selectionKind; + + String get label => selectionKind?.label ?? 'None'; +} /// How the mobile Upload button is sized: [auto] expands on Files/Photos and /// shrinks to an icon elsewhere, [mini] is always icon-only, [expanded] diff --git a/lib/views/files_view.dart b/lib/views/files_view.dart index 10e9ed6..487b53f 100644 --- a/lib/views/files_view.dart +++ b/lib/views/files_view.dart @@ -629,30 +629,23 @@ class _FilesViewState extends State { ); } - /// The user's configured swipe action for one side of a row, or null if - /// that side is off or set to an action the design's two-slot - /// `NooSwipeAction` has no room for (`SwipeAction.share` - still reachable - /// via the row's overflow menu / the "Share" bulk action). - NooSwipeActionSpec? _swipeSpec( - SwipeAction action, - NextcloudItem item, - ItemOperations ops, - ) { - switch (action) { - case SwipeAction.favorite: - return NooSwipeActionSpec( - kind: NooSwipeActionKind.favorite, - onTriggered: () => ops.toggleItemFavorite(item), - ); - case SwipeAction.delete: - return NooSwipeActionSpec( - kind: NooSwipeActionKind.delete, - onTriggered: () => _confirmAndDeleteViaSwipe(item), - ); - case SwipeAction.share: - case SwipeAction.none: - return null; - } + /// Reuse the single-item action handlers so swipe gestures and the action + /// bar agree on labels, sheets and mutations. Delete retains its swipe + /// confirmation before making any server changes. + NooSwipeActionSpec? _swipeSpec(SwipeAction action, NextcloudItem item) { + final kind = action.selectionKind; + if (kind == null) return null; + final selectedAction = _buildSelectionActions(context, [ + item, + ]).firstWhere((candidate) => candidate.kind == kind); + return NooSwipeActionSpec( + kind: kind, + label: selectedAction.label, + icon: selectedAction.icon, + onTriggered: kind == SelectionActionKind.delete + ? () => _confirmAndDeleteViaSwipe(item) + : selectedAction.onTap, + ); } @override @@ -1343,7 +1336,6 @@ class _FilesViewState extends State { final sync = context.watch(); final session = context.watch(); final settings = context.watch(); - final ops = context.read(); final isSelected = _selectedIds.contains(item.id); // Pick mode and selection are server-side features - never on Offline. final picking = !_offline && pick.isPicking; @@ -1381,8 +1373,8 @@ class _FilesViewState extends State { final swipeable = _isSelecting || picking || _offline ? row : NooSwipeAction( - startAction: _swipeSpec(settings.swipeRightAction, item, ops), - endAction: _swipeSpec(settings.swipeLeftAction, item, ops), + startAction: _swipeSpec(settings.swipeRightAction, item), + endAction: _swipeSpec(settings.swipeLeftAction, item), child: row, ); diff --git a/lib/widgets/noo/files/noo_swipe_action.dart b/lib/widgets/noo/files/noo_swipe_action.dart index 7eb9ce9..e729ad7 100644 --- a/lib/widgets/noo/files/noo_swipe_action.dart +++ b/lib/widgets/noo/files/noo_swipe_action.dart @@ -1,23 +1,21 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:lucide_icons_flutter/lucide_icons.dart'; +import '../../../models/selection_action.dart'; import '../../../theme/design_tokens.dart'; -/// The two swipe actions the design system defines (`DESIGN_SYSTEM.md` 2, -/// "Swipe action"); which one sits on which side is a user setting -/// (Settings -> Swipe on a file). -enum NooSwipeActionKind { delete, favorite } +/// Swipe blocks share the action bar's identities, icons and default labels. +typedef NooSwipeActionKind = SelectionActionKind; /// One side's action for [NooSwipeAction]. class NooSwipeActionSpec { final NooSwipeActionKind kind; final VoidCallback onTriggered; - /// Overrides the default label ("Delete" / "Favorite"), e.g. + /// Overrides the action's default label, e.g. /// "Unfavorite" for an already-starred file. final String? label; - /// Overrides the default icon (`trash-2` / `star`). + /// Overrides the action's default icon. final IconData? icon; const NooSwipeActionSpec({ @@ -30,7 +28,7 @@ class NooSwipeActionSpec { /// Wraps a row (normally a `NooFileRow`) so dragging it horizontally slides /// it aside to uncover a 96px action block: delete is white on -/// `danger-fill`, favorite is white on `accent`, each a 20px icon over a +/// `danger-fill`, other actions are white on `accent`, each a 20px icon over a /// 12/600 label. Releasing past half the block snaps it open, otherwise it /// springs shut - both on [NooMotion.base] / [NooMotion.ease], no bounce. /// Tapping the block fires the action and closes it; tapping the row while @@ -256,9 +254,8 @@ class _ActionBlock extends StatelessWidget { Widget build(BuildContext context) { final isDelete = spec.kind == NooSwipeActionKind.delete; final bg = isDelete ? colors.dangerFill : colors.accent; - final icon = - spec.icon ?? (isDelete ? LucideIcons.trash2 : LucideIcons.star); - final label = spec.label ?? (isDelete ? 'Delete' : 'Favorite'); + final icon = spec.icon ?? spec.kind.icon; + final label = spec.label ?? spec.kind.label; return GestureDetector( behavior: HitTestBehavior.opaque, diff --git a/lib/widgets/settings/settings_action_bar.dart b/lib/widgets/settings/settings_action_bar.dart index d58e3a8..2c5197b 100644 --- a/lib/widgets/settings/settings_action_bar.dart +++ b/lib/widgets/settings/settings_action_bar.dart @@ -4,6 +4,7 @@ import '../../models/selection_action.dart'; import '../../providers/settings_controller.dart'; import '../../theme/design_tokens.dart'; import '../noo/lists/noo_tab_order_row.dart'; +import '../noo/lists/noo_selection_bar.dart'; import '../noo/lists/noo_settings_row.dart'; import '../noo/lists/noo_grouped_list.dart'; import '../noo/core/noo_segmented_control.dart'; @@ -58,7 +59,11 @@ class SettingsActionBarSection extends StatelessWidget { if (!desktop) { return NooGroupedList( footer: const Text(_subtitle), - children: [positionRow, orderList], + children: [ + _ActionBarPreview(order: settings.selectionActionOrder), + positionRow, + orderList, + ], ); } @@ -79,6 +84,8 @@ class SettingsActionBarSection extends StatelessWidget { const SizedBox(height: 4), Text(_subtitle, style: NooText.meta.copyWith(color: colors.fg3)), const SizedBox(height: NooSpace.md), + _ActionBarPreview(order: settings.selectionActionOrder), + const SizedBox(height: NooSpace.md), positionRow, const SizedBox(height: NooSpace.md), orderList, @@ -88,6 +95,55 @@ class SettingsActionBarSection extends StatelessWidget { } } +class _ActionBarPreview extends StatelessWidget { + final List order; + + const _ActionBarPreview({required this.order}); + + @override + Widget build(BuildContext context) { + final colors = context.nooColors; + final desktop = NooLayout.isDesktop(context); + return Container( + padding: const EdgeInsets.all(NooSpace.sm), + color: colors.surface2, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Preview', style: NooText.meta.copyWith(color: colors.fg3)), + const SizedBox(height: NooSpace.xs), + // Settings has a narrower column than the file browser. Scale the + // real bar to fit rather than changing its inline/overflow split. + FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerLeft, + child: SizedBox( + width: desktop ? 1000 : 400, + child: NooSelectionBar( + count: 1, + isDesktop: desktop, + iosStyle: NooLayout.iosStyle(context), + onClose: () {}, + actions: [ + for (final kind in order) + SelectionAction( + kind: kind, + icon: kind.icon, + label: kind.label, + onTap: () => ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('${kind.label} preview')), + ), + ), + ], + ), + ), + ), + ], + ), + ); + } +} + /// The reorderable [NooTabOrderRow] stack for [SelectionActionKind]s - same /// radius-20, line-divided card `settings_tabs.dart`'s `_TabOrderList` /// draws, minus the pin toggle (`showPin: false`): there's nothing to diff --git a/lib/widgets/settings/settings_security.dart b/lib/widgets/settings/settings_security.dart index e9de0de..02865e8 100644 --- a/lib/widgets/settings/settings_security.dart +++ b/lib/widgets/settings/settings_security.dart @@ -6,26 +6,6 @@ import '../noo/core/noo_toggle.dart'; import '../noo/lists/noo_settings_row.dart'; import 'settings_section.dart'; -/// Per-platform copy for the login-lock row's label (DESIGN_SYSTEM.md 3's -/// "Biometric label" row, both the mobile table and the macOS/Windows -/// desktop table). Falls back to generic copy on platforms the spec -/// doesn't name (Linux, tests). -String biometricLabel(TargetPlatform platform) { - switch (platform) { - case TargetPlatform.iOS: - return 'Lock with Face ID'; - case TargetPlatform.android: - return 'Lock with fingerprint'; - case TargetPlatform.macOS: - return 'Touch ID'; - case TargetPlatform.windows: - return 'Windows Hello'; - case TargetPlatform.linux: - case TargetPlatform.fuchsia: - return 'Lock with device credential'; - } -} - /// Settings section 3: three independent locks - opening the app, switching /// accounts, and revealing hidden files - each behind the device's own PIN/ /// biometric credential (see `AppLockService` - this app never stores or @@ -58,14 +38,13 @@ class SettingsSecuritySection extends StatelessWidget { @override Widget build(BuildContext context) { final session = context.watch(); - final platform = Theme.of(context).platform; return SettingsSection( title: 'Security', children: [ NooSettingsRow( icon: LucideIcons.lock, - label: Text(biometricLabel(platform)), + label: const Text('App lock'), subtitle: const Text( "Require this device's PIN or biometric to open Noo", ), diff --git a/lib/widgets/settings/settings_swipe.dart b/lib/widgets/settings/settings_swipe.dart index 0165994..eb01b8c 100644 --- a/lib/widgets/settings/settings_swipe.dart +++ b/lib/widgets/settings/settings_swipe.dart @@ -6,19 +6,6 @@ import '../../theme/design_tokens.dart'; import '../noo/lists/noo_settings_row.dart'; import 'settings_section.dart'; -String _swipeActionLabel(SwipeAction action) { - switch (action) { - case SwipeAction.none: - return 'None'; - case SwipeAction.favorite: - return 'Favorite'; - case SwipeAction.delete: - return 'Delete'; - case SwipeAction.share: - return 'Share'; - } -} - /// Settings section 8: what swiping a file left/right does in list view. class SettingsSwipeSection extends StatelessWidget { const SettingsSwipeSection({super.key}); @@ -35,7 +22,8 @@ class SettingsSwipeSection extends StatelessWidget { options: [ for (final action in SwipeAction.values) NooSettingsRow( - label: Text(_swipeActionLabel(action)), + icon: action.selectionKind?.icon, + label: Text(action.label), trailing: action == current ? Icon( LucideIcons.check, @@ -64,7 +52,7 @@ class SettingsSwipeSection extends StatelessWidget { NooSettingsRow( icon: LucideIcons.chevronsRight, label: const Text('Swipe right'), - value: _swipeActionLabel(settings.swipeRightAction), + value: settings.swipeRightAction.label, onTap: () => _openPicker( context, title: 'Swipe right', @@ -75,7 +63,7 @@ class SettingsSwipeSection extends StatelessWidget { NooSettingsRow( icon: LucideIcons.chevronsLeft, label: const Text('Swipe left'), - value: _swipeActionLabel(settings.swipeLeftAction), + value: settings.swipeLeftAction.label, onTap: () => _openPicker( context, title: 'Swipe left', diff --git a/test/widgets/noo/files_swipe_test.dart b/test/widgets/noo/files_swipe_test.dart index 1a86f7f..14c4159 100644 --- a/test/widgets/noo/files_swipe_test.dart +++ b/test/widgets/noo/files_swipe_test.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:noo/widgets/noo/files/noo_swipe_action.dart'; +import 'package:noo/models/selection_action.dart'; +import 'package:noo/providers/settings_controller.dart'; import 'noo_test_utils.dart'; @@ -8,6 +10,52 @@ void main() { setUpNooTests(); group('NooSwipeAction', () { + test('every action-bar action has a swipe setting', () { + expect( + SwipeAction.values + .map((action) => action.selectionKind) + .whereType() + .toSet(), + SelectionActionKind.values.toSet(), + ); + }); + + for (final kind in SelectionActionKind.values) { + testNooWidgets('a full swipe runs ${kind.label}', ( + tester, + theme, + c, + ) async { + var triggered = 0; + await pumpNoo( + tester, + SizedBox( + width: 360, + height: 64, + child: NooSwipeAction( + startAction: NooSwipeActionSpec( + kind: kind, + onTriggered: () => triggered++, + ), + child: const ColoredBox( + color: Colors.white, + child: SizedBox(width: 360, height: 64), + ), + ), + ), + theme: theme, + ); + await tester.drag(find.byType(NooSwipeAction), const Offset(60, 0)); + await tester.pumpAndSettle(); + expect(triggered, 0); + expect(find.text(kind.label), findsOneWidget); + expect(find.byIcon(kind.icon), findsOneWidget); + await tester.drag(find.byType(NooSwipeAction), const Offset(300, 0)); + await tester.pump(); + expect(triggered, 1); + }); + } + testNooWidgets('a moderate swipe opens the action without triggering it', ( tester, theme, diff --git a/test/widgets/settings_action_bar_test.dart b/test/widgets/settings_action_bar_test.dart new file mode 100644 index 0000000..e5d1aab --- /dev/null +++ b/test/widgets/settings_action_bar_test.dart @@ -0,0 +1,105 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:noo/models/selection_action.dart'; +import 'package:noo/providers/settings_controller.dart'; +import 'package:noo/widgets/noo/lists/noo_selection_bar.dart'; +import 'package:noo/widgets/settings/settings_action_bar.dart'; +import 'package:noo/widgets/settings/settings_swipe.dart'; + +import 'noo/noo_test_utils.dart'; + +void main() { + setUpNooTests(); + setUp(() => SharedPreferences.setMockInitialValues({})); + + for (final desktop in [false, true]) { + testNooWidgets( + 'action preview follows reorder and exposes overflow (${desktop ? 'tablet' : 'phone'})', + (tester, theme, colors) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = desktop + ? const Size(1200, 900) + : const Size(380, 900); + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final settings = SettingsController(); + addTearDown(settings.dispose); + await pumpNoo( + tester, + ChangeNotifierProvider.value( + value: settings, + child: SizedBox( + width: desktop ? 640 : 340, + child: const SingleChildScrollView( + child: SettingsActionBarSection(), + ), + ), + ), + theme: theme, + surfaceSize: desktop ? const Size(1200, 900) : const Size(380, 900), + ); + await tester.pumpAndSettle(); + final bar = find.byType(NooSelectionBar); + expect(bar, findsOneWidget); + expect(tester.widget(bar).isDesktop, desktop); + expect(find.text('Preview'), findsOneWidget); + settings.setSelectionActionOrder([ + SelectionActionKind.details, + ...SelectionActionKind.values.where( + (kind) => kind != SelectionActionKind.details, + ), + ]); + await tester.pumpAndSettle(); + await tester.tap( + find.descendant(of: bar, matching: find.byIcon(LucideIcons.info)), + ); + await tester.pump(); + expect(find.text('Details preview'), findsOneWidget); + await tester.tap( + find.descendant( + of: bar, + matching: desktop + ? find.text('More') + : find.byIcon(LucideIcons.ellipsisVertical), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('Sync to device'), findsNWidgets(2)); + expect(tester.takeException(), isNull); + }, + ); + } + + testNooWidgets('swipe picker offers every action and saves the choice', ( + tester, + theme, + colors, + ) async { + final settings = SettingsController(); + addTearDown(settings.dispose); + await pumpNoo( + tester, + ChangeNotifierProvider.value( + value: settings, + child: const SettingsSwipeSection(), + ), + theme: theme, + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Swipe right')); + await tester.pumpAndSettle(); + for (final kind in SelectionActionKind.values) { + expect(find.text(kind.label), findsAtLeastNWidgets(1)); + } + await tester.ensureVisible(find.text('Details')); + await tester.tap(find.text('Details')); + await tester.pumpAndSettle(); + expect(settings.swipeRightAction, SwipeAction.details); + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getString('ui_swipe_right_action'), 'details'); + expect(tester.takeException(), isNull); + }); +}