diff --git a/.claude/context/architecture.md b/.claude/context/architecture.md index 2c0da71..bc60a55 100644 --- a/.claude/context/architecture.md +++ b/.claude/context/architecture.md @@ -284,7 +284,21 @@ bare system prompt gives no context on its own. other tab has a create/upload flow), and the account avatar; 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. `BottomNavBar` (`widgets/bottom_nav_bar.dart`) adapts + icon for 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`, + wrapping it in the framework's `SliverFloatingHeader`) instead of passing + it to `Scaffold.appBar` - see that file's doc comment for why (Material's + native "floating" app bar behavior, tied to that tab's own + `ScrollController`: scrolls away as the list scrolls down and reappears + the moment the drag reverses, not only once scrolled back to the top). + `topBar` is null (no top bar rendered) on desktop and while picking, + matching `Scaffold.appBar`'s old `pickRequest == null` guard - see + `buildAppTabView`'s doc comment. It sits above each tab's own pinned + in-content header (the sort/filter controls row, or Files/Photos' + selection bar - see below); the two float/scroll independently. + `BottomNavBar` (`widgets/bottom_nav_bar.dart`) adapts the pinned `AppTab`s onto `NooBottomBar`. `AppDrawer` (`widgets/app_drawer.dart`) builds a `NooDrawer`: account block, storage meter, a "More" list of the hidden tabs, Settings, and an "Edit tabs" diff --git a/.claude/context/styling.md b/.claude/context/styling.md index 0220b14..cd788b3 100644 --- a/.claude/context/styling.md +++ b/.claude/context/styling.md @@ -137,6 +137,12 @@ blocks are noted where they matter: `lib/widgets/tabs/` (`tab_state_slivers.dart`, `tab_day_groups.dart`, `tab_location.dart`) — the loading/error/empty-state slivers and day/month grouping helpers shared by Recent/Activity/Trash/Shares. + `tab_state_slivers.dart` also has `topBarSliver`, which every regular + tab's view uses to plant its own `AppTopBar` instance as the first sliver + in its `CustomScrollView` (a thin wrapper around the framework's + `SliverFloatingHeader`) instead of the shell passing one shared instance + to `Scaffold.appBar` - see its doc comment and `architecture.md`'s + "Mobile" bullet for why. `lib/widgets/settings/` — Settings' 8 section widgets plus `settings_section.dart`'s `SettingsSection`/`showSettingsPicker` and `settings_dialogs.dart`'s `confirmRemoveAccount`. diff --git a/lib/main.dart b/lib/main.dart index 0b44578..d50398d 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -381,15 +381,51 @@ class _MainShellViewState extends State { String? trashBadge(AppTab tab) => tab == AppTab.trash && trashCount > 0 ? '$trashCount' : null; + final isDesktop = NooLayout.isDesktop(context); + final navStyle = NooLayout.navStyle(context); + // Picking/offline already override the tab set itself (see pinnedTabs + // above) - Search doesn't belong in either: there's nothing to search + // for a file-picking flow, and Search needs the network Offline mode + // doesn't have. + final showBottomBarSearch = !overrideActive && settings.searchInBottomBar; + // Each tab renders its own sticky selection toolbar inline (right under // its sort/filter row) instead of the shell swapping in a shared one, // so the nav chrome stays put and usable regardless of selection state. + // + // Each tab also builds its own [AppTopBar] (labelled for that tab, not + // just whichever is currently selected) and plants it as that tab's own + // first sliver - see `buildAppTabView`'s doc comment and `topBarSliver` + // in `tabs/tab_state_slivers.dart` - instead of one shared instance + // living in `Scaffold.appBar`. That's what gives each tab's top bar + // Material's native floating-away-on-scroll-down/reappear-on-scroll-up + // behavior, tied to that tab's own `ScrollController`: a fixed + // `Scaffold.appBar` can't do that (no per-tab scroll signal reaches + // it), and a shared single instance can't show 7 different tab labels + // at once now that every tab keeps its own independent scroll state. + // Null on desktop (which shows `NooToolbar` instead - embedding it here + // too, since `tabStack` is shared by both layouts below, would double + // up the top chrome there) and while picking (no top bar at all, same + // as this screen's old `Scaffold.appBar: pickRequest == null ? ... : + // null`). final tabStack = Stack( children: [ IndexedStack( index: selectedIndex, children: displayTabs - .map((tab) => buildAppTabView(tab, _scrollControllers[tab]!)) + .map( + (tab) => buildAppTabView( + tab, + _scrollControllers[tab]!, + topBar: isDesktop || pickRequest != null + ? null + : AppTopBar( + style: navStyle, + tab: tab, + searchInBottomBar: showBottomBarSearch, + ), + ), + ) .toList(), ), if (pick.isDownloadingForPick) const _PickingProgressOverlay(), @@ -401,7 +437,7 @@ class _MainShellViewState extends State { (selectedTab == AppTab.files || selectedTab == AppTab.photos); Widget scaffold; - if (NooLayout.isDesktop(context)) { + if (isDesktop) { scaffold = Scaffold( backgroundColor: colors.bg, body: Row( @@ -471,23 +507,11 @@ class _MainShellViewState extends State { ), ); } else { - final navStyle = NooLayout.navStyle(context); final bottomBarStyle = settings.bottomBarStyle; - // Picking/offline already override the tab set itself (see - // pinnedTabs above) - Search doesn't belong in either: there's - // nothing to search for a file-picking flow, and Search needs the - // network Offline mode doesn't have. - final showBottomBarSearch = !overrideActive && settings.searchInBottomBar; scaffold = Scaffold( backgroundColor: colors.bg, drawerScrimColor: colors.scrim, - appBar: pickRequest == null - ? AppTopBar( - style: navStyle, - tab: selectedTab, - searchInBottomBar: showBottomBarSearch, - ) - : null, + // The top bar itself no longer lives here - see `tabStack` above. drawer: pickRequest == null ? const AppDrawer() : null, // Floating needs the body to draw behind the bar's own transparent // margin (see NooBottomBarStyle's doc comment) instead of stopping diff --git a/lib/views/activity_view.dart b/lib/views/activity_view.dart index 90f693e..e2ac073 100644 --- a/lib/views/activity_view.dart +++ b/lib/views/activity_view.dart @@ -29,7 +29,11 @@ import '../widgets/tabs/tab_state_slivers.dart'; class ActivityView extends StatelessWidget { final ScrollController scrollController; - const ActivityView({super.key, required this.scrollController}); + /// 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; + + const ActivityView({super.key, required this.scrollController, this.topBar}); @override Widget build(BuildContext context) { @@ -90,6 +94,7 @@ class ActivityView extends StatelessWidget { } final List contentSlivers = [ + if (topBar != null) topBarSliver(topBar!), const SliverToBoxAdapter(child: SizedBox(height: NooSpace.md)), feed, ...tabBottomInsetSlivers, diff --git a/lib/views/favorites_view.dart b/lib/views/favorites_view.dart index cae784c..b4d236c 100644 --- a/lib/views/favorites_view.dart +++ b/lib/views/favorites_view.dart @@ -30,6 +30,7 @@ import '../widgets/noo/core/noo_button.dart'; import '../widgets/share_sheet.dart'; import '../widgets/sticky_header_delegate.dart'; import '../widgets/synced_header_scaffold.dart' show formatBytes; +import '../widgets/tabs/tab_state_slivers.dart'; import 'file_viewer_screen.dart'; import 'move_copy_destination_picker.dart'; @@ -50,7 +51,11 @@ import 'move_copy_destination_picker.dart'; class FavoritesView extends StatefulWidget { final ScrollController scrollController; - const FavoritesView({super.key, required this.scrollController}); + /// 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; + + const FavoritesView({super.key, required this.scrollController, this.topBar}); @override State createState() => _FavoritesViewState(); @@ -507,6 +512,7 @@ class _FavoritesViewState extends State { ); final contentSlivers = [ + if (widget.topBar != null) topBarSliver(widget.topBar!), SliverPersistentHeader( pinned: !_isSelecting, delegate: StickyHeaderDelegate( diff --git a/lib/views/files_view.dart b/lib/views/files_view.dart index a64f1bc..e947b47 100644 --- a/lib/views/files_view.dart +++ b/lib/views/files_view.dart @@ -43,6 +43,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/tabs/tab_state_slivers.dart'; import '../widgets/synced_header_scaffold.dart' show formatBytes; import 'file_viewer_screen.dart'; import 'move_copy_destination_picker.dart'; @@ -59,10 +60,15 @@ class FilesView extends StatefulWidget { final ScrollController scrollController; final bool offline; + /// 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; + const FilesView({ super.key, required this.scrollController, this.offline = false, + this.topBar, }); @override @@ -653,6 +659,7 @@ class _FilesViewState extends State { ); final List contentSlivers = [ + if (widget.topBar != null) topBarSliver(widget.topBar!), // Pinned in both states - while browsing this is the controls row // (+ breadcrumbs), while selecting it's the selection bar (see // `topRow` above): either way it's the one thing that always stays diff --git a/lib/views/photos_view.dart b/lib/views/photos_view.dart index 4fa9152..1d72d3c 100644 --- a/lib/views/photos_view.dart +++ b/lib/views/photos_view.dart @@ -30,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/tabs/tab_state_slivers.dart'; import 'file_viewer_screen.dart'; import 'move_copy_destination_picker.dart'; @@ -53,7 +54,11 @@ class _MonthGroup { class PhotosView extends StatefulWidget { final ScrollController scrollController; - const PhotosView({super.key, required this.scrollController}); + /// 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; + + const PhotosView({super.key, required this.scrollController, this.topBar}); @override State createState() => _PhotosViewState(); @@ -239,6 +244,7 @@ class _PhotosViewState extends State { ); final List contentSlivers = [ + if (widget.topBar != null) topBarSliver(widget.topBar!), // Sticky while browsing; once selecting, the selection bar takes over // the same slot instead. SliverPersistentHeader( diff --git a/lib/views/recent_view.dart b/lib/views/recent_view.dart index 02af7e3..0ecd184 100644 --- a/lib/views/recent_view.dart +++ b/lib/views/recent_view.dart @@ -27,7 +27,11 @@ import 'file_viewer_screen.dart'; class RecentView extends StatefulWidget { final ScrollController scrollController; - const RecentView({super.key, required this.scrollController}); + /// 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; + + const RecentView({super.key, required this.scrollController, this.topBar}); @override State createState() => _RecentViewState(); @@ -54,6 +58,7 @@ class _RecentViewState extends State { ); final List contentSlivers = [ + if (widget.topBar != null) topBarSliver(widget.topBar!), const SliverToBoxAdapter(child: SizedBox(height: NooSpace.md)), if (recent.isLoading && items.isEmpty) tabLoadingSliver diff --git a/lib/views/shares_view.dart b/lib/views/shares_view.dart index e833975..707d9b6 100644 --- a/lib/views/shares_view.dart +++ b/lib/views/shares_view.dart @@ -53,7 +53,11 @@ String _permissionLabel(int permissions) { class SharesView extends StatefulWidget { final ScrollController scrollController; - const SharesView({super.key, required this.scrollController}); + /// 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; + + const SharesView({super.key, required this.scrollController, this.topBar}); @override State createState() => _SharesViewState(); @@ -81,6 +85,7 @@ class _SharesViewState extends State { final isDesktop = NooLayout.isDesktop(context); final List contentSlivers = [ + if (widget.topBar != null) topBarSliver(widget.topBar!), SliverPadding( padding: EdgeInsets.fromLTRB( NooLayout.gutter(context), diff --git a/lib/views/trash_view.dart b/lib/views/trash_view.dart index 6a23610..33d20f7 100644 --- a/lib/views/trash_view.dart +++ b/lib/views/trash_view.dart @@ -25,7 +25,11 @@ import '../widgets/tabs/tab_state_slivers.dart'; class TrashView extends StatefulWidget { final ScrollController scrollController; - const TrashView({super.key, required this.scrollController}); + /// 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; + + const TrashView({super.key, required this.scrollController, this.topBar}); @override State createState() => _TrashViewState(); @@ -50,6 +54,7 @@ class _TrashViewState extends State { final isDesktop = NooLayout.isDesktop(context); final List contentSlivers = [ + if (widget.topBar != null) topBarSliver(widget.topBar!), SliverPadding( padding: EdgeInsets.fromLTRB( NooLayout.gutter(context), diff --git a/lib/widgets/app_tab_view_builder.dart b/lib/widgets/app_tab_view_builder.dart index f170cd8..9be3456 100644 --- a/lib/widgets/app_tab_view_builder.dart +++ b/lib/widgets/app_tab_view_builder.dart @@ -11,7 +11,20 @@ import '../views/trash_view.dart'; /// Builds the view widget for a given [AppTab]. Shared by the main shell /// (for visible tabs) and the "more tabs" dropdown (for launching a tab /// that's currently hidden from the bottom nav bar). -Widget buildAppTabView(AppTab tab, ScrollController controller) { +/// +/// [topBar], when given, is that tab's own [AppTopBar] instance (built by +/// the caller, which owns `NooLayout.navStyle`/pick-mode/search-in-bottom- +/// bar state) - each view plants it as its own first sliver (see +/// `topBarSliver` in `tabs/tab_state_slivers.dart`) so it scrolls away and +/// reappears independently, tied to that tab's own `ScrollController` +/// rather than living in the shared `Scaffold.appBar`. Null on desktop +/// (which shows `NooToolbar` instead) and while picking (no top bar at +/// all), matching `Scaffold.appBar`'s old `pickRequest == null` guard. +Widget buildAppTabView( + AppTab tab, + ScrollController controller, { + PreferredSizeWidget? topBar, +}) { switch (tab) { case AppTab.files: // Keyed so the Files and Offline tabs (same widget type) never share @@ -19,24 +32,26 @@ Widget buildAppTabView(AppTab tab, ScrollController controller) { return FilesView( key: const ValueKey('files'), scrollController: controller, + topBar: topBar, ); case AppTab.photos: - return PhotosView(scrollController: controller); + return PhotosView(scrollController: controller, topBar: topBar); case AppTab.favorites: - return FavoritesView(scrollController: controller); + return FavoritesView(scrollController: controller, topBar: topBar); case AppTab.activity: - return ActivityView(scrollController: controller); + return ActivityView(scrollController: controller, topBar: topBar); case AppTab.trash: - return TrashView(scrollController: controller); + return TrashView(scrollController: controller, topBar: topBar); case AppTab.shares: - return SharesView(scrollController: controller); + return SharesView(scrollController: controller, topBar: topBar); case AppTab.recent: - return RecentView(scrollController: controller); + return RecentView(scrollController: controller, topBar: topBar); case AppTab.offline: return FilesView( key: const ValueKey('offline'), scrollController: controller, offline: true, + topBar: topBar, ); } } diff --git a/lib/widgets/tabs/tab_state_slivers.dart b/lib/widgets/tabs/tab_state_slivers.dart index 97c5b6f..e611087 100644 --- a/lib/widgets/tabs/tab_state_slivers.dart +++ b/lib/widgets/tabs/tab_state_slivers.dart @@ -94,3 +94,29 @@ const List tabBottomInsetSlivers = [ SliverToBoxAdapter(child: SizedBox(height: 100)), SliverFillRemaining(hasScrollBody: false, child: SizedBox()), ]; + +/// Wraps a tab's shell top bar ([AppTopBar], passed in as the generic +/// [PreferredSizeWidget] it implements - this file can't import +/// `app_top_bar.dart` without a cycle) as that tab's own first sliver, +/// living inside its `CustomScrollView` instead of `Scaffold.appBar`. Gives +/// it Material's native "floating app bar" behavior, via the framework's own +/// [SliverFloatingHeader]: it scrolls away as the list scrolls down, and - +/// unlike a plain `SliverToBoxAdapter`, which only reappears once scrolled +/// all the way back to the top - reappears immediately on any upward +/// scroll, following the finger while dragging and settling fully open or +/// fully closed once the gesture ends. +/// +/// [SliverFloatingHeader] sizes itself from [topBar]'s own natural layout +/// (like `SliverToBoxAdapter`) rather than a fixed extent declared up +/// front - so [topBar]'s own internal `SafeArea` (see `NooTopBar`'s doc +/// comment) already accounts for the status-bar inset correctly, with no +/// extra height math needed here (unlike building this on the general- +/// purpose `SliverPersistentHeader` would have required). +/// +/// Sits above a tab's own pinned in-content header (built with +/// [StickyHeaderDelegate] - the sort/filter controls row, or the selection +/// bar that replaces it) - put this sliver first in `contentSlivers` so +/// that header stays exactly where it already is, independent of whether +/// [topBar] is currently shown or scrolled away. +Widget topBarSliver(PreferredSizeWidget topBar) => + SliverFloatingHeader(child: topBar); diff --git a/test/widgets/tabs/tab_state_slivers_test.dart b/test/widgets/tabs/tab_state_slivers_test.dart new file mode 100644 index 0000000..1d989ad --- /dev/null +++ b/test/widgets/tabs/tab_state_slivers_test.dart @@ -0,0 +1,143 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:noo/widgets/tabs/tab_state_slivers.dart'; + +import '../noo/noo_test_utils.dart'; + +/// A minimal stand-in for `AppTopBar` - just needs to be a fixed-height +/// [PreferredSizeWidget], same contract `topBarSliver` wraps in real tabs. +class _FakeTopBar extends StatelessWidget implements PreferredSizeWidget { + const _FakeTopBar(); + + static const double height = 56; + + @override + Size get preferredSize => const Size.fromHeight(height); + + @override + Widget build(BuildContext context) => const SizedBox( + height: height, + child: ColoredBox(color: Colors.blue), + ); +} + +/// Matches the one sliver `topBarSliver` inserts, even once it's fully +/// scrolled away - `find.byType`'s default `skipOffstage: true` treats a +/// sliver with `geometry.visible == false` (paintExtent 0, exactly the +/// "fully hidden" state part of this test exercises) as offstage and +/// excludes it, which would otherwise make `tester.renderObject` throw +/// instead of reporting the 0 this test expects. A function, not a +/// top-level constant, so each call re-evaluates against whichever widget +/// tree is currently pumped. +Finder _header() => find.byType(SliverFloatingHeader, skipOffstage: false); + +Future _pumpHost(WidgetTester tester, ScrollController controller) { + return tester.pumpWidget( + MaterialApp( + theme: nooTheme(Brightness.light), + home: Scaffold( + body: CustomScrollView( + controller: controller, + slivers: [ + topBarSliver(const _FakeTopBar()), + SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) => + SizedBox(height: 60, child: Text('Item $index')), + childCount: 40, + ), + ), + ], + ), + ), + ), + ); +} + +void main() { + setUpNooTests(); + + testWidgets( + 'topBarSliver floats away scrolling down and reappears the moment the ' + 'drag reverses, not only once back at the top (Material floating app ' + 'bar behavior)', + (tester) async { + final controller = ScrollController(); + addTearDown(controller.dispose); + await _pumpHost(tester, controller); + + // The render object's own `geometry.paintExtent` is the ground truth + // for how much of the bar is actually visible - more reliable than + // inferring visibility from hit-testing/finding the child, which a + // floating header sliver keeps built regardless of paint extent. + double paintExtent() => + tester.renderObject(_header()).geometry!.paintExtent; + + // Fully visible before any scroll. + expect(paintExtent(), _FakeTopBar.height); + expect(controller.offset, 0); + + // Drive one continuous drag by hand (rather than `tester.drag`, which + // only pumps once the whole gesture - including the final pointer-up + // - has already finished): the floating header's reveal-while- + // dragging behavior keys off the *live* scroll direction as reported + // mid-gesture, so this needs an intermediate pump while the pointer + // is still down to actually observe it. + final gesture = await tester.startGesture(const Offset(200, 300)); + + // Drag up (content scrolls down) well past the bar's own height, so + // it scrolls fully away, same as a plain `SliverToBoxAdapter` would. + await gesture.moveBy(const Offset(0, -200)); + await tester.pump(); + + expect(paintExtent(), 0); + final scrolledOffset = controller.offset; + expect( + scrolledOffset, + greaterThan(_FakeTopBar.height), + reason: "the drag should have moved past the bar's own height", + ); + + // Reverse direction by a small amount, still mid-gesture and nowhere + // near the top of the list. A plain `SliverToBoxAdapter`, or a header + // that only reappears once back at the top, would stay fully hidden + // here. The floating bar should start reappearing immediately + // instead, following the drag. + await gesture.moveBy(const Offset(0, 40)); + await tester.pump(); + + expect(controller.offset, lessThan(scrolledOffset)); + expect( + controller.offset, + greaterThan(_FakeTopBar.height / 2), + reason: 'still far from the top of the list', + ); + expect( + paintExtent(), + greaterThan(0), + reason: + 'the bar should already be reappearing on the first upward ' + 'movement, without needing to reach the top', + ); + + await gesture.up(); + await tester.pumpAndSettle(); + }, + ); + + testWidgets( + "topBarSliver sizes itself from the wrapped bar's own layout (e.g. its " + 'SafeArea-padded height), not a value declared up front', + (tester) async { + final controller = ScrollController(); + addTearDown(controller.dispose); + await _pumpHost(tester, controller); + + expect( + tester.getSize(find.byType(_FakeTopBar)).height, + _FakeTopBar.height, + ); + }, + ); +}