14 Commits
Author SHA1 Message Date
ayushyaandClaude Code 16aadda7b8 Make the top-bar search launcher fill the gap beside the tab title
Build APK / build (push) Successful in 5m27s
The title was a Flexible and the search an Expanded, so both defaulted to
flex 1 and split the free space evenly. The title is now a non-flex child
capped at 40% of the width, leaving the search all remaining space.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-01 07:44:33 -04:00
ayushyaandClaude Sonnet 5 fb4829d035 Fix avatar menu's missing border and put the tab title beside search
Build APK / build (push) Successful in 5m36s
- The avatar menu card's border was going missing wherever an opaque
  row sat against it (every corner but the header's) - a Container
  combining `border` with its own `clipBehavior` 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 stroke to show
  through. Fixed with the standard pattern: no clipBehavior on the
  bordered Container, a 1px-inset ClipRRect around the filled, clipped
  content instead.
- NooTopBar.androidTitleTrailing (renamed from androidTitleReplacement)
  now sits beside the tab title instead of replacing it - the title
  keeps its natural width (Flexible, ellipsizing only if truly out of
  room) with the inline search bar taking the remaining space to its
  right, rather than the title disappearing whenever search moves into
  the top bar.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-01 02:16:30 -04:00
ayushyaandClaude Sonnet 5 9f47d0a3e3 Fix status-bar overlap on scroll, and reposition/elevate the avatar menu
- Every tab now wraps its CustomScrollView in SafeArea(top: true) - the
  pinned sort/filter row was riding up underneath the status bar once
  the floating top bar above it fully collapsed, since that bar's own
  internal SafeArea only reserved the inset while it had some height
  left to put it in. Reserving it outside the scrolling region entirely
  fixes this regardless of the floating header's state. Caught live on
  device and backed by a new regression test.
- The avatar menu now covers the top bar (title included) instead of
  sitting below it - positioned off just the status-bar inset rather
  than the top bar's full height on top of that.
- The avatar menu's card now carries a second, tighter contact shadow
  alongside nooDialogShadow - that shadow alone was essentially
  invisible on a small card over a dark theme's near-black background.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-30 22:59:19 -04:00
ayushyaandClaude Sonnet 5 e6d96a6f9f Add avatar-menu navigation style and inline Android search bar
- New Settings > Appearance > "Navigation menu" toggle
  (SettingsController.navMenuStyle, NooNavMenuStyle.drawer/avatarMenu):
  the hamburger-opens-a-Drawer pattern stays the default, or the avatar
  button opens a full-width dropdown (showAvatarMenu) instead, holding
  the same hidden-tabs + Settings content the drawer does. Removes one
  of the two top-corner targets the shell asks a thumb to reach, with
  no permanent chrome added.
- NooTopBar gains androidTitleReplacement so Android's top bar can swap
  its title for an inline search-field-styled launcher
  (ShellSearchLauncher) when search isn't in the bottom bar, instead of
  a bare search icon beside it.
- ShellAvatarButton's onTap/label are now overridable so AppTopBar can
  repoint it per the new setting without this file depending on
  avatar_menu.dart (which already depends on it for shared helpers).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-30 22:25:04 -04:00
ayushyaandClaude Sonnet 5 840ec4c301 Fix duplicate tab_state_slivers.dart import left over from the merge
Both the bottom-clearance and the top-bar-sliver work independently
added this import to files_view.dart and favorites_view.dart.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-30 16:32:15 -04:00
ayushyaandClaude Sonnet 5 aeea4847b4 Merge: make the shell top bar hide/reveal with scroll, per tab
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-30 16:31:11 -04:00
ayushyaandClaude Sonnet 5 43f36ed532 Merge: restructure mobile Settings into a two-level category menu
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-30 16:30:45 -04:00
ayushyaandClaude Sonnet 5 5e5acc3fc6 Polish floating bottom bar: elevation, height, overlap, and FAB shape
- Give the floating bar and search satellite the app's one shadow
  (nooDialogShadow) plus a smaller, tighter height on both platforms.
- Let every tab's scrollable list compute its own bottom clearance
  (bottomBarClearance) instead of a flat 100px, so the floating bar no
  longer overlaps the last row/card.
- Square the Android upload FAB off to a rounded-square radius when the
  bottom bar is attached, keeping it fully round only when floating.
- Let a swipe on a file row trigger its action directly once dragged
  past 1.8x the reveal width, instead of requiring a second tap.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-30 16:30:14 -04:00
ayushyaandClaude Sonnet 5 5dea9038f7 Make the shell top bar hide/reveal with scroll, per tab
Each regular tab (Files/Photos/Favorites/Shares/Recent/Activity/Trash)
now plants its own AppTopBar as the first sliver in its own
CustomScrollView, via a new topBarSliver() helper (tab_state_slivers.dart)
wrapping the framework's SliverFloatingHeader, instead of one shared
instance living in Scaffold.appBar.

This gives each tab's top bar Material's native floating-app-bar
behavior - tied to that tab's own ScrollController - scrolling away as
the list scrolls down and reappearing the moment the drag reverses,
not only once scrolled all the way back to the top, which a single
fixed Scaffold.appBar (or the "put it in a SliverToBoxAdapter" escape
hatch NooTopBar's doc comment already mentioned) can't do. It sits
above each tab's own pinned in-content sticky header (the sort/filter
row, or the selection bar that replaces it), which keeps working
unchanged underneath it.

main.dart builds a separate, correctly-labelled AppTopBar per tab
(rather than one for "whichever tab is selected") and passes it through
buildAppTabView's new optional topBar parameter; it's null on desktop
(which keeps its own NooToolbar) and while picking, matching the old
Scaffold.appBar: pickRequest == null guard.

SliverFloatingHeader sizes itself from the bar's own natural layout
(like SliverToBoxAdapter) rather than a fixed extent declared up front,
so NooTopBar's own internal SafeArea keeps handling the status-bar inset
correctly with no extra height math needed on this side.

Adds a widget test (test/widgets/tabs/tab_state_slivers_test.dart)
driving a raw gesture mid-drag to verify the reveal-follows-finger
behavior, since a single tester.drag() only pumps after the gesture -
including release - has already finished, which doesn't exercise the
live per-frame reveal this change depends on.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-30 16:28:36 -04:00
ayushyaandClaude Sonnet 5 a8b14b8744 Restructure mobile Settings into a two-level category menu
On mobile, AccountView used to render all 9 Settings sections inline
in one long, individually-collapsible column (_MobileList). Replace
that with a native-style menu: the account card stays pinned at the
top, and every other section becomes a NooSettingsRow in a
NooGroupedList that pushes a dedicated screen (NooTopBar +
NooTopBarBack) holding just that section's content. This removes the
scroll-depth problem outright instead of working around it with
per-section collapsing, so SettingsSection no longer needs
NooGroupedList's collapsible mode on mobile. Desktop's 2-column grid
is unchanged - it already shows every section at once.

Audited every lib/widgets/settings/*.dart file for the reported
"description and controls are flipped" row-layout bug: every row goes
through NooSettingsRow directly, or - for the few hand-rolled rows
(_SavedAccountRow, _ThemeRow/_BottomBarStyleRow, _CacheIntervalRow) -
preserves its icon/description-then-control order (the stacked
control-below-label shape used by _ThemeRow and _CacheIntervalRow is
an intentional, spec'd variant, not a flip). Found no instance of the
bug; no fix was needed.

Updates DESIGN_SYSTEM.md's Settings recipe and styling.md's
NooGroupedList notes to describe the new menu-then-pushed-screen
pattern, and adds a widget test covering the category menu and the
push/pop navigation.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-30 16:15:32 -04:00
ayushyaandClaude Sonnet 5 5f4caa2a3b Fix floating bottom bar filling the screen when search is enabled
Build APK / build (push) Successful in 5m40s
The Row wrapping the pill + search satellite used
crossAxisAlignment.stretch, but Scaffold.bottomNavigationBar gives its
child a loose (unbounded-max) height constraint - stretch on an
unbounded cross axis blows up to fill all available space in
profile/release builds (the debug assertion that would catch it is
stripped there), same class of bug files_controls_row.dart hit earlier
from a different cause. Both children already sized themselves
explicitly, so stretch was never actually needed - wrapping the Row in
a fixed-height SizedBox instead fixes it.

Added a regression test that reproduces this inside a real Scaffold
(the bare SizedBox host the other tests use gives a bounded height and
doesn't trigger it) - confirmed it fails without the fix (height 600,
the full test viewport) and passes with it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 22:59:52 -04:00
ayushyaandClaude Sonnet 5 4927416277 Add product website (deploy target: noo.ayushya.dev)
A single-page, static site under website/ - no build step, no
framework, just HTML/CSS/vanilla JS plus the two Google Fonts the app
already uses. Colors, radii, type scale and motion timing are copied
from lib/theme/design_tokens.dart so it reads as the same product as
the app, not a separate marketing skin.

Sections: hero with an exploded, parallaxing stack of the app's own UI
pieces (file row, photo grid, accent swatches, a toggle, the floating
bar) that drift apart on scroll and tilt toward the pointer; feature
highlights; a Customize section with two live interactive demos
(accent color, floating vs attached bottom bar); a privacy section
covering Login Flow v2; and a closing CTA.

GitHub/download links are placeholders (documented in website/README.md)
until real release URLs exist.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 22:25:18 -04:00
ayushyaandClaude Sonnet 5 58312511c5 Add "Search in bottom bar" to Settings
Build APK / build (push) Successful in 5m30s
New Settings > Appearance toggle moves Search into the bottom bar and
out of the top bar, so there's only one search entry point on screen.
Tapping it pushes SearchView same as before - it's never highlighted,
since it isn't a real destination.

- Attached: Search becomes the row's last item, styled like any other
  tab but permanently idle.
- Floating: Search gets its own separate, always fully round satellite
  circle beside the bar instead.

Enabling it costs one regular tab: SettingsController.maxVisibleTabs
is now a dynamic getter (defaultMaxVisibleTabs - 1 while the toggle is
on) instead of the old flat constant, re-enforced immediately if the
user already has all 5 slots filled - the same cleanup path used for
a fresh install or an app update that added a new tab.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 22:19:13 -04:00
ayushyaandClaude Sonnet 5 a70be19f80 Add a floating bottom bar style, user-configurable in Settings
Settings > Appearance > "Bottom bar" now switches between the
original edge-to-edge bar and a floating one - inset 16px from both
side edges and clear of the bottom safe area, 28px corners (the same
radius the sheet top/drawer edge use), a 1px line border standing in
for elevation instead of a shadow (product UI stays flat). Persisted
via NooBottomBarStyle, defaulting to the existing attached behavior.

On Android, floating's idle tabs drop their reserved label space -
with no label to leave room for, the icon centers in the whole button
and renders a touch bigger (27 vs 24px) instead of sitting high with a
gap held open under it; the active tab's icon/pill/label position is
unchanged. iOS's tab content is unchanged either way, only the
container.

main.dart sets Scaffold.extendBody while floating so the body scrolls
behind the bar's transparent margin instead of stopping short of it,
matching how the design canvas explored it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 16:21:13 -04:00
40 changed files with 3717 additions and 218 deletions
+25 -3
View File
@@ -284,12 +284,34 @@ bare system prompt gives no context on its own.
other tab has a create/upload flow), and the account avatar; Android other tab has a create/upload flow), and the account avatar; Android
gets a compact title row with `search`/avatar actions, relying on an gets a compact title row with `search`/avatar actions, relying on an
extended `NooFab` ("Upload", Files/Photos only) instead of a top-bar 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`,
the pinned `AppTab`s onto `NooBottomBar`. `AppDrawer` `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 - each
tab also wraps its whole `CustomScrollView` in `SafeArea(top: true,
bottom: false, ...)` so that pinned header stays clear of the status bar
once the floating top bar above it fully collapses (see
`topBarSliver`'s own doc comment for why that reservation can't live
inside the top bar itself). `BottomNavBar` (`widgets/bottom_nav_bar.dart`)
adapts the pinned `AppTab`s onto `NooBottomBar`. `AppDrawer`
(`widgets/app_drawer.dart`) builds a `NooDrawer`: account block, storage (`widgets/app_drawer.dart`) builds a `NooDrawer`: account block, storage
meter, a "More" list of the hidden tabs, Settings, and an "Edit tabs" meter, a "More" list of the hidden tabs, Settings, and an "Edit tabs"
link (opens Settings - there's no in-page anchor to scroll to its Tabs link (opens Settings - there's no in-page anchor to scroll to its Tabs
section yet). 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.
- **Desktop:** a `NooSidebar` (account card, pinned tabs, divider, - **Desktop:** a `NooSidebar` (account card, pinned tabs, divider,
remaining tabs, storage meter, Settings) sits beside a `NooToolbar` remaining tabs, storage meter, Settings) sits beside a `NooToolbar`
(tab title, search, an "Upload" action on Files/Photos) over the same (tab title, search, an "Upload" action on Files/Photos) over the same
+36 -15
View File
@@ -109,10 +109,18 @@ Use **Lucide** (pinned to `lucide-static@0.460.0`) at a 1.8 stroke. Sizes are 14
- An extended pill: 56px tall, accent fill, labelled "Upload". - An extended pill: 56px tall, accent fill, labelled "Upload".
- Sits 16px from the right and bottom edges of the content area. - Sits 16px from the right and bottom edges of the content area.
- Stays mounted on every tab; it's only the extended label that's tied to - Stays mounted on every tab; it's only the extended label that's tied to
Files/Photos. Elsewhere it collapses to an icon-only 56px circle (still Files/Photos. Elsewhere it collapses to an icon-only 56px circle or
tappable - it always targets the Files tab's current folder), animating rounded square (see below) - still tappable, it always targets the Files
the width/label change rather than the button popping fully in/out as tab's current folder - animating the width/label change rather than the
the Scaffold's default FAB transition would on every tab switch. button popping fully in/out as the Scaffold's default FAB transition
would on every tab switch.
- Corner radius follows the bottom bar style (Settings → Appearance):
fully round (`pill`, 999) when the bar is floating, matching that mode's
other already-rounder elements (the bar itself at 28px, its search
satellite a full circle); a rounded square (`card`, 20) when the bar is
attached, to suit that mode's flatter, edge-to-edge corners. Same radius
whether the FAB is collapsed to a circle/square or expanded to the full
pill shape.
**Chip** **Chip**
- A pill, 32–34px tall, 12px horizontal padding, Label 13–14. - A pill, 32–34px tall, 12px horizontal padding, Label 13–14.
@@ -162,6 +170,7 @@ Use **Lucide** (pinned to `lucide-static@0.460.0`) at a 1.8 stroke. Sizes are 14
**Swipe action** **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; Favorite is white on accent.
- The block has a 20px icon above a 12/600 label. - 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.
**Selection action bar** (Files, Photos, Favorites - anywhere with **Selection action bar** (Files, Photos, Favorites - anywhere with
@@ -271,9 +280,13 @@ Mobile always has **5 pinned tabs in the bottom bar**. The rest go in the drawer
| Element | iOS | Android | | Element | iOS | Android |
|---|---|---| |---|---|---|
| Status bar | 54px, Dynamic Island | 40px | | Status bar | 54px, Dynamic Island | 40px |
| Top bar | 44px row: `menu` on the left; `plus` and avatar on the right (all accent-text). Then a 34px large title, then the search field. | 64px: `menu`, 22px title, `search`, avatar | | Top bar | 44px row: `menu` on the left; `plus` and avatar on the right (all accent-text). Then a 34px large title, then the search field. | 64px: `menu`, 22px title, avatar - see "Search" below for what replaces `search` there, and "Navigation menu" for `menu` |
| Bottom bar | Surface fill, 1px top line. Icon 24 above a 10px label. Active: accent-text. Idle: fg-3. 34px home indicator below. | Surface fill, 80px. Icon 24 above a 12px label (label space is always reserved, just invisible when idle, so nothing shifts on selection). The active icon sits inside a 56×32 accent-soft pill that slides between tabs as one shared indicator, rather than popping in/out per tab. 20px gesture bar below. | | Bottom bar | Surface fill, 1px top line. Icon 24 above a 10px label. Active: accent-text. Idle: fg-3. 34px home indicator below. | Surface fill, 80px. Icon 24 above a 12px label (label space is always reserved, just invisible when idle, so nothing shifts on selection). The active icon sits inside a 56×32 accent-soft pill that slides between tabs as one shared indicator, rather than popping in/out per tab. 20px gesture bar below. |
| Upload | `plus` in the nav bar | Extended FAB | | Bottom bar - floating (Settings → Appearance → "Bottom bar", both platforms) | Same content, inset 16px from both side edges and clear of the bottom safe area instead of edge to edge, radius 28 (the sheet-top/drawer-edge radius) rather than square corners. Product UI otherwise stays flat (see 1.4), but this is the one other place - besides desktop dialogs - that gets the app's one shadow: a 1px `line` border plus the dialog shadow, since a bar that's genuinely floating above scrolling content needs to read as elevated. Row height drops (iOS 50, same as attached; Android 64 vs 80) to suit. Android's idle tabs drop their reserved label space in this mode - the icon just centers in the button and renders a touch bigger (25 vs 24px) - rather than sitting high with a gap held open under it. `Scaffold.extendBody: true` while floating, so the body scrolls behind the bar's transparent margin instead of stopping short of it - which means every tab's scrollable list has to pad its own bottom enough to clear the bar's footprint, since nothing does that for it automatically once the body draws behind the bar. The Android FAB stays fully round in this mode (see "Upload" below), consistent with the bar's own rounder shape. |
| Bottom bar - "Search in bottom bar" (Settings → Appearance, either bar style) | Adds a Search entry that's never highlighted (tapping it pushes Search, same as the top bar's own search action, which this replaces so there's only one entry point on screen) - the row's last item when attached (same styling as a real tab, just permanently idle), or its own separate, always fully round satellite circle beside the bar when floating. Costs one regular tab: `SettingsController.maxVisibleTabs` drops from 5 to 4 while this is on. |
| Search (top bar, when not in the bottom bar) | A real inline search field (`NooSearchField`, non-editable launcher) below the large title | A search-field-styled launcher takes the title's own flexible slot instead of a bare `search` icon beside it - no second row to put it under the way iOS has |
| Navigation menu (Settings → Appearance → "Navigation menu", both platforms) | `drawer` (default): the `menu` icon opens the left `Drawer` (hidden tabs + Settings), unchanged. `avatarMenu`: no `menu` icon at all - the avatar button opens a dropdown instead (`showAvatarMenu`), anchored under the top bar, full width minus the same 16px gutter every content row uses, with the same hidden-tabs + Settings content the drawer holds (not its storage bar - this is a quick menu, not the drawer's full account summary). The avatar sits in the exact same (right-end) position either way, and the dropdown's own header repeats it there too, so the open menu reads as having grown out of the button that opened it. Not a mandatory replacement: this halves the top-corner surface a thumb has to reach (from `menu` + avatar down to avatar alone) without costing any permanent screen width the way an earlier side-rail exploration would have. |
| 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` | | 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. | | 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" | | Biometric label | "Lock with Face ID" | "Lock with fingerprint" |
@@ -356,15 +369,23 @@ Sidebar items are 38px tall with radius 12, an 18px icon and a 14/500 label. The
slots, the rest sit behind "More". See §2 "Selection action bar". slots, the rest sit behind "More". See §2 "Selection action bar".
9. Swipe on a file 9. Swipe on a file
Mobile uses one column of grouped lists, each individually collapsible Mobile is a two-level menu, the way native iOS/Android Settings apps
(tap its label, expanded by default - `NooGroupedList`'s `collapsible` work: the account card stays pinned at the top of a single top-level
param) so the full list can be collapsed down instead of needing a list, and every other section (2-9 above) becomes one tappable
separate way to navigate it; an earlier version had a trailing jump rail `NooSettingsRow` - icon, title, chevron - in a `NooGroupedList` below it.
(one small icon per section, pinned where the scrollbar would sit) Tapping a row pushes a new screen (`NooTopBar`/`NooTopBarBack`) holding
instead, dropped for adding a second, redundant navigation method without just that section's own content full-screen, so no page is ever more
shortening the page. Desktop uses a 2-column grid of cards with a 1px than one category deep and no section needs to be individually
line and radius 20, wide enough to see most sections without scrolling, collapsible any more. Two earlier designs were tried and dropped: a
so it gets neither. trailing jump rail (one small icon per section, pinned where the
scrollbar would sit), and - after that - one long column of every
section inline, each individually collapsible (`NooGroupedList`'s
`collapsible` param) so the page could at least be collapsed down. Both
scrolled the *same* page to or past an anchor; a genuinely separate
pushed screen per category removes the scroll-depth problem outright
instead of just working around it. Desktop is unchanged: a 2-column grid
of cards with a 1px line and radius 20, wide enough to see most sections
without scrolling, so it gets neither a menu nor collapsing.
- **Share sheet / dialog:** sections in this order: - **Share sheet / dialog:** sections in this order:
1. Header: file tile, name, size · folder, and close. 1. Header: file tile, name, size · folder, and close.
2. **Share with people:** an input ("Name, email or group"), then the people with access. The owner comes first; the others each have a permission pill ("Can edit ▾"). 2. **Share with people:** an input ("Name, email or group"), then the people with access. The owner comes first; the others each have a permission pill ("Can edit ▾").
+75 -5
View File
@@ -90,10 +90,16 @@ Gotchas:
- `NooGroupedList` draws dividers by showing `line` through 1px gaps, so each - `NooGroupedList` draws dividers by showing `line` through 1px gaps, so each
child must paint its own surface (`NooSettingsRow` and `NooTabOrderRow` child must paint its own surface (`NooSettingsRow` and `NooTabOrderRow`
do). Its `collapsible`/`initiallyExpanded` params (off by default) make do). Its `collapsible`/`initiallyExpanded` params (off by default) make
`label` a tap target that shows/hides the card - `SettingsSection` is the `label` a tap target that shows/hides the card - no current caller opts
only caller that opts in, for Settings' mobile sections. in (Settings' mobile sections used to, when every section rendered
- `NooSwipeAction` only reveals its action. The user has to tap the block to inline in one long column; now each section is its own pushed screen -
trigger it; a full swipe never deletes. see `account_view.dart`'s doc comment - so there's nothing left to
collapse). The params stay on the component itself since it's otherwise
generic.
- `NooSwipeAction` reveals its action on a normal swipe (tap the block to
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.
- Window chrome (macOS traffic lights, the Windows 40px title bar) isn't - Window chrome (macOS traffic lights, the Windows 40px title bar) isn't
built yet. `NooSidebar.windowControls` is the slot for it. built yet. `NooSidebar.windowControls` is the slot for it.
- Always read colors through `context.nooColors`, never - Always read colors through `context.nooColors`, never
@@ -137,6 +143,12 @@ blocks are noted where they matter:
`lib/widgets/tabs/` (`tab_state_slivers.dart`, `tab_day_groups.dart`, `lib/widgets/tabs/` (`tab_state_slivers.dart`, `tab_day_groups.dart`,
`tab_location.dart`) — the loading/error/empty-state slivers and `tab_location.dart`) — the loading/error/empty-state slivers and
day/month grouping helpers shared by Recent/Activity/Trash/Shares. 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 `lib/widgets/settings/` — Settings' 8 section widgets plus
`settings_section.dart`'s `SettingsSection`/`showSettingsPicker` and `settings_section.dart`'s `SettingsSection`/`showSettingsPicker` and
`settings_dialogs.dart`'s `confirmRemoveAccount`. `settings_dialogs.dart`'s `confirmRemoveAccount`.
@@ -191,7 +203,65 @@ blocks are noted where they matter:
opacity/blur knob - flat surfaces per the design system), grid tiles are opacity/blur knob - flat surfaces per the design system), grid tiles are
`NooGridCard`, swipe actions are `NooSwipeAction`, and per-item sync `NooGridCard`, swipe actions are `NooSwipeAction`, and per-item sync
status is `NooFileRow`/`NooFileTableRow`'s built-in `NooStatusIcon` list status is `NooFileRow`/`NooFileTableRow`'s built-in `NooStatusIcon` list
instead of a corner badge. 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
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
(`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
body draws behind it. Use `bottomBarClearance(context)`
(`tab_state_slivers.dart`) for that rather than a flat `100`; it already
accounts for both bar styles.
Settings' separate "Search in bottom bar" toggle
(`SettingsController.searchInBottomBar`) adds a never-highlighted Search
entry to either bar style (`NooBottomBar`'s `searchDestination`/
`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. `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
`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
search isn't in the bottom bar) sits to the title's own right in that
flexible slot - `Flexible`, not `Expanded`, so the title still shrinks/
ellipsizes if there's truly no room but doesn't claim more than it needs
otherwise - instead of the title being replaced by it.
- [`SyncedHeaderScaffold`](../../lib/widgets/synced_header_scaffold.dart) — - [`SyncedHeaderScaffold`](../../lib/widgets/synced_header_scaffold.dart) —
the pull-to-sync `CustomScrollView` header with the persistent sync-status the pull-to-sync `CustomScrollView` header with the persistent sync-status
chip and pull-to-refresh gesture/spinner. Every screen (including chip and pull-to-refresh gesture/spinner. Every screen (including
+48 -6
View File
@@ -381,15 +381,52 @@ class _MainShellViewState extends State<MainShellView> {
String? trashBadge(AppTab tab) => String? trashBadge(AppTab tab) =>
tab == AppTab.trash && trashCount > 0 ? '$trashCount' : null; 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 // Each tab renders its own sticky selection toolbar inline (right under
// its sort/filter row) instead of the shell swapping in a shared one, // 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. // 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( final tabStack = Stack(
children: [ children: [
IndexedStack( IndexedStack(
index: selectedIndex, index: selectedIndex,
children: displayTabs 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,
navMenuStyle: settings.navMenuStyle,
),
),
)
.toList(), .toList(),
), ),
if (pick.isDownloadingForPick) const _PickingProgressOverlay(), if (pick.isDownloadingForPick) const _PickingProgressOverlay(),
@@ -401,7 +438,7 @@ class _MainShellViewState extends State<MainShellView> {
(selectedTab == AppTab.files || selectedTab == AppTab.photos); (selectedTab == AppTab.files || selectedTab == AppTab.photos);
Widget scaffold; Widget scaffold;
if (NooLayout.isDesktop(context)) { if (isDesktop) {
scaffold = Scaffold( scaffold = Scaffold(
backgroundColor: colors.bg, backgroundColor: colors.bg,
body: Row( body: Row(
@@ -471,14 +508,16 @@ class _MainShellViewState extends State<MainShellView> {
), ),
); );
} else { } else {
final navStyle = NooLayout.navStyle(context); final bottomBarStyle = settings.bottomBarStyle;
scaffold = Scaffold( scaffold = Scaffold(
backgroundColor: colors.bg, backgroundColor: colors.bg,
drawerScrimColor: colors.scrim, drawerScrimColor: colors.scrim,
appBar: pickRequest == null // The top bar itself no longer lives here - see `tabStack` above.
? AppTopBar(style: navStyle, tab: selectedTab)
: null,
drawer: pickRequest == null ? const AppDrawer() : null, 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
// short of it like attached does.
extendBody: bottomBarStyle == NooBottomBarStyle.floating,
// Android-only extended Upload FAB - iOS uses the top bar's `plus` // Android-only extended Upload FAB - iOS uses the top bar's `plus`
// instead (see AppTopBar). Stays mounted across every tab (picking // instead (see AppTopBar). Stays mounted across every tab (picking
// aside) and collapses to an icon-only circle off Files/Photos, // aside) and collapses to an icon-only circle off Files/Photos,
@@ -488,14 +527,17 @@ class _MainShellViewState extends State<MainShellView> {
pickRequest == null && navStyle == NooNavStyle.android pickRequest == null && navStyle == NooNavStyle.android
? NooFab( ? NooFab(
collapsed: !canUpload, collapsed: !canUpload,
barStyle: bottomBarStyle,
onTap: () => showCreateMenu(context), onTap: () => showCreateMenu(context),
) )
: null, : null,
body: tabStack, body: tabStack,
bottomNavigationBar: BottomNavBar( bottomNavigationBar: BottomNavBar(
style: navStyle, style: navStyle,
barStyle: bottomBarStyle,
tabs: pinnedTabs, tabs: pinnedTabs,
selectedIndex: pinnedIndex, selectedIndex: pinnedIndex,
onSearchTap: showBottomBarSearch ? () => openSearch(context) : null,
onDestinationSelected: (index) { onDestinationSelected: (index) {
final tappedTab = pinnedTabs[index]; final tappedTab = pinnedTabs[index];
if (tappedTab == _currentTab) { if (tappedTab == _currentTab) {
+7 -2
View File
@@ -16,8 +16,13 @@ enum AppTab {
} }
/// At most this many tabs may be visible in the bottom nav bar at once — /// At most this many tabs may be visible in the bottom nav bar at once —
/// the rest are reachable through the "more" dropdown instead. /// the rest are reachable through the "more" dropdown instead. The
const int maxVisibleTabs = 5; /// effective cap a screen should actually use is
/// [SettingsController.maxVisibleTabs], not this directly - it's one
/// lower than this while `searchInBottomBar` is on, since Search then
/// takes the freed-up slot itself rather than competing with the user's
/// own reorderable tabs for one of these.
const int defaultMaxVisibleTabs = 5;
extension AppTabInfo on AppTab { extension AppTabInfo on AppTab {
String get label { String get label {
+60
View File
@@ -3,6 +3,7 @@ import 'package:shared_preferences/shared_preferences.dart';
import '../models/app_tab.dart'; import '../models/app_tab.dart';
import '../models/selection_action.dart'; import '../models/selection_action.dart';
import '../theme/app_theme.dart'; import '../theme/app_theme.dart';
import '../widgets/noo/nav/noo_nav_style.dart';
/// What swiping a Files list-view item left/right does, user-configurable /// What swiping a Files list-view item left/right does, user-configurable
/// in Settings. /// in Settings.
@@ -30,6 +31,9 @@ class SettingsController extends ChangeNotifier {
static const _prefDefaultTab = 'ui_default_tab'; static const _prefDefaultTab = 'ui_default_tab';
static const _prefSwipeLeftAction = 'ui_swipe_left_action'; static const _prefSwipeLeftAction = 'ui_swipe_left_action';
static const _prefSwipeRightAction = 'ui_swipe_right_action'; static const _prefSwipeRightAction = 'ui_swipe_right_action';
static const _prefBottomBarStyle = 'ui_bottom_bar_style';
static const _prefNavMenuStyle = 'ui_nav_menu_style';
static const _prefSearchInBottomBar = 'ui_search_in_bottom_bar';
static const _prefAmoledDark = 'ui_amoled_dark'; static const _prefAmoledDark = 'ui_amoled_dark';
static const _prefMediaProgressBarStyle = 'ui_media_progress_bar_style'; static const _prefMediaProgressBarStyle = 'ui_media_progress_bar_style';
static const _prefTapTabToScrollTop = 'ui_tap_tab_to_scroll_top'; static const _prefTapTabToScrollTop = 'ui_tap_tab_to_scroll_top';
@@ -39,6 +43,9 @@ class SettingsController extends ChangeNotifier {
Color _seedColor = AppTheme.defaultAccent; Color _seedColor = AppTheme.defaultAccent;
ThemeMode _themeMode = ThemeMode.system; ThemeMode _themeMode = ThemeMode.system;
NooBottomBarStyle _bottomBarStyle = NooBottomBarStyle.attached;
NooNavMenuStyle _navMenuStyle = NooNavMenuStyle.drawer;
bool _searchInBottomBar = false;
bool _useDynamicColor = true; bool _useDynamicColor = true;
bool _amoledDark = false; bool _amoledDark = false;
MediaProgressBarStyle _mediaProgressBarStyle = MediaProgressBarStyle.wavy; MediaProgressBarStyle _mediaProgressBarStyle = MediaProgressBarStyle.wavy;
@@ -80,11 +87,22 @@ class SettingsController extends ChangeNotifier {
Color get seedColor => _seedColor; Color get seedColor => _seedColor;
ThemeMode get themeMode => _themeMode; ThemeMode get themeMode => _themeMode;
NooBottomBarStyle get bottomBarStyle => _bottomBarStyle;
NooNavMenuStyle get navMenuStyle => _navMenuStyle;
bool get searchInBottomBar => _searchInBottomBar;
bool get useDynamicColor => _useDynamicColor; bool get useDynamicColor => _useDynamicColor;
bool get amoledDark => _amoledDark; bool get amoledDark => _amoledDark;
MediaProgressBarStyle get mediaProgressBarStyle => _mediaProgressBarStyle; MediaProgressBarStyle get mediaProgressBarStyle => _mediaProgressBarStyle;
bool get tapTabToScrollTop => _tapTabToScrollTop; bool get tapTabToScrollTop => _tapTabToScrollTop;
/// The cap a screen should actually enforce for the *regular*, user-
/// reorderable tabs - one below [defaultMaxVisibleTabs] while
/// [searchInBottomBar] is on, since Search then takes that freed-up slot
/// itself (the row's last tab when attached, its own satellite circle
/// when floating - see `NooBottomBar`) rather than counting against it.
int get maxVisibleTabs =>
defaultMaxVisibleTabs - (_searchInBottomBar ? 1 : 0);
/// Every tab in the user's configured order, including hidden ones — used /// Every tab in the user's configured order, including hidden ones — used
/// by the reorder/visibility settings UI. /// by the reorder/visibility settings UI.
List<AppTab> get tabOrder => _tabOrder; List<AppTab> get tabOrder => _tabOrder;
@@ -115,6 +133,22 @@ class SettingsController extends ChangeNotifier {
orElse: () => ThemeMode.system, orElse: () => ThemeMode.system,
); );
} }
final bottomBarStyleName = prefs.getString(_prefBottomBarStyle);
if (bottomBarStyleName != null) {
_bottomBarStyle = NooBottomBarStyle.values.firstWhere(
(s) => s.name == bottomBarStyleName,
orElse: () => _bottomBarStyle,
);
}
final navMenuStyleName = prefs.getString(_prefNavMenuStyle);
if (navMenuStyleName != null) {
_navMenuStyle = NooNavMenuStyle.values.firstWhere(
(s) => s.name == navMenuStyleName,
orElse: () => _navMenuStyle,
);
}
_searchInBottomBar =
prefs.getBool(_prefSearchInBottomBar) ?? _searchInBottomBar;
_useDynamicColor = _useDynamicColor =
prefs.getBool(_prefUseDynamicColor) ?? _useDynamicColor; prefs.getBool(_prefUseDynamicColor) ?? _useDynamicColor;
_amoledDark = prefs.getBool(_prefAmoledDark) ?? _amoledDark; _amoledDark = prefs.getBool(_prefAmoledDark) ?? _amoledDark;
@@ -256,6 +290,32 @@ class SettingsController extends ChangeNotifier {
_prefsFuture.then((p) => p.setString(_prefThemeMode, mode.name)); _prefsFuture.then((p) => p.setString(_prefThemeMode, mode.name));
} }
void setBottomBarStyle(NooBottomBarStyle style) {
if (_bottomBarStyle == style) return;
_bottomBarStyle = style;
notifyListeners();
_prefsFuture.then((p) => p.setString(_prefBottomBarStyle, style.name));
}
void setNavMenuStyle(NooNavMenuStyle style) {
if (_navMenuStyle == style) return;
_navMenuStyle = style;
notifyListeners();
_prefsFuture.then((p) => p.setString(_prefNavMenuStyle, style.name));
}
/// Turning this on lowers [maxVisibleTabs] by one, so re-enforces the cap
/// immediately in case the user already has a full 5 regular tabs pinned
/// - same cleanup [_enforceMaxVisibleTabs] already does for a fresh
/// install/an app update adding a new tab.
void setSearchInBottomBar(bool value) {
if (_searchInBottomBar == value) return;
_searchInBottomBar = value;
if (value) _enforceMaxVisibleTabs();
notifyListeners();
_prefsFuture.then((p) => p.setBool(_prefSearchInBottomBar, value));
}
void setTabOrder(List<AppTab> order) { void setTabOrder(List<AppTab> order) {
_tabOrder = order; _tabOrder = order;
notifyListeners(); notifyListeners();
+3 -2
View File
@@ -356,8 +356,9 @@ class NooMotion {
} }
/// The one shadow the design system allows, from `tokens/spacing.css`'s /// The one shadow the design system allows, from `tokens/spacing.css`'s
/// `--shadow-dialog` - desktop dialogs only. Nothing else in the app should /// `--shadow-dialog` - desktop dialogs, and [NooBottomBarStyle.floating]'s
/// use a `BoxShadow`. /// bar/search satellite (the only other thing in the app that's genuinely
/// floating above other content). Nothing else should use a `BoxShadow`.
const nooDialogShadow = BoxShadow( const nooDialogShadow = BoxShadow(
color: Color(0x241E002F), // rgba(30,0,47,.14) color: Color(0x241E002F), // rgba(30,0,47,.14)
blurRadius: 48, blurRadius: 48,
+160 -18
View File
@@ -1,5 +1,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../theme/design_tokens.dart'; import '../theme/design_tokens.dart';
import '../widgets/noo/lists/noo_grouped_list.dart';
import '../widgets/noo/lists/noo_settings_row.dart';
import '../widgets/noo/nav/noo_top_bar.dart'; import '../widgets/noo/nav/noo_top_bar.dart';
import '../widgets/noo/nav/noo_toolbar.dart'; import '../widgets/noo/nav/noo_toolbar.dart';
import '../widgets/noo/noo_layout.dart'; import '../widgets/noo/noo_layout.dart';
@@ -15,18 +18,32 @@ import '../widgets/settings/settings_tabs.dart';
/// Settings, pushed on top of the shell (DESIGN_SYSTEM.md 4's 9-section /// Settings, pushed on top of the shell (DESIGN_SYSTEM.md 4's 9-section
/// order: account card, accounts, security, file sync, files cache, /// order: account card, accounts, security, file sync, files cache,
/// appearance, tabs, action bar, swipe on a file). One column of /// appearance, tabs, action bar, swipe on a file).
/// [SettingsSection]s (and [SettingsActionBarSection], which has no option ///
/// rows of its own to put in one - just the reorder list) on mobile, each /// Desktop is unchanged: a 2-column grid of cards ([_DesktopGrid]) wide
/// individually collapsible (expanded by default, tap its label to /// enough to see every section at once, so it has no scroll-depth problem
/// collapse - see `SettingsSection`/`NooGroupedList`'s `collapsible` param) /// and needs no menu.
/// so a long Settings screen can be collapsed down rather than needing a ///
/// separate jump rail (an earlier version had one; it added a second, /// Mobile is a two-level menu, the way native iOS/Android Settings apps
/// redundant way to navigate on top of plain scrolling and still didn't /// work: [SettingsAccountCard] (the account summary, not a settings picker)
/// shorten the page). Desktop uses a 2-column grid of cards instead, wide /// stays pinned at the top of a single top-level list ([_MobileMenu]), and
/// enough to see most sections without scrolling, so it gets neither. See /// every other section becomes one tappable [NooSettingsRow] - icon, title,
/// each `widgets/settings/*.dart` file for a section's own content and any /// chevron - in a [NooGroupedList] below it. Tapping a row pushes a new
/// setting that had to be slotted in or grouped under "Advanced appearance". /// [_SettingsCategoryScreen] with its own [NooTopBar]/[NooTopBarBack],
/// containing just that section's content full-screen. This replaces an
/// earlier design where every section rendered inline in one long
/// collapsible-sections column (`_MobileList`, since removed) - and before
/// that, a trailing jump rail (an even earlier version) that scrolled that
/// *same* page to an anchor. Both were rejected: the jump rail added a
/// second, redundant way to navigate on top of plain scrolling without
/// shortening the page, and the collapsible-sections column still left a
/// long page to scroll past even collapsed. A genuinely separate pushed
/// screen per category removes the scroll-depth problem outright, so
/// neither a jump rail nor per-section collapsing is needed any more - see
/// [SettingsSection]'s doc comment for how that reflects in its mobile
/// layout. See each `widgets/settings/*.dart` file for a section's own
/// content and any setting that had to be slotted in or grouped under
/// "Advanced appearance".
class AccountView extends StatelessWidget { class AccountView extends StatelessWidget {
const AccountView({super.key}); const AccountView({super.key});
@@ -42,6 +59,49 @@ class AccountView extends StatelessWidget {
SettingsSwipeSection(), SettingsSwipeSection(),
]; ];
static final _categories = <_SettingsCategory>[
_SettingsCategory(
title: 'Accounts',
icon: LucideIcons.users,
builder: (_) => const SettingsAccountsSection(),
),
_SettingsCategory(
title: 'Security',
icon: LucideIcons.lock,
builder: (_) => const SettingsSecuritySection(),
),
_SettingsCategory(
title: 'File sync',
icon: LucideIcons.cloud,
builder: (_) => const SettingsFileSyncSection(),
),
_SettingsCategory(
title: 'Files cache',
icon: LucideIcons.database,
builder: (_) => const SettingsFilesCacheSection(),
),
_SettingsCategory(
title: 'Appearance',
icon: LucideIcons.sunMoon,
builder: (_) => const SettingsAppearanceSection(),
),
_SettingsCategory(
title: 'Tabs',
icon: LucideIcons.layoutGrid,
builder: (_) => const SettingsTabsSection(),
),
_SettingsCategory(
title: 'Action bar',
icon: LucideIcons.slidersHorizontal,
builder: (_) => const SettingsActionBarSection(),
),
_SettingsCategory(
title: 'Swipe on a file',
icon: LucideIcons.chevronsLeftRight,
builder: (_) => const SettingsSwipeSection(),
),
];
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colors = context.nooColors; final colors = context.nooColors;
@@ -60,18 +120,49 @@ class AccountView extends StatelessWidget {
top: false, top: false,
child: desktop child: desktop
? _DesktopGrid(sections: _sections) ? _DesktopGrid(sections: _sections)
: _MobileList(sections: _sections), : _MobileMenu(categories: _categories),
), ),
); );
} }
} }
class _MobileList extends StatelessWidget { /// One row of [AccountView]'s mobile top-level menu: a title, a leading
final List<Widget> sections; /// icon (reused from that section's own first/most-representative row, so
const _MobileList({required this.sections}); /// the menu icon and the content the user lands on agree), and a builder
/// for the section content shown on [_SettingsCategoryScreen].
class _SettingsCategory {
final String title;
final IconData icon;
final WidgetBuilder builder;
_SettingsCategory({
required this.title,
required this.icon,
required this.builder,
});
}
/// The mobile top-level Settings list: [SettingsAccountCard] pinned above a
/// single [NooGroupedList] of category rows, one per [AccountView._categories]
/// entry - the menu half of the menu-then-pushed-screen pattern described on
/// [AccountView]'s own doc comment.
class _MobileMenu extends StatelessWidget {
final List<_SettingsCategory> categories;
const _MobileMenu({required this.categories});
void _open(BuildContext context, _SettingsCategory category) {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => _SettingsCategoryScreen(category: category),
),
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colors = context.nooColors;
return ListView( return ListView(
padding: const EdgeInsets.fromLTRB( padding: const EdgeInsets.fromLTRB(
NooSpace.sm, NooSpace.sm,
@@ -81,15 +172,66 @@ class _MobileList extends StatelessWidget {
), ),
physics: const BouncingScrollPhysics(), physics: const BouncingScrollPhysics(),
children: [ children: [
for (final section in sections) ...[ const SettingsAccountCard(),
section,
const SizedBox(height: NooSpace.xl), const SizedBox(height: NooSpace.xl),
NooGroupedList(
children: [
for (final category in categories)
NooSettingsRow(
icon: category.icon,
label: Text(category.title),
trailing: Icon(
LucideIcons.chevronRight,
size: 18,
color: colors.fg3,
),
onTap: () => _open(context, category),
),
], ],
),
], ],
); );
} }
} }
/// A pushed, single-category Settings screen: [NooTopBar] titled with the
/// category, a back button, and just that section's own content - the
/// pushed half of [AccountView]'s mobile menu-then-screen pattern. Always
/// built in a mobile-width context (desktop never opens this screen; it
/// shows every section inline in its own grid instead), so the section
/// widgets inside render their normal mobile layout unchanged.
class _SettingsCategoryScreen extends StatelessWidget {
final _SettingsCategory category;
const _SettingsCategoryScreen({required this.category});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
return Scaffold(
backgroundColor: colors.bg,
appBar: NooTopBar(
style: NooLayout.navStyle(context),
title: category.title,
leading: const NooTopBarBack(),
),
body: SafeArea(
top: false,
child: ListView(
padding: const EdgeInsets.fromLTRB(
NooSpace.sm,
NooSpace.sm,
NooSpace.sm,
NooSpace.xxl,
),
physics: const BouncingScrollPhysics(),
children: [category.builder(context)],
),
),
);
}
}
/// Splits the sections between two columns rather than a strict grid, since /// Splits the sections between two columns rather than a strict grid, since
/// each card's content height varies a lot (the tab reorder list and the /// each card's content height varies a lot (the tab reorder list and the
/// accounts list can both run much taller than, say, Security) - a fixed /// accounts list can both run much taller than, say, Security) - a fixed
+15 -3
View File
@@ -29,7 +29,11 @@ import '../widgets/tabs/tab_state_slivers.dart';
class ActivityView extends StatelessWidget { class ActivityView extends StatelessWidget {
final ScrollController scrollController; 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -90,17 +94,25 @@ class ActivityView extends StatelessWidget {
} }
final List<Widget> contentSlivers = [ final List<Widget> contentSlivers = [
if (topBar != null) topBarSliver(topBar!),
const SliverToBoxAdapter(child: SizedBox(height: NooSpace.md)), const SliverToBoxAdapter(child: SizedBox(height: NooSpace.md)),
feed, feed,
...tabBottomInsetSlivers, ...tabBottomInsetSlivers(context),
]; ];
final scrollView = CustomScrollView( // 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.
final scrollView = SafeArea(
top: true,
bottom: false,
child: CustomScrollView(
controller: scrollController, controller: scrollController,
// See files_view.dart's identical fix - without this, pull-to- // See files_view.dart's identical fix - without this, pull-to-
// refresh can't be triggered on an empty or single-item feed. // refresh can't be triggered on an empty or single-item feed.
physics: const AlwaysScrollableScrollPhysics(), physics: const AlwaysScrollableScrollPhysics(),
slivers: contentSlivers, slivers: contentSlivers,
),
); );
return ColoredBox( return ColoredBox(
+15 -2
View File
@@ -29,6 +29,7 @@ import '../widgets/noo/overlays/noo_sheet.dart';
import '../widgets/noo/core/noo_button.dart'; import '../widgets/noo/core/noo_button.dart';
import '../widgets/share_sheet.dart'; import '../widgets/share_sheet.dart';
import '../widgets/sticky_header_delegate.dart'; import '../widgets/sticky_header_delegate.dart';
import '../widgets/tabs/tab_state_slivers.dart';
import '../widgets/synced_header_scaffold.dart' show formatBytes; import '../widgets/synced_header_scaffold.dart' show formatBytes;
import 'file_viewer_screen.dart'; import 'file_viewer_screen.dart';
import 'move_copy_destination_picker.dart'; import 'move_copy_destination_picker.dart';
@@ -50,7 +51,11 @@ import 'move_copy_destination_picker.dart';
class FavoritesView extends StatefulWidget { class FavoritesView extends StatefulWidget {
final ScrollController scrollController; 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 @override
State<FavoritesView> createState() => _FavoritesViewState(); State<FavoritesView> createState() => _FavoritesViewState();
@@ -507,6 +512,7 @@ class _FavoritesViewState extends State<FavoritesView> {
); );
final contentSlivers = <Widget>[ final contentSlivers = <Widget>[
if (widget.topBar != null) topBarSliver(widget.topBar!),
SliverPersistentHeader( SliverPersistentHeader(
pinned: !_isSelecting, pinned: !_isSelecting,
delegate: StickyHeaderDelegate( delegate: StickyHeaderDelegate(
@@ -638,7 +644,7 @@ class _FavoritesViewState extends State<FavoritesView> {
}, childCount: favorites.length), }, childCount: favorites.length),
), ),
), ),
const SliverToBoxAdapter(child: SizedBox(height: 100)), SliverToBoxAdapter(child: SizedBox(height: bottomBarClearance(context))),
]; ];
return PopScope( return PopScope(
@@ -652,6 +658,12 @@ class _FavoritesViewState extends State<FavoritesView> {
color: colors.accent, color: colors.accent,
backgroundColor: colors.surface, backgroundColor: colors.surface,
onRefresh: favoritesController.fetchAll, 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( child: CustomScrollView(
controller: widget.scrollController, controller: widget.scrollController,
// See files_view.dart's identical fix - without this, pull-to- // See files_view.dart's identical fix - without this, pull-to-
@@ -661,6 +673,7 @@ class _FavoritesViewState extends State<FavoritesView> {
), ),
), ),
), ),
),
); );
} }
+35 -2
View File
@@ -39,10 +39,12 @@ import '../widgets/noo/lists/noo_selection_bar.dart';
import '../widgets/noo/lists/noo_settings_row.dart'; import '../widgets/noo/lists/noo_settings_row.dart';
import '../widgets/noo/lists/noo_summary_card.dart'; import '../widgets/noo/lists/noo_summary_card.dart';
import '../widgets/noo/media/noo_grid_card.dart'; import '../widgets/noo/media/noo_grid_card.dart';
import '../widgets/noo/nav/noo_bottom_bar.dart';
import '../widgets/noo/noo_layout.dart'; import '../widgets/noo/noo_layout.dart';
import '../widgets/noo/overlays/noo_sheet.dart'; import '../widgets/noo/overlays/noo_sheet.dart';
import '../widgets/share_sheet.dart'; import '../widgets/share_sheet.dart';
import '../widgets/sticky_header_delegate.dart'; import '../widgets/sticky_header_delegate.dart';
import '../widgets/tabs/tab_state_slivers.dart';
import '../widgets/synced_header_scaffold.dart' show formatBytes; import '../widgets/synced_header_scaffold.dart' show formatBytes;
import 'file_viewer_screen.dart'; import 'file_viewer_screen.dart';
import 'move_copy_destination_picker.dart'; import 'move_copy_destination_picker.dart';
@@ -59,10 +61,15 @@ class FilesView extends StatefulWidget {
final ScrollController scrollController; final ScrollController scrollController;
final bool offline; 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({ const FilesView({
super.key, super.key,
required this.scrollController, required this.scrollController,
this.offline = false, this.offline = false,
this.topBar,
}); });
@override @override
@@ -653,6 +660,7 @@ class _FilesViewState extends State<FilesView> {
); );
final List<Widget> contentSlivers = [ final List<Widget> contentSlivers = [
if (widget.topBar != null) topBarSliver(widget.topBar!),
// Pinned in both states - while browsing this is the controls row // Pinned in both states - while browsing this is the controls row
// (+ breadcrumbs), while selecting it's the selection bar (see // (+ breadcrumbs), while selecting it's the selection bar (see
// `topRow` above): either way it's the one thing that always stays // `topRow` above): either way it's the one thing that always stays
@@ -815,6 +823,15 @@ class _FilesViewState extends State<FilesView> {
}, childCount: browser.items.length), }, childCount: browser.items.length),
), ),
), ),
// 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<SettingsController>().bottomBarStyle ==
NooBottomBarStyle.floating)
SliverToBoxAdapter(
child: SizedBox(height: bottomBarClearance(context)),
),
]; ];
return PopScope( return PopScope(
@@ -836,17 +853,33 @@ class _FilesViewState extends State<FilesView> {
unawaited(sync.syncOnPull()); unawaited(sync.syncOnPull());
return browser.reload(); 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( child: CustomScrollView(
controller: widget.scrollController, controller: widget.scrollController,
// Pull-to-refresh needs a scroll physics that allows dragging // Pull-to-refresh needs a scroll physics that allows dragging
// past the edge even when content doesn't fill the viewport - // past the edge even when content doesn't fill the viewport -
// an empty or single-item list otherwise can't be pulled at all // an empty or single-item list otherwise can't be pulled at
// under the platform default physics. // all under the platform default physics.
physics: const AlwaysScrollableScrollPhysics(), physics: const AlwaysScrollableScrollPhysics(),
slivers: contentSlivers, slivers: contentSlivers,
), ),
), ),
), ),
),
); );
} }
+18 -4
View File
@@ -30,6 +30,7 @@ import '../widgets/noo/overlays/noo_sheet.dart';
import '../widgets/share_sheet.dart'; import '../widgets/share_sheet.dart';
import '../widgets/sort_menu_button.dart' show sortFieldLabel; import '../widgets/sort_menu_button.dart' show sortFieldLabel;
import '../widgets/sticky_header_delegate.dart'; import '../widgets/sticky_header_delegate.dart';
import '../widgets/tabs/tab_state_slivers.dart';
import 'file_viewer_screen.dart'; import 'file_viewer_screen.dart';
import 'move_copy_destination_picker.dart'; import 'move_copy_destination_picker.dart';
@@ -53,7 +54,11 @@ class _MonthGroup {
class PhotosView extends StatefulWidget { class PhotosView extends StatefulWidget {
final ScrollController scrollController; 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 @override
State<PhotosView> createState() => _PhotosViewState(); State<PhotosView> createState() => _PhotosViewState();
@@ -239,6 +244,7 @@ class _PhotosViewState extends State<PhotosView> {
); );
final List<Widget> contentSlivers = [ final List<Widget> contentSlivers = [
if (widget.topBar != null) topBarSliver(widget.topBar!),
// Sticky while browsing; once selecting, the selection bar takes over // Sticky while browsing; once selecting, the selection bar takes over
// the same slot instead. // the same slot instead.
SliverPersistentHeader( SliverPersistentHeader(
@@ -346,9 +352,10 @@ class _PhotosViewState extends State<PhotosView> {
), ),
], ],
// Fixed clearance so the last row isn't hidden behind the floating // Clearance so the last row isn't hidden behind the nav bar,
// nav bar, regardless of grid length. // regardless of grid length - see `bottomBarClearance`'s own doc
const SliverToBoxAdapter(child: SizedBox(height: 100)), // comment for why this has to be dynamic rather than a flat 100.
SliverToBoxAdapter(child: SizedBox(height: bottomBarClearance(context))),
]; ];
return PopScope( return PopScope(
@@ -362,6 +369,12 @@ class _PhotosViewState extends State<PhotosView> {
color: colors.accent, color: colors.accent,
backgroundColor: colors.surface, backgroundColor: colors.surface,
onRefresh: photosController.fetchAllMedia, 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( child: CustomScrollView(
controller: widget.scrollController, controller: widget.scrollController,
// See files_view.dart's identical fix - without this, pull-to- // See files_view.dart's identical fix - without this, pull-to-
@@ -371,6 +384,7 @@ class _PhotosViewState extends State<PhotosView> {
), ),
), ),
), ),
),
); );
} }
+14 -2
View File
@@ -27,7 +27,11 @@ import 'file_viewer_screen.dart';
class RecentView extends StatefulWidget { class RecentView extends StatefulWidget {
final ScrollController scrollController; 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 @override
State<RecentView> createState() => _RecentViewState(); State<RecentView> createState() => _RecentViewState();
@@ -54,6 +58,7 @@ class _RecentViewState extends State<RecentView> {
); );
final List<Widget> contentSlivers = [ final List<Widget> contentSlivers = [
if (widget.topBar != null) topBarSliver(widget.topBar!),
const SliverToBoxAdapter(child: SizedBox(height: NooSpace.md)), const SliverToBoxAdapter(child: SizedBox(height: NooSpace.md)),
if (recent.isLoading && items.isEmpty) if (recent.isLoading && items.isEmpty)
tabLoadingSliver tabLoadingSliver
@@ -74,7 +79,7 @@ class _RecentViewState extends State<RecentView> {
_buildDesktopTable(context, groups) _buildDesktopTable(context, groups)
else else
_buildMobileGroups(context, groups), _buildMobileGroups(context, groups),
...tabBottomInsetSlivers, ...tabBottomInsetSlivers(context),
]; ];
return ColoredBox( return ColoredBox(
@@ -83,6 +88,12 @@ class _RecentViewState extends State<RecentView> {
color: colors.accent, color: colors.accent,
backgroundColor: colors.surface, backgroundColor: colors.surface,
onRefresh: recent.fetchAll, onRefresh: recent.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( child: CustomScrollView(
controller: widget.scrollController, controller: widget.scrollController,
// See files_view.dart's identical fix - without this, pull-to- // See files_view.dart's identical fix - without this, pull-to-
@@ -91,6 +102,7 @@ class _RecentViewState extends State<RecentView> {
slivers: contentSlivers, slivers: contentSlivers,
), ),
), ),
),
); );
} }
+14 -2
View File
@@ -53,7 +53,11 @@ String _permissionLabel(int permissions) {
class SharesView extends StatefulWidget { class SharesView extends StatefulWidget {
final ScrollController scrollController; 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 @override
State<SharesView> createState() => _SharesViewState(); State<SharesView> createState() => _SharesViewState();
@@ -81,6 +85,7 @@ class _SharesViewState extends State<SharesView> {
final isDesktop = NooLayout.isDesktop(context); final isDesktop = NooLayout.isDesktop(context);
final List<Widget> contentSlivers = [ final List<Widget> contentSlivers = [
if (widget.topBar != null) topBarSliver(widget.topBar!),
SliverPadding( SliverPadding(
padding: EdgeInsets.fromLTRB( padding: EdgeInsets.fromLTRB(
NooLayout.gutter(context), NooLayout.gutter(context),
@@ -120,7 +125,7 @@ class _SharesViewState extends State<SharesView> {
_buildDesktopTable(context, shares) _buildDesktopTable(context, shares)
else else
_buildMobileList(context, shares), _buildMobileList(context, shares),
...tabBottomInsetSlivers, ...tabBottomInsetSlivers(context),
]; ];
return ColoredBox( return ColoredBox(
@@ -129,6 +134,12 @@ class _SharesViewState extends State<SharesView> {
color: colors.accent, color: colors.accent,
backgroundColor: colors.surface, backgroundColor: colors.surface,
onRefresh: sharesController.fetchAll, onRefresh: sharesController.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( child: CustomScrollView(
controller: widget.scrollController, controller: widget.scrollController,
// See files_view.dart's identical fix - without this, pull-to- // See files_view.dart's identical fix - without this, pull-to-
@@ -137,6 +148,7 @@ class _SharesViewState extends State<SharesView> {
slivers: contentSlivers, slivers: contentSlivers,
), ),
), ),
),
); );
} }
+14 -2
View File
@@ -25,7 +25,11 @@ import '../widgets/tabs/tab_state_slivers.dart';
class TrashView extends StatefulWidget { class TrashView extends StatefulWidget {
final ScrollController scrollController; 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 @override
State<TrashView> createState() => _TrashViewState(); State<TrashView> createState() => _TrashViewState();
@@ -50,6 +54,7 @@ class _TrashViewState extends State<TrashView> {
final isDesktop = NooLayout.isDesktop(context); final isDesktop = NooLayout.isDesktop(context);
final List<Widget> contentSlivers = [ final List<Widget> contentSlivers = [
if (widget.topBar != null) topBarSliver(widget.topBar!),
SliverPadding( SliverPadding(
padding: EdgeInsets.fromLTRB( padding: EdgeInsets.fromLTRB(
NooLayout.gutter(context), NooLayout.gutter(context),
@@ -89,7 +94,7 @@ class _TrashViewState extends State<TrashView> {
_buildDesktopTable(context, trash) _buildDesktopTable(context, trash)
else else
_buildMobileList(context, trash), _buildMobileList(context, trash),
...tabBottomInsetSlivers, ...tabBottomInsetSlivers(context),
]; ];
return ColoredBox( return ColoredBox(
@@ -98,6 +103,12 @@ class _TrashViewState extends State<TrashView> {
color: colors.accent, color: colors.accent,
backgroundColor: colors.surface, backgroundColor: colors.surface,
onRefresh: trashController.fetchAll, onRefresh: trashController.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( child: CustomScrollView(
controller: widget.scrollController, controller: widget.scrollController,
// See files_view.dart's identical fix - without this, pull-to- // See files_view.dart's identical fix - without this, pull-to-
@@ -106,6 +117,7 @@ class _TrashViewState extends State<TrashView> {
slivers: contentSlivers, slivers: contentSlivers,
), ),
), ),
),
); );
} }
+22 -7
View File
@@ -11,7 +11,20 @@ import '../views/trash_view.dart';
/// Builds the view widget for a given [AppTab]. Shared by the main shell /// 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 /// (for visible tabs) and the "more tabs" dropdown (for launching a tab
/// that's currently hidden from the bottom nav bar). /// 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) { switch (tab) {
case AppTab.files: case AppTab.files:
// Keyed so the Files and Offline tabs (same widget type) never share // Keyed so the Files and Offline tabs (same widget type) never share
@@ -19,24 +32,26 @@ Widget buildAppTabView(AppTab tab, ScrollController controller) {
return FilesView( return FilesView(
key: const ValueKey('files'), key: const ValueKey('files'),
scrollController: controller, scrollController: controller,
topBar: topBar,
); );
case AppTab.photos: case AppTab.photos:
return PhotosView(scrollController: controller); return PhotosView(scrollController: controller, topBar: topBar);
case AppTab.favorites: case AppTab.favorites:
return FavoritesView(scrollController: controller); return FavoritesView(scrollController: controller, topBar: topBar);
case AppTab.activity: case AppTab.activity:
return ActivityView(scrollController: controller); return ActivityView(scrollController: controller, topBar: topBar);
case AppTab.trash: case AppTab.trash:
return TrashView(scrollController: controller); return TrashView(scrollController: controller, topBar: topBar);
case AppTab.shares: case AppTab.shares:
return SharesView(scrollController: controller); return SharesView(scrollController: controller, topBar: topBar);
case AppTab.recent: case AppTab.recent:
return RecentView(scrollController: controller); return RecentView(scrollController: controller, topBar: topBar);
case AppTab.offline: case AppTab.offline:
return FilesView( return FilesView(
key: const ValueKey('offline'), key: const ValueKey('offline'),
scrollController: controller, scrollController: controller,
offline: true, offline: true,
topBar: topBar,
); );
} }
} }
+45 -13
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../models/app_tab.dart'; import '../models/app_tab.dart';
import 'avatar_menu.dart';
import 'create_menu.dart'; import 'create_menu.dart';
import 'noo/nav/noo_top_bar.dart'; import 'noo/nav/noo_top_bar.dart';
import 'shell/shell_common.dart'; import 'shell/shell_common.dart';
@@ -13,19 +14,40 @@ import 'shell/shell_common.dart';
/// - iOS: `menu` leading, `plus` (Files only - no tab besides Files has a /// - iOS: `menu` leading, `plus` (Files only - no tab besides Files has a
/// create/upload flow) and the account avatar trailing, then the large /// create/upload flow) and the account avatar trailing, then the large
/// title, then an inline search field that opens [SearchView]. /// title, then an inline search field that opens [SearchView].
/// - Android: `menu`, title, `search` and avatar actions. Upload has no /// - Android: `menu`, title, avatar actions. Upload has no top-bar icon
/// top-bar icon here - it's the [NooFab] `MainShellView` shows instead. /// here - it's the [NooFab] `MainShellView` shows instead. When search
/// isn't in the bottom bar, an inline search-field-styled launcher
/// (`androidTitleTrailing`) sits to the title's own right, rather than a
/// bare search icon over in `actions`.
/// ///
/// [style] is resolved by the caller (`NooLayout.navStyle`) rather than /// [style] is resolved by the caller (`NooLayout.navStyle`) rather than
/// read from context here, since [preferredSize] is a plain getter with no /// read from context here, since [preferredSize] is a plain getter with no
/// BuildContext - the same reason [NooTopBar] itself takes it as a field. /// BuildContext - the same reason [NooTopBar] itself takes it as a field.
/// [searchInBottomBar] is [SettingsController.searchInBottomBar], passed in
/// for the same reason: it hides this bar's own search field/icon so
/// there's only ever one search entry point on screen once the bottom bar
/// has taken it over. [navMenuStyle] is
/// [SettingsController.navMenuStyle]: [NooNavMenuStyle.drawer] wires the
/// `menu` icon to `Scaffold.of(context).openDrawer()` as before;
/// [NooNavMenuStyle.avatarMenu] drops that icon (`onMenu: null`) and makes
/// the avatar button open `showAvatarMenu` instead of the account
/// switcher.
class AppTopBar extends StatelessWidget implements PreferredSizeWidget { class AppTopBar extends StatelessWidget implements PreferredSizeWidget {
final NooNavStyle style; final NooNavStyle style;
final AppTab tab; final AppTab tab;
final bool searchInBottomBar;
final NooNavMenuStyle navMenuStyle;
const AppTopBar({super.key, required this.style, required this.tab}); const AppTopBar({
super.key,
required this.style,
required this.tab,
this.searchInBottomBar = false,
this.navMenuStyle = NooNavMenuStyle.drawer,
});
bool get _ios => style == NooNavStyle.ios; bool get _ios => style == NooNavStyle.ios;
bool get _showSearch => !searchInBottomBar;
@override @override
Size get preferredSize => NooTopBar( Size get preferredSize => NooTopBar(
@@ -34,7 +56,7 @@ class AppTopBar extends StatelessWidget implements PreferredSizeWidget {
// A throwaway placeholder purely to make preferredSize's "is there a // A throwaway placeholder purely to make preferredSize's "is there a
// search slot" check match what build() actually shows - never // search slot" check match what build() actually shows - never
// inserted into the tree, so its content doesn't matter. // inserted into the tree, so its content doesn't matter.
search: _ios ? const SizedBox.shrink() : null, search: _ios && _showSearch ? const SizedBox.shrink() : null,
).preferredSize; ).preferredSize;
@override @override
@@ -42,23 +64,33 @@ class AppTopBar extends StatelessWidget implements PreferredSizeWidget {
return NooTopBar( return NooTopBar(
style: style, style: style,
title: tab.label, title: tab.label,
onMenu: () => Scaffold.of(context).openDrawer(), onMenu: navMenuStyle == NooNavMenuStyle.drawer
? () => Scaffold.of(context).openDrawer()
: null,
// Android has no large title to put a second search row under the
// way iOS does (`search:` below) - an inline bar sits beside the
// title instead, rather than a plain search icon over in `actions`.
androidTitleTrailing: !_ios && _showSearch
? const ShellSearchLauncher()
: null,
actions: [ actions: [
if (!_ios)
NooTopBarButton(
icon: LucideIcons.search,
tooltip: 'Search',
onTap: () => openSearch(context),
),
if (_ios && tab == AppTab.files) if (_ios && tab == AppTab.files)
NooTopBarButton( NooTopBarButton(
icon: LucideIcons.plus, icon: LucideIcons.plus,
tooltip: 'Create', tooltip: 'Create',
onTap: () => showCreateMenu(context), onTap: () => showCreateMenu(context),
), ),
ShellAvatarButton(hitBox: _ios ? 44 : 48), ShellAvatarButton(
hitBox: _ios ? 44 : 48,
onTap: navMenuStyle == NooNavMenuStyle.avatarMenu
? () => showAvatarMenu(context)
: null,
label: navMenuStyle == NooNavMenuStyle.avatarMenu
? 'Menu'
: 'Accounts',
),
], ],
search: _ios ? const ShellSearchLauncher(ios: true) : null, search: _ios && _showSearch ? const ShellSearchLauncher(ios: true) : null,
); );
} }
} }
+203
View File
@@ -0,0 +1,203 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:provider/provider.dart';
import '../models/app_tab.dart';
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 'noo/core/noo_badge.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<void> showAvatarMenu(BuildContext context) {
return showGeneralDialog<void>(
context: context,
barrierColor: Colors.transparent,
barrierDismissible: true,
barrierLabel: 'Close menu',
transitionDuration: NooMotion.fast,
pageBuilder: (context, _, _) => const _AvatarMenuContent(),
transitionBuilder: (context, animation, _, child) => FadeTransition(
opacity: animation,
child: ScaleTransition(
alignment: Alignment.topRight,
scale: Tween<double>(
begin: 0.96,
end: 1,
).animate(CurvedAnimation(parent: animation, curve: NooMotion.ease)),
child: child,
),
),
);
}
class _AvatarMenuContent extends StatelessWidget {
const _AvatarMenuContent();
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
final session = context.watch<SessionController>();
final settings = context.watch<SettingsController>();
final trashCount = context.watch<TrashController>().items.length;
final hiddenTabs = settings.tabOrder
.where((t) => settings.hiddenTabs.contains(t))
.toList();
void closeAndOpenSettings() {
Navigator.pop(context);
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(
padding: const EdgeInsets.symmetric(horizontal: NooSpace.md),
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: closeAndOpenSettings,
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,
),
],
),
),
),
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,
),
],
),
),
),
),
),
),
),
),
);
}
}
+15
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../models/app_tab.dart'; import '../models/app_tab.dart';
import 'noo/nav/noo_bottom_bar.dart'; import 'noo/nav/noo_bottom_bar.dart';
@@ -7,30 +8,44 @@ import 'noo/nav/noo_bottom_bar.dart';
/// doesn't need to import `noo/nav` itself. [selectedIndex] may be -1 (no /// doesn't need to import `noo/nav` itself. [selectedIndex] may be -1 (no
/// destination highlighted) when the shell is showing a tab opened from the /// destination highlighted) when the shell is showing a tab opened from the
/// drawer's "More" list that isn't one of the pinned [tabs]. /// drawer's "More" list that isn't one of the pinned [tabs].
///
/// [onSearchTap] (from Settings' "Search in bottom bar") adds the Search
/// entry [NooBottomBar] itself knows how to place (row's last item when
/// attached, its own satellite when floating) - null leaves the bar
/// exactly as before.
class BottomNavBar extends StatelessWidget { class BottomNavBar extends StatelessWidget {
final NooNavStyle style; final NooNavStyle style;
final NooBottomBarStyle barStyle;
final List<AppTab> tabs; final List<AppTab> tabs;
final int selectedIndex; final int selectedIndex;
final ValueChanged<int> onDestinationSelected; final ValueChanged<int> onDestinationSelected;
final VoidCallback? onSearchTap;
const BottomNavBar({ const BottomNavBar({
super.key, super.key,
required this.style, required this.style,
this.barStyle = NooBottomBarStyle.attached,
required this.tabs, required this.tabs,
required this.selectedIndex, required this.selectedIndex,
required this.onDestinationSelected, required this.onDestinationSelected,
this.onSearchTap,
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return NooBottomBar( return NooBottomBar(
style: style, style: style,
barStyle: barStyle,
destinations: [ destinations: [
for (final tab in tabs) for (final tab in tabs)
NooNavDestination(icon: tab.icon, label: tab.label), NooNavDestination(icon: tab.icon, label: tab.label),
], ],
selectedIndex: selectedIndex, selectedIndex: selectedIndex,
onSelected: onDestinationSelected, onSelected: onDestinationSelected,
searchDestination: onSearchTap == null
? null
: const NooNavDestination(icon: LucideIcons.search, label: 'Search'),
onSearchTap: onSearchTap,
); );
} }
} }
+17 -2
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../../theme/design_tokens.dart'; import '../../../theme/design_tokens.dart';
import '../nav/noo_nav_style.dart';
/// Android-only extended Upload pill, bottom-right of Files and Photos (Noo /// Android-only extended Upload pill, bottom-right of Files and Photos (Noo
/// Design System project, `components/core/FAB.jsx`). 56px tall, accent /// Design System project, `components/core/FAB.jsx`). 56px tall, accent
@@ -10,11 +11,21 @@ import '../../../theme/design_tokens.dart';
/// don't support upload, so the button stays in place and resizes as the /// don't support upload, so the button stays in place and resizes as the
/// user switches tabs instead of the Scaffold popping it fully in/out - /// user switches tabs instead of the Scaffold popping it fully in/out -
/// the label's width animates away rather than the FAB disappearing. /// the label's width animates away rather than the FAB disappearing.
///
/// [barStyle] shapes the corners to match the bottom bar it sits above
/// (Settings > Appearance). With [NooBottomBarStyle.floating] the FAB stays
/// fully round (`NooRadii.pill`) - consistent with that mode's other
/// elements, which are already rounder (the bar itself at 28px, its search
/// satellite a full circle). With [NooBottomBarStyle.attached] the FAB
/// squares off to `NooRadii.card`, a rounded square that fits attached's
/// flatter, edge-to-edge aesthetic; this applies whether [collapsed] or not,
/// so the FAB keeps one consistent identity per bar style.
class NooFab extends StatelessWidget { class NooFab extends StatelessWidget {
final String label; final String label;
final IconData icon; final IconData icon;
final VoidCallback? onTap; final VoidCallback? onTap;
final bool collapsed; final bool collapsed;
final NooBottomBarStyle barStyle;
const NooFab({ const NooFab({
super.key, super.key,
@@ -22,17 +33,21 @@ class NooFab extends StatelessWidget {
this.icon = LucideIcons.plus, this.icon = LucideIcons.plus,
this.onTap, this.onTap,
this.collapsed = false, this.collapsed = false,
required this.barStyle,
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colors = context.nooColors; final colors = context.nooColors;
final radius = BorderRadius.circular(
barStyle == NooBottomBarStyle.attached ? NooRadii.card : NooRadii.pill,
);
return Material( return Material(
color: colors.accent, color: colors.accent,
borderRadius: BorderRadius.circular(NooRadii.pill), borderRadius: radius,
child: InkWell( child: InkWell(
onTap: onTap, onTap: onTap,
borderRadius: BorderRadius.circular(NooRadii.pill), borderRadius: radius,
child: AnimatedContainer( child: AnimatedContainer(
duration: NooMotion.base, duration: NooMotion.base,
curve: NooMotion.ease, curve: NooMotion.ease,
+61 -8
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../../theme/design_tokens.dart'; import '../../../theme/design_tokens.dart';
@@ -34,6 +35,13 @@ class NooSwipeActionSpec {
/// springs shut - both on [NooMotion.base] / [NooMotion.ease], no bounce. /// springs shut - both on [NooMotion.base] / [NooMotion.ease], no bounce.
/// Tapping the block fires the action and closes it; tapping the row while /// Tapping the block fires the action and closes it; tapping the row while
/// open just closes it. /// open just closes it.
///
/// Dragging further still - past [_kTriggerExtent] of the block's own
/// width, the block growing to fill the extra reveal so there's no gap -
/// arms the action (a [HapticFeedback.mediumImpact] marks the crossing);
/// releasing while armed fires it immediately instead of just leaving it
/// open, so a single swipe-through gesture can do the whole thing without a
/// second tap.
class NooSwipeAction extends StatefulWidget { class NooSwipeAction extends StatefulWidget {
final Widget child; final Widget child;
@@ -59,12 +67,24 @@ class NooSwipeAction extends StatefulWidget {
class _NooSwipeActionState extends State<NooSwipeAction> class _NooSwipeActionState extends State<NooSwipeAction>
with SingleTickerProviderStateMixin { with SingleTickerProviderStateMixin {
/// How far past the fully-revealed block (1.0) a drag has to go before
/// releasing fires the action outright rather than just snapping open -
/// "swipe through" in one gesture instead of open-then-tap.
static const double _kTriggerExtent = 1.8;
/// Hard ceiling on the controller's value - past [_kTriggerExtent] there's
/// nothing more for further drag to *do*, but a little extra travel still
/// gives the gesture room to keep moving under the finger instead of
/// hitting a dead stop right at the trigger point.
static const double _kMaxDrag = 2.4;
/// -1 = end action fully revealed, 0 = closed, 1 = start action revealed /// -1 = end action fully revealed, 0 = closed, 1 = start action revealed
/// (in visual left/right terms after [_dir] is applied). /// (in visual left/right terms after [_dir] is applied) - and on past
/// either bound up to [_kMaxDrag] while armed for [_kTriggerExtent].
late final AnimationController _ctrl = AnimationController( late final AnimationController _ctrl = AnimationController(
vsync: this, vsync: this,
lowerBound: -1, lowerBound: -_kMaxDrag,
upperBound: 1, upperBound: _kMaxDrag,
value: 0, value: 0,
); );
@@ -85,13 +105,21 @@ class _NooSwipeActionState extends State<NooSwipeAction>
void _onDragUpdate(DragUpdateDetails d) { void _onDragUpdate(DragUpdateDetails d) {
// Positive = revealing the start action. // Positive = revealing the start action.
final delta = d.primaryDelta! * _dir / NooSwipeAction.actionWidth; final delta = d.primaryDelta! * _dir / NooSwipeAction.actionWidth;
final min = widget.endAction != null ? -1.0 : 0.0; final min = widget.endAction != null ? -_kMaxDrag : 0.0;
final max = widget.startAction != null ? 1.0 : 0.0; final max = widget.startAction != null ? _kMaxDrag : 0.0;
_ctrl.value = (_ctrl.value + delta).clamp(min, max); final next = (_ctrl.value + delta).clamp(min, max);
final wasArmed = _ctrl.value.abs() >= _kTriggerExtent;
final nowArmed = next.abs() >= _kTriggerExtent;
if (nowArmed && !wasArmed) HapticFeedback.mediumImpact();
_ctrl.value = next;
} }
void _onDragEnd(DragEndDetails d) { void _onDragEnd(DragEndDetails d) {
final v = _ctrl.value; final v = _ctrl.value;
if (v.abs() >= _kTriggerExtent) {
_trigger(v > 0 ? widget.startAction! : widget.endAction!);
return;
}
final velocity = d.primaryVelocity! * _dir; final velocity = d.primaryVelocity! * _dir;
double target; double target;
if (velocity.abs() > 700) { if (velocity.abs() > 700) {
@@ -141,6 +169,13 @@ class _NooSwipeActionState extends State<NooSwipeAction>
child: _ActionBlock( child: _ActionBlock(
spec: showing, spec: showing,
colors: colors, colors: colors,
// Grows past its own min width to fill the extra
// reveal once dragged further than a plain "open"
// - otherwise the row's translated edge would pull
// away from the block and expose bare space behind
// it.
width: v.abs() * NooSwipeAction.actionWidth,
armed: v.abs() >= _kTriggerExtent,
onTap: () => _trigger(showing), onTap: () => _trigger(showing),
), ),
), ),
@@ -196,11 +231,24 @@ class _OpenAwareChild extends AnimatedWidget {
class _ActionBlock extends StatelessWidget { class _ActionBlock extends StatelessWidget {
final NooSwipeActionSpec spec; final NooSwipeActionSpec spec;
final NooColors colors; final NooColors colors;
/// The block's own width - grows past [NooSwipeAction.actionWidth] once
/// dragged further than a plain reveal, so it always fills exactly what's
/// exposed behind the row.
final double width;
/// True once the drag has gone far enough that releasing now fires the
/// action - bumps the icon up a touch as a "you're past the point of no
/// return" cue, on top of the haptic tick that fired at the same moment.
final bool armed;
final VoidCallback onTap; final VoidCallback onTap;
const _ActionBlock({ const _ActionBlock({
required this.spec, required this.spec,
required this.colors, required this.colors,
required this.width,
required this.armed,
required this.onTap, required this.onTap,
}); });
@@ -216,13 +264,18 @@ class _ActionBlock extends StatelessWidget {
behavior: HitTestBehavior.opaque, behavior: HitTestBehavior.opaque,
onTap: onTap, onTap: onTap,
child: Container( child: Container(
width: NooSwipeAction.actionWidth, width: width.clamp(NooSwipeAction.actionWidth, double.infinity),
color: bg, color: bg,
alignment: Alignment.center, alignment: Alignment.center,
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon(icon, size: 20, color: Colors.white), AnimatedScale(
scale: armed ? 1.15 : 1,
duration: NooMotion.fast,
curve: NooMotion.ease,
child: Icon(icon, size: 20, color: Colors.white),
),
const SizedBox(height: 6), const SizedBox(height: 6),
Text( Text(
label, label,
+214 -30
View File
@@ -5,8 +5,11 @@ import 'noo_nav_style.dart';
export 'noo_nav_style.dart'; export 'noo_nav_style.dart';
/// Android indicator geometry, shared between the sliding pill and each /// Android indicator geometry, shared between the sliding pill and each
/// item's own icon box so they line up exactly. /// item's own icon box so they line up exactly. Floating's own top offset
const double _kAndroidPillTop = 14; /// is shorter than attached's - its row is 16px shorter overall (64 vs 80)
/// and there's no edge-to-edge safe-area strip inside it eating into that.
const double _kAttachedPillTop = 14;
const double _kFloatingPillTop = 8;
const double _kAndroidPillWidth = 56; const double _kAndroidPillWidth = 56;
const double _kAndroidPillHeight = 32; const double _kAndroidPillHeight = 32;
@@ -23,29 +26,133 @@ const double _kAndroidPillHeight = 32;
/// 56x32 accent-soft pill that slides between tabs as selection moves, /// 56x32 accent-soft pill that slides between tabs as selection moves,
/// rather than popping in/out on the destination item itself. /// rather than popping in/out on the destination item itself.
/// ///
/// The home indicator / gesture bar area below the row comes from the /// [barStyle] (user-configurable in Settings, Appearance) picks between
/// bottom safe-area inset rather than a fixed 34/20px spacer, so it's /// that edge-to-edge [NooBottomBarStyle.attached] bar and
/// right on every device. Meant for `Scaffold.bottomNavigationBar`. /// [NooBottomBarStyle.floating] - inset 16px from both side edges, 28px
/// corners, a 1px `line` border plus [nooDialogShadow] (the one shadow the
/// rest of the app allows itself, see that constant's own doc comment) so
/// it actually reads as floating above the content scrolling behind it.
/// Floating's Android row is both shorter (64 vs 80) and drops its idle
/// tabs' reserved label space - with no label to leave room for, an idle
/// icon just centers in the whole button, rather than sitting high with a
/// gap under it. The home indicator / gesture bar area below the row comes
/// from the bottom safe-area inset rather than a fixed 34/20px spacer, so
/// it's right on every device. Meant for `Scaffold.bottomNavigationBar` -
/// the host `Scaffold` needs `extendBody: true` while floating, and a
/// scrollable body needs enough trailing padding to clear the bar's own
/// footprint ([NooBottomBar.rowHeight] + [NooBottomBar.floatingBottomMargin]
/// + the bottom safe area) since nothing does that automatically once the
/// body draws behind it - see `tab_state_slivers.dart`'s
/// `bottomBarClearance`.
///
/// [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
/// `SearchView`, it doesn't select anything) - the row's last item when
/// attached, or its own satellite circle beside the bar when floating,
/// always fully round regardless of the bar's own corner radius.
class NooBottomBar extends StatelessWidget { class NooBottomBar extends StatelessWidget {
final NooNavStyle style; final NooNavStyle style;
final NooBottomBarStyle barStyle;
final List<NooNavDestination> destinations; final List<NooNavDestination> destinations;
final int selectedIndex; final int selectedIndex;
final ValueChanged<int> onSelected; final ValueChanged<int> onSelected;
final NooNavDestination? searchDestination;
final VoidCallback? onSearchTap;
const NooBottomBar({ const NooBottomBar({
super.key, super.key,
required this.style, required this.style,
this.barStyle = NooBottomBarStyle.attached,
required this.destinations, required this.destinations,
required this.selectedIndex, required this.selectedIndex,
required this.onSelected, required this.onSelected,
this.searchDestination,
this.onSearchTap,
}); });
/// The row height [build] draws for [style]/[barStyle] - exposed so a
/// 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;
return barStyle == NooBottomBarStyle.floating ? 64 : 80;
}
/// 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;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colors = context.nooColors; final colors = context.nooColors;
final ios = style == NooNavStyle.ios; final ios = style == NooNavStyle.ios;
final floating = barStyle == NooBottomBarStyle.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
// row builders only ever see it as a trailing item in the former case.
final rowSearch = hasSearch && !floating ? searchDestination : null;
final row = ios ? _buildIosRow() : _buildAndroidRow(colors); final row = ios
? _buildIosRow(trailingSearch: rowSearch)
: _buildAndroidRow(
colors,
floating: floating,
trailingSearch: rowSearch,
);
final barHeight = NooBottomBar.rowHeight(style, barStyle);
if (floating) {
final pill = Container(
height: barHeight,
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
color: colors.surface,
border: Border.all(color: colors.line),
borderRadius: BorderRadius.circular(28),
boxShadow: const [nooDialogShadow],
),
child: row,
);
return SafeArea(
top: false,
child: Padding(
padding: EdgeInsets.fromLTRB(
16,
0,
16,
NooBottomBar.floatingBottomMargin,
),
child: hasSearch
// A fixed-height SizedBox, not just a Row with
// crossAxisAlignment.stretch - the bottomNavigationBar slot
// gives this widget a *loose* (unbounded-max) height
// constraint, and stretch on an unbounded cross axis blows up
// to fill the screen in profile/release (the assertion that
// would catch it in debug is stripped there) - the same
// silent-oversizing bug files_controls_row.dart hit earlier
// from a different cause. Both children already size
// themselves explicitly (pill's own `height`, the
// satellite's `size`), so stretch was never actually needed.
? SizedBox(
height: barHeight,
child: Row(
children: [
Expanded(child: pill),
const SizedBox(width: 8),
_SearchSatellite(
destination: searchDestination!,
onTap: onSearchTap!,
size: barHeight,
),
],
),
)
: pill,
),
);
}
return Container( return Container(
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -54,12 +161,12 @@ class NooBottomBar extends StatelessWidget {
), ),
child: SafeArea( child: SafeArea(
top: false, top: false,
child: SizedBox(height: ios ? 50 : 80, child: row), child: SizedBox(height: barHeight, child: row),
), ),
); );
} }
Widget _buildIosRow() { Widget _buildIosRow({NooNavDestination? trailingSearch}) {
return Row( return Row(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
@@ -71,20 +178,34 @@ class NooBottomBar extends StatelessWidget {
onTap: () => onSelected(i), onTap: () => onSelected(i),
), ),
), ),
if (trailingSearch != null)
Expanded(
child: _IosItem(
destination: trailingSearch,
selected: false,
onTap: onSearchTap!,
),
),
], ],
); );
} }
Widget _buildAndroidRow(NooColors colors) { Widget _buildAndroidRow(
NooColors colors, {
required bool floating,
NooNavDestination? trailingSearch,
}) {
final pillTop = floating ? _kFloatingPillTop : _kAttachedPillTop;
final itemCount = destinations.length + (trailingSearch != null ? 1 : 0);
return LayoutBuilder( return LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final itemWidth = constraints.maxWidth / destinations.length; final itemWidth = constraints.maxWidth / itemCount;
return Stack( return Stack(
children: [ children: [
AnimatedPositioned( AnimatedPositioned(
duration: NooMotion.base, duration: NooMotion.base,
curve: NooMotion.ease, curve: NooMotion.ease,
top: _kAndroidPillTop, top: pillTop,
left: left:
itemWidth * selectedIndex + itemWidth * selectedIndex +
(itemWidth - _kAndroidPillWidth) / 2, (itemWidth - _kAndroidPillWidth) / 2,
@@ -105,9 +226,19 @@ class NooBottomBar extends StatelessWidget {
child: _AndroidItem( child: _AndroidItem(
destination: destinations[i], destination: destinations[i],
selected: i == selectedIndex, selected: i == selectedIndex,
floating: floating,
onTap: () => onSelected(i), onTap: () => onSelected(i),
), ),
), ),
if (trailingSearch != null)
Expanded(
child: _AndroidItem(
destination: trailingSearch,
selected: false,
floating: floating,
onTap: onSearchTap!,
),
),
], ],
), ),
], ],
@@ -117,6 +248,46 @@ class NooBottomBar extends StatelessWidget {
} }
} }
/// 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.
class _SearchSatellite extends StatelessWidget {
final NooNavDestination destination;
final VoidCallback onTap;
final double size;
const _SearchSatellite({
required this.destination,
required this.onTap,
required this.size,
});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
return Semantics(
button: true,
label: destination.label,
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),
),
),
);
}
}
class _IosItem extends StatelessWidget { class _IosItem extends StatelessWidget {
final NooNavDestination destination; final NooNavDestination destination;
final bool selected; final bool selected;
@@ -165,44 +336,47 @@ class _IosItem extends StatelessWidget {
} }
/// Icon (over the shared sliding pill, drawn separately by [NooBottomBar]) /// Icon (over the shared sliding pill, drawn separately by [NooBottomBar])
/// with its label always below it - the label's space is reserved /// with its label below it. Attached always reserves the label's space
/// whether or not it's showing (only its opacity changes), so the icon /// (just invisible when idle, per [_kAttachedPillTop]'s doc comment on
/// never shifts vertically as selection changes, and stays aligned with /// [NooBottomBar]) so the icon never shifts vertically as selection
/// the pill's fixed [_kAndroidPillTop]/[_kAndroidPillHeight]. /// changes. Floating drops that reserved space when idle instead: there's
/// no label to leave room for, so the icon centers in the whole button and
/// renders a touch bigger.
class _AndroidItem extends StatelessWidget { class _AndroidItem extends StatelessWidget {
final NooNavDestination destination; final NooNavDestination destination;
final bool selected; final bool selected;
final bool floating;
final VoidCallback onTap; final VoidCallback onTap;
const _AndroidItem({ const _AndroidItem({
required this.destination, required this.destination,
required this.selected, required this.selected,
required this.floating,
required this.onTap, required this.onTap,
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colors = context.nooColors; final colors = context.nooColors;
return Semantics( final iconColor = selected ? colors.accentText : colors.fg2;
button: true,
selected: selected, final Widget content;
label: destination.label, if (floating && !selected) {
child: GestureDetector( content = Center(
behavior: HitTestBehavior.opaque, child: Icon(destination.icon, size: 25, color: iconColor),
onTap: onTap, );
child: Padding( } else {
padding: const EdgeInsets.only(top: _kAndroidPillTop), content = Padding(
padding: EdgeInsets.only(
top: floating ? _kFloatingPillTop : _kAttachedPillTop,
),
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.start,
children: [ children: [
SizedBox( SizedBox(
height: _kAndroidPillHeight, height: _kAndroidPillHeight,
child: Center( child: Center(
child: Icon( child: Icon(destination.icon, size: 24, color: iconColor),
destination.icon,
size: 24,
color: selected ? colors.accentText : colors.fg2,
),
), ),
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
@@ -222,7 +396,17 @@ class _AndroidItem extends StatelessWidget {
), ),
], ],
), ),
), );
}
return Semantics(
button: true,
selected: selected,
label: destination.label,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: content,
), ),
); );
} }
+29
View File
@@ -17,6 +17,35 @@ enum NooNavStyle {
}; };
} }
/// How [NooBottomBar] sits on the screen - user-configurable in Settings
/// (Appearance). [attached] is the original edge-to-edge bar with no
/// side/bottom margin. [floating] insets it 16px from both side edges and
/// clear of the bottom safe area, rounded (28px - the same radius the
/// sheet top/drawer edge use) rather than square, with a 1px `line` border
/// plus `nooDialogShadow` - product UI otherwise stays flat (DESIGN_SYSTEM.md
/// 1.4), but a bar that's genuinely floating above scrolling content reads
/// as attached without some elevation, so it borrows the one shadow the
/// rest of the app allows itself rather than inventing a second one. The
/// host `Scaffold` needs `extendBody: true` while floating, so its body
/// scrolls behind the bar's transparent margin instead of stopping short of
/// it like [attached] does - which also means that body needs its own
/// trailing padding to clear the bar (see `NooBottomBar.rowHeight`).
enum NooBottomBarStyle { attached, floating }
/// Which widget opens the shell's navigation menu (hidden tabs + Settings)
/// on mobile - user-configurable in Settings (Appearance), same precedent
/// 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
/// `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
/// Android-specific - only [AppDrawer]'s left-`Drawer` convention is being
/// offered an alternative, not anything platform-only.
enum NooNavMenuStyle { drawer, avatarMenu }
/// One destination in a [NooBottomBar]. Data-agnostic on purpose - the app /// One destination in a [NooBottomBar]. Data-agnostic on purpose - the app
/// maps its own tab model (e.g. `AppTab`) onto these. /// maps its own tab model (e.g. `AppTab`) onto these.
@immutable @immutable
+32 -2
View File
@@ -56,6 +56,16 @@ class NooTopBar extends StatelessWidget implements PreferredSizeWidget {
/// iOS only: the search field shown below the large title. /// iOS only: the search field shown below the large title.
final Widget? search; final Widget? search;
/// Android only: an extra widget (e.g. a search-field-styled launcher) in
/// the flexible middle slot, to the title's own right - [title] keeps
/// its natural (shrink-to-fit, ellipsized) width on the left, and this
/// takes whatever space is left rather than replacing it. iOS already has
/// a dedicated [search] slot below its large title for this; Android has
/// no large title to put a second row under, so a caller that wants an
/// inline search bar there puts it beside the title instead of adding a
/// row.
final Widget? androidTitleTrailing;
/// Defaults to `bg` - the top bar sits on the screen background. /// Defaults to `bg` - the top bar sits on the screen background.
final Color? backgroundColor; final Color? backgroundColor;
@@ -67,6 +77,7 @@ class NooTopBar extends StatelessWidget implements PreferredSizeWidget {
this.onMenu, this.onMenu,
this.actions = const [], this.actions = const [],
this.search, this.search,
this.androidTitleTrailing,
this.backgroundColor, this.backgroundColor,
}); });
@@ -98,7 +109,7 @@ class NooTopBar extends StatelessWidget implements PreferredSizeWidget {
final Widget body = _ios final Widget body = _ios
? _buildIos(colors, lead) ? _buildIos(colors, lead)
: _buildAndroid(colors, lead); : _buildAndroid(context, colors, lead);
return _NooTopBarScope( return _NooTopBarScope(
style: style, style: style,
@@ -166,7 +177,7 @@ class NooTopBar extends StatelessWidget implements PreferredSizeWidget {
); );
} }
Widget _buildAndroid(NooColors colors, Widget? lead) { Widget _buildAndroid(BuildContext context, NooColors colors, Widget? lead) {
return SizedBox( return SizedBox(
height: _androidRow, height: _androidRow,
child: Padding( child: Padding(
@@ -178,6 +189,25 @@ class NooTopBar extends StatelessWidget implements PreferredSizeWidget {
spacing: 4, spacing: 4,
children: [ children: [
?lead, ?lead,
// The title is a non-flex child capped at 40% of the screen
// width (so it keeps its natural width but still ellipsizes),
// leaving the `Expanded` trailing widget all the remaining
// space. A `Flexible` title would instead split the free space
// 1:1 with it, since both default to flex 1.
if (androidTitleTrailing != null) ...[
ConstrainedBox(
constraints: BoxConstraints(
maxWidth: MediaQuery.sizeOf(context).width * 0.4,
),
child: Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: NooText.title.copyWith(height: 1, color: colors.fg1),
),
),
Expanded(child: androidTitleTrailing!),
] else
Expanded( Expanded(
child: Text( child: Text(
title, title,
+136 -1
View File
@@ -7,6 +7,7 @@ import '../../theme/design_tokens.dart';
import '../noo/core/noo_segmented_control.dart'; import '../noo/core/noo_segmented_control.dart';
import '../noo/core/noo_toggle.dart'; import '../noo/core/noo_toggle.dart';
import '../noo/lists/noo_settings_row.dart'; import '../noo/lists/noo_settings_row.dart';
import '../noo/nav/noo_nav_style.dart';
import '../noo/noo_layout.dart'; import '../noo/noo_layout.dart';
import '../noo/overlays/noo_dialog.dart'; import '../noo/overlays/noo_dialog.dart';
import '../noo/overlays/noo_sheet.dart'; import '../noo/overlays/noo_sheet.dart';
@@ -47,7 +48,23 @@ class SettingsAppearanceSection extends StatelessWidget {
children: [ children: [
SettingsSection( SettingsSection(
title: 'Appearance', title: 'Appearance',
children: [_ThemeRow(settings: settings)], children: [
_ThemeRow(settings: settings),
_BottomBarStyleRow(settings: settings),
_NavMenuStyleRow(settings: settings),
NooSettingsRow(
icon: LucideIcons.search,
label: const Text('Search in bottom bar'),
subtitle: const Text(
'Adds Search to the bottom bar and removes it from the top '
'bar - leaves room for one fewer regular tab',
),
trailing: NooToggle(
checked: settings.searchInBottomBar,
onChanged: settings.setSearchInBottomBar,
),
),
],
), ),
const SizedBox(height: NooSpace.xl), const SizedBox(height: NooSpace.xl),
SettingsSection( SettingsSection(
@@ -156,6 +173,124 @@ class _ThemeRow extends StatelessWidget {
} }
} }
/// The "Bottom bar" row: same "label, then a full-width segmented control
/// below it" shape as [_ThemeRow] right above it. Attached is the original
/// edge-to-edge bar; floating insets it from both side/bottom edges with
/// rounded corners - see [NooBottomBarStyle]'s own doc comment for the
/// full visual rationale.
class _BottomBarStyleRow extends StatelessWidget {
final SettingsController settings;
const _BottomBarStyleRow({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.panelBottom, size: 20, color: colors.fg2),
const SizedBox(width: 14),
Text(
'Bottom bar',
style: NooText.bodyL.copyWith(color: colors.fg1),
),
],
),
const SizedBox(height: 12),
NooSegmentedControl<NooBottomBarStyle>(
fill: true,
value: settings.bottomBarStyle,
onChanged: settings.setBottomBarStyle,
options: const [
NooSegmentOption(
value: NooBottomBarStyle.attached,
icon: LucideIcons.panelBottom,
label: 'Attached',
),
NooSegmentOption(
value: NooBottomBarStyle.floating,
icon: LucideIcons.panelBottomOpen,
label: 'Floating',
),
],
),
],
),
),
);
}
}
/// "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
/// anchored under it) - see [NooNavMenuStyle]'s own doc comment for the
/// full reachability rationale (dropping the hamburger removes one of the
/// two top-corner targets the shell asks a thumb to reach, rather than
/// adding a third kind of chrome like a side rail would).
class _NavMenuStyleRow extends StatelessWidget {
final SettingsController settings;
const _NavMenuStyleRow({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.panelLeft, size: 20, color: colors.fg2),
const SizedBox(width: 14),
Text(
'Navigation menu',
style: NooText.bodyL.copyWith(color: colors.fg1),
),
],
),
const SizedBox(height: 12),
NooSegmentedControl<NooNavMenuStyle>(
fill: true,
value: settings.navMenuStyle,
onChanged: settings.setNavMenuStyle,
options: const [
NooSegmentOption(
value: NooNavMenuStyle.drawer,
icon: LucideIcons.menu,
label: 'Hamburger',
),
NooSegmentOption(
value: NooNavMenuStyle.avatarMenu,
icon: LucideIcons.userRound,
label: 'Avatar',
),
],
),
],
),
),
);
}
}
/// The current accent-color choice, shown as a small filled circle in the /// The current accent-color choice, shown as a small filled circle in the
/// "Accent color" row - a wallpaper icon when following the OS's dynamic /// "Accent color" row - a wallpaper icon when following the OS's dynamic
/// color instead of a fixed swatch. /// color instead of a fixed swatch.
+8 -6
View File
@@ -5,7 +5,7 @@ import '../noo/noo_layout.dart';
import '../noo/overlays/noo_dialog.dart'; import '../noo/overlays/noo_dialog.dart';
import '../noo/overlays/noo_sheet.dart'; import '../noo/overlays/noo_sheet.dart';
/// One block of Settings (DESIGN_SYSTEM.md 4's 8-part order): a /// One block of Settings (DESIGN_SYSTEM.md 4's 9-part order): a
/// [NooGroupedList] on mobile (label above a radius-20 card), or a titled, /// [NooGroupedList] on mobile (label above a radius-20 card), or a titled,
/// bordered radius-20 card holding a flat row group on desktop /// bordered radius-20 card holding a flat row group on desktop
/// ("Mobile uses one column of grouped lists. Desktop uses a 2-column grid /// ("Mobile uses one column of grouped lists. Desktop uses a 2-column grid
@@ -31,11 +31,13 @@ class SettingsSection extends StatelessWidget {
return NooGroupedList( return NooGroupedList(
label: title, label: title,
footer: subtitle != null ? Text(subtitle!) : null, footer: subtitle != null ? Text(subtitle!) : null,
// Every mobile Settings section is individually collapsible, // Not collapsible: each mobile Settings section now renders on its
// expanded by default - replaces the old trailing jump rail (see // own pushed screen (see `account_view.dart`'s doc comment for the
// `account_view.dart`'s doc comment) as the way to navigate a long // menu-then-pushed-screen pattern), so there's no long single-scroll
// Settings screen quickly. // page left to collapse sections *within* - an earlier design had
collapsible: true, // every section inline in one column and made them individually
// collapsible for exactly that reason; that's gone now that each
// one is already isolated on its own screen.
children: children, children: children,
); );
} }
+2 -1
View File
@@ -11,7 +11,8 @@ import '../noo/noo_layout.dart';
import 'settings_section.dart'; import 'settings_section.dart';
/// Settings section 7: bottom-nav tab order, visibility (pinned = shown, up /// Settings section 7: bottom-nav tab order, visibility (pinned = shown, up
/// to [maxVisibleTabs]) and default tab. /// to [SettingsController.maxVisibleTabs] - one fewer while Appearance's
/// "Search in bottom bar" is on) and default tab.
class SettingsTabsSection extends StatelessWidget { class SettingsTabsSection extends StatelessWidget {
const SettingsTabsSection({super.key}); const SettingsTabsSection({super.key});
+25 -7
View File
@@ -212,13 +212,31 @@ class _AccountSwitchRow extends StatelessWidget {
} }
/// The current user's avatar in the mobile top bar. Tap opens the accounts /// The current user's avatar in the mobile top bar. Tap opens the accounts
/// sheet ([showAccountSwitcher]); swiping up/down on it cycles to the /// sheet ([showAccountSwitcher]) by default - or [onTap], when the caller
/// next/previous saved account immediately, with no confirmation - a /// overrides it (`AppTopBar` does this for
/// shortcut alongside the full switcher. /// `SettingsController.navMenuStyle`'s `avatarMenu` option, which
/// repurposes this same button to open `showAvatarMenu` instead; avoids
/// this file importing `widgets/avatar_menu.dart`, which itself imports
/// this file for `openSettings`/`accountInitial`/`serverHost`). Swiping
/// up/down on it cycles to the next/previous saved account immediately,
/// with no confirmation, regardless of [onTap] - a shortcut that stays
/// available either way.
class ShellAvatarButton extends StatelessWidget { class ShellAvatarButton extends StatelessWidget {
final double hitBox; final double hitBox;
final VoidCallback? onTap;
const ShellAvatarButton({super.key, this.hitBox = 48}); /// Describes whatever [onTap] actually does - defaults to "Accounts"
/// (the account switcher), but a caller overriding [onTap] should
/// override this too so the tooltip/semantics describe the real action
/// instead of a stale default.
final String label;
const ShellAvatarButton({
super.key,
this.hitBox = 48,
this.onTap,
this.label = 'Accounts',
});
Future<void> _handleVerticalSwipe( Future<void> _handleVerticalSwipe(
BuildContext context, BuildContext context,
@@ -246,17 +264,17 @@ class ShellAvatarButton extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final session = context.watch<SessionController>(); final session = context.watch<SessionController>();
return Tooltip( return Tooltip(
message: 'Accounts', message: label,
child: Semantics( child: Semantics(
button: true, button: true,
label: 'Accounts', label: label,
child: GestureDetector( child: GestureDetector(
onVerticalDragEnd: (details) => onVerticalDragEnd: (details) =>
_handleVerticalSwipe(context, details), _handleVerticalSwipe(context, details),
child: SizedBox.square( child: SizedBox.square(
dimension: hitBox, dimension: hitBox,
child: InkResponse( child: InkResponse(
onTap: () => showAccountSwitcher(context), onTap: onTap ?? () => showAccountSwitcher(context),
radius: hitBox / 2, radius: hitBox / 2,
child: Center( child: Center(
child: NooAvatar( child: NooAvatar(
+70 -3
View File
@@ -1,7 +1,11 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:provider/provider.dart';
import '../../providers/settings_controller.dart';
import '../../theme/design_tokens.dart'; import '../../theme/design_tokens.dart';
import '../noo/core/noo_button.dart'; import '../noo/core/noo_button.dart';
import '../noo/nav/noo_bottom_bar.dart';
import '../noo/noo_layout.dart';
/// Shared loading/error/empty slivers for Recent/Activity/Trash/Shares (and /// Shared loading/error/empty slivers for Recent/Activity/Trash/Shares (and
/// any future tab shaped like them: a `CustomScrollView` over one flat /// any future tab shaped like them: a `CustomScrollView` over one flat
@@ -88,9 +92,72 @@ Widget tabEmptySliver(
); );
} }
/// How much bottom padding a tab's scrollable list needs to clear
/// [NooBottomBar] and not just butt up against it. [NooBottomBarStyle.
/// 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
/// 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<SettingsController>();
if (settings.bottomBarStyle == NooBottomBarStyle.attached) return 100;
final barHeight = NooBottomBar.rowHeight(
NooLayout.navStyle(context),
NooBottomBarStyle.floating,
);
final safeBottom = MediaQuery.paddingOf(context).bottom;
return barHeight + NooBottomBar.floatingBottomMargin + safeBottom + 24;
}
/// Bottom padding sliver so the last row/card clears the bottom nav/FAB - /// Bottom padding sliver so the last row/card clears the bottom nav/FAB -
/// same trailing pair every rebuilt tab list ends on. /// same trailing pair every rebuilt tab list ends on.
const List<Widget> tabBottomInsetSlivers = [ List<Widget> tabBottomInsetSlivers(BuildContext context) => [
SliverToBoxAdapter(child: SizedBox(height: 100)), SliverToBoxAdapter(child: SizedBox(height: bottomBarClearance(context))),
SliverFillRemaining(hasScrollBody: false, child: SizedBox()), const 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 while
/// [topBar] itself is visible, 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.
///
/// That pinned header needs its OWN protection from the status bar too,
/// though: [topBar]'s `SafeArea` only reserves space while [topBar] has
/// some height to put it in - once it's fully collapsed (0 height, [topBar]
/// scrolled all the way away), that reservation disappears with it, and
/// the pinned header would ride up underneath the status bar instead of
/// stopping below it (a real bug this shipped with once already - caught
/// by `tab_state_slivers_test.dart`'s regression test for it). Every tab
/// view wraps its whole `CustomScrollView` (this sliver, the pinned header,
/// and everything else) in `SafeArea(top: true, bottom: false, ...)` to
/// fix this - that reserves the inset outside the scrolling/collapsing
/// region entirely, so it's never implicated in this sliver's own
/// collapse math regardless of [topBar]'s current state. Flutter's
/// `SafeArea` nesting means this doesn't double the inset: the outer one
/// zeroes `MediaQuery.padding.top` for everything below it, so [topBar]'s
/// own inner `SafeArea` sees nothing left to add.
Widget topBarSliver(PreferredSizeWidget topBar) =>
SliverFloatingHeader(child: topBar);
+198
View File
@@ -0,0 +1,198 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:noo/models/saved_account.dart';
import 'package:noo/providers/connectivity_controller.dart';
import 'package:noo/providers/files_controller.dart';
import 'package:noo/providers/session_controller.dart';
import 'package:noo/providers/settings_controller.dart';
import 'package:noo/providers/sync_status_controller.dart';
import 'package:noo/theme/app_theme.dart';
import 'package:noo/views/account_view.dart';
import 'package:noo/widgets/noo/nav/noo_top_bar.dart';
/// Covers the mobile Settings navigation restructure: a top-level menu of
/// category rows (`account_view.dart`'s `_MobileMenu`) that pushes a
/// single-section screen per row, replacing the old design where every
/// section rendered inline in one long scrolling column. This only covers
/// the menu/push/back mechanics - each section's own content already has
/// (or doesn't need) its own coverage elsewhere.
void main() {
const secureStorageChannel = MethodChannel(
'plugins.it_nomads.com/flutter_secure_storage',
);
const connectivityChannel = MethodChannel(
'dev.fluttercommunity.plus/connectivity',
);
const connectivityStatusChannel = EventChannel(
'dev.fluttercommunity.plus/connectivity_status',
);
const accountId = 'server_example_com__alice';
const passwordKey = 'nc_app_password_$accountId';
const password = 'app-password';
setUpAll(() {
GoogleFonts.config.allowRuntimeFetching = false;
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(secureStorageChannel, (call) async {
if (call.method == 'readAll') {
return <String, String>{passwordKey: password};
}
if (call.method == 'read') {
final key = (call.arguments as Map)['key'] as String?;
return key == passwordKey ? password : null;
}
return null;
});
// Offline, like `session_controller_test.dart`'s setup: keeps
// SessionController in a provisional login (no real HTTP calls) and
// stops FilesController/SyncStatusController from starting their
// network-fetch/periodic-refresh machinery, which would otherwise
// leave timers pending forever in a test.
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(connectivityChannel, (call) async {
if (call.method == 'check') return <String>['none'];
return null;
});
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockStreamHandler(
connectivityStatusChannel,
MockStreamHandler.inline(onListen: (arguments, events) {}),
);
});
setUp(() {
SharedPreferences.setMockInitialValues({
'account_migration_v1_done': true,
'accounts_list': jsonEncode([
const SavedAccount(
id: accountId,
serverUrl: 'https://server.example.com',
username: 'alice',
).toJson(),
]),
'active_account_id': accountId,
});
});
Future<void> pumpSettings(WidgetTester tester) async {
await tester.binding.setSurfaceSize(const Size(400, 800));
addTearDown(() => tester.binding.setSurfaceSize(null));
await tester.pumpWidget(
MultiProvider(
providers: [
ChangeNotifierProvider(create: (_) => ConnectivityController()),
ChangeNotifierProvider(
create: (context) => SessionController(context.read()),
),
ChangeNotifierProvider(create: (_) => SettingsController()),
ChangeNotifierProvider(
create: (context) => FilesController(context.read()),
),
ChangeNotifierProvider(
create: (context) =>
SyncStatusController(context.read(), context.read()),
),
],
child: MaterialApp(
theme: AppTheme.light(AppTheme.defaultAccent, useDynamicColor: false),
home: const AccountView(),
),
),
);
// Not `pumpAndSettle`: once the saved account is ready,
// FilesController/SyncStatusController start their own periodic
// refresh timers, which `pumpAndSettle` would spin on forever. A
// bounded number of small pumps is enough to flush the async gaps in
// SessionController's prefs restore and the dependent controllers'
// one-shot account-ready reactions (mirrors
// `session_controller_test.dart`'s `pumpUntil` loop).
for (var i = 0; i < 30; i++) {
await tester.pump(const Duration(milliseconds: 10));
}
}
/// Advances exactly far enough to finish a push/pop transition
/// (`MaterialPageRoute`'s default is 300ms) without risking
/// `pumpAndSettle` picking up a pending periodic timer.
Future<void> settleNav(WidgetTester tester) async {
await tester.pump();
for (var i = 0; i < 10; i++) {
await tester.pump(const Duration(milliseconds: 50));
}
}
const categoryTitles = [
'Accounts',
'Security',
'File sync',
'Files cache',
'Appearance',
'Tabs',
'Action bar',
'Swipe on a file',
];
testWidgets(
'shows a top-level menu of category rows, not every section inline',
(tester) async {
await pumpSettings(tester);
// The account card (pinned, not a category row of its own) ...
expect(find.text('alice'), findsOneWidget);
// ... plus exactly one row per section.
for (final title in categoryTitles) {
expect(find.text(title), findsOneWidget);
}
// A section's own content isn't rendered until its row is tapped -
// this is a menu, not the old all-sections-inline column.
expect(
find.text("Require this device's PIN or biometric to open Noo"),
findsNothing,
);
},
);
testWidgets(
'tapping a category row pushes just that section, with a way back',
(tester) async {
await pumpSettings(tester);
await tester.tap(find.text('Security'));
await settleNav(tester);
// Landed on a separate, pushed Security screen: its own content -
// not shown anywhere on the top-level menu - is now visible. (The
// menu screen below it may stay mounted per `PageRoute.maintainState`,
// so this checks for the pushed screen's content rather than the
// menu's absence.)
expect(
find.text("Require this device's PIN or biometric to open Noo"),
findsOneWidget,
);
// At least one `NooTopBarBack` now leads back - the pushed screen's
// own, on top of `AccountView`'s own (for returning to the shell).
expect(find.byType(NooTopBarBack), findsAtLeastNWidgets(1));
// Tapping the topmost back button pops the pushed screen back off,
// taking its content with it - unlike the underlying menu, a popped
// route is actually removed, so this absence check is meaningful.
await tester.tap(find.byType(NooTopBarBack).last);
await settleNav(tester);
expect(
find.text("Require this device's PIN or biometric to open Noo"),
findsNothing,
);
expect(find.text('Security'), findsOneWidget);
expect(find.text('alice'), findsOneWidget);
},
);
}
+177
View File
@@ -0,0 +1,177 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:google_fonts/google_fonts.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/app_tab.dart';
import 'package:noo/models/saved_account.dart';
import 'package:noo/providers/connectivity_controller.dart';
import 'package:noo/providers/files_controller.dart';
import 'package:noo/providers/session_controller.dart';
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/widgets/app_top_bar.dart';
import 'package:noo/widgets/noo/nav/noo_top_bar.dart';
/// Covers `SettingsController.navMenuStyle`'s two options as wired through
/// `AppTopBar`/`ShellAvatarButton`: [NooNavMenuStyle.drawer] (today's
/// hamburger-opens-a-left-Drawer pattern, unchanged) vs
/// [NooNavMenuStyle.avatarMenu] (no hamburger at all - the avatar opens
/// `showAvatarMenu`'s dropdown instead of the account switcher).
void main() {
const secureStorageChannel = MethodChannel(
'plugins.it_nomads.com/flutter_secure_storage',
);
const connectivityChannel = MethodChannel(
'dev.fluttercommunity.plus/connectivity',
);
const connectivityStatusChannel = EventChannel(
'dev.fluttercommunity.plus/connectivity_status',
);
const accountId = 'server_example_com__alice';
const passwordKey = 'nc_app_password_$accountId';
const password = 'app-password';
setUpAll(() {
GoogleFonts.config.allowRuntimeFetching = false;
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(secureStorageChannel, (call) async {
if (call.method == 'readAll') {
return <String, String>{passwordKey: password};
}
if (call.method == 'read') {
final key = (call.arguments as Map)['key'] as String?;
return key == passwordKey ? password : null;
}
return null;
});
// Offline, same as `account_view_test.dart`'s setup: keeps
// SessionController in a provisional login and stops
// FilesController/SyncStatusController/TrashController's network/timer
// machinery from hanging the test.
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(connectivityChannel, (call) async {
if (call.method == 'check') return <String>['none'];
return null;
});
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockStreamHandler(
connectivityStatusChannel,
MockStreamHandler.inline(onListen: (arguments, events) {}),
);
});
setUp(() {
SharedPreferences.setMockInitialValues({
'account_migration_v1_done': true,
'accounts_list': jsonEncode([
const SavedAccount(
id: accountId,
serverUrl: 'https://server.example.com',
username: 'alice',
).toJson(),
]),
'active_account_id': accountId,
});
});
Future<void> pumpTopBar(
WidgetTester tester, {
required NooNavMenuStyle navMenuStyle,
}) async {
await tester.binding.setSurfaceSize(const Size(400, 800));
addTearDown(() => tester.binding.setSurfaceSize(null));
await tester.pumpWidget(
MultiProvider(
providers: [
ChangeNotifierProvider(create: (_) => ConnectivityController()),
ChangeNotifierProvider(
create: (context) => SessionController(context.read()),
),
ChangeNotifierProvider(create: (_) => SettingsController()),
ChangeNotifierProvider(
create: (context) => FilesController(context.read()),
),
ChangeNotifierProvider(
create: (context) =>
SyncStatusController(context.read(), context.read()),
),
ChangeNotifierProvider(
create: (context) => TrashController(context.read()),
),
],
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(),
),
),
),
);
// 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++) {
await tester.pump(const Duration(milliseconds: 10));
}
}
testWidgets('avatarMenu style: no hamburger, avatar opens the dropdown', (
tester,
) async {
await pumpTopBar(tester, navMenuStyle: NooNavMenuStyle.avatarMenu);
expect(find.byTooltip('Menu'), findsOneWidget);
expect(find.byIcon(LucideIcons.menu), findsNothing);
await tester.tap(find.byTooltip('Menu'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 200));
// The dropdown's own header + Settings row - not the account
// switcher's "Add account"/"Manage accounts" rows.
expect(find.text('alice'), findsOneWidget);
expect(find.text('Settings'), findsOneWidget);
expect(find.text('Add account'), findsNothing);
});
testWidgets('drawer style (default): hamburger opens the real drawer', (
tester,
) async {
await pumpTopBar(tester, navMenuStyle: NooNavMenuStyle.drawer);
expect(find.byTooltip('Menu'), findsOneWidget);
await tester.tap(find.byTooltip('Menu'));
await tester.pumpAndSettle();
expect(find.text('drawer open'), findsOneWidget);
});
testWidgets(
'drawer style (default): avatar still opens the account switcher',
(tester) async {
await pumpTopBar(tester, navMenuStyle: NooNavMenuStyle.drawer);
expect(find.byTooltip('Accounts'), findsOneWidget);
await tester.tap(find.byTooltip('Accounts'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 200));
expect(find.text('Add account'), findsOneWidget);
},
);
}
+49 -2
View File
@@ -12,6 +12,7 @@ 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_search_field.dart';
import 'package:noo/widgets/noo/core/noo_segmented_control.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/core/noo_toggle.dart';
import 'package:noo/widgets/noo/nav/noo_nav_style.dart';
import 'noo_test_utils.dart'; import 'noo_test_utils.dart';
@@ -247,7 +248,11 @@ void main() {
group('NooFab', () { group('NooFab', () {
testNooWidgets('shows label and fires onTap', (tester, theme, c) async { testNooWidgets('shows label and fires onTap', (tester, theme, c) async {
var taps = 0; var taps = 0;
await pumpNoo(tester, NooFab(onTap: () => taps++), theme: theme); await pumpNoo(
tester,
NooFab(barStyle: NooBottomBarStyle.floating, onTap: () => taps++),
theme: theme,
);
expect(find.text('Upload'), findsOneWidget); expect(find.text('Upload'), findsOneWidget);
expect(tester.getSize(find.byType(NooFab)).height, 56); expect(tester.getSize(find.byType(NooFab)).height, 56);
expect(tester.getSize(find.byType(NooFab)).width, lessThan(200)); expect(tester.getSize(find.byType(NooFab)).width, lessThan(200));
@@ -263,7 +268,11 @@ void main() {
var taps = 0; var taps = 0;
await pumpNoo( await pumpNoo(
tester, tester,
NooFab(collapsed: true, onTap: () => taps++), NooFab(
collapsed: true,
barStyle: NooBottomBarStyle.floating,
onTap: () => taps++,
),
theme: theme, theme: theme,
); );
await tester.pumpAndSettle(); await tester.pumpAndSettle();
@@ -274,6 +283,44 @@ void main() {
await tester.tap(find.byType(NooFab)); await tester.tap(find.byType(NooFab));
expect(taps, 1); expect(taps, 1);
}); });
testNooWidgets('floating bar style stays fully round', (
tester,
theme,
c,
) async {
await pumpNoo(
tester,
const NooFab(barStyle: NooBottomBarStyle.floating),
theme: theme,
);
final material = tester.widget<Material>(
find.descendant(
of: find.byType(NooFab),
matching: find.byType(Material),
),
);
expect((material.borderRadius as BorderRadius).topLeft.x, NooRadii.pill);
});
testNooWidgets('attached bar style squares off to a rounded square', (
tester,
theme,
c,
) async {
await pumpNoo(
tester,
const NooFab(barStyle: NooBottomBarStyle.attached),
theme: theme,
);
final material = tester.widget<Material>(
find.descendant(
of: find.byType(NooFab),
matching: find.byType(Material),
),
);
expect((material.borderRadius as BorderRadius).topLeft.x, NooRadii.card);
});
}); });
group('NooProgressBar', () { group('NooProgressBar', () {
+106
View File
@@ -0,0 +1,106 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:noo/widgets/noo/files/noo_swipe_action.dart';
import 'noo_test_utils.dart';
void main() {
setUpNooTests();
group('NooSwipeAction', () {
testNooWidgets('a moderate swipe opens the action without triggering it', (
tester,
theme,
c,
) async {
var triggered = 0;
await pumpNoo(
tester,
SizedBox(
width: 360,
height: 64,
child: NooSwipeAction(
endAction: NooSwipeActionSpec(
kind: NooSwipeActionKind.delete,
onTriggered: () => triggered++,
),
child: const ColoredBox(
color: Colors.white,
child: SizedBox(width: 360, height: 64),
),
),
),
theme: theme,
);
// Half the block's own width (96) is well past the "snap open"
// threshold but nowhere near the 1.8x trigger extent.
await tester.drag(find.byType(NooSwipeAction), const Offset(-60, 0));
await tester.pumpAndSettle();
expect(triggered, 0);
expect(find.text('Delete'), findsOneWidget);
});
testNooWidgets(
'swiping past the trigger extent fires the action on release - no '
'second tap needed',
(tester, theme, c) async {
var triggered = 0;
await pumpNoo(
tester,
SizedBox(
width: 360,
height: 64,
child: NooSwipeAction(
endAction: NooSwipeActionSpec(
kind: NooSwipeActionKind.delete,
onTriggered: () => triggered++,
),
child: const ColoredBox(
color: Colors.white,
child: SizedBox(width: 360, height: 64),
),
),
),
theme: theme,
);
// 300px of drag on a 96px-wide block clears the 1.8x (~173px)
// trigger extent well past the clamp ceiling.
await tester.drag(find.byType(NooSwipeAction), const Offset(-300, 0));
await tester.pump();
expect(triggered, 1);
},
);
testNooWidgets('tapping the revealed block still triggers the action too', (
tester,
theme,
c,
) async {
var triggered = 0;
await pumpNoo(
tester,
SizedBox(
width: 360,
height: 64,
child: NooSwipeAction(
startAction: NooSwipeActionSpec(
kind: NooSwipeActionKind.favorite,
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);
await tester.tap(find.text('Favorite'));
await tester.pump();
expect(triggered, 1);
});
});
}
+200
View File
@@ -137,5 +137,205 @@ void main() {
final labelCenter = tester.getCenter(find.text('Photos')); final labelCenter = tester.getCenter(find.text('Photos'));
expect(labelCenter.dy, greaterThan(iconCenter.dy)); expect(labelCenter.dy, greaterThan(iconCenter.dy));
}); });
testNooWidgets('floating: inset, rounded, bordered - not edge to edge', (
tester,
theme,
c,
) async {
await pumpNoo(
tester,
SizedBox(
width: 360,
height: 96,
child: NooBottomBar(
style: NooNavStyle.android,
barStyle: NooBottomBarStyle.floating,
destinations: _destinations,
selectedIndex: 0,
onSelected: (_) {},
),
),
theme: theme,
);
final pillFinder = find.descendant(
of: find.byType(NooBottomBar),
matching: find.byWidgetPredicate(
(w) => w is Container && w.decoration != null,
),
);
final container = tester.widget<Container>(pillFinder);
final decoration = container.decoration as BoxDecoration;
expect(decoration.borderRadius, BorderRadius.circular(28));
expect(decoration.border, Border.all(color: c.line));
// Inset from both side edges of the bar's own host box, not flush
// against it - both rects are in the same (global) coordinate space,
// so comparing them directly still holds regardless of where
// `pumpNoo`'s own harness centers that host box on screen.
final hostRect = tester.getRect(find.byType(NooBottomBar));
final rect = tester.getRect(pillFinder);
expect(rect.left, greaterThan(hostRect.left));
expect(rect.right, lessThan(hostRect.right));
});
testNooWidgets(
'floating android: idle icon has no reserved label space and is '
'bigger than the active one',
(tester, theme, c) async {
await pumpNoo(
tester,
SizedBox(
width: 360,
height: 96,
child: NooBottomBar(
style: NooNavStyle.android,
barStyle: NooBottomBarStyle.floating,
destinations: _destinations,
selectedIndex: 0,
onSelected: (_) {},
),
),
theme: theme,
);
// Unlike attached, an idle floating tab has no label at all - not
// just an invisible one - so there's nothing to find here.
expect(find.text('Photos'), findsNothing);
expect(
find.ancestor(
of: find.byIcon(LucideIcons.images),
matching: find.byType(AnimatedOpacity),
),
findsNothing,
);
final activeIcon = tester.widget<Icon>(find.byIcon(LucideIcons.folder));
final idleIcon = tester.widget<Icon>(find.byIcon(LucideIcons.images));
expect(idleIcon.size, greaterThan(activeIcon.size!));
},
);
testNooWidgets(
'attached: search is the row\'s last item and never highlights',
(tester, theme, c) async {
var searchTaps = 0;
var selected = 0;
await pumpNoo(
tester,
StatefulBuilder(
builder: (context, setState) => SizedBox(
width: 360,
height: 80,
child: NooBottomBar(
style: NooNavStyle.android,
destinations: _destinations,
selectedIndex: selected,
onSelected: (i) => setState(() => selected = i),
searchDestination: const NooNavDestination(
icon: LucideIcons.search,
label: 'Search',
),
onSearchTap: () => searchTaps++,
),
),
),
theme: theme,
);
expect(find.byIcon(LucideIcons.search), findsOneWidget);
final iconCenters = [
tester.getCenter(find.byIcon(LucideIcons.folder)),
tester.getCenter(find.byIcon(LucideIcons.images)),
tester.getCenter(find.byIcon(LucideIcons.star)),
tester.getCenter(find.byIcon(LucideIcons.search)),
];
// Evenly spaced, search landing after every real destination.
for (var i = 1; i < iconCenters.length; i++) {
expect(iconCenters[i].dx, greaterThan(iconCenters[i - 1].dx));
}
await tester.tap(find.byIcon(LucideIcons.search));
await tester.pump();
expect(searchTaps, 1);
// Tapping search never selects it or moves the indicator.
expect(selected, 0);
expect(_indicator(tester, c), findsOneWidget);
final indicatorLeft = tester.getTopLeft(_indicator(tester, c)).dx;
expect(indicatorLeft, lessThan(iconCenters[3].dx));
},
);
testNooWidgets('floating: search is a separate round satellite', (
tester,
theme,
c,
) async {
var searchTaps = 0;
await pumpNoo(
tester,
SizedBox(
width: 360,
height: 96,
child: NooBottomBar(
style: NooNavStyle.ios,
barStyle: NooBottomBarStyle.floating,
destinations: _destinations,
selectedIndex: 0,
onSelected: (_) {},
searchDestination: const NooNavDestination(
icon: LucideIcons.search,
label: 'Search',
),
onSearchTap: () => searchTaps++,
),
),
theme: theme,
);
// Not part of the main pill - its own separately-tappable icon.
expect(find.text('Search'), findsNothing);
final satellite = tester.widget<Container>(
find.ancestor(
of: find.byIcon(LucideIcons.search),
matching: find.byType(Container),
),
);
final decoration = satellite.decoration as BoxDecoration;
expect(decoration.shape, BoxShape.circle);
await tester.tap(find.byIcon(LucideIcons.search));
await tester.pump();
expect(searchTaps, 1);
});
testWidgets('floating with search sizes correctly in a real Scaffold '
'(regression: unbounded stretch used to fill the whole screen)', (
tester,
) async {
// A bare SizedBox host (as the other tests use) gives this widget a
// bounded height, masking this exact bug - Scaffold.bottomNavigationBar
// gives its child a *loose*, unbounded-max height instead, which is
// what a Row with crossAxisAlignment.stretch blows up against in
// profile/release (the debug assertion that would catch it is
// stripped there). Only a real Scaffold reproduces that.
await tester.pumpWidget(
MaterialApp(
theme: nooTheme(Brightness.light),
home: Scaffold(
body: const SizedBox.expand(),
bottomNavigationBar: NooBottomBar(
style: NooNavStyle.android,
barStyle: NooBottomBarStyle.floating,
destinations: _destinations,
selectedIndex: 0,
onSelected: (_) {},
searchDestination: const NooNavDestination(
icon: LucideIcons.search,
label: 'Search',
),
onSearchTap: () {},
),
),
),
);
final size = tester.getSize(find.byType(NooBottomBar));
expect(size.height, lessThan(150));
});
}); });
} }
@@ -0,0 +1,253 @@
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);
/// A minimal stand-in for each tab's own pinned sort/filter row
/// (`StickyHeaderDelegate`) - just needs to be a `SliverPersistentHeader`
/// with `pinned: true` below `topBarSliver`, same contract every real tab
/// view uses.
class _FakeStickyHeader extends StatelessWidget {
const _FakeStickyHeader();
static const double height = 48;
@override
Widget build(BuildContext context) => const SizedBox(
height: height,
child: ColoredBox(color: Colors.red),
);
}
class _FakeStickyHeaderDelegate extends SliverPersistentHeaderDelegate {
@override
double get minExtent => _FakeStickyHeader.height;
@override
double get maxExtent => _FakeStickyHeader.height;
@override
Widget build(
BuildContext context,
double shrinkOffset,
bool overlapsContent,
) => const _FakeStickyHeader();
@override
bool shouldRebuild(covariant SliverPersistentHeaderDelegate oldDelegate) =>
false;
}
Future<void> _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<RenderSliver>(_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,
);
},
);
testWidgets(
'a pinned header below topBarSliver stays clear of the status bar once '
'the floating bar fully collapses (regression: it used to ride up '
'underneath the status bar once the only thing reserving that space '
"disappeared along with the bar's own height)",
(tester) async {
final controller = ScrollController();
addTearDown(controller.dispose);
const statusBarHeight = 40.0;
await tester.pumpWidget(
MediaQuery(
data: const MediaQueryData(
padding: EdgeInsets.only(top: statusBarHeight),
),
child: MaterialApp(
theme: nooTheme(Brightness.light),
home: Scaffold(
// The fix under test: wrapping the scroll view (not just the
// top bar) in `SafeArea(top: true)` reserves the status-bar
// inset outside the scrolling/collapsing region entirely, so
// it's never implicated in `topBarSliver`'s own collapse math.
body: SafeArea(
top: true,
bottom: false,
child: CustomScrollView(
controller: controller,
slivers: [
topBarSliver(const _FakeTopBar()),
SliverPersistentHeader(
pinned: true,
delegate: _FakeStickyHeaderDelegate(),
),
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) =>
SizedBox(height: 60, child: Text('Item $index')),
childCount: 40,
),
),
],
),
),
),
),
),
);
// Fully visible before any scroll - right below the reserved inset.
expect(
tester.getTopLeft(find.byType(_FakeStickyHeader)).dy,
_FakeTopBar.height + statusBarHeight,
);
// Scroll well past the top bar's own height so it collapses fully.
final gesture = await tester.startGesture(const Offset(200, 300));
await gesture.moveBy(const Offset(0, -300));
await tester.pump();
await gesture.up();
await tester.pump();
expect(
tester.renderObject<RenderSliver>(_header()).geometry!.paintExtent,
0,
reason:
'the top bar should be fully collapsed for this check to '
'mean anything',
);
// The regression: without the fix, this would be 0 (or negative,
// scrolled up under the status bar) instead of sitting right at the
// reserved inset.
expect(
tester.getTopLeft(find.byType(_FakeStickyHeader)).dy,
statusBarHeight,
);
},
);
}
+38
View File
@@ -0,0 +1,38 @@
# Noo website
The product site for Noo, meant to be deployed at `noo.ayushya.dev`.
Static HTML/CSS/vanilla JS, no build step and no dependencies beyond the
two Google Fonts already used by the app (Schibsted Grotesk, Instrument
Sans). Colors, radii, type scale and motion timing are copied from
`lib/theme/design_tokens.dart` so the site and the app read as the same
product.
## Files
- `index.html` — the whole page (hero, features, customize, privacy, CTA).
- `styles.css` — all styling, incl. the design tokens as CSS custom
properties and a dark-mode variant (`prefers-color-scheme` and an
explicit `data-theme` override for a future toggle).
- `script.js` — mobile nav, the hero's exploded-layer parallax
(scroll + pointer), the two interactive Customize demos (accent color,
bottom bar style), and scroll-reveal.
- `favicon.svg`.
## Local preview
Any static file server works, e.g.:
```bash
cd website
python3 -m http.server 8000
```
Then open `http://localhost:8000`.
## Deploying
Point any static host (Cloudflare Pages, Netlify, GitHub Pages, or a
plain nginx `root`) at this folder - there's nothing to build. Update the
`https://github.com` placeholder links and the two "Download for..."
buttons in `index.html` once real release links exist.
+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="9" fill="#8D0DE3"/>
<rect x="9" y="9" width="14" height="14" rx="5" fill="#FFFFFF" fill-opacity="0.92"/>
</svg>

After

Width:  |  Height:  |  Size: 210 B

+307
View File
@@ -0,0 +1,307 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Noo — a Nextcloud client that feels like it belongs on your phone</title>
<meta name="description" content="Noo is a fast, private, deeply customizable Nextcloud client for Android and iOS. Browse files and photos, sync for offline, and make it look like yours.">
<meta property="og:title" content="Noo — a Nextcloud client that feels like it belongs on your phone">
<meta property="og:description" content="Fast, private, and deeply customizable. Browse files and photos, sync for offline, and make Noo look like yours.">
<meta property="og:type" content="website">
<meta property="og:url" content="https://noo.ayushya.dev">
<meta name="theme-color" content="#F3EFE6">
<link rel="icon" href="favicon.svg" type="image/svg+xml">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Instrument+Sans:wght@400;500;600&family=Schibsted+Grotesk:wght@500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="styles.css">
</head>
<body>
<a class="skip-link" href="#main">Skip to content</a>
<header class="site-header">
<div class="wrap header-row">
<a class="wordmark" href="#top">
<span class="wordmark-mark" aria-hidden="true"></span>
noo
</a>
<nav class="site-nav" aria-label="Primary">
<a href="#features">Features</a>
<a href="#customize">Customize</a>
<a href="#privacy">Privacy</a>
</nav>
<a class="btn btn-primary btn-sm" href="#get">Get Noo</a>
<button class="nav-toggle" id="navToggle" aria-label="Open menu" aria-expanded="false">
<span></span><span></span><span></span>
</button>
</div>
<nav class="mobile-nav" id="mobileNav" aria-label="Primary">
<a href="#features">Features</a>
<a href="#customize">Customize</a>
<a href="#privacy">Privacy</a>
<a class="btn btn-primary btn-sm" href="#get">Get Noo</a>
</nav>
</header>
<main id="main">
<!-- ============ HERO ============ -->
<section class="hero" id="top">
<div class="wrap hero-grid">
<div class="hero-copy">
<p class="eyebrow">For Nextcloud, unofficial</p>
<h1>Your cloud.<br>Looking like <em>yours</em>.</h1>
<p class="hero-sub">
Noo is a fast, private Nextcloud client that browses files and
photos the way a native app should — and lets you rebuild its
whole shell around how you actually use your phone.
</p>
<div class="hero-actions">
<a class="btn btn-primary btn-lg" href="#get">Get Noo</a>
<a class="btn btn-outline btn-lg" href="https://github.com" target="_blank" rel="noopener">View source</a>
</div>
<ul class="hero-proof">
<li>No password stored — Login Flow v2</li>
<li>Offline device sync</li>
<li>Open source</li>
</ul>
</div>
<div class="hero-visual" id="heroVisual" aria-hidden="true">
<div class="glow"></div>
<div class="phone" data-depth="0.15">
<div class="phone-notch"></div>
<div class="phone-screen">
<div class="mini-topbar">
<span class="mini-title">Files</span>
<span class="mini-dot"></span>
</div>
<div class="mini-list">
<div class="mini-row"><span class="mini-ic ic-folder"></span><span class="mini-lines"><b></b><i></i></span></div>
<div class="mini-row"><span class="mini-ic ic-image"></span><span class="mini-lines"><b></b><i></i></span></div>
<div class="mini-row mini-row-active"><span class="mini-ic ic-file"></span><span class="mini-lines"><b></b><i></i></span></div>
<div class="mini-row"><span class="mini-ic ic-folder"></span><span class="mini-lines"><b></b><i></i></span></div>
</div>
<div class="mini-bar">
<span class="mini-bar-pill"></span>
<span class="mini-bar-dot"></span>
<span class="mini-bar-dot"></span>
<span class="mini-bar-dot"></span>
<span class="mini-bar-dot"></span>
</div>
</div>
</div>
<div class="chip chip-photo" data-depth="0.55">
<div class="chip-photo-grid">
<span></span><span></span><span></span><span></span>
</div>
</div>
<div class="chip chip-row" data-depth="0.4">
<span class="mini-ic ic-share"></span>
<span class="mini-lines"><b></b><i></i></span>
</div>
<div class="chip chip-swatches" data-depth="0.7">
<span class="swatch" style="--c:#8D0DE3"></span>
<span class="swatch" style="--c:#2940A8"></span>
<span class="swatch" style="--c:#3E7A23"></span>
<span class="swatch" style="--c:#8C5A0E"></span>
</div>
<div class="chip chip-toggle" data-depth="0.5">
<span class="mini-lines mini-lines-toggle"><b></b><i></i></span>
<span class="mini-toggle mini-toggle-on"><i></i></span>
</div>
<div class="chip chip-badge" data-depth="0.85">
<span class="mini-ic ic-lock"></span>
Offline ready
</div>
</div>
</div>
</section>
<!-- ============ FEATURES ============ -->
<section class="section" id="features">
<div class="wrap">
<p class="eyebrow">Features</p>
<h2>Everything your Nextcloud does. Nothing it doesn't.</h2>
<p class="section-sub">
Files, photos, sharing and sync — built as a real mobile app, not a
browser tab pinned to your home screen.
</p>
<div class="grid grid-features">
<article class="card">
<span class="card-ic ic-folder-lg"></span>
<h3>Files, browsed properly</h3>
<p>Grid or list, sorted your way, with the same filters and
controls whether you're picking a file, uploading, or moving
one into place.</p>
</article>
<article class="card">
<span class="card-ic ic-image-lg"></span>
<h3>Photos, grouped by month</h3>
<p>Every image and video across your account in one gallery —
filter to just photos, just videos, or just favorites.</p>
</article>
<article class="card">
<span class="card-ic ic-download-lg"></span>
<h3>Offline, for real</h3>
<p>Pick folders to mirror locally and keep browsing — reading,
opening, previewing — with no connection at all.</p>
</article>
<article class="card">
<span class="card-ic ic-share-lg"></span>
<h3>Sharing, without the detour</h3>
<p>Share with people or a public link, see who has access, and
copy that link — all from one sheet, not three menus.</p>
</article>
<article class="card">
<span class="card-ic ic-clock-lg"></span>
<h3>Activity &amp; recent</h3>
<p>What changed, and where — grouped into today, yesterday and
this week, so catching up takes seconds.</p>
</article>
<article class="card">
<span class="card-ic ic-users-lg"></span>
<h3>Multiple accounts</h3>
<p>Switch between personal and work Nextcloud instances with a
swipe — each with its own sync, cache and lock.</p>
</article>
</div>
</div>
</section>
<!-- ============ CUSTOMIZE ============ -->
<section class="section section-alt" id="customize">
<div class="wrap">
<p class="eyebrow">Customize</p>
<h2>Shape it around your thumb, not the other way round.</h2>
<p class="section-sub">
Every screen bends to a handful of real settings — try a few
right here.
</p>
<div class="customize-grid">
<div class="customize-demo" id="accentDemo">
<div class="demo-preview">
<div class="demo-topbar">
<span class="demo-dot" style="background:var(--demo-accent)"></span>
<span class="demo-line" style="width:38%"></span>
</div>
<div class="demo-pill" style="background:var(--surface3); background:color-mix(in srgb, var(--demo-accent) 16%, transparent); color:var(--demo-accent)">Active tab</div>
<div class="demo-row"><span class="demo-line" style="width:60%"></span></div>
<div class="demo-row"><span class="demo-line" style="width:44%"></span></div>
</div>
<div class="demo-controls">
<h3>Pick your accent</h3>
<p>Six built-in colors, or match your wallpaper with dynamic
color on Android.</p>
<div class="swatch-row" role="group" aria-label="Accent color">
<button class="swatch-btn is-active" style="--c:#8D0DE3" data-accent="#8D0DE3" aria-label="Violet"></button>
<button class="swatch-btn" style="--c:#2940A8" data-accent="#2940A8" aria-label="Blue"></button>
<button class="swatch-btn" style="--c:#3E7A23" data-accent="#3E7A23" aria-label="Green"></button>
<button class="swatch-btn" style="--c:#8C5A0E" data-accent="#8C5A0E" aria-label="Amber"></button>
<button class="swatch-btn" style="--c:#A8202A" data-accent="#A8202A" aria-label="Red"></button>
<button class="swatch-btn" style="--c:#0E0D0B" data-accent="#0E0D0B" aria-label="Wallpaper"></button>
</div>
</div>
</div>
<div class="customize-demo" id="barDemo">
<div class="demo-preview demo-preview-bar">
<div class="demo-screen-fill"></div>
<div class="demo-bar" id="demoBar">
<span class="demo-bar-item is-on"></span>
<span class="demo-bar-item"></span>
<span class="demo-bar-item"></span>
<span class="demo-bar-item"></span>
</div>
</div>
<div class="demo-controls">
<h3>Floating or attached</h3>
<p>The bottom bar can sit flush with the edge, or float free
with rounded corners — your call.</p>
<div class="segmented" role="group" aria-label="Bottom bar style">
<button class="segmented-btn" data-bar="attached">Attached</button>
<button class="segmented-btn is-active" data-bar="floating">Floating</button>
</div>
</div>
</div>
<div class="customize-list">
<h3>And the rest</h3>
<ul class="feature-list">
<li>Reorder tabs, hide the ones you never open</li>
<li>Move Search into the bottom bar</li>
<li>Light, dark, or match the system</li>
<li>Pure black backgrounds in dark mode</li>
<li>Choose what swiping a file left or right does</li>
<li>Reorder the bulk-action bar for multi-select</li>
</ul>
</div>
</div>
</div>
</section>
<!-- ============ PRIVACY ============ -->
<section class="section" id="privacy">
<div class="wrap privacy-grid">
<div>
<p class="eyebrow">Privacy</p>
<h2>It's your server. Noo just visits.</h2>
<p class="section-sub">
Noo never asks for your password. Signing in hands off to your
Nextcloud server's own login page (Login Flow v2), which returns
a revocable app token — not your credentials. Nothing but that
token, and whatever you choose to sync, ever leaves your device.
</p>
</div>
<ul class="privacy-points">
<li><span class="pt-ic ic-lock"></span> No password stored on device</li>
<li><span class="pt-ic ic-server"></span> Talks only to your own server</li>
<li><span class="pt-ic ic-code"></span> Open source, auditable</li>
<li><span class="pt-ic ic-off"></span> No analytics, no trackers</li>
</ul>
</div>
</section>
<!-- ============ GET ============ -->
<section class="section section-cta" id="get">
<div class="wrap cta-inner">
<h2>Bring your Nextcloud with you.</h2>
<p class="section-sub">Free and open source. Point it at your server and go.</p>
<div class="hero-actions cta-actions">
<a class="btn btn-primary btn-lg" href="#">Download for Android</a>
<a class="btn btn-outline btn-lg" href="#">Download for iOS</a>
</div>
<p class="cta-fine">Or <a href="https://github.com" target="_blank" rel="noopener">build it from source</a> — it's just Flutter.</p>
</div>
</section>
</main>
<footer class="site-footer">
<div class="wrap footer-row">
<a class="wordmark wordmark-sm" href="#top">
<span class="wordmark-mark" aria-hidden="true"></span>
noo
</a>
<p class="footer-note">Noo is an independent, unofficial Nextcloud client. Nextcloud is a trademark of Nextcloud GmbH.</p>
<nav class="footer-links" aria-label="Footer">
<a href="https://github.com" target="_blank" rel="noopener">GitHub</a>
<a href="#privacy">Privacy</a>
</nav>
</div>
</footer>
<script src="script.js"></script>
</body>
</html>
+141
View File
@@ -0,0 +1,141 @@
(() => {
"use strict";
/* ---------- mobile nav ---------- */
const navToggle = document.getElementById("navToggle");
const mobileNav = document.getElementById("mobileNav");
if (navToggle && mobileNav) {
navToggle.addEventListener("click", () => {
const open = mobileNav.classList.toggle("is-open");
navToggle.setAttribute("aria-expanded", String(open));
navToggle.setAttribute("aria-label", open ? "Close menu" : "Open menu");
});
mobileNav.querySelectorAll("a").forEach((a) =>
a.addEventListener("click", () => {
mobileNav.classList.remove("is-open");
navToggle.setAttribute("aria-expanded", "false");
})
);
}
/* ---------- exploded hero parallax ---------- */
/* Each [data-depth] layer drifts at its own rate on scroll, and tilts
gently toward the pointer - the "exploded" pieces read as separate
depths rather than a flat collage. Skipped entirely under
prefers-reduced-motion. */
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const heroVisual = document.getElementById("heroVisual");
if (heroVisual && !reduceMotion) {
const layers = Array.from(heroVisual.querySelectorAll("[data-depth]")).map((el) => ({
el,
depth: parseFloat(el.dataset.depth) || 0.3,
}));
let scrollOffset = 0;
let pointer = { x: 0, y: 0 }; // -1..1
let ticking = false;
const apply = () => {
const rect = heroVisual.getBoundingClientRect();
const inView = rect.bottom > 0 && rect.top < window.innerHeight;
if (inView) {
for (const layer of layers) {
const scrollShift = scrollOffset * layer.depth * 40;
const px = pointer.x * layer.depth * 14;
const py = pointer.y * layer.depth * 14;
layer.el.style.transform =
`translate3d(${px}px, ${scrollShift + py}px, 0)`;
}
}
ticking = false;
};
const requestTick = () => {
if (!ticking) {
ticking = true;
requestAnimationFrame(apply);
}
};
const onScroll = () => {
const rect = heroVisual.getBoundingClientRect();
// -1 (scrolled well past) .. 1 (below viewport) - 0 while centered.
scrollOffset = (window.innerHeight / 2 - (rect.top + rect.height / 2)) / window.innerHeight;
requestTick();
};
const onPointerMove = (e) => {
const rect = heroVisual.getBoundingClientRect();
pointer.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
pointer.y = ((e.clientY - rect.top) / rect.height) * 2 - 1;
requestTick();
};
const onPointerLeave = () => {
pointer = { x: 0, y: 0 };
requestTick();
};
window.addEventListener("scroll", onScroll, { passive: true });
heroVisual.addEventListener("pointermove", onPointerMove);
heroVisual.addEventListener("pointerleave", onPointerLeave);
onScroll();
}
/* ---------- accent color demo ---------- */
const accentDemo = document.getElementById("accentDemo");
if (accentDemo) {
const buttons = accentDemo.querySelectorAll(".swatch-btn");
buttons.forEach((btn) => {
btn.addEventListener("click", () => {
buttons.forEach((b) => b.classList.remove("is-active"));
btn.classList.add("is-active");
const color = btn.dataset.accent;
accentDemo.style.setProperty("--demo-accent", color);
});
});
}
/* ---------- bottom bar style demo ---------- */
const barDemo = document.getElementById("barDemo");
const demoBar = document.getElementById("demoBar");
if (barDemo && demoBar) {
const buttons = barDemo.querySelectorAll(".segmented-btn");
buttons.forEach((btn) => {
btn.addEventListener("click", () => {
buttons.forEach((b) => b.classList.remove("is-active"));
btn.classList.add("is-active");
demoBar.classList.toggle("is-attached", btn.dataset.bar === "attached");
});
});
}
/* ---------- scroll reveal ---------- */
const revealTargets = document.querySelectorAll(
".card, .customize-demo, .customize-list, .privacy-points li"
);
revealTargets.forEach((el) => el.classList.add("reveal"));
if ("IntersectionObserver" in window && !reduceMotion) {
const io = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
entry.target.classList.add("is-visible");
io.unobserve(entry.target);
}
}
},
{ threshold: 0.15, rootMargin: "0px 0px -40px 0px" }
);
revealTargets.forEach((el) => io.observe(el));
} else {
revealTargets.forEach((el) => el.classList.add("is-visible"));
}
})();
+758
View File
@@ -0,0 +1,758 @@
/* Noo marketing site — tokens mirror lib/theme/design_tokens.dart exactly,
so this reads as the same product, not a separate marketing skin. */
:root {
--bg: #F3EFE6;
--surface: #FFFFFF;
--surface2: #F6F5F2;
--surface3: #EAE8E2;
--line: #EAE8E2;
--fg1: #0E0D0B;
--fg2: #5A5852;
--fg3: #7C7A72;
--accent: #8D0DE3;
--accent-text: #8D0DE3;
--accent-soft: #ECDFFF;
--danger: #A8202A;
--demo-accent: #8D0DE3;
--font-display: 'Schibsted Grotesk', system-ui, sans-serif;
--font-body: 'Instrument Sans', system-ui, sans-serif;
--ease: cubic-bezier(0.7, 0, 0.15, 1);
--motion-fast: 180ms;
--motion-base: 280ms;
--radius-pill: 999px;
--radius-card: 20px;
--radius-input: 14px;
--radius-panel: 28px;
--gutter: clamp(20px, 5vw, 64px);
--content-max: 1120px;
color-scheme: light;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg: #0E0D0B;
--surface: #25241F;
--surface2: #3D3C38;
--surface3: #5A5852;
--line: #3D3C38;
--fg1: #F3EFE6;
--fg2: #BDBAB1;
--fg3: #9E9B92;
--accent: #8D0DE3;
--accent-text: #CBA5FD;
--accent-soft: #2F0454;
--danger: #F49E9E;
color-scheme: dark;
}
}
:root[data-theme="dark"] {
--bg: #0E0D0B;
--surface: #25241F;
--surface2: #3D3C38;
--surface3: #5A5852;
--line: #3D3C38;
--fg1: #F3EFE6;
--fg2: #BDBAB1;
--fg3: #9E9B92;
--accent: #8D0DE3;
--accent-text: #CBA5FD;
--accent-soft: #2F0454;
--danger: #F49E9E;
color-scheme: dark;
}
* { box-sizing: border-box; }
html { scroll-behavior: smooth; }
body {
margin: 0;
background: var(--bg);
color: var(--fg1);
font-family: var(--font-body);
font-size: 16px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
img, svg { display: block; max-width: 100%; }
a { color: inherit; }
h1, h2, h3 {
font-family: var(--font-display);
font-weight: 600;
margin: 0;
letter-spacing: -0.02em;
}
.wrap {
max-width: var(--content-max);
margin: 0 auto;
padding-inline: var(--gutter);
}
.skip-link {
position: absolute;
left: -9999px;
top: 0;
background: var(--accent);
color: #fff;
padding: 10px 16px;
border-radius: var(--radius-input);
z-index: 100;
}
.skip-link:focus { left: 16px; top: 16px; }
.eyebrow {
font-family: var(--font-body);
font-size: 13px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--accent-text);
margin: 0 0 12px;
}
/* ---------- buttons ---------- */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
font-family: var(--font-body);
font-weight: 600;
text-decoration: none;
border-radius: var(--radius-pill);
border: 1px solid transparent;
cursor: pointer;
transition: transform var(--motion-fast) var(--ease), opacity var(--motion-fast) var(--ease), background var(--motion-fast) var(--ease);
white-space: nowrap;
}
.btn:active { transform: scale(0.98); }
.btn-primary { background: var(--accent); color: #fff; }
.btn-primary:hover { opacity: 0.92; }
.btn-outline { background: transparent; color: var(--fg1); border-color: var(--line); }
.btn-outline:hover { background: var(--surface2); }
.btn-lg { padding: 14px 26px; font-size: 16px; }
.btn-sm { padding: 9px 18px; font-size: 14px; }
/* ---------- header ---------- */
.site-header {
position: sticky;
top: 0;
z-index: 40;
background: var(--bg);
border-bottom: 1px solid var(--line);
}
.header-row {
display: flex;
align-items: center;
gap: 24px;
height: 72px;
}
.wordmark {
display: flex;
align-items: center;
gap: 10px;
font-family: var(--font-display);
font-weight: 700;
font-size: 20px;
text-decoration: none;
color: var(--fg1);
margin-right: auto;
}
.wordmark-sm { font-size: 17px; }
.wordmark-mark {
width: 26px;
height: 26px;
border-radius: 8px;
background: var(--accent);
position: relative;
flex: none;
}
.wordmark-mark::after {
content: "";
position: absolute;
inset: 7px;
border-radius: 4px;
background: #fff;
opacity: 0.92;
}
.site-nav {
display: flex;
gap: 28px;
}
.site-nav a {
font-size: 15px;
font-weight: 500;
text-decoration: none;
color: var(--fg2);
transition: color var(--motion-fast) var(--ease);
}
.site-nav a:hover { color: var(--fg1); }
.nav-toggle {
display: none;
flex-direction: column;
justify-content: center;
gap: 4px;
width: 40px;
height: 40px;
border: none;
background: none;
cursor: pointer;
margin-left: -6px;
}
.nav-toggle span {
display: block;
height: 2px;
border-radius: 2px;
background: var(--fg1);
transition: transform var(--motion-base) var(--ease), opacity var(--motion-base) var(--ease);
}
.nav-toggle[aria-expanded="true"] span:nth-child(1) { transform: translateY(6px) rotate(45deg); }
.nav-toggle[aria-expanded="true"] span:nth-child(2) { opacity: 0; }
.nav-toggle[aria-expanded="true"] span:nth-child(3) { transform: translateY(-6px) rotate(-45deg); }
.mobile-nav {
display: none;
flex-direction: column;
gap: 4px;
padding: 8px var(--gutter) 20px;
border-top: 1px solid var(--line);
}
.mobile-nav a {
padding: 12px 4px;
text-decoration: none;
color: var(--fg1);
font-weight: 500;
border-bottom: 1px solid var(--line);
}
.mobile-nav a:last-child { border-bottom: none; margin-top: 8px; text-align: center; }
.mobile-nav.is-open { display: flex; }
@media (max-width: 760px) {
.site-nav, .header-row > .btn { display: none; }
.nav-toggle { display: flex; }
}
/* ---------- hero ---------- */
.hero { padding: clamp(48px, 8vw, 96px) 0 clamp(40px, 6vw, 72px); overflow: clip; }
.hero-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: clamp(32px, 6vw, 64px);
align-items: center;
}
.hero-copy h1 {
font-size: clamp(38px, 5.2vw, 60px);
line-height: 1.02;
margin-bottom: 20px;
}
.hero-copy h1 em {
font-style: normal;
color: var(--accent-text);
}
.hero-sub {
font-size: clamp(16px, 1.6vw, 18px);
color: var(--fg2);
max-width: 46ch;
margin: 0 0 28px;
}
.hero-actions { display: flex; gap: 14px; flex-wrap: wrap; }
.hero-proof {
list-style: none;
margin: 28px 0 0;
padding: 0;
display: flex;
gap: 20px;
flex-wrap: wrap;
font-size: 13px;
color: var(--fg3);
}
.hero-proof li { display: flex; align-items: center; gap: 7px; }
.hero-proof li::before {
content: "";
width: 6px; height: 6px;
border-radius: 50%;
background: var(--accent);
}
/* ---------- hero exploded visual ---------- */
.hero-visual {
position: relative;
height: clamp(360px, 46vw, 520px);
display: flex;
align-items: center;
justify-content: center;
}
.glow {
position: absolute;
inset: -10%;
background: radial-gradient(closest-side, color-mix(in srgb, var(--accent) 16%, transparent), transparent 70%);
pointer-events: none;
}
.phone {
position: relative;
width: clamp(180px, 19vw, 220px);
aspect-ratio: 9 / 18.5;
background: var(--fg1);
border-radius: 34px;
padding: 10px;
will-change: transform;
}
.phone-notch {
position: absolute;
top: 10px; left: 50%;
transform: translateX(-50%);
width: 34%; height: 16px;
background: var(--fg1);
border-radius: 0 0 12px 12px;
z-index: 2;
}
.phone-screen {
position: relative;
height: 100%;
background: var(--bg);
border-radius: 24px;
overflow: hidden;
display: flex;
flex-direction: column;
}
.mini-topbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 22px 14px 8px;
}
.mini-title { font-family: var(--font-display); font-weight: 600; font-size: 15px; color: var(--fg1); }
.mini-dot { width: 16px; height: 16px; border-radius: 50%; background: var(--surface2); }
.mini-list { flex: 1; padding: 4px 10px; display: flex; flex-direction: column; gap: 6px; }
.mini-row {
display: flex;
align-items: center;
gap: 8px;
background: var(--surface);
border-radius: 10px;
padding: 7px 8px;
}
.mini-row-active { background: var(--accent-soft); }
.mini-ic { width: 16px; height: 16px; border-radius: 5px; flex: none; }
.ic-folder { background: var(--accent); opacity: 0.85; }
.ic-image { background: #2940A8; opacity: 0.8; }
.ic-file { background: var(--fg2); opacity: 0.6; }
.ic-share { background: #3E7A23; opacity: 0.85; }
.ic-lock { background: var(--fg1); opacity: 0.9; }
.mini-lines { display: flex; flex-direction: column; gap: 4px; flex: 1; min-width: 0; }
.mini-lines b, .mini-lines i {
display: block;
height: 5px;
border-radius: 3px;
background: var(--fg2);
opacity: 0.35;
font-style: normal;
}
.mini-lines b { width: 70%; opacity: 0.55; }
.mini-lines i { width: 40%; }
.mini-bar {
display: flex;
align-items: center;
justify-content: space-around;
padding: 10px 8px;
border-top: 1px solid var(--line);
position: relative;
}
.mini-bar-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--fg3); opacity: 0.4; }
.mini-bar-pill {
position: absolute;
left: 10px;
width: 20px; height: 10px;
border-radius: var(--radius-pill);
background: var(--accent-soft);
}
.chip {
position: absolute;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius-card);
will-change: transform;
}
.chip-photo {
top: 6%; right: 2%;
width: 92px; height: 92px;
padding: 8px;
transform: rotate(-6deg);
}
.chip-photo-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; height: 100%; }
.chip-photo-grid span:nth-child(1) { background: #8DCDE2; border-radius: 8px; }
.chip-photo-grid span:nth-child(2) { background: #E5D8BD; border-radius: 8px; }
.chip-photo-grid span:nth-child(3) { background: #DA9AC7; border-radius: 8px; }
.chip-photo-grid span:nth-child(4) { background: #A7D296; border-radius: 8px; }
.chip-row {
bottom: 20%; right: -2%;
display: flex; align-items: center; gap: 10px;
padding: 12px 16px;
transform: rotate(4deg);
width: 160px;
}
.chip-swatches {
bottom: 6%; left: 4%;
display: flex; gap: 8px;
padding: 12px;
transform: rotate(-4deg);
}
.chip-swatches .swatch { width: 18px; height: 18px; border-radius: 50%; background: var(--c); }
.chip-toggle {
top: 16%; left: -4%;
display: flex; align-items: center; gap: 14px;
padding: 12px 14px;
width: 150px;
transform: rotate(3deg);
}
.mini-lines-toggle { flex: 1; }
.mini-toggle { width: 30px; height: 18px; border-radius: var(--radius-pill); background: var(--surface3); position: relative; flex: none; }
.mini-toggle i { position: absolute; top: 2px; left: 2px; width: 14px; height: 14px; border-radius: 50%; background: var(--fg1); opacity: 0.3; }
.mini-toggle-on { background: var(--accent); }
.mini-toggle-on i { left: 14px; background: #fff; opacity: 1; }
.chip-badge {
bottom: -2%; left: 30%;
display: flex; align-items: center; gap: 8px;
padding: 9px 14px;
font-size: 13px;
font-weight: 500;
border-radius: var(--radius-pill);
transform: rotate(-2deg);
}
.chip-badge .mini-ic { width: 14px; height: 14px; }
@media (max-width: 980px) {
.hero-grid { grid-template-columns: 1fr; text-align: center; }
.hero-copy { order: 2; }
.hero-copy .hero-sub { margin-inline: auto; }
.hero-actions, .hero-proof { justify-content: center; }
.hero-visual { order: 1; }
}
/* ---------- sections ---------- */
.section { padding: clamp(56px, 8vw, 104px) 0; }
.section-alt { background: var(--surface); }
.section h2 {
font-size: clamp(28px, 3.4vw, 42px);
line-height: 1.08;
max-width: 20ch;
}
.section-sub {
font-size: clamp(15px, 1.4vw, 17px);
color: var(--fg2);
max-width: 56ch;
margin: 16px 0 0;
}
.grid { display: grid; gap: 20px; margin-top: 44px; }
.grid-features { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.card {
background: var(--surface);
border-radius: var(--radius-card);
padding: 28px;
}
.section-alt .card { background: var(--bg); }
.card h3 {
font-size: 19px;
margin: 16px 0 8px;
}
.card p { margin: 0; color: var(--fg2); font-size: 15px; }
.card-ic, .pt-ic {
display: inline-flex;
align-items: center;
justify-content: center;
width: 44px; height: 44px;
border-radius: var(--radius-input);
background: var(--accent-soft);
position: relative;
}
.card-ic::after, .pt-ic::after {
content: "";
width: 20px; height: 20px;
background: var(--accent-text);
-webkit-mask-size: contain;
mask-size: contain;
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
-webkit-mask-position: center;
mask-position: center;
}
/* Minimal inline icon set (stroke-style, Lucide-ish) via CSS mask so the
whole page stays dependency-free - no icon font, no SVG sprite fetch. */
.ic-folder-lg::after { -webkit-mask-image: var(--i-folder); mask-image: var(--i-folder); }
.ic-image-lg::after { -webkit-mask-image: var(--i-image); mask-image: var(--i-image); }
.ic-download-lg::after { -webkit-mask-image: var(--i-download); mask-image: var(--i-download); }
.ic-share-lg::after { -webkit-mask-image: var(--i-share); mask-image: var(--i-share); }
.ic-clock-lg::after { -webkit-mask-image: var(--i-clock); mask-image: var(--i-clock); }
.ic-users-lg::after { -webkit-mask-image: var(--i-users); mask-image: var(--i-users); }
.pt-ic.ic-lock::after { -webkit-mask-image: var(--i-lock); mask-image: var(--i-lock); }
.pt-ic.ic-server::after { -webkit-mask-image: var(--i-server); mask-image: var(--i-server); }
.pt-ic.ic-code::after { -webkit-mask-image: var(--i-code); mask-image: var(--i-code); }
.pt-ic.ic-off::after { -webkit-mask-image: var(--i-off); mask-image: var(--i-off); }
:root {
--i-folder: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M4 20h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13c0 1.1.9 2 2 2Z'/%3E%3C/svg%3E");
--i-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect width='18' height='18' x='3' y='3' rx='2'/%3E%3Ccircle cx='9' cy='9' r='2'/%3E%3Cpath d='m21 15-3.1-3.1a2 2 0 0 0-2.8 0L6 21'/%3E%3C/svg%3E");
--i-download: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4'/%3E%3Cpolyline points='7 10 12 15 17 10'/%3E%3Cline x1='12' y1='15' x2='12' y2='3'/%3E%3C/svg%3E");
--i-share: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='18' cy='5' r='3'/%3E%3Ccircle cx='6' cy='12' r='3'/%3E%3Ccircle cx='18' cy='19' r='3'/%3E%3Cline x1='8.6' y1='10.5' x2='15.4' y2='6.5'/%3E%3Cline x1='8.6' y1='13.5' x2='15.4' y2='17.5'/%3E%3C/svg%3E");
--i-clock: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='9'/%3E%3Cpolyline points='12 7 12 12 15.5 14.5'/%3E%3C/svg%3E");
--i-users: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2'/%3E%3Ccircle cx='9' cy='7' r='4'/%3E%3Cpath d='M22 21v-2a4 4 0 0 0-3-3.87'/%3E%3Cpath d='M16 3.13a4 4 0 0 1 0 7.75'/%3E%3C/svg%3E");
--i-lock: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='3' y='11' width='18' height='11' rx='2'/%3E%3Cpath d='M7 11V7a5 5 0 0 1 10 0v4'/%3E%3C/svg%3E");
--i-server: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='2' y='3' width='20' height='7' rx='1.5'/%3E%3Crect x='2' y='14' width='20' height='7' rx='1.5'/%3E%3Cline x1='6' y1='6.5' x2='6.01' y2='6.5'/%3E%3Cline x1='6' y1='17.5' x2='6.01' y2='17.5'/%3E%3C/svg%3E");
--i-code: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='16 18 22 12 16 6'/%3E%3Cpolyline points='8 6 2 12 8 18'/%3E%3C/svg%3E");
--i-off: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='2' y1='2' x2='22' y2='22'/%3E%3Cpath d='M16.9 16.9A9 9 0 0 1 4 12a9 9 0 0 1 2.6-6.3'/%3E%3Cpath d='M9.9 4.24A9 9 0 0 1 20 12a9 9 0 0 1-.8 3.7'/%3E%3C/svg%3E");
}
@media (max-width: 900px) { .grid-features { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 600px) { .grid-features { grid-template-columns: 1fr; } }
/* ---------- customize ---------- */
.customize-grid {
margin-top: 44px;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 20px;
}
.customize-demo {
background: var(--bg);
border-radius: var(--radius-card);
padding: 24px;
display: flex;
flex-direction: column;
gap: 22px;
}
.customize-demo h3 { font-size: 18px; margin: 0 0 8px; }
.customize-demo p { margin: 0; color: var(--fg2); font-size: 14px; }
.demo-preview {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius-input);
padding: 16px;
display: flex;
flex-direction: column;
gap: 10px;
}
.demo-topbar { display: flex; align-items: center; gap: 10px; }
.demo-dot { width: 12px; height: 12px; border-radius: 50%; transition: background var(--motion-base) var(--ease); }
.demo-line { display: block; height: 8px; border-radius: 4px; background: var(--surface3); }
.demo-pill {
align-self: flex-start;
padding: 6px 14px;
border-radius: var(--radius-pill);
font-size: 13px;
font-weight: 600;
transition: background var(--motion-base) var(--ease), color var(--motion-base) var(--ease);
}
.demo-row { padding: 6px 0; }
.swatch-row { display: flex; gap: 10px; }
.swatch-btn {
width: 32px; height: 32px;
border-radius: 50%;
border: 2px solid transparent;
background: var(--c);
cursor: pointer;
padding: 0;
transition: transform var(--motion-fast) var(--ease), border-color var(--motion-fast) var(--ease);
}
.swatch-btn:last-child {
background: linear-gradient(135deg, #8DCDE2, #DA9AC7 50%, #E5D8BD);
}
.swatch-btn.is-active { border-color: var(--fg1); transform: scale(1.08); }
.swatch-btn:hover { transform: scale(1.08); }
.demo-preview-bar { position: relative; height: 128px; padding: 0; overflow: hidden; }
.demo-screen-fill {
position: absolute; inset: 0;
background: repeating-linear-gradient(180deg, var(--surface2) 0 34px, var(--surface) 34px 68px);
opacity: 0.6;
}
.demo-bar {
position: absolute;
left: 12px; right: 12px; bottom: 12px;
height: 44px;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius-pill);
display: flex;
align-items: center;
justify-content: space-around;
transition: left var(--motion-base) var(--ease), right var(--motion-base) var(--ease), bottom var(--motion-base) var(--ease), border-radius var(--motion-base) var(--ease);
}
.demo-bar.is-attached { left: 0; right: 0; bottom: 0; border-radius: 0; border-width: 1px 0 0; }
.demo-bar-item { width: 18px; height: 18px; border-radius: 6px; background: var(--surface3); transition: background var(--motion-fast) var(--ease); }
.demo-bar-item.is-on { background: var(--accent); }
.segmented {
display: inline-flex;
padding: 3px;
background: var(--surface);
border-radius: var(--radius-pill);
gap: 2px;
align-self: flex-start;
}
.segmented-btn {
border: none;
background: transparent;
padding: 8px 16px;
border-radius: var(--radius-pill);
font-family: var(--font-body);
font-size: 13px;
font-weight: 600;
color: var(--fg2);
cursor: pointer;
transition: background var(--motion-fast) var(--ease), color var(--motion-fast) var(--ease);
}
.segmented-btn.is-active { background: var(--accent-soft); color: var(--accent-text); }
.customize-list {
grid-column: 1 / -1;
background: var(--bg);
border-radius: var(--radius-card);
padding: 28px 32px;
}
.customize-list h3 { font-size: 18px; margin: 0 0 16px; }
.feature-list {
margin: 0;
padding: 0;
list-style: none;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px 32px;
color: var(--fg2);
font-size: 15px;
}
.feature-list li { display: flex; align-items: center; gap: 10px; }
.feature-list li::before {
content: "";
width: 6px; height: 6px;
border-radius: 50%;
background: var(--accent);
flex: none;
}
@media (max-width: 760px) {
.customize-grid { grid-template-columns: 1fr; }
.feature-list { grid-template-columns: 1fr; }
}
/* ---------- privacy ---------- */
.privacy-grid {
display: grid;
grid-template-columns: minmax(0, 1.1fr) minmax(0, 0.9fr);
gap: clamp(32px, 6vw, 64px);
align-items: center;
}
.privacy-points {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 14px;
}
.privacy-points li {
display: flex;
align-items: center;
gap: 14px;
background: var(--surface);
border-radius: var(--radius-input);
padding: 16px 18px;
font-weight: 500;
}
@media (max-width: 820px) {
.privacy-grid { grid-template-columns: 1fr; }
}
/* ---------- cta ---------- */
.section-cta { text-align: center; background: var(--surface); }
.cta-inner { display: flex; flex-direction: column; align-items: center; }
.cta-inner .section-sub { margin-bottom: 32px; }
.cta-actions { justify-content: center; }
.cta-fine { margin-top: 20px; font-size: 14px; color: var(--fg3); }
.cta-fine a { color: var(--accent-text); font-weight: 600; text-decoration: none; }
/* ---------- footer ---------- */
.site-footer { border-top: 1px solid var(--line); padding: 32px 0; }
.footer-row {
display: flex;
align-items: center;
gap: 24px;
flex-wrap: wrap;
}
.footer-note { color: var(--fg3); font-size: 13px; margin: 0; flex: 1; min-width: 200px; }
.footer-links { display: flex; gap: 20px; }
.footer-links a { font-size: 14px; text-decoration: none; color: var(--fg2); }
.footer-links a:hover { color: var(--fg1); }
/* ---------- motion ---------- */
@media (prefers-reduced-motion: reduce) {
html { scroll-behavior: auto; }
.phone, .chip, .btn { transition: none !important; }
}
.reveal {
opacity: 0;
transform: translateY(16px);
transition: opacity 600ms var(--ease), transform 600ms var(--ease);
}
.reveal.is-visible { opacity: 1; transform: none; }