47 Commits
Author SHA1 Message Date
ayushyaandClaude Code da3b8a4f05 README: explain license choice and Gitea source of truth
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-01 20:34:08 -04:00
ayushyaandClaude Code 6ad37e4a36 Force LF endings on shell scripts
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-01 20:31:11 -04:00
ayushyaandClaude Code 1df27bdc13 Add GitHub PR import script, PR check workflow, issue templates
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-01 20:31:11 -04:00
ayushya 3a4d6142e7 Update README.md 2026-10-01 19:46:19 -04:00
ayushyaandClaude Code 5136ea2eb3 Prepare repository for public release
Add PolyForm Shield license, contributing and security docs, PR and commit
templates; rewrite README; ignore local Claude and signing files.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-01 19:45:17 -04:00
ayushyaandClaude Code d938df6aee Auto-publish Release-* builds to the Play internal track
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-01 18:48:21 -04:00
ayushyaandClaude Code 4611bc2142 Media viewer details panel, Upload FAB size setting, PDF zoom/viewport fixes, avatar menu account switcher; bump to 1.0.2+5
Build App Bundle / build (push) Successful in 6m2s
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-01 18:41:51 -04:00
ayushyaandClaude Code 0b7df833f6 Tablet layout polish: tablet-only sidebar layout, floating toolbar, Settings submenus, info notes, adaptive grids; bump to 1.0.1+4
Build App Bundle / build (push) Successful in 6m0s
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-01 17:08:22 -04:00
ayushyaandClaude Code ed747a0407 Upload release AAB to Google Play internal track from CI
Build App Bundle / build (push) Successful in 7m36s
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-01 16:18:45 -04:00
ayushyaandClaude Code 9c9e57ec22 Set app version to 1.0.0+3; share sheet and markdown viewer polish; remove demo screenshot tooling
Build App Bundle / build (push) Successful in 5m22s
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-01 14:50:07 -04:00
ayushyaandClaude Code 8aa7015c3f Set app version to 0.0.1+2
Build App Bundle / build (push) Successful in 5m38s
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-01 10:18:21 -04:00
ayushya 88abca647d Move Website source 2026-10-01 10:13:33 -04:00
ayushya 65b772d6e7 Merge pull request 'Set app version to 0.0.1' (#2) from noo-design-system-rebuild into main
Reviewed-on: #2
2026-10-01 09:37:12 -04:00
ayushyaandClaude Code 8179bf9683 Set app version to 0.0.1
Build App Bundle / build (push) Successful in 5m21s
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-01 09:35:45 -04:00
ayushya af69e5e751 Merge pull request 'Noo design system rebuild' (#1) from noo-design-system-rebuild into main
Build App Bundle / build (push) Successful in 5m45s
Reviewed-on: #1
2026-10-01 09:23:57 -04:00
ayushyaandClaude Code 1a232fc71a Drop Play Console upload from the Release workflow
The Release-* workflow now only builds Noo-<version>.aab and attaches it to
the Gitea release. The now-unused upload script is removed with it.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-01 09:14:26 -04:00
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
ayushyaandClaude Sonnet 5 c18fc98dc3 Refresh Files list automatically when an upload finishes
Build APK / build (push) Successful in 5m32s
Uploads run entirely in ShareUploadService.kt, an Android foreground
service with no channel back to Dart once started - by design, so
closing the app mid-upload doesn't interrupt it. That meant
FilesController had no way to know a batch had finished, so a newly
uploaded file only appeared after a manual pull-to-refresh.

Adds a one-shot completion signal instead of a full stream: the
service publishes into a new UploadEventBus (in-process pub/sub,
mirroring SyncStatusBus) once a batch finishes with at least one
success, MainActivity forwards it to Dart over a new
dev.ayushya.noo/upload_service/status EventChannel, and
FilesController (subscribed in its own constructor, same pattern
OfflineController already uses for sync completion) calls
refreshData() when the event's destination folder matches
currentFolderPath. Covers both upload entry points (share-to-Noo and
Files' own "+" -> Upload file), since they already share the same
ShareUploadView/UploadService path.

Move/Copy needed no fix - ItemOperations already calls
files.invalidateCache() + refreshData() on success.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 13:09:08 -04:00
ayushyaandClaude Sonnet 5 b73f451fff Fix pull-to-refresh, header tint, sheet controls, and Shares tap
- Every tab's RefreshIndicator+CustomScrollView was missing an explicit
  AlwaysScrollableScrollPhysics, so pull-to-refresh silently couldn't be
  triggered once the list was short enough to fit the viewport (empty,
  or a single item) - the platform default physics only allows the
  overscroll drag RefreshIndicator listens for once content overflows.
- StickyHeaderDelegate (Files'/Photos'/Favorites' pinned controls row)
  painted its background from Theme.of(context).colorScheme.surface -
  Flutter's own Material 3 scheme, reseeded by the user's accent color
  choice - instead of context.nooColors.bg like the rest of the screen,
  showing as a stray colored cast behind the row.
- NooSegmentedControl gains onSurface: true wherever one sits inside a
  sheet (Files'/Photos' sort and type-filter controls) - it defaulted to
  the same surface fill as the sheet itself, so the control had no
  visible boundary showing it as a grouped unit.
- Shares tab: removed the per-row "..." overflow menu on every scope
  (With you/By you/Links); tapping a row now opens the full Share sheet
  for that item instead, fetched via the new
  FilesController.fetchItemAtPath (a share only carries enough metadata
  for its own row, not what the sheet's header needs).
- fetchShares() now reuses _shareFromJson instead of its own thinner
  inline parser, so url/token/permissions/expireDate are actually
  populated - a public-link share could never show "Copy link" before.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 10:45:05 -04:00
ayushyaandClaude Sonnet 5 f9653bc0f2 Fix share copy-link, destination-picker filters, and type-filter UX
- SharesController's fetchShares() built NextcloudShare objects through
  its own thin inline parser instead of _shareFromJson, so url/token/
  permissions/expireDate were always null - a public link share could
  never show "Copy link" in the Shares tab. Reuses _shareFromJson.
- Upload/Move/Copy's destination picker hid External storage in its
  filter sheet (showStorageScope: false) unlike Files' own controls
  row, despite the design doc already calling for full parity.
- Files' and Photos' type filter (All/Files/Folders, All/Photos/Videos)
  now render as one consistent style in both filter sheets: every icon
  always shown, label only on the selected segment
  (NooSegmentedControl's new labelOnlySelected), replacing Files' old
  checkmark list and Photos' own always-labelled track.
- Fixes Photos' type filter only visually updating after closing and
  reopening the filter sheet - it lived on PhotosView's own State, so
  Listenable.merge([photos, files]) never rebuilt the sheet when it
  changed; a StatefulBuilder now gives it that trigger.
- Replaces Settings' trailing section jump rail with per-section
  collapsible cards (NooGroupedList's new collapsible param, expanded
  by default) - one less parallel way to navigate a long screen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 03:49:24 -04:00
ayushyaandClaude Sonnet 5 4e6477a3ae Fix recurring empty-list-on-login race at the root
addAccountActivatedListener/addAccountReadyListener fired once, to
whichever listeners were registered at that instant. Most per-tab
controllers are lazy providers, only constructed (and so only
registering) whenever something first reads them - if that happened
after the one-shot event already fired (e.g. a cold-start connectivity
misdetection delaying a tab's construction until after login
verified), that controller's initial fetch never ran, leaving its list
permanently empty. Both listeners now call back immediately on
registration if the account is already in the state being subscribed
to, closing the race regardless of construction timing.

Documents the failure class and the guardrail for future controllers
in architecture.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 23:51:16 -04:00
ayushya a127d72d56 Ten fixes: controls-row alignment, banners, shares, viewer bounds
- FilesControlsRow (and Photos' own controls row) wrapped its Row in a
  SingleChildScrollView, which gives a child unbounded width - silently
  breaking the trailing Spacer (a flex child needs bounded width) and
  leaving the List/Grid toggle sitting right after the filter chip
  instead of pinned to the row's right edge, "not enough controls to
  fill the row" or not. Removed the scroll wrapper (two chips and an
  icon toggle never need it) and the dead scroll-hint plumbing that
  went with it in files_view.dart.
- Photos' All/Photos/Videos chips moved into the filter sheet as a
  full-width segmented control (matching Settings' Theme control),
  instead of their own row of chips competing for space with the sort
  and filter chips.
- Settings' Theme row: the System/Light/Dark segmented control now
  sits on its own row below the label instead of squeezed into a
  trailing slot beside it, where three icon+label segments were too
  cramped to read comfortably - same shape as the sort sheet's
  Ascending/Descending control.
- NooBanner (Trash's "Empty trash") and NooSummaryCard (Offline's
  "Sync now") both move their action onto its own row below the
  description text instead of squeezed onto the same line, where a
  longer caption had no room to share with it.
- SharesController now caches each scope (with you / by you, which
  also covers links) after its first fetch instead of re-fetching on
  every single switch, including switching straight back to a scope
  already loaded seconds ago.
- Shares' per-row overflow menu jumped straight to the unshare
  confirmation with no menu at all - a link share had no way to copy
  its own URL short of opening the full Share sheet. It's a real menu
  now: "Copy link" (link shares only) plus remove/unshare.
- Settings gained a trailing jump rail on mobile (one small icon per
  section) so a specific section can be reached in one tap instead of
  scrolling the whole list by hand; desktop's 2-column grid doesn't
  need one.
- The media viewer's PageView now disables its own left/right swipe
  while the current photo is zoomed in, so panning around it doesn't
  also swipe to the next item.
- The PDF preview no longer sets minScale below 1.0: pdfx's
  PdfViewPinch hard-codes an *infinite* pan boundary whenever minScale
  < 1 (not an exposed parameter, so there's no way to override it),
  which let a page be panned arbitrarily far off-screen without ever
  zooming.
2026-09-28 22:55:31 -04:00
ayushya 0abea46b1c Fix sheet peek for async content, and rework several screens' details
showNooSheet's peek detection previously measured content once, right
after the first frame - Details' Versions/Activity tabs fetch their
data asynchronously, so that one-shot measurement only ever saw the
initial loading-spinner height, never the eventual (often long) list,
and the sheet still snapped to full height once data arrived. Replaced
it with a ScrollMetricsNotification-based check that keeps content
capped at the peek height from frame one and upgrades to a draggable
sheet whenever it actually overflows that cap, however and whenever
that happens - fixed for good this time, with a regression test that
mirrors the async-load shape.

Also, in the same pass:
- Upload/Move/Copy's bottom bar meta line now always reads as a count
  ("Uploading 3 items"), never a filename for the single-item case,
  and is centered rather than left-aligned - matches the approved
  design and removes a bespoke marquee/chip implementation that only
  Upload had.
- Upload/Move/Copy now show the same FilesControlsRow (sort, filter,
  List/Grid) Files itself uses, including a folder-grid rendering
  path for when List/Grid is set to grid.
- Files' filter chip reads "Filters" instead of "All types" when idle.
- Trash's overflow button - whose only action is "Delete forever" -
  is now a direct delete icon (mobile) / "Delete" danger pill
  (desktop) instead of a generic "..." menu that hid a single action
  behind an extra tap.
- The media viewer's floating chrome (top bar, bottom action bar,
  video transport row) now follows the app's light/dark theme instead
  of a fixed dark panel - a dark panel read as out of place over a
  light-theme stage. Panel tint and icon/text colors come from the
  theme's own surface/fg1 tokens (at a higher blur opacity to stay
  legible over arbitrary photo/video brightness), and the edge scrim
  gradients fade toward the panel's own tint instead of a fixed black.
  Mirrored the same change into the design canvas's Main.dc.html/
  MainAndroid.dc.html mockups (now with a theme prop) so they match.
2026-09-28 19:26:18 -04:00
ayushya c0f702b00e Sheet peek/expand, theme-aware media stage, and edge-to-edge viewer bars
Long-content bottom sheets (Details' Versions/Activity tabs with many
rows) opened locked to nearly full screen height with no room left to
adjust. showNooSheet now measures its content once against the
available height: anything that fits comfortably still sizes to
content as before, but content tall enough to need scrolling opens at
a native-style draggable peek instead, which the user can drag up to
reveal more of (and drag back down to dismiss).

The media viewer's stage was pinned to black regardless of theme,
which read as jarringly out of place in light mode - it now follows
the app's own `bg`. The floating top/bottom bars stay a fixed dark
panel either way (they need to read over arbitrary photo/video
brightness, not over the stage), but both had their SafeArea nested
the wrong way around: it wrapped the whole panel instead of just its
content, so the panel's background stopped short of the actual screen
edge - most visibly the bottom bar ending above the gesture area
instead of extending behind it. The background now wraps the SafeArea
instead.
2026-09-28 18:16:44 -04:00
ayushya 8d17ae1f4b Fix black sheet/dialog barriers and Details tabs refetching on switch
Every sheet and dialog barrier goes solid black instead of dimming the
screen behind it whenever dynamic color ("Match wallpaper") is on -
Material 3's ColorScheme.scrim role is fully opaque black by spec,
meant to be used at reduced opacity by whoever paints it, but
NooColors.fromDynamicScheme mapped it straight into barrierColor.
Use the fixed palette's already-translucent scrim instead, matching
how success/warning/info already fall back to it since they have no
dynamic equivalent either.

Also fix the Details sheet's Info/Versions/Activity segmented control
refetching a tab's data every time it's selected: switching on the
active tab picked which single widget to build, so Flutter tore down
and recreated whichever tab wasn't showing on every switch, discarding
its fetched data. Keep all three mounted in an IndexedStack instead
(the same pattern the app's own main tab bar already uses for this
exact reason), so each tab's state - and its one-shot fetch - survives
switching back and forth.
2026-09-28 17:49:26 -04:00
ayushya 1b8bbaa22f Fix every item's "created" date reading January 1970
Nextcloud's WebDAV server has no real per-file creation-time tracking
for most setups, so its creationdate property routinely comes back as
a placeholder Unix-epoch date ("Thu, 01 Jan 1970 00:00:00 GMT")
instead of being omitted. _parseDavDate parsed that "successfully"
into a real (if bogus) DateTime, so every item's dateCreated stuck at
the epoch instead of falling back to lastModified as it would for a
genuinely missing/unparseable value.

Add _parseDavCreationDate, which treats that placeholder the same as
an absent value, and use it at all four PROPFIND/SEARCH call sites
that read creationdate (folder listing, search, favorites, recent).
2026-09-28 17:37:03 -04:00
ayushya 72472c771a Make the custom accent-color picker actually retint the app
NooColors (the color source for nearly the whole redesigned UI) only
had two states: the fixed violet palette, or a Material You dynamic
scheme when "Match wallpaper" was on. The 5-swatch custom accent
picker still wrote to SettingsController.seedColor, but nothing read
it back into NooColors - it only reached the underlying Material
ColorScheme, which the Noo-styled UI doesn't use, so every custom
swatch looked identically purple.

Add NooColors.fromSeed, which retints just the accent/accentText/
accentSoft roles from a chosen seed color via ColorScheme.fromSeed
(bg/surface/fg etc. stay the fixed warm-neutral palette, unlike
dynamic color which reshapes everything to match the wallpaper), and
wire it into AppTheme.light/dark. Add a "Default" (Noo violet) swatch
to the picker, and move SettingsController's initial seed color to
that same violet so a fresh install - or any device without dynamic
color - keeps today's look instead of picking up the leftover
Nextcloud-blue default from before seed colors did anything visible.
2026-09-28 13:11:11 -04:00
ayushya c9889c69b8 Fix Trash/Shares list perf and live-update the Files sort/filter sheet
Trash and Shares built their mobile lists eagerly via NooGroupedList's
own Column instead of a lazy sliver, so an account with hundreds of
items made those tabs laggy - both now use a SliverList/
SliverChildBuilderDelegate instead, matching Files' own lazy list,
while keeping the same one-continuous-card look via per-row corner
rounding and a 1px divider.

FilesControlsRow's sort/filter sheets also had a live-update bug: since
showNooSheet's children are built once up front, tapping a row inside
the open sheet updated the controller but the already-built
checkmarks/toggles stayed stale until the sheet was closed and
reopened. Wrapping each sheet's content in a ListenableBuilder fixes
that.

Also adds NooSelectionBar to styling.md's widget inventory table.
2026-09-28 13:02:31 -04:00
ayushya 254db255a7 Add a Settings option to reorder the selection action bar's actions
NooSelectionBar's fixed inline slots (3 mobile / 4 desktop, the rest
behind "More") previously just took the first N actions in whatever
order each screen happened to build them in. Give bulk actions a
stable SelectionActionKind identity (independent of their dynamic
icon/label, e.g. favorite vs. unfavorite) so Settings can offer a
drag-to-reorder list - mirroring the existing Tabs section, minus the
pin/hide concept, which doesn't apply here - and each screen applies
that saved priority order via orderSelectionActions before handing
its action list to the bar.
2026-09-28 12:21:36 -04:00
ayushya 800e8ebf44 Fix five Android nav/shell UX issues
- Bottom bar: labels now sit below each icon instead of beside it, and
  the active-tab pill is one shared indicator that slides between tabs
  instead of popping in/out per item.
- Upload FAB stays mounted across every tab and collapses to an
  icon-only circle off Files/Photos, instead of the Scaffold popping it
  fully in/out on every tab switch.
- Tapping the top-bar avatar now opens the accounts sheet instead of
  jumping straight to Settings.
- Settings tab reordering only starts a drag from the grip icon now -
  previously the whole row claimed vertical drags, so scrolling the
  section could trigger an accidental reorder.
2026-09-28 12:08:23 -04:00
ayushya d2c34b000f Redesign the selection action bar to remove horizontal scrolling
The multi-select action bar (Files/Photos/Favorites) hid bulk actions
behind a horizontally-scrolling row with no visible cue that there was
more to find, once there were more than a handful (up to 9: favorite,
share, download, delete, copy, move, rename, sync, details).

Designed the replacement on the linked design canvas first (Selection
action bar boards), then implemented it: NooSelectionBar now shows a
fixed number of actions inline (3 on mobile, 4 on desktop) with the rest
behind a trailing "More" button that opens the same overflow sheet a
file row's own "..." menu already uses. Removed the now-dead
scroll-hint plumbing from the three screens that render it.
2026-09-28 11:27:03 -04:00
ayushyaandClaude Opus 5.5 c2540ec2ce Fix media viewer chrome to match the approved Noo design
The viewer's top/bottom bars still used the pre-rework
FrostedGlassContainer verbatim: a floating inset pill with a Material
colorScheme tint, an outline border and a drop shadow (violating the
design system's no-shadow rule), and theme-derived icon colors that
went near-invisible against the new dark panel in light mode. The
video transport controls were a second, separate floating pill above
the action bar instead of one continuous panel.

- FrostedGlassContainer: drop the shadow/border/ColorScheme tint for a
  fixed dark translucent fill matching the approved design canvas: no
  shadow anywhere in this screen now.
- file_viewer_screen.dart: top/bottom bars sit flush edge-to-edge
  (no floating-pill inset) with a gradient scrim behind each, and use
  fixed white (not theme-derived) text/icon color, since this chrome
  always sits over a black media stage regardless of app theme.
- ViewerIconButton: same fix - fixed white by default instead of
  colors.fg1.
- MediaActionBar: takes an optional transportControls slot so video's
  transport row renders inside the same panel as the action row,
  above a thin divider, instead of as a separate floating pill.
- MediaVideoPreview: renders only the video surface now; reports its
  controller up via onController (only when active, so a neighboring
  PageView page can't steal the transport row) instead of owning a
  floating panel. VideoTransportControls is the extracted, chrome-less
  transport row content, rendered by the parent via MediaActionBar's
  new slot.

Verified live on device: photo and video viewing, favorite/delete
coloring, and the merged transport+action panel all match the
approved canvas (https://claude.ai/artifact/3AGPqqMdkLSC2ypCh2CQs4).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 12:34:06 -04:00
ayushyaandClaude Opus 5.5 56784ef212 Rebuild UI on the Noo design system
Replaces the Material 3 screens with the Noo design system: warm
neutrals, one violet accent, pill controls, Schibsted Grotesk/
Instrument Sans, Lucide icons, no gradients/shadows.

- New lib/widgets/noo/ component kit (core, lists, files, media, nav,
  overlays) plus lib/theme/design_tokens.dart for the color/type/space/
  radius/motion tokens.
- Rebuilt the app shell (top/bottom bars, drawer, desktop sidebar and
  toolbar), every tab (Files/Offline, Photos, Favorites, Recent,
  Activity, Trash, Shares), Settings, the lock and login screens, the
  media viewer, search, the details/share sheets, and the share-upload
  and move/copy destination pickers.
- Added a design canvas (linked from DESIGN_SYSTEM.md) covering the
  screens the spec didn't already describe, with matching Android and
  iOS chrome; wrote up the approved recipes into DESIGN_SYSTEM.md §4.
- Removed now-dead legacy widgets (media_grid_tile, swipeable_item,
  sync_status_badge, selectable_thumbnail) and updated
  architecture.md/styling.md/standards.md to describe the new
  structure and component/testing conventions.
- Added widget tests for the noo/ component kit.

This is a UI-only rework: no provider/model/service behavior changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-26 01:51:58 -04:00
ayushyaandClaude Sonnet 5 51682a901d Add design system spec for the upcoming UI rework
Moved into .claude/context/ (alongside the other topic docs CLAUDE.md
points agents at) rather than the repo root, so it's discovered the same
way as architecture.md/styling.md/etc. Flags styling.md as documenting the
pre-rework theme in the meantime, so the two don't read as contradictory.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-24 14:56:47 -04:00
172 changed files with 24228 additions and 7379 deletions
+70
View File
@@ -0,0 +1,70 @@
---
name: flutter-kgp-doctor
description: Diagnoses and fixes Flutter's "plugin applies Kotlin Gradle Plugin (KGP)" build warning - the one that names specific plugins (e.g. "Your app uses the following plugins that apply Kotlin Gradle Plugin (KGP): file_saver, pdfx") and links to the Built-in Kotlin migration guide. Use whenever `flutter build`/`flutter run` prints this warning, or when checking whether it's safe to upgrade a plugin that previously triggered it.
tools: Read, Edit, Grep, Glob, Bash, WebFetch, WebSearch
---
You fix Flutter's Kotlin Gradle Plugin (KGP) migration warning, which looks
like:
```
WARNING: Your app uses the following plugins that apply Kotlin Gradle Plugin (KGP): file_saver, pdfx
Future versions of Flutter will fail to build if your app uses plugins that apply KGP.
```
## Background
Flutter is moving to a model where the Flutter Gradle plugin applies Kotlin
itself ("Built-in Kotlin"), rather than each Android plugin applying its own
copy of the Kotlin Gradle Plugin in its `android/build.gradle`. Plugins that
haven't migrated yet trigger this warning today and will hard-fail the build
in a future Flutter release. The fix lives upstream, in the plugin's own
`android/build.gradle` (removing its own `apply plugin:
'kotlin-android'`/`id("kotlin-android")` in favor of Flutter's built-in
support) - not in this app.
Reference: https://docs.flutter.dev/release/breaking-changes/migrate-to-built-in-kotlin/for-app-developers
## What to do
1. Run `flutter build apk --release` (faster than `appbundle`) or use the
warning text the user already has, to get the exact list of named
plugins.
2. For each plugin, find its current pinned version in `pubspec.yaml` and
the resolved version in `pubspec.lock`.
3. Check whether a newer published version has migrated:
- Check the plugin's CHANGELOG.md (pub.dev shows it, or the package's
GitHub repo) for entries mentioning "Built-in Kotlin", "KGP", or
removing `kotlin-android` from its `android/build.gradle`.
- If unsure from the changelog alone, fetch the candidate version's
`android/build.gradle` from pub.dev/GitHub directly and check whether
it still applies the Kotlin Gradle Plugin itself.
4. If a migrated version exists and satisfies (or can reasonably relax) the
app's other constraints:
- Bump the version constraint in `pubspec.yaml`.
- Run `flutter pub get`.
- Rebuild (`flutter build apk --release` is enough to confirm) and
verify that specific plugin no longer appears in the warning's list.
- Run `flutter analyze` and skim for anything the upgrade broke (API
changes) before calling it done.
5. If no migrated version exists yet:
- Do not attempt to silence the warning (no Gradle flags, no
suppressing build output) - it is telling the truth about a real
future break.
- Do not fork or hand-patch the plugin's Android Gradle files inside
`pub-cache` - that gets overwritten on the next `flutter pub get` and
isn't tracked by the repo anyway.
- Report which plugin(s) are still blocked, their current version, and
whether the upstream issue tracker already has an open issue for this
(search it) - file a note for the user rather than opening the issue
yourself.
## Reporting
For each plugin named in the original warning, state one of:
- **Fixed**: upgraded from `X` to `Y` in `pubspec.yaml`, confirmed the
warning no longer names it.
- **Blocked**: still on `X`, no migrated release exists yet, link to the
changelog/issue you checked.
Keep the report short - a couple of lines per plugin is enough.
+185 -46
View File
@@ -13,20 +13,34 @@ lib/
# ItemOperations, a plain cross-domain coordinator # ItemOperations, a plain cross-domain coordinator
services/ # network/IO: NextcloudService, LoginFlowService, services/ # network/IO: NextcloudService, LoginFlowService,
# AccountStore, AppLockService # AccountStore, AppLockService
theme/ # AppTheme (Material 3 ThemeData) theme/ # AppTheme (Material 3 ThemeData, pre-rework
# screens) + design_tokens.dart (NooColors/NooText/
# NooSpace/NooRadii/NooSizes/NooMotion - see
# styling.md's "Noo design-system components")
views/ # one screen each (FilesView, PhotosView, TrashView, views/ # one screen each (FilesView, PhotosView, TrashView,
# SharesView, RecentView, ActivityView, SearchView, # SharesView, RecentView, ActivityView, SearchView,
# AccountView, LoginView, FileViewerScreen, # AccountView, LoginView, FileViewerScreen,
# ShareUploadView, LockScreenView) # ShareUploadView, LockScreenView)
widgets/ # reusable pieces shared across views widgets/ # reusable pieces shared across views
noo/ # the Noo design-system component kit - see
# styling.md's catalog
details/ # the file-details bottom sheet and its tabs details/ # the file-details bottom sheet and its tabs
shell/ # pieces shared by the mobile/desktop app shell
settings/ # Settings' section widgets
tabs/ # loading/error/empty slivers + grouping helpers
# shared by Recent/Activity/Trash/Shares
files/ # pieces split out of FilesView (e.g. the
# breadcrumb row)
``` ```
`views/` files are screens routed to directly (a tab, or pushed via `views/` files are screens routed to directly (a tab, or pushed via
`Navigator`). `widgets/` files are building blocks used by more than one `Navigator`). `widgets/` files are building blocks used by more than one
view (or complex enough to warrant their own file) — nothing in `widgets/` view (or complex enough to warrant their own file) — nothing in `widgets/`
owns app state itself; it reads whichever controller(s) it needs via owns app state itself; it reads whichever controller(s) it needs via
`context.watch`/`context.read`. `context.watch`/`context.read`. Most tabs, the app shell, and Settings are
now built from `widgets/noo/` rather than raw Material widgets - see
`styling.md`'s "Noo design-system components" for the token/component
catalog and the current rebuild status.
## State management ## State management
@@ -100,8 +114,9 @@ new provider instance):
mirror, not a second view. `offline` only swaps the data source, reads mirror, not a second view. `offline` only swaps the data source, reads
images from the local file (`localFileFor`) instead of a server preview, images from the local file (`localFileFor`) instead of a server preview,
and turns off what needs the server: selection and bulk actions, swipe and turns off what needs the server: selection and bulk actions, swipe
actions, sync badges, "+" (replaced by "Manage synced folders"), pick actions, per-item sync status icons, "+" (replaced by "Manage synced
mode. The two `FilesView`s are keyed in `buildAppTabView` so the folders"), pick mode. The two `FilesView`s are keyed in `buildAppTabView`
so the
`IndexedStack` never hands one's State to the other. `IndexedStack` never hands one's State to the other.
- [`OfflineController`](../../lib/providers/offline_controller.dart) — the - [`OfflineController`](../../lib/providers/offline_controller.dart) — the
Files tab's folder listing read from local storage instead of the Files tab's folder listing read from local storage instead of the
@@ -138,6 +153,42 @@ new provider instance):
(shares/activity/versions/restore) — stateless, so they don't need a (shares/activity/versions/restore) — stateless, so they don't need a
controller of their own. controller of their own.
**Guardrail - the "empty list on first load" failure class.** This bug has
recurred more than once, always the same shape: a tab's list is empty after
login even though the account is fine, because the controller behind it
never ran its initial fetch. The cause is a race, not a network bug:
`addAccountActivatedListener`/`addAccountReadyListener` fire *once*, at the
moment login is verified/ready, to whichever listeners are registered at
that exact instant - and most per-domain controllers (`FilesController`,
`PhotosController`, `FavoritesController`, `TrashController`,
`SharesController`, `RecentController`) are lazy `ChangeNotifierProvider`s,
only constructed (and so only registering their listener) whenever
something first reads them. If that first read happens to land *after*
the one-shot event already fired - e.g. `ConnectivityController`
misreporting offline for its first couple of seconds after a cold Android
start (see its own doc comment) collapses `main.dart`'s bottom nav to just
the Offline tab, delaying construction of every other tab's controller
until connectivity corrects itself and login has already finished
verifying - that controller's listener registers too late and its initial
fetch simply never happens. `SessionController.addAccountActivatedListener`/
`addAccountReadyListener` now close this at the root: registering either
one calls back **immediately** if the account is already in the state
being subscribed to, not just on the next fresh event, so a late-registering
controller always gets its initial fetch regardless of when its lazy
`Provider` happens to be built. Some views (`FilesView`/`PhotosView`, not
`FavoritesView` - see `FavoritesController`'s own doc comment) additionally
carry a build-time fallback (`_requestedInitialLoad` et al.: reload if
items are empty and not loading, once, after first build) predating this
fix; they're now redundant but harmless, and not worth touching for
cleanup alone. **Guardrail for new code:** any new network-fetching
controller that needs a one-time fetch on login must register through
`addAccountActivatedListener`/`addAccountReadyListener` in its constructor
and rely on their catch-up behavior - don't reach for a per-view
build-time "fetch if empty" fallback as the primary mechanism, since that
pattern is exactly what let this bug keep recurring silently (three
near-duplicate, slightly-diverging implementations, none of them fixing
the actual race).
`sessionGeneration` (on `SessionController`, read by every other `sessionGeneration` (on `SessionController`, read by every other
controller) is incremented on every account switch so an in-flight fetch controller) is incremented on every account switch so an in-flight fetch
from the account just left can recognize it's stale and discard its result from the account just left can recognize it's stale and discard its result
@@ -175,15 +226,18 @@ directly, which call `NextcloudService`/`LoginFlowService`/`AccountStore`/
- else `session.needsUnlock` → `LockScreenView` (login lock — see above) - else `session.needsUnlock` → `LockScreenView` (login lock — see above)
- else `MainShellView` - else `MainShellView`
`MainShellView` is a bottom-nav `IndexedStack` over up to 8 tabs — Files, `MainShellView` is an `IndexedStack` over up to 8 tabs — Files, Photos,
Photos, Favorites, Activity, Trash, Shares, Recent, Offline — user-configurable Favorites, Activity, Trash, Shares, Recent, Offline — user-configurable
(order, visibility up to `maxVisibleTabs`, default tab) via (order, visibility up to `maxVisibleTabs`, default tab) via
`AppTab`/`SettingsController` and rendered through `buildAppTabView` `AppTab`/`SettingsController` and rendered through `buildAppTabView`
(`widgets/app_tab_view_builder.dart`). `maxVisibleTabs` (5) is less than the (`widgets/app_tab_view_builder.dart`). `maxVisibleTabs` (5) is less than the
total tab count, and `SettingsController._enforceMaxVisibleTabs` already total tab count, and `SettingsController._enforceMaxVisibleTabs` already
auto-hides overflow on load (fresh install, or - as when Favorites was auto-hides overflow on load (fresh install, or - as when Favorites was
added - an existing saved tab order from before a new tab existed), so added - an existing saved tab order from before a new tab existed), so
adding a tab to the `AppTab` enum needs no extra migration. adding a tab to the `AppTab` enum needs no extra migration. Only the (up
to) 5 pinned tabs (`settings.visibleTabs`) become bottom-nav/sidebar
destinations; the rest sit in the "More" section of the drawer (mobile) or
sidebar (desktop) - see below.
Each tab keeps its own `ScrollController` (survives tab switches via Each tab keeps its own `ScrollController` (survives tab switches via
`IndexedStack`'s built-but-hidden trees) and tapping the already-active tab `IndexedStack`'s built-but-hidden trees) and tapping the already-active tab
scrolls it back to top (`tapTabToScrollTop` setting). `MainShellView` also scrolls it back to top (`tapTabToScrollTop` setting). `MainShellView` also
@@ -206,40 +260,109 @@ only two that make sense as external "choose a file" sources. The same
override mechanism restricts the visible tabs to just Offline while override mechanism restricts the visible tabs to just Offline while
`ConnectivityController.isOffline` - see `server.md`'s "Working offline" `ConnectivityController.isOffline` - see `server.md`'s "Working offline"
section for the full story, including why session restore itself has to section for the full story, including why session restore itself has to
avoid making a network request in that case. `MoreTabsButton` hides avoid making a network request in that case. The drawer/sidebar's "More"
entirely in both cases (picking, offline) for the same reason - there's section is empty in both cases (picking, offline) for the same reason -
nothing it could usefully open. there's nothing useful in it to switch to - and the mobile drawer doesn't
open at all while picking (`Scaffold.drawer` is null).
`MainShellView` also fires a one-time notification-permission prompt on `MainShellView` also fires a one-time notification-permission prompt on
its first mount (`_maybeRequestNotificationPermission`, gated by a plain its first mount (`_maybeRequestNotificationPermission`, gated by a plain
`shared_preferences` flag so it only ever asks once, not on every `shared_preferences` flag so it only ever asks once, not on every
launch): a plain-language `AlertDialog` explaining why Noo wants it launch): a plain-language dialog (`showNooDialog`, two `NooButton`s)
(upload/download progress notifications - see `ShareUploadService.kt`/ explaining why Noo wants it (upload/download progress notifications - see
`DownloadService.kt`) before the OS's own `permission_handler`-driven `ShareUploadService.kt`/`DownloadService.kt`) before the OS's own
`Permission.notification.request()`, since the bare system prompt gives `permission_handler`-driven `Permission.notification.request()`, since the
no context on its own. bare system prompt gives no context on its own.
`FloatingBottomNavBar` (`widgets/floating_bottom_bar.dart`) wraps its `MainShellView` builds its chrome from the Noo nav kit
pill's item row in a horizontal `SingleChildScrollView` rather than a (`widgets/noo/nav/`) and switches between two layouts on
plain `Row`, so a wide selected-item label plus several icon-only tabs `NooLayout.isDesktop` (width >= 900dp *and* shortest side >= 600dp, so a
scrolls instead of overflowing on narrower screens. phone in landscape keeps the mobile layout and only tablet-class windows get
the sidebar):
All eight tabs, plus `ShareUploadView` (the share-to-upload destination - **Mobile:** `AppTopBar` (`widgets/app_top_bar.dart`) wraps `NooTopBar`
picker, pushed rather than a tab - see below), share for *every* tab (previously only Files had shell-level top chrome, with
[`SyncedHeaderScaffold`](../../lib/widgets/synced_header_scaffold.dart) — a the rest building their own via `SyncedHeaderScaffold`) - iOS gets a
large title, an inline search field, a `plus` action on Files only (no
other tab has a create/upload flow), and the account avatar; Android
gets a compact title row with `search`/avatar actions, relying on an
extended `NooFab` ("Upload", Files/Photos only) instead of a top-bar
icon for upload. Rather than one shared instance in `Scaffold.appBar`,
`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
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
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,
remaining tabs, storage meter, Settings) sits beside a `NooToolbar`
(tab title, search, an "Upload" action on Files/Photos) over the same
`IndexedStack`, both built inline in `main.dart` rather than as separate
widgets.
Both layouts share one detail: tapping a hidden ("More") tab calls
`SettingsController.requestTab` - the same one-shot request
`SearchView`/`FavoritesView`/`ShareUploadView` already use to jump the
shell to a tab from outside it - instead of pushing that tab as its own
screen. `MainShellView` then shows it as the active tab (still just an
entry in the `IndexedStack`) with no bottom-bar/sidebar destination
highlighted, since it isn't one of the pinned five, until the user taps a
pinned or another "More" tab. `widgets/shell/shell_common.dart` holds the
pieces both layouts share: account/storage formatting, `openSettings`/
`openSearch`, `showAccountSwitcher` (the saved-accounts list behind the
drawer's chevron and the sidebar's account card - a sheet on mobile, a
dialog on desktop), `ShellAvatarButton` and `ShellSearchLauncher`.
All eight tabs used to share
[`SyncedHeaderScaffold`](../../lib/widgets/synced_header_scaffold.dart) - a
`CustomScrollView` with a pull-down "sync status" header (Google `CustomScrollView` with a pull-down "sync status" header (Google
Photos-style) and a classic Material refresh spinner shown during a Photos-style) and a classic Material refresh spinner shown during a
pull-triggered sync. `ShareUploadView` mirrors the Files tab's own pull-triggered sync. Now that every tab is rebuilt on Noo, each renders its
controls-row/breadcrumbs sticky header almost exactly, so arriving via own content directly in a plain `RefreshIndicator` + `CustomScrollView`
another app's "Share to..." sheet still lands on the same top chrome instead (`ColoredBox(colors.bg)` background, no shared header widget);
instead of a plain `AppBar` - its `actions` are `[ProfileAvatarButton()]` device-sync status shows per-row (`NooFileRow`/`NooFileTableRow`'s
only (no `MoreTabsButton`: there's nowhere useful for it to go mid-upload, `NooStatusIcon`s) or in the Offline tab's `NooSummaryCard`, not a shared
since jumping to another tab would abandon the destination picker); pinned chip. `SyncedHeaderScaffold` is now unused - the last two screens on
backing out is the system back gesture/button, not a bespoke close icon in it, `ShareUploadView` (the share-to-upload destination picker, pushed
the app bar. Its bottom action - "Upload to rather than a tab - see below) and `MoveCopyDestinationPicker`, are
{folder}" - and the uploading-file-name summary above it (single line, rebuilt too: both are a `Scaffold` with a `NooTopBar`+`NooTopBarBack`
auto-scrolling via `MarqueeTitle` if it doesn't fit) live together in one (mobile) / `NooToolbar` (desktop) top bar, a noo-styled
rounded-top, elevated `Material` bar as `bottomNavigationBar`, reading as a [`Breadcrumbs`](../../lib/widgets/breadcrumbs.dart) row + folder list
sheet peeking up from the bottom edge rather than a plain flat bar. (`NooFileRow` mobile, `NooFileTableRow` desktop - both pickers only ever
browse *folders*, so the old dimmed-but-visible file rows are gone; a
`RefreshIndicator`+`CustomScrollView`) for a body, and a `colors.surface`
bottom bar with a top `line` (no more rounded-top elevated `Material`
sheet) holding the primary CTA. `ShareUploadView` is pushed from outside
`MainShellView` (a cold share-intent launch, or Files' "+" → "Upload
file"), so it builds its own top bar rather than relying on the shell's -
its `actions` are `[ShellAvatarButton()]` only (no `MoreTabsButton`:
there's nowhere useful for it to go mid-upload, since jumping to another
tab would abandon the destination picker); `ShellAvatarButton` is the same
tap-to-Settings/swipe-to-cycle-accounts widget the shell itself uses, and a
straight replacement for the old `ProfileAvatarButton` (now unused
anywhere, since this was its last call site). Its bottom bar holds the
uploading-file-name summary (single line, auto-scrolling via
`MarqueeTitle` if it doesn't fit) above the "Upload to {folder}" CTA.
[`MarqueeTitle`](../../lib/widgets/marquee_title.dart) (`package:marquee`) is [`MarqueeTitle`](../../lib/widgets/marquee_title.dart) (`package:marquee`) is
shared with `FileViewerScreen`'s title - falls back to a plain ellipsized shared with `FileViewerScreen`'s title - falls back to a plain ellipsized
`Text` when the content already fits, so short text never marquees. `Text` when the content already fits, so short text never marquees.
@@ -259,16 +382,32 @@ no prior browsing session to preserve. The Move/Copy picker is pushed
*while the user is actively browsing a specific Files-tab folder*, so *while the user is actively browsing a specific Files-tab folder*, so
reusing the shared state would strand that folder's `pathStack` under it; reusing the shared state would strand that folder's `pathStack` under it;
its local state means popping back always lands the user exactly where its local state means popping back always lands the user exactly where
they were, untouched. they were, untouched. Unlike `ShareUploadView`, it keeps `MoreTabsButton`
`SearchView`, `AccountView` (Settings), the file-details sheet, and the in its top bar: that button pushes a hidden tab as its own stacked screen
share sheet are pushed on top via rather than jumping the shell there, so it doesn't abandon this picker the
`Navigator`/`showModalBottomSheet`/`showGradualBottomSheet` rather than way jumping tabs would. Its bottom bar shows a "Moving/Copying N item(s)"
being tabs. `ProfileAvatarButton` (top-right on every tab) opens Settings on summary line (replaced by a danger-colored warning when the current folder
tap and cycles between saved accounts on a vertical swipe. is an invalid destination) above a "Move here"/"Copy here" CTA - the
per-item conflict follow-up, if any, is
[`MoveCopyConflictSheet`](../../lib/widgets/move_copy_conflict_sheet.dart).
`SearchView`, `AccountView` (Settings), the file-details sheet
(`DetailsSheet`), and the share sheet (`ShareSheet`) are pushed on top via
`Navigator`/`showNooSheet`/`showNooDialog` rather than being tabs (the two
per-item sheets pick between the mobile sheet and the desktop dialog via
`NooLayout.isDesktop`, same as every other overlay in the app;
`showGradualBottomSheet`, the custom drag-to-resize sheet they used before,
has no remaining callers). On mobile, `ShellAvatarButton`
(`widgets/shell/shell_common.dart`,
shown in `AppTopBar`'s trailing actions on every tab, and reused directly by
`ShareUploadView`) opens Settings on tap and cycles between saved accounts
on a vertical swipe. Desktop has no avatar in the
toolbar; `NooSidebarAccount`'s account card opens the full switcher instead
(`showAccountSwitcher`, the same one behind the mobile drawer's chevron).
Files and Photos (the two tabs with multi-select) pass their selection Files and Photos (the two tabs with multi-select) render their selection
toolbar into `SyncedHeaderScaffold`'s `selectionBar` param rather than bar as a `pinned: true` sliver at the top of their own `CustomScrollView`,
rendering it as a second sliver app bar inside their own content: while swapped in for the controls row/type-chips row while `_isSelecting` -
non-null, it fully takes over the pinned top bar in place of the selecting reads as replacing that row in place, not adding a strip
sync-status chip/`actions`/pull-to-reveal quota panel, so selecting reads beneath it. (An earlier version of this routed the selection bar through
as replacing the whole top chrome rather than adding a strip beneath it. `SyncedHeaderScaffold`'s `selectionBar` param; that's gone along with the
scaffold itself in these two tabs.)
Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

@@ -0,0 +1,454 @@
# Noo Design System
Noo is a Nextcloud client for iOS, Android, macOS and Windows. ("Noo" is a placeholder name.)
Its visual language is warm neutrals with one violet accent, flat surfaces, pill-shaped controls, and no gradients.
**Platform rule:** keep one visual language everywhere. Adapt only the navigation chrome (status bar, top bar, bottom bar, window controls) to each platform. Content components look the same on every platform.
Reference files in this project:
- `Noo Screens.dc.html` has every screen on every platform, in light and dark.
- `Noo Design System.dc.html` is the visual component sheet.
- `noo-kit.js` holds the tokens (`TH`), the icon set (`SVG`/`ic`), file-type mapping (`KIND`) and mock data.
- `Mobile Screen.dc.html` and `Desktop Screen.dc.html` are the reference builds for each screen.
- [Noo — Missing Screens](https://claude.ai/artifact/3AGPqqMdkLSC2ypCh2CQs4) is a live design canvas (Claude Design, not a static file in this repo) covering pieces this doc originally had no recipe for: Media viewer, Search, the Upload/Move/Copy destination picker, the Details sheet's tab switch, and the Selection action bar (mobile + desktop). It has both an iOS row (built first, ready for later) and an Android row (built to match right now) - same content, chrome adapted per the platform rule above. §4 below is the written-up version of what's approved there; go back to the canvas for pixel-level layout, not just the summary.
---
## 1. Tokens
### 1.1 Color (semantic). Use these names in code.
| Token | Role | Light | Dark |
|---|---|---|---|
| `bg` | App background, desktop sidebar | `#F3EFE6` warm off-white | `#0E0D0B` |
| `surface` (`sf`) | Cards, rows, bars, sheets, desktop main pane | `#FFFFFF` | `#25241F` |
| `surface-2` (`sf2`) | Inputs, inset panels, secondary buttons | `#F6F5F2` | `#3D3C38` |
| `surface-3` (`sf3`) | Progress tracks, toggle off, grabbers | `#EAE8E2` | `#5A5852` |
| `line` (`ln`) | Dividers, 1px borders | `#EAE8E2` | `#3D3C38` |
| `fg-1` | Primary text | `#0E0D0B` | `#F3EFE6` |
| `fg-2` | Secondary text, idle icons | `#5A5852` | `#BDBAB1` |
| `fg-3` | Meta text, placeholders, trailing icons | `#7C7A72` | `#9E9B92` |
| `accent` (`ac`) | Primary fills (buttons, FAB, toggle on, progress) | `#8D0DE3` | `#8D0DE3` |
| `accent-text` (`act`) | Accent text and icons, active nav | `#8D0DE3` | `#CBA5FD` |
| `accent-soft` (`acs`) | Selected and active backgrounds, tonal buttons | `#ECDFFF` | `#2F0454` |
| `danger` / `danger-soft` | Destructive text / bg | `#A8202A` / `#FDE7E7` | `#F49E9E` / `#560F18` |
| `danger-fill` | Swipe-delete background (white text) | `#A8202A` | `#A8202A` |
| `success` / `success-soft` | Synced, connected | `#3E7A23` / `#EAF5DF` | `#B7DD9F` / `#1E3F10` |
| `warning` / `warning-soft` | Video file type | `#8C5A0E` / `#FBEFD0` | `#F0C26C` / `#4A3208` |
| `info` / `info-soft` | Docs, images | `#2940A8` / `#E6ECFE` | `#B6C3FB` / `#1C2B73` |
| `scrim` | Behind sheets, drawers, dialogs | `rgba(14,13,11,.45)` | `rgba(0,0,0,.6)` |
Rules:
- Text on `accent` is always `#FFFFFF`. Accent-colored text uses `accent-text`, never `accent` in dark mode.
- On-color text always uses the matching pair: `accent-text` on `accent-soft`, `danger` on `danger-soft`, and so on.
- Don't use gradients. Don't add new colors. Avatar and photo placeholder fills use the secondary palette: `#8DCDE2 #96B0FD #E5D8BD #EEEDB3 #DA9AC7 #A7D296 #E9A6A7 #85D1BD #CBA5FD`, always with `#0E0D0B` text.
### 1.2 File-type tiles (`KIND`)
| Kind | Background | Icon color | Lucide icon |
|---|---|---|---|
| folder | accent-soft | accent-text | `folder` |
| pdf | danger-soft | danger | `file-text` |
| doc / text | info-soft | info | `file-text` |
| sheet | success-soft | success | `sheet` |
| video | warning-soft | warning | `film` |
| image | info-soft | info | `image` |
| archive | surface-3 | fg-2 | `file-archive` |
Tile sizes: 40px/r12 for mobile rows, 30px/r9 for desktop rows, 44px/r12 for the share header, 32px/r10 for activity. The icon is half the tile size (20, 16, 24, 16).
### 1.3 Typography
Fonts (Google Fonts): **Schibsted Grotesk** for headings (weights 500/600) and **Instrument Sans** for UI (weights 400/500/600). Load them with `https://fonts.googleapis.com/css2?family=Instrument+Sans:wght@400;500;600&family=Schibsted+Grotesk:wght@500;600&display=swap`. Don't use italics, all-caps or wide tracking.
| Style | Font | Size / line-height | Tracking | Use |
|---|---|---|---|---|
| Large title | Schibsted 600 | 34 / 0.9 | −3% | iOS large title |
| Stat | Schibsted 600 | 32 (mobile) · 28 (desktop) / 0.9 | −3% | Offline GB, counters |
| Title | Schibsted 500 | 22 / 1 | −2% | Android top bar, desktop toolbar |
| Card title | Schibsted 500 | 17–20 / 1 | −2% | Settings cards, sheet sections, account name |
| Group heading | Schibsted 500 | 18 / 1 | −2% | Photos month |
| Body L | Instrument 400/500 | 16 / 1.2 | 0 | Mobile row title, settings label |
| Body | Instrument 400/500 | 14–15 / 1.3 | 0 | Desktop rows, activity text, banners |
| Label | Instrument 600 | 13 / 1 | 0 | Section labels (fg-2), chips, table headers (12, fg-3) |
| Meta | Instrument 400 | 13 (mobile) · 12–13 (desktop) / 1.2 | 0 | Size · date, subtitles (fg-3) |
| Nav label | Instrument 500 / 600 (active) | 12 Android · 10 iOS | 0 | Bottom bars |
| Button | Instrument 600 | 14–16 / 1 | 0 | All buttons |
| Mono | ui-monospace 400 | 13–14 | 0 | Share URLs, local paths |
### 1.4 Space, radius, elevation, motion
- **Spacing** uses a 4px base: 4, 8, 12, 14, 16, 20, 24, 32. Mobile screen gutter is **12px** (cards) and 16px (bars). Desktop content gutter is **24px**.
- **Radius:**
- pills (`999px`) for every button, chip, segmented control, toggle, search field and badge
- 20px for cards and grouped lists
- 18px for grid cards and stat cards
- 14px for inputs and inset panels
- 12px for desktop sidebar items and selected table rows
- 28px for the sheet top and drawer edge
- 24px for desktop dialogs
- screen corners are 48 on iOS and 36 on Android
- **Elevation:** product UI is flat, with no shadows on buttons or cards. The only shadow is `0 24px 48px rgba(30,0,47,.14)`, and only on floating desktop dialogs. Separate things by surface color, not by shadow.
- **Motion:** use `cubic-bezier(.7,0,.15,1)` with durations of 180, 280 and 480ms. Nothing bounces. Pressing a button scales it to 0.98.
### 1.5 Icons
Use **Lucide** (pinned to `lucide-static@0.460.0`) at a 1.8 stroke. Sizes are 14, 16, 18, 20, 24 and 32. Icons render in the current text color. Tab icons: Files `folder`, Photos `images`, Favorites `star`, Offline `hard-drive-download`, Shares `share-2`, Recent `clock`, Activity `activity`, Trash `trash-2`, Settings `settings`.
---
## 2. Components
**Button**
- Always a pill. Heights: 52 (sheet CTA), 44 (mobile card), 40 (inline in a field), 36 (desktop toolbar), 28–32 (compact).
- Variants:
- *Primary*: accent fill, white text.
- *Tonal*: accent-soft fill, accent-text.
- *Secondary*: surface-2 fill, fg-1.
- *Danger*: danger-soft fill, danger text.
- *Outline*: 1.5px surface-3 border.
- An icon goes on the left, with a 6–10px gap.
**FAB** (Android only)
- An extended pill: 56px tall, accent fill, labelled "Upload".
- 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
Files/Photos. Elsewhere it collapses to an icon-only 56px circle or
rounded square (see below) - still tappable, it always targets the Files
tab's current folder - animating the width/label change rather than the
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**
- A pill, 32–34px tall, 12px horizontal padding, Label 13–14.
- Idle: surface fill on mobile, or a 1px line border on desktop.
- Selected: accent-soft fill with accent-text.
- A trailing `chevron-down` means it opens a menu. A trailing `x` means it's an active filter.
**Segmented control**
- A pill track (surface or surface-2) with 3px padding.
- The active segment gets an accent-soft fill with accent-text at weight 600. Idle segments use fg-2 at weight 500.
- Use it for the List/Grid toggle (icons only, 36×28), Shares scope, Theme, and default view.
**Toggle**
- 48×28 on mobile, 44×26 on desktop, with a white knob 3px inset.
- On: accent track. Off: surface-3 track. It looks the same on every platform.
**Search field**
- A pill (radius 12 on the iOS inline field), 36–38px tall.
- Surface fill on bg, or surface-2 fill on surface. Leading search icon and placeholder in fg-3.
- Placement:
- iOS: below the large title.
- Android: an icon in the top bar that expands.
- Desktop: 260px wide in the toolbar.
**Grouped list (card)**
- A surface card with radius 20 and 1px gaps showing `line`, which draws the dividers.
- An optional group label above it: 13/600 in fg-2, inset 8px.
- Each group is 18px from the next.
**File row**
- Mobile: 64px tall.
- Left to right: tile 40, then a column with the name (16/500, one line, ellipsis) above a meta line (13, fg-3), then optional trailing icons, then the overflow menu (`ellipsis` on iOS, `ellipsis-vertical` on Android).
- The meta line is `[status icon][shared icon] size · modified`.
- A favorite shows an accent-text star before the overflow menu.
- Trailing icon by screen: star in Favorites, `rotate-ccw` in Trash.
- Desktop: a table row 52px tall.
- Columns are `minmax(0,1fr) 180px 160px 120px`: Name, two data columns, then status icons and the overflow menu.
- The header row is 36px, 12/600 in fg-3, with a sort arrow on the active column.
- A selected row gets an accent-soft fill with radius 12.
**Status icons (14px)**
- Synced: `circle-check` in success.
- Syncing: `refresh-cw` in accent-text.
- Error: `circle-alert` in danger, and the meta text says what to do ("Couldn't sync · Tap to retry").
- Shared: `users` in fg-3.
**Swipe action**
- The row slides and uncovers a 96px action block. Delete is white on danger-fill; Favorite is white on accent.
- The 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.
**Selection action bar** (Files, Photos, Favorites - anywhere with
multi-select)
- Replaces that screen's own sort/filter row in place while selecting -
never a second bar stacked underneath it, and never the shell's own top
bar/toolbar, which don't change for selection.
- A `surface` card, radius 20, in the screen's usual gutter (12 mobile, 24
desktop) - selection reads as a distinct mode, not a bare row of buttons
on `bg`.
- Left to right: a 36px `surface-2` close circle (`x`), then "N selected"
(17/500), then the bulk actions, trailing-anchored. Fixed width, never
scrolls: only the first few actions show inline (3 on mobile, 4 on
desktop - there can be up to 9: favorite, share, download, delete, copy,
move, rename, sync, details); the rest sit behind a trailing "More"
button that opens the same grouped-list sheet a file row's own overflow
menu (`ellipsis`/`ellipsis-vertical`) already uses. Which actions land in
the inline slots vs. "More" is the user's own priority order (Settings
→ Action bar, §4's 9-part list), not a fixed per-kind assignment.
- Mobile: inline actions are plain 20px accent-text icons, no fill; "More"
is the same 40px `NooOverflowButton` treatment (`ellipsis`/
`ellipsis-vertical`, fg-3) file rows use for their own overflow menu.
- Desktop: inline actions are labelled tonal pills (accent-soft/
accent-text), danger-soft/danger for the one labelled "Delete" - desktop
has the room for labels, and the app's other toolbar actions are already
labelled buttons rather than bare icons. "More" is a secondary pill
(surface-2/fg-1) with a trailing `chevron-down`, so it reads as "opens a
menu" rather than another bulk action.
**Grid card**
- Surface fill, radius 18. Mobile uses 2 columns with a 10px gap; desktop uses 5 columns with a 16px gap.
- The thumbnail area is 104–118px tall, filled with the file-type soft color and a 32px icon. Real thumbnails replace it when they exist.
- Below it: the name (14/500) with an overflow menu, then the meta (12).
**Photo grid**
- Square tiles with a 2px gap on mobile (3 columns, edge to edge) and a 4px gap on desktop (8 columns).
- Tiles are grouped by month, with the group heading on the left and the count on the right.
- Videos get a badge in the bottom-right: a `play` icon and the duration on `rgba(14,13,11,.7)`.
**Activity item**
- Avatar 36 (initials on a secondary-palette color), then a sentence: **Actor** verb **object** tail.
- Time in meta style. The file tile (32) goes on the right.
**Banner / summary card**
- A surface card with radius 20 (surface-2 on desktop) and padding of 14–18.
- The action sits on its own row below the description text, not squeezed
onto the same line - a long retention message/caption and the action
used to fight for the same row's width.
- Trash: an info icon, the retention text, then "Empty trash" as a
right-aligned danger text button on the row below.
- Offline: the stat and caption, then a "Sync now" tonal button on the row
below, then the 6px progress bar and the last-sync meta.
**Progress bar**
- 6px tall pill. Surface-3 track with an accent fill.
- Used for storage quota, cache use and the offline limit.
**Avatar**
- A circle with initials, weight 600, at 0.36× the avatar size.
- Sizes: 30–32 in bars, 36 in lists, 48 in the drawer, 52–56 on the account card.
- The current user gets an accent-soft fill with accent-text.
**Settings row**
- 52px tall, or 60px with a subtitle.
- Left to right: an optional 20px icon in fg-2, then the label (16) with an optional subtitle (13, fg-3), then a trailing control.
- Trailing control is one of: a toggle, a value in fg-3 with a chevron (desktop uses a 32px dropdown with radius 10), a segmented control, a pill button, or a status badge.
- A destructive row uses danger text.
- Exception: a segmented control with 3+ labelled segments (Theme's
System/Light/Dark) gets its own row below the label instead of a
trailing slot - three icon+label segments crammed in next to the label
left each one too cramped to read. Label row, then the full-width
segmented control on the row below, both on one continuous surface (no
divider between them) - same shape the sort sheet's Ascending/Descending
control and Photos' filter-sheet type control already use.
**Tab order row**
- A grip icon (`grip-vertical`, fg-3), the tab icon, the label, then a pin button: a 36px circle (30 on desktop) that is accent-soft with `pin` when pinned, or surface-2 with `pin-off` when not.
- Allow at most 5 pinned tabs.
**Sheet** (mobile)
- Surface fill, radius 28 at the top. A 36×5 grabber in surface-3. Padding 20, with 22px between sections.
- It sits on a scrim.
**Dialog** (desktop)
- 540px wide, radius 24, padding 24, sitting on a scrim.
- Header: file tile, title "Share "<name>"", and a close button (32px circle, surface-2).
**Drawer** (mobile)
- 316px wide, surface fill, radius 28 on the trailing edge, on a scrim.
- Top to bottom:
- account block with a chevron to switch accounts
- storage bar
- "More" label, then the unpinned tabs as 52px pill items (icon 24, label 16/500, trailing count)
- Settings
- an "Edit tabs" link at the bottom, in accent-text
---
## 3. Navigation & platform mapping
**Tabs** (the user sets the order): Files, Photos, Favorites, Offline, Shares, Recent, Activity, Trash. Settings is always separate.
### Mobile
Mobile always has **5 pinned tabs in the bottom bar**. The rest go in the drawer, which opens from the top-left menu icon.
| Element | iOS | Android |
|---|---|---|
| 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, 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 - 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` |
| 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" |
### Desktop
Desktop has a 256px sidebar in `bg` and a main pane in `surface`, separated by a 1px line.
**Sidebar**, top to bottom:
- account switcher card
- the 5 pinned tabs
- a divider
- the remaining tabs
- a spacer
- the storage meter
- Settings
Sidebar items are 38px tall with radius 12, an 18px icon and a 14/500 label. The active item gets an accent-soft fill with accent-text at weight 600. Counts sit on the right: Trash 12, Offline errors, new shares.
**Toolbar** is 64px tall:
- the title (22px Schibsted Grotesk), with a breadcrumb in Files
- a spacer
- the search field
- context actions: List/Grid toggle and Upload in Files, Upload in Photos, Empty trash in Trash, Sync now in Offline
| Element | macOS | Windows |
|---|---|---|
| Window | Radius 12 | Radius 8 |
| Controls | Traffic lights at the top of the sidebar | 40px title bar with the app mark and name; minimize, maximize and close buttons 46px wide |
| Navigation | Back and forward chevrons at the start of the toolbar | Not shown in the toolbar |
| Biometric | "Touch ID" | "Windows Hello" |
| Local path | `~/Noo/…` | `C:\Users\…\Noo` |
| Swipe setting | "Trackpad swipe on a file" | "Touch swipe on a file" |
---
## 4. Screen recipes
- **Files:**
- Toolbar: sort chip (`Name ↑`), type filter chip, List/Grid segmented toggle.
- Desktop adds Modified and Shared filter chips, an active-filter chip, and an item count.
- Content: a list, or a grid when the toggle is set.
- **Photos:** a sort chip and a filter chip (mirroring Files' own controls
row - no List/Grid toggle, the grid is the only view). The type filter
(All, Photos, Videos - Camera isn't backed by real data, see the view's
doc comment) lives inside the filter sheet as a full-width segmented
control, the same "label, then the control on its own row below" shape
Settings' Theme row uses, not its own row of chips. Every icon (All,
Photos, Videos in the filter sheet; All, Files, Folders in Files' own
filter sheet, same treatment) always shows; only the *selected* segment
also shows its label (`NooSegmentedControl`'s `labelOnlySelected`) - the
same List/Grid-toggle-style pill both screens' filter sheets use, not
Files' old checkmark list or Photos' own always-labelled track. Then the
photo grid grouped by month.
- **Favorites:** a file list with a star as the trailing icon.
- **Offline:** a summary card (or 4 stat cards on desktop), then a list whose rows show sync status icons and status meta.
- **Shares:** a segmented control (With you, By you, Links), then a list. The
meta reads "Owner · Permission". No per-row overflow menu on any of the
three scopes - tapping a row (file or folder) opens the full Share sheet
for that item (fetched fresh via `FilesController.fetchItemAtPath`, since
a share only carries enough metadata for its own row), the same sheet
Files/Photos open from their own Share action - that's already where
copying a link or removing access lives, so a second, row-local menu here
was redundant.
- **Recent:** a list grouped into Today, Yesterday and This week. The meta is the action plus the location.
- **Activity:** the feed grouped by day. Desktop limits it to 760px wide.
- **Trash:** a retention banner, then a list. Mobile rows get a restore icon; desktop rows get a tonal "Restore" pill.
- **Settings:** sections in this order:
1. Account card
2. Accounts (list, then "Add account")
3. Security
4. File sync
5. Files cache
6. Appearance
7. Tabs
8. Action bar - a reorder-only list (no pin/hide, unlike Tabs): the
priority order for the Selection action bar's bulk actions (favorite,
share, download, delete, copy, move, rename, sync, details) - the
first few (3 mobile / 4 desktop) land in the bar's fixed inline
slots, the rest sit behind "More". See §2 "Selection action bar".
9. Swipe on a file
Mobile is a two-level menu, the way native iOS/Android Settings apps
work: the account card stays pinned at the top of a single top-level
list, and every other section (2-9 above) becomes one tappable
`NooSettingsRow` - icon, title, chevron - in a `NooGroupedList` below it.
Tapping a row pushes a new screen (`NooTopBar`/`NooTopBarBack`) holding
just that section's own content full-screen, so no page is ever more
than one category deep and no section needs to be individually
collapsible any more. Two earlier designs were tried and dropped: a
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:
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 ▾").
3. **Share link:** a toggle, then the URL field in mono with a primary "Copy link" button, then option chips (permission, expiry, password, allow download).
4. **Send file directly:** an outline button, with a caption saying that link settings don't apply.
- **Details sheet:** header (file tile, name, size · folder, close) as in the
Share sheet above, then a segmented control switching Info/Versions/Activity
in place below it - there's no separate tab-strip component, so the
segmented control (already used for List/Grid and the Shares scope) is the
one that does this job too. Info is a flat label/value list (Size, Type,
Location, Modified, Created, ...). Versions and Activity reuse their own
row/feed treatment. Same on every platform - this sheet has no iOS/Android
split.
- **Media viewer** (the full-screen photo/video viewer): the stage is `bg`,
following the app's own theme rather than a fixed black - a black stage
in light mode read as jarringly out of place. A translucent, blurred top
bar (back, filename, meta) and bottom bar float over the media in a
`surface`-tinted panel (also theme-following, at a higher opacity than a
typical blur so it stays legible over arbitrary photo/video brightness
underneath - icons/text are `fg1`, matching); this is the one deliberate
exception to "no blur" in product UI, since it's chrome over photo/video
content, not over the app's own surfaces. A short edge gradient outside
each panel (toward `surface`, not a fixed black) extends that same
contrast a little past the panel's hard edge. Both bars' background
extends edge-to-edge behind the status bar/gesture area, with only their
content padded clear of it. Back is a plain arrow (`arrow-left`), not the iOS
chevron+label pushed-screen pattern - platform split still to do. The
bottom bar holds every action in one row (share, favorite, open
externally, download, delete, details) on every platform; don't add a
top-bar overflow menu for the same actions. Video adds a transport row
above the action row: time · seek bar · time, then play/pause and mute
centered below it. A photo can be pinch-zoomed; while zoomed in, the
gallery's own left/right swipe between items is disabled so panning
around the zoomed photo doesn't also swipe to the next one - it comes
back the moment the photo returns to its un-zoomed scale. A PDF preview
stays at its fit-width scale as its floor rather than letting the user
zoom out past it, since going below 1.0 scale removes any limit on how
far the page can be panned, including off-screen entirely.
- **Search:** pushed from the shell's search entry point (`menu`/`search`
icon in the top bar, or the inline field below an iOS large title -
DESIGN_SYSTEM §2 "Search field" placement). The destination screen is one
shared recipe for both platforms: back arrow + the pill `NooSearchField`
(not the inset-panel text-field shape - a search field is always the pill,
except the one named iOS-inline exception) taking over the top row,
autofocus, a clear button once there's a query. Below it: a file list
(mobile rows / desktop table, same as Files), or a centered icon + short
sentence-case message for the empty ("Search your files") and no-results
states.
- **Upload / Move / Copy destination picker:** a pushed screen (outside the
tab shell, so it carries its own complete top bar) titled "Upload to" /
"Move to" / "Copy to". Back arrow (Android) or "Cancel" text (iOS) leading,
no trailing action. The same `FilesControlsRow` (sort chip, filter chip,
List/Grid toggle right-anchored) Files itself uses, then a breadcrumb row,
then a folder-only list/grid (no files: this screen only browses folders)
using the standard file row/tile/grid-card at the folder kind. Bottom bar:
a centered meta line ("Moving 3 items") above a full-width 52px primary
CTA ("Upload here" / "Move here" / "Copy here") - a count, never a
filename, even for a single item.
---
## 5. Copy
- Use plain, active and short sentences, under 15 words. Say "you".
- Name the problem and the fix: "Couldn't sync · Tap to retry", not "An error occurred".
- Use sentence case everywhere and no all-caps. Use a middle dot ( · ) to separate meta values.
- Don't use emoji in the product UI.
@@ -0,0 +1,340 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="./support.js"></script>
</head>
<body>
<x-dc>
<helmet>
<script src="noo-kit.js"></script>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Instrument+Sans:wght@400;500;600&family=Schibsted+Grotesk:wght@500;600&display=swap">
<style>
body{margin:0}
a{color:#8D0DE3;text-decoration:none}
a:hover{color:#6E08B3}
</style>
</helmet>
<sc-if value="{{ ready }}" hint-placeholder-val="{{ false }}">
<div style="{{ root }}">
<sc-if value="{{ win }}">
<div style="height:40px;flex:none;display:flex;align-items:center;background:var(--bg);color:var(--fg2)">
<div style="display:flex;align-items:center;gap:10px;padding:0 16px;flex:1;font:400 12px/1 var(--ft)"><div style="width:16px;height:16px;border-radius:4px;background:var(--ac)"></div>Noo</div>
<div style="width:46px;height:40px;display:flex;align-items:center;justify-content:center"><span style="{{ i16.minus }}"></span></div>
<div style="width:46px;height:40px;display:flex;align-items:center;justify-content:center"><span style="{{ i14.square }}"></span></div>
<div style="width:46px;height:40px;display:flex;align-items:center;justify-content:center"><span style="{{ i16.x }}"></span></div>
</div>
</sc-if>
<div style="flex:1;min-height:0;display:flex">
<div style="width:256px;flex:none;background:var(--bg);display:flex;flex-direction:column;padding:12px 12px 16px;box-sizing:border-box;gap:2px">
<sc-if value="{{ mac }}">
<div style="height:32px;display:flex;align-items:center;gap:8px;padding:0 8px 8px"><div style="width:12px;height:12px;border-radius:999px;background:#FF5F57"></div><div style="width:12px;height:12px;border-radius:999px;background:#FEBC2E"></div><div style="width:12px;height:12px;border-radius:999px;background:#28C840"></div></div>
</sc-if>
<div style="display:flex;align-items:center;gap:10px;padding:8px 10px;border-radius:14px;background:var(--sf);margin-bottom:14px">
<div style="width:32px;height:32px;border-radius:999px;background:var(--acs);color:var(--act);display:flex;align-items:center;justify-content:center;font:600 12px/1 var(--ft);flex:none">MR</div>
<div style="flex:1;min-width:0;display:flex;flex-direction:column;gap:4px"><div style="font:600 14px/1 var(--ft)">Marina Rocha</div><div style="font:400 12px/1 var(--ft);color:var(--fg3)">cloud.rocha.family</div></div>
<span style="{{ chevG }}"></span>
</div>
<sc-for list="{{ navA }}" as="n" hint-placeholder-count="5">
<sc-if value="{{ n.on }}"><div style="height:38px;border-radius:12px;padding:0 12px;display:flex;align-items:center;gap:12px;background:var(--acs);color:var(--act);font:600 14px/1 var(--ft)"><span style="{{ n.ico }}"></span><div style="flex:1">{{ n.label }}</div><div style="font:500 12px/1 var(--ft)">{{ n.count }}</div></div></sc-if>
<sc-if value="{{ n.off }}"><div style="height:38px;border-radius:12px;padding:0 12px;display:flex;align-items:center;gap:12px;color:var(--fg1);font:500 14px/1 var(--ft)"><span style="{{ n.icoG }}"></span><div style="flex:1">{{ n.label }}</div><div style="font:500 12px/1 var(--ft);color:var(--fg3)">{{ n.count }}</div></div></sc-if>
</sc-for>
<div style="height:1px;background:var(--ln);margin:8px 12px"></div>
<sc-for list="{{ navB }}" as="n" hint-placeholder-count="3">
<sc-if value="{{ n.on }}"><div style="height:38px;border-radius:12px;padding:0 12px;display:flex;align-items:center;gap:12px;background:var(--acs);color:var(--act);font:600 14px/1 var(--ft)"><span style="{{ n.ico }}"></span><div style="flex:1">{{ n.label }}</div><div style="font:500 12px/1 var(--ft)">{{ n.count }}</div></div></sc-if>
<sc-if value="{{ n.off }}"><div style="height:38px;border-radius:12px;padding:0 12px;display:flex;align-items:center;gap:12px;color:var(--fg1);font:500 14px/1 var(--ft)"><span style="{{ n.icoG }}"></span><div style="flex:1">{{ n.label }}</div><div style="font:500 12px/1 var(--ft);color:var(--fg3)">{{ n.count }}</div></div></sc-if>
</sc-for>
<div style="flex:1"></div>
<div style="display:flex;flex-direction:column;gap:8px;padding:12px 12px 14px">
<div style="display:flex;justify-content:space-between;font:400 12px/1 var(--ft);color:var(--fg2)"><span style="font-weight:500;color:var(--fg1)">Storage</span><span>38.2 of 100 GB</span></div>
<div style="height:6px;border-radius:999px;background:var(--sf3);overflow:hidden"><div style="width:38%;height:100%;background:var(--ac)"></div></div>
</div>
<sc-if value="{{ setOn }}"><div style="height:38px;border-radius:12px;padding:0 12px;display:flex;align-items:center;gap:12px;background:var(--acs);color:var(--act);font:600 14px/1 var(--ft)"><span style="{{ i18.settings }}"></span>Settings</div></sc-if>
<sc-if value="{{ setOff }}"><div style="height:38px;border-radius:12px;padding:0 12px;display:flex;align-items:center;gap:12px;color:var(--fg1);font:500 14px/1 var(--ft)"><span style="{{ setIcoG }}"></span>Settings</div></sc-if>
</div>
<div style="flex:1;min-width:0;background:var(--sf);display:flex;flex-direction:column;position:relative;border-left:1px solid var(--ln)">
<div style="height:64px;flex:none;display:flex;align-items:center;gap:12px;padding:0 20px 0 24px;border-bottom:1px solid var(--ln)">
<sc-if value="{{ mac }}"><div style="display:flex;gap:4px;color:var(--fg3);margin-right:4px"><span style="{{ i20.chevL }}"></span><span style="{{ i20.chevR }}"></span></div></sc-if>
<div style="display:flex;align-items:center;gap:8px;font:500 22px/1 var(--fd);letter-spacing:-0.02em">{{ title }}<sc-if value="{{ s.files }}"><span style="{{ chevG }}"></span><span style="font:400 15px/1 var(--ft);letter-spacing:0;color:var(--fg2);white-space:nowrap">All files</span></sc-if></div>
<div style="flex:1"></div>
<sc-if value="{{ notSettings }}"><div style="width:260px;height:36px;border-radius:999px;background:var(--sf2);display:flex;align-items:center;gap:8px;padding:0 14px;color:var(--fg3);font:400 14px/1 var(--ft)"><span style="{{ i16.search }}"></span>Search in {{ title }}</div></sc-if>
<sc-if value="{{ s.files }}">
<div style="display:flex;padding:3px;border-radius:999px;background:var(--sf2);gap:2px"><div style="{{ segL }}"><span style="{{ i16.list }}"></span></div><div style="{{ segG }}"><span style="{{ i16.grid }}"></span></div></div>
<div style="height:36px;padding:0 16px 0 12px;border-radius:999px;background:var(--ac);color:#FFFFFF;display:flex;align-items:center;gap:8px;font:600 14px/1 var(--ft);white-space:nowrap"><span style="{{ i18.plus }}"></span>Upload</div>
</sc-if>
<sc-if value="{{ s.photos }}"><div style="height:36px;padding:0 16px 0 12px;border-radius:999px;background:var(--ac);color:#FFFFFF;display:flex;align-items:center;gap:8px;font:600 14px/1 var(--ft);white-space:nowrap"><span style="{{ i18.upload }}"></span>Upload</div></sc-if>
<sc-if value="{{ s.trash }}"><div style="height:36px;padding:0 16px;border-radius:999px;background:var(--dgs);color:var(--dg);display:flex;align-items:center;gap:8px;font:600 14px/1 var(--ft);white-space:nowrap"><span style="{{ i16.trash }}"></span>Empty trash</div></sc-if>
<sc-if value="{{ s.offline }}"><div style="height:36px;padding:0 16px;border-radius:999px;background:var(--acs);color:var(--act);display:flex;align-items:center;gap:8px;font:600 14px/1 var(--ft);white-space:nowrap"><span style="{{ i16.sync }}"></span>Sync now</div></sc-if>
</div>
<div style="flex:1;min-height:0;overflow:hidden;display:flex;flex-direction:column;padding:0 24px">
<sc-if value="{{ s.files }}">
<div style="flex:none;display:flex;align-items:center;gap:8px;padding:14px 0 10px">
<div style="height:32px;padding:0 12px;border-radius:999px;border:1px solid var(--ln);display:flex;align-items:center;gap:6px;font:500 13px/1 var(--ft);white-space:nowrap"><span style="{{ i14.filter }}"></span>All types<span style="{{ i14.chevD }}"></span></div>
<div style="height:32px;padding:0 12px;border-radius:999px;border:1px solid var(--ln);display:flex;align-items:center;gap:6px;font:500 13px/1 var(--ft);white-space:nowrap">Modified<span style="{{ i14.chevD }}"></span></div>
<div style="height:32px;padding:0 12px;border-radius:999px;border:1px solid var(--ln);display:flex;align-items:center;gap:6px;font:500 13px/1 var(--ft);white-space:nowrap">Shared<span style="{{ i14.chevD }}"></span></div>
<div style="height:32px;padding:0 12px;border-radius:999px;background:var(--acs);color:var(--act);display:flex;align-items:center;gap:6px;font:600 13px/1 var(--ft);white-space:nowrap"><span style="{{ i14.offline }}"></span>Available offline<span style="{{ i14.x }}"></span></div>
<div style="flex:1"></div>
<div style="font:400 13px/1 var(--ft);color:var(--fg3)">10 items · 440 MB</div>
</div>
</sc-if>
<sc-if value="{{ s.trash }}">
<div style="flex:none;margin:16px 0 4px;padding:12px 16px;border-radius:14px;background:var(--sf2);display:flex;gap:10px;align-items:center;color:var(--fg2);font:400 14px/1.2 var(--ft)"><span style="{{ i18.info }}"></span>Items here are deleted for good after 30 days.</div>
</sc-if>
<sc-if value="{{ s.offline }}">
<div style="flex:none;display:grid;grid-template-columns:minmax(0,2fr) repeat(3,minmax(0,1fr));gap:12px;padding:18px 0 8px">
<div style="padding:16px 18px;border-radius:18px;background:var(--sf2);display:flex;flex-direction:column;gap:10px"><div style="display:flex;justify-content:space-between;align-items:baseline"><div style="font:600 28px/0.9 var(--fd);letter-spacing:-0.03em">2.4 GB</div><div style="font:400 13px/1 var(--ft);color:var(--fg3)">of 5 GB limit</div></div><div style="height:6px;border-radius:999px;background:var(--sf3);overflow:hidden"><div style="width:48%;height:100%;background:var(--ac)"></div></div></div>
<div style="padding:16px 18px;border-radius:18px;background:var(--sf2);display:flex;flex-direction:column;gap:8px"><div style="font:600 28px/0.9 var(--fd);letter-spacing:-0.03em">142</div><div style="font:400 13px/1 var(--ft);color:var(--fg2)">Files up to date</div></div>
<div style="padding:16px 18px;border-radius:18px;background:var(--sf2);display:flex;flex-direction:column;gap:8px"><div style="font:600 28px/0.9 var(--fd);letter-spacing:-0.03em;color:var(--act)">6</div><div style="font:400 13px/1 var(--ft);color:var(--fg2)">Syncing now</div></div>
<div style="padding:16px 18px;border-radius:18px;background:var(--dgs);display:flex;flex-direction:column;gap:8px"><div style="font:600 28px/0.9 var(--fd);letter-spacing:-0.03em;color:var(--dg)">1</div><div style="font:400 13px/1 var(--ft);color:var(--dg)">Needs attention</div></div>
</div>
</sc-if>
<sc-if value="{{ s.shares }}">
<div style="flex:none;display:flex;padding:14px 0 8px"><div style="display:flex;padding:3px;border-radius:999px;background:var(--sf2);gap:2px">
<div style="height:32px;padding:0 16px;border-radius:999px;background:var(--acs);color:var(--act);display:flex;align-items:center;font:600 13px/1 var(--ft);white-space:nowrap">With you</div>
<div style="height:32px;padding:0 16px;display:flex;align-items:center;font:500 13px/1 var(--ft);color:var(--fg2)">By you</div>
<div style="height:32px;padding:0 16px;display:flex;align-items:center;font:500 13px/1 var(--ft);color:var(--fg2)">Links</div>
</div></div>
</sc-if>
<sc-if value="{{ showTable }}">
<sc-for list="{{ groups }}" as="g" hint-placeholder-count="1">
<sc-if value="{{ g.hasLabel }}"><div style="padding:18px 12px 6px;font:600 13px/1 var(--ft);color:var(--fg2)">{{ g.label }}</div></sc-if>
<div style="display:grid;grid-template-columns:minmax(0,1fr) 180px 160px 120px;align-items:center;height:36px;padding:0 12px;border-bottom:1px solid var(--ln);font:600 12px/1 var(--ft);color:var(--fg3)">
<div style="display:flex;align-items:center;gap:4px">{{ c0 }}<sc-if value="{{ sortable }}"><span style="{{ i14.up }}"></span></sc-if></div><div>{{ c1 }}</div><div>{{ c2 }}</div><div></div>
</div>
<sc-for list="{{ g.rows }}" as="f" hint-placeholder-count="8">
<div style="{{ f.rs }}">
<div style="display:flex;align-items:center;gap:12px;min-width:0"><div style="{{ f.tile }}"><span style="{{ f.ico }}"></span></div><div style="font:500 14px/1.2 var(--ft);white-space:nowrap;overflow:hidden;text-overflow:ellipsis">{{ f.name }}</div></div>
<div style="font:400 13px/1.2 var(--ft);color:var(--fg2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis">{{ f.a }}</div>
<div style="display:flex;align-items:center;gap:6px;font:400 13px/1.2 var(--ft);color:var(--fg2);white-space:nowrap;overflow:hidden"><sc-if value="{{ f.bInline }}"><span style="{{ f.badge }}"></span></sc-if>{{ f.b }}</div>
<div style="display:flex;align-items:center;justify-content:flex-end;gap:10px;color:var(--fg3)">
<sc-if value="{{ f.bIcon }}"><span style="{{ f.badge }}"></span></sc-if>
<sc-if value="{{ f.shared }}"><span style="{{ i16.users }}"></span></sc-if>
<sc-if value="{{ f.fav }}"><span style="{{ starSm }}"></span></sc-if>
<sc-if value="{{ restore }}"><div style="height:28px;padding:0 12px;border-radius:999px;background:var(--acs);color:var(--act);display:flex;align-items:center;gap:6px;font:600 12px/1 var(--ft);white-space:nowrap"><span style="{{ i14.restore }}"></span>Restore</div></sc-if>
<span style="{{ i18.moreH }}"></span>
</div>
</div>
</sc-for>
</sc-for>
</sc-if>
<sc-if value="{{ showGrid }}">
<div style="display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:16px;padding-top:6px">
<sc-for list="{{ gridRows }}" as="f" hint-placeholder-count="10">
<div style="border:1px solid var(--ln);border-radius:18px;overflow:hidden;display:flex;flex-direction:column;background:var(--sf)">
<div style="{{ f.thumb }}"><span style="{{ f.icoL }}"></span></div>
<div style="padding:10px 10px 12px 14px;display:flex;flex-direction:column;gap:5px">
<div style="display:flex;align-items:center;gap:4px"><div style="flex:1;min-width:0;font:500 14px/1.2 var(--ft);white-space:nowrap;overflow:hidden;text-overflow:ellipsis">{{ f.name }}</div><span style="{{ moreSm }}"></span></div>
<div style="display:flex;align-items:center;gap:5px;font:400 12px/1.2 var(--ft);color:var(--fg3)"><sc-if value="{{ f.hasBadge }}"><span style="{{ f.badge }}"></span></sc-if>{{ f.meta }}</div>
</div>
</div>
</sc-for>
</div>
</sc-if>
<sc-if value="{{ s.photos }}">
<div style="flex:none;display:flex;gap:8px;padding:14px 0 4px">
<div style="height:32px;padding:0 14px;border-radius:999px;background:var(--acs);color:var(--act);display:flex;align-items:center;font:600 13px/1 var(--ft);white-space:nowrap">All</div>
<div style="height:32px;padding:0 14px;border-radius:999px;border:1px solid var(--ln);display:flex;align-items:center;font:500 13px/1 var(--ft);white-space:nowrap">Photos</div>
<div style="height:32px;padding:0 14px;border-radius:999px;border:1px solid var(--ln);display:flex;align-items:center;font:500 13px/1 var(--ft);white-space:nowrap">Videos</div>
<div style="height:32px;padding:0 14px;border-radius:999px;border:1px solid var(--ln);display:flex;align-items:center;gap:6px;font:500 13px/1 var(--ft);white-space:nowrap"><span style="{{ i14.camera }}"></span>Camera uploads</div>
</div>
<sc-for list="{{ photoGroups }}" as="g" hint-placeholder-count="2">
<div style="display:flex;justify-content:space-between;align-items:baseline;padding:16px 0 10px"><div style="font:500 18px/1 var(--fd);letter-spacing:-0.02em">{{ g.label }}</div><div style="font:400 13px/1 var(--ft);color:var(--fg3)">{{ g.count }}</div></div>
<div style="display:grid;grid-template-columns:repeat(8,minmax(0,1fr));gap:4px">
<sc-for list="{{ g.tiles }}" as="t" hint-placeholder-count="16">
<div style="{{ t.s }}"><sc-if value="{{ t.vid }}"><div style="position:absolute;right:6px;bottom:6px;height:20px;padding:0 7px 0 5px;border-radius:999px;background:rgba(14,13,11,0.7);color:#FFFFFF;display:flex;align-items:center;gap:3px;font:600 11px/1 var(--ft)"><span style="{{ i14.play }}"></span>{{ t.dur }}</div></sc-if></div>
</sc-for>
</div>
</sc-for>
</sc-if>
<sc-if value="{{ s.activity }}">
<div style="max-width:760px;width:100%;display:flex;flex-direction:column">
<sc-for list="{{ act }}" as="g" hint-placeholder-count="2">
<div style="padding:20px 0 8px;font:600 13px/1 var(--ft);color:var(--fg2)">{{ g.label }}</div>
<sc-for list="{{ g.items }}" as="a" hint-placeholder-count="3">
<div style="display:flex;gap:14px;padding:14px 0;align-items:center;border-bottom:1px solid var(--ln)">
<div style="{{ a.av }}">{{ a.who }}</div>
<div style="flex:1;min-width:0;font:400 14px/1.3 var(--ft)"><span style="font-weight:600">{{ a.name }}</span> {{ a.verb }} <span style="font-weight:600">{{ a.obj }}</span> {{ a.tail }}</div>
<div style="{{ a.tile }}"><span style="{{ a.ico }}"></span></div>
<div style="width:48px;text-align:right;font:400 13px/1 var(--ft);color:var(--fg3)">{{ a.time }}</div>
</div>
</sc-for>
</sc-for>
</div>
</sc-if>
<sc-if value="{{ s.settings }}">
<div style="display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:16px;padding:20px 0;align-items:start">
<div style="display:flex;flex-direction:column;gap:16px">
<div style="padding:20px;border-radius:20px;border:1px solid var(--ln);display:flex;flex-direction:column;gap:16px">
<div style="display:flex;gap:14px;align-items:center"><div style="width:52px;height:52px;border-radius:999px;background:var(--acs);color:var(--act);display:flex;align-items:center;justify-content:center;font:600 18px/1 var(--ft);flex:none">MR</div><div style="flex:1;display:flex;flex-direction:column;gap:5px"><div style="font:500 20px/1 var(--fd);letter-spacing:-0.02em">Marina Rocha</div><div style="font:400 14px/1 var(--ft);color:var(--fg2)">marina@rocha.family</div></div><div style="height:24px;padding:0 10px;border-radius:999px;background:var(--oks);color:var(--ok);display:flex;align-items:center;font:600 12px/1 var(--ft);white-space:nowrap">Connected</div></div>
<div style="display:grid;grid-template-columns:auto minmax(0,1fr);gap:10px 16px;font:400 14px/1.2 var(--ft)"><span style="color:var(--fg3)">Server</span><span>cloud.rocha.family</span><span style="color:var(--fg3)">Version</span><span>Nextcloud 30.0.2</span><span style="color:var(--fg3)">Storage</span><div style="display:flex;align-items:center;gap:10px"><div style="flex:1;height:6px;border-radius:999px;background:var(--sf3);overflow:hidden"><div style="width:38%;height:100%;background:var(--ac)"></div></div><span style="color:var(--fg2)">38.2 of 100 GB</span></div></div>
<div style="display:flex;gap:8px"><div style="height:36px;padding:0 16px;border-radius:999px;background:var(--sf2);display:flex;align-items:center;gap:8px;font:600 14px/1 var(--ft);white-space:nowrap"><span style="{{ i14.external }}"></span>Manage on web</div><div style="height:36px;padding:0 16px;border-radius:999px;background:var(--dgs);color:var(--dg);display:flex;align-items:center;gap:8px;font:600 14px/1 var(--ft);white-space:nowrap"><span style="{{ i14.logout }}"></span>Log out</div></div>
</div>
<div style="padding:8px 20px 12px;border-radius:20px;border:1px solid var(--ln);display:flex;flex-direction:column">
<div style="padding:12px 0 8px;font:500 17px/1 var(--fd);letter-spacing:-0.02em">Accounts</div>
<div style="display:flex;align-items:center;gap:12px;height:56px;border-bottom:1px solid var(--ln)"><div style="width:32px;height:32px;border-radius:999px;background:var(--acs);color:var(--act);display:flex;align-items:center;justify-content:center;font:600 12px/1 var(--ft)">MR</div><div style="flex:1;display:flex;flex-direction:column;gap:4px"><div style="font:500 14px/1 var(--ft)">Marina Rocha</div><div style="font:400 12px/1 var(--ft);color:var(--fg3)">cloud.rocha.family</div></div><span style="{{ checkA }}"></span></div>
<div style="display:flex;align-items:center;gap:12px;height:56px;border-bottom:1px solid var(--ln)"><div style="width:32px;height:32px;border-radius:999px;background:#85D1BD;color:#0E0D0B;display:flex;align-items:center;justify-content:center;font:600 12px/1 var(--ft)">MR</div><div style="flex:1;display:flex;flex-direction:column;gap:4px"><div style="font:500 14px/1 var(--ft)">Marina · Work</div><div style="font:400 12px/1 var(--ft);color:var(--fg3)">files.acme-studio.com</div></div><div style="font:600 13px/1 var(--ft);color:var(--act)">Switch</div></div>
<div style="display:flex;align-items:center;gap:12px;height:48px;color:var(--act);font:600 14px/1 var(--ft)"><div style="width:32px;display:flex;justify-content:center"><span style="{{ i18.plus }}"></span></div>Add account</div>
</div>
</div>
<div style="display:flex;flex-direction:column;gap:16px">
<div style="padding:8px 20px 8px;border-radius:20px;border:1px solid var(--ln);display:flex;flex-direction:column">
<div style="padding:12px 0 8px;font:500 17px/1 var(--fd);letter-spacing:-0.02em">Security</div>
<div style="display:flex;align-items:center;gap:14px;min-height:60px;border-bottom:1px solid var(--ln)"><span style="{{ bioIco }}"></span><div style="flex:1;display:flex;flex-direction:column;gap:4px"><div style="font:400 14px/1.1 var(--ft)">{{ bioLabel }}</div><div style="font:400 12px/1.2 var(--ft);color:var(--fg3)">Needed every time you open Noo</div></div><div style="width:44px;height:26px;border-radius:999px;background:var(--ac);padding:3px;box-sizing:border-box;display:flex;justify-content:flex-end;flex:none"><div style="width:20px;height:20px;border-radius:999px;background:#FFFFFF"></div></div></div>
<div style="display:flex;align-items:center;gap:14px;height:52px"><span style="{{ i18.timer }}"></span><div style="flex:1;font:400 14px/1 var(--ft)">Lock after</div><div style="height:32px;padding:0 10px 0 12px;border-radius:10px;border:1px solid var(--ln);display:flex;align-items:center;gap:6px;font:500 13px/1 var(--ft)">5 minutes idle<span style="{{ i14.chevD }}"></span></div></div>
</div>
<div style="padding:8px 20px 8px;border-radius:20px;border:1px solid var(--ln);display:flex;flex-direction:column">
<div style="padding:12px 0 8px;font:500 17px/1 var(--fd);letter-spacing:-0.02em">File sync</div>
<div style="display:flex;align-items:center;gap:14px;min-height:60px;border-bottom:1px solid var(--ln)"><span style="{{ i18.folder }}"></span><div style="flex:1;display:flex;flex-direction:column;gap:4px"><div style="font:400 14px/1.1 var(--ft)">Local sync folder</div><div style="font:400 12px/1.2 var(--ft);color:var(--fg3);font-family:ui-monospace,Menlo,monospace">{{ syncPath }}</div></div><div style="height:32px;padding:0 12px;border-radius:999px;background:var(--sf2);display:flex;align-items:center;font:600 13px/1 var(--ft);white-space:nowrap">Change</div></div>
<div style="display:flex;align-items:center;gap:14px;height:52px;border-bottom:1px solid var(--ln)"><span style="{{ i18.sync }}"></span><div style="flex:1;font:400 14px/1 var(--ft)">Sync automatically</div><div style="width:44px;height:26px;border-radius:999px;background:var(--ac);padding:3px;box-sizing:border-box;display:flex;justify-content:flex-end;flex:none"><div style="width:20px;height:20px;border-radius:999px;background:#FFFFFF"></div></div></div>
<div style="display:flex;align-items:center;gap:14px;height:52px;border-bottom:1px solid var(--ln)"><span style="{{ i18.download }}"></span><div style="flex:1;font:400 14px/1 var(--ft)">Download files on demand</div><div style="width:44px;height:26px;border-radius:999px;background:var(--ac);padding:3px;box-sizing:border-box;display:flex;justify-content:flex-end;flex:none"><div style="width:20px;height:20px;border-radius:999px;background:#FFFFFF"></div></div></div>
<div style="display:flex;align-items:center;gap:14px;height:52px"><span style="{{ i18.wifi }}"></span><div style="flex:1;font:400 14px/1 var(--ft)">Pause on metered networks</div><div style="width:44px;height:26px;border-radius:999px;background:var(--sf3);padding:3px;box-sizing:border-box;display:flex;justify-content:flex-start;flex:none"><div style="width:20px;height:20px;border-radius:999px;background:#FFFFFF"></div></div></div>
</div>
</div>
</div>
</sc-if>
<sc-if value="{{ s.settings2 }}">
<div style="display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:16px;padding:20px 0;align-items:start">
<div style="display:flex;flex-direction:column;gap:16px">
<div style="padding:8px 20px 8px;border-radius:20px;border:1px solid var(--ln);display:flex;flex-direction:column">
<div style="padding:12px 0 8px;font:500 17px/1 var(--fd);letter-spacing:-0.02em">Files cache</div>
<div style="display:flex;flex-direction:column;gap:10px;padding:10px 0 14px;border-bottom:1px solid var(--ln)"><div style="display:flex;justify-content:space-between;font:400 14px/1 var(--ft)"><span>Cache in use</span><span style="color:var(--fg2)">1.2 of 2 GB</span></div><div style="height:6px;border-radius:999px;background:var(--sf3);overflow:hidden"><div style="width:60%;height:100%;background:var(--ac)"></div></div></div>
<div style="display:flex;align-items:center;gap:14px;height:52px;border-bottom:1px solid var(--ln)"><span style="{{ i18.database }}"></span><div style="flex:1;font:400 14px/1 var(--ft)">Cache limit</div><div style="height:32px;padding:0 10px 0 12px;border-radius:10px;border:1px solid var(--ln);display:flex;align-items:center;gap:6px;font:500 13px/1 var(--ft)">2 GB<span style="{{ i14.chevD }}"></span></div></div>
<div style="display:flex;align-items:center;gap:14px;height:52px"><span style="{{ i18.trash }}"></span><div style="flex:1;font:400 14px/1 var(--ft)">Clear cached files</div><div style="height:32px;padding:0 14px;border-radius:999px;background:var(--dgs);color:var(--dg);display:flex;align-items:center;font:600 13px/1 var(--ft);white-space:nowrap">Clear cache</div></div>
</div>
<div style="padding:8px 20px 8px;border-radius:20px;border:1px solid var(--ln);display:flex;flex-direction:column">
<div style="padding:12px 0 8px;font:500 17px/1 var(--fd);letter-spacing:-0.02em">Appearance</div>
<div style="display:flex;align-items:center;gap:14px;height:56px;border-bottom:1px solid var(--ln)"><span style="{{ i18.palette }}"></span><div style="flex:1;font:400 14px/1 var(--ft)">Theme</div><div style="display:flex;padding:3px;border-radius:999px;background:var(--sf2);gap:2px"><div style="height:28px;padding:0 12px;border-radius:999px;background:var(--acs);color:var(--act);display:flex;align-items:center;font:600 13px/1 var(--ft);white-space:nowrap">System</div><div style="height:28px;padding:0 12px;display:flex;align-items:center;font:500 13px/1 var(--ft);color:var(--fg2)">Light</div><div style="height:28px;padding:0 12px;display:flex;align-items:center;font:500 13px/1 var(--ft);color:var(--fg2)">Dark</div></div></div>
<div style="display:flex;align-items:center;gap:14px;height:56px;border-bottom:1px solid var(--ln)"><span style="{{ i18.grid }}"></span><div style="flex:1;font:400 14px/1 var(--ft)">Default file view</div><div style="display:flex;padding:3px;border-radius:999px;background:var(--sf2);gap:2px"><div style="height:28px;padding:0 12px;border-radius:999px;background:var(--acs);color:var(--act);display:flex;align-items:center;gap:6px;font:600 13px/1 var(--ft);white-space:nowrap"><span style="{{ i14.list }}"></span>List</div><div style="height:28px;padding:0 12px;display:flex;align-items:center;gap:6px;font:500 13px/1 var(--ft);color:var(--fg2)"><span style="{{ i14.grid }}"></span>Grid</div></div></div>
<div style="display:flex;align-items:center;gap:14px;height:52px"><span style="{{ i18.hidden }}"></span><div style="flex:1;font:400 14px/1 var(--ft)">Show hidden files</div><div style="width:44px;height:26px;border-radius:999px;background:var(--sf3);padding:3px;box-sizing:border-box;display:flex;justify-content:flex-start;flex:none"><div style="width:20px;height:20px;border-radius:999px;background:#FFFFFF"></div></div></div>
</div>
</div>
<div style="display:flex;flex-direction:column;gap:16px">
<div style="padding:8px 20px 12px;border-radius:20px;border:1px solid var(--ln);display:flex;flex-direction:column">
<div style="padding:12px 0 4px;display:flex;justify-content:space-between;align-items:baseline"><div style="font:500 17px/1 var(--fd);letter-spacing:-0.02em">Sidebar tabs</div><div style="font:400 12px/1 var(--ft);color:var(--fg3)">Drag to reorder</div></div>
<div style="padding:4px 0 8px;font:400 12px/1.3 var(--ft);color:var(--fg3)">Pinned tabs show at the top of the sidebar, and in the bottom bar on mobile.</div>
<sc-for list="{{ tabRows }}" as="t" hint-placeholder-count="8">
<div style="display:flex;align-items:center;gap:12px;height:44px;border-bottom:1px solid var(--ln)"><span style="{{ gripS }}"></span><span style="{{ t.ico }}"></span><div style="flex:1;font:400 14px/1 var(--ft)">{{ t.label }}</div><div style="{{ t.pinS }}"><span style="{{ t.pinI }}"></span></div></div>
</sc-for>
</div>
<div style="padding:8px 20px 8px;border-radius:20px;border:1px solid var(--ln);display:flex;flex-direction:column">
<div style="padding:12px 0 8px;font:500 17px/1 var(--fd);letter-spacing:-0.02em">{{ swipeTitle }}</div>
<div style="display:flex;align-items:center;gap:14px;height:52px;border-bottom:1px solid var(--ln)"><span style="{{ i18.swipe }}"></span><div style="flex:1;font:400 14px/1 var(--ft)">Swipe left</div><div style="height:32px;padding:0 10px 0 12px;border-radius:10px;border:1px solid var(--ln);display:flex;align-items:center;gap:6px;font:500 13px/1 var(--ft);color:var(--dg)"><span style="{{ i14.trash }}"></span>Delete<span style="{{ i14.chevD }}"></span></div></div>
<div style="display:flex;align-items:center;gap:14px;height:52px"><span style="{{ i18.swipe }}"></span><div style="flex:1;font:400 14px/1 var(--ft)">Swipe right</div><div style="height:32px;padding:0 10px 0 12px;border-radius:10px;border:1px solid var(--ln);display:flex;align-items:center;gap:6px;font:500 13px/1 var(--ft);color:var(--act)"><span style="{{ i14.star }}"></span>Favorite<span style="{{ i14.chevD }}"></span></div></div>
</div>
</div>
</div>
</sc-if>
</div>
<sc-if value="{{ share }}">
<div style="position:absolute;inset:0;background:var(--scrim);display:flex;align-items:center;justify-content:center">
<div style="width:540px;background:var(--sf);border-radius:24px;padding:24px;box-sizing:border-box;display:flex;flex-direction:column;gap:20px;box-shadow:0 24px 48px rgba(30,0,47,0.14)">
<div style="display:flex;gap:12px;align-items:center">
<div style="{{ shareTile }}"><span style="{{ i20.doc }}"></span></div>
<div style="flex:1;min-width:0;display:flex;flex-direction:column;gap:5px"><div style="font:500 19px/1 var(--fd);letter-spacing:-0.02em">Share “Q3 Planning.pdf”</div><div style="font:400 13px/1 var(--ft);color:var(--fg3)">2.4 MB · Documents</div></div>
<div style="width:32px;height:32px;border-radius:999px;background:var(--sf2);display:flex;align-items:center;justify-content:center"><span style="{{ i16.x }}"></span></div>
</div>
<div style="display:flex;flex-direction:column;gap:12px">
<div style="font:500 16px/1 var(--fd);letter-spacing:-0.02em">Share with people</div>
<div style="display:flex;gap:8px"><div style="flex:1;height:42px;border-radius:12px;background:var(--sf2);display:flex;align-items:center;gap:10px;padding:0 12px;color:var(--fg3);font:400 14px/1 var(--ft)"><span style="{{ i18.userPlus }}"></span>Name, email or group</div><div style="height:42px;padding:0 10px 0 14px;border-radius:12px;border:1px solid var(--ln);display:flex;align-items:center;gap:6px;font:500 13px/1 var(--ft)">Can edit<span style="{{ i14.chevD }}"></span></div></div>
<div style="display:flex;align-items:center;gap:12px;height:44px"><div style="{{ avYou }}">MR</div><div style="flex:1;display:flex;flex-direction:column;gap:4px"><div style="font:500 14px/1 var(--ft)">You</div><div style="font:400 12px/1 var(--ft);color:var(--fg3)">marina@rocha.family</div></div><div style="font:500 13px/1 var(--ft);color:var(--fg3)">Owner</div></div>
<div style="display:flex;align-items:center;gap:12px;height:44px"><div style="{{ avAna }}">AS</div><div style="flex:1;display:flex;flex-direction:column;gap:4px"><div style="font:500 14px/1 var(--ft)">Ana Souza</div><div style="font:400 12px/1 var(--ft);color:var(--fg3)">ana@rocha.family</div></div><div style="height:28px;padding:0 8px 0 12px;border-radius:999px;border:1px solid var(--ln);display:flex;align-items:center;gap:4px;font:500 12px/1 var(--ft);white-space:nowrap">Can edit<span style="{{ i14.chevD }}"></span></div></div>
<div style="display:flex;align-items:center;gap:12px;height:44px"><div style="width:32px;height:32px;border-radius:999px;background:var(--sf2);color:var(--fg2);display:flex;align-items:center;justify-content:center;flex:none"><span style="{{ i16.users }}"></span></div><div style="flex:1;display:flex;flex-direction:column;gap:4px"><div style="font:500 14px/1 var(--ft)">Design team</div><div style="font:400 12px/1 var(--ft);color:var(--fg3)">Group · 6 people</div></div><div style="height:28px;padding:0 8px 0 12px;border-radius:999px;border:1px solid var(--ln);display:flex;align-items:center;gap:4px;font:500 12px/1 var(--ft);white-space:nowrap">Can view<span style="{{ i14.chevD }}"></span></div></div>
</div>
<div style="height:1px;background:var(--ln)"></div>
<div style="display:flex;flex-direction:column;gap:12px">
<div style="display:flex;justify-content:space-between;align-items:center"><div style="display:flex;flex-direction:column;gap:5px"><div style="font:500 16px/1 var(--fd);letter-spacing:-0.02em">Share link</div><div style="font:400 12px/1 var(--ft);color:var(--fg3)">Anyone with the link can view</div></div><div style="width:44px;height:26px;border-radius:999px;background:var(--ac);padding:3px;box-sizing:border-box;display:flex;justify-content:flex-end;flex:none"><div style="width:20px;height:20px;border-radius:999px;background:#FFFFFF"></div></div></div>
<div style="border-radius:14px;background:var(--sf2);padding:5px 5px 5px 14px;display:flex;gap:10px;align-items:center;color:var(--fg2)"><span style="{{ i16.link }}"></span><div style="flex:1;min-width:0;font:400 13px/1 ui-monospace,Menlo,monospace;color:var(--fg1);white-space:nowrap;overflow:hidden;text-overflow:ellipsis">https://cloud.rocha.family/s/k7Pq2xW</div><div style="height:34px;padding:0 14px;border-radius:999px;background:var(--ac);color:#FFFFFF;display:flex;align-items:center;gap:6px;font:600 13px/1 var(--ft);white-space:nowrap"><span style="{{ i14.copy }}"></span>Copy link</div></div>
<div style="display:flex;gap:8px;flex-wrap:wrap">
<div style="height:30px;padding:0 12px;border-radius:999px;border:1px solid var(--ln);display:flex;align-items:center;gap:6px;font:500 12px/1 var(--ft);white-space:nowrap"><span style="{{ i14.eye }}"></span>Can view<span style="{{ i14.chevD }}"></span></div>
<div style="height:30px;padding:0 12px;border-radius:999px;background:var(--acs);color:var(--act);display:flex;align-items:center;gap:6px;font:600 12px/1 var(--ft);white-space:nowrap"><span style="{{ i14.calendar }}"></span>Expires Oct 24</div>
<div style="height:30px;padding:0 12px;border-radius:999px;border:1px solid var(--ln);display:flex;align-items:center;gap:6px;font:500 12px/1 var(--ft);white-space:nowrap"><span style="{{ i14.lock }}"></span>Add password</div>
<div style="height:30px;padding:0 12px;border-radius:999px;border:1px solid var(--ln);display:flex;align-items:center;gap:6px;font:500 12px/1 var(--ft);white-space:nowrap"><span style="{{ i14.download }}"></span>Allow download</div>
</div>
</div>
<div style="height:1px;background:var(--ln)"></div>
<div style="display:flex;align-items:center;gap:12px">
<div style="flex:1;font:400 12px/1.3 var(--ft);color:var(--fg3)">Sends a copy through another app. Link settings don’t apply.</div>
<div style="height:38px;padding:0 16px;border-radius:999px;border:1.5px solid var(--sf3);display:flex;align-items:center;gap:8px;font:600 14px/1 var(--ft);white-space:nowrap"><span style="{{ i16.send }}"></span>Send file directly</div>
</div>
</div>
</div>
</sc-if>
</div>
</div>
</div>
</sc-if>
</x-dc>
<script type="text/x-dc" data-dc-script data-props="{&quot;$preview&quot;:{&quot;width&quot;:1280,&quot;height&quot;:800},&quot;os&quot;:{&quot;editor&quot;:&quot;enum&quot;,&quot;options&quot;:[&quot;mac&quot;,&quot;windows&quot;],&quot;default&quot;:&quot;mac&quot;,&quot;tsType&quot;:&quot;'mac'|'windows'&quot;},&quot;screen&quot;:{&quot;editor&quot;:&quot;enum&quot;,&quot;options&quot;:[&quot;files&quot;,&quot;filesGrid&quot;,&quot;photos&quot;,&quot;favorites&quot;,&quot;offline&quot;,&quot;shares&quot;,&quot;recent&quot;,&quot;activity&quot;,&quot;trash&quot;,&quot;share&quot;,&quot;settings&quot;,&quot;settings2&quot;],&quot;default&quot;:&quot;files&quot;,&quot;tsType&quot;:&quot;string&quot;},&quot;theme&quot;:{&quot;editor&quot;:&quot;enum&quot;,&quot;options&quot;:[&quot;light&quot;,&quot;dark&quot;],&quot;default&quot;:&quot;light&quot;,&quot;tsType&quot;:&quot;'light'|'dark'&quot;}}">
class Component extends DCLogic {
componentDidMount() {
if (!window.NK) this._t = setInterval(() => { if (window.NK) { clearInterval(this._t); this.forceUpdate(); } }, 40);
}
componentWillUnmount() { clearInterval(this._t); }
renderVals() {
const K = window.NK;
if (!K) return { ready: false };
const { I, ic, D, row, TH, tile, tiles, avatar } = K;
const mac = this.props.os !== 'windows', win = !mac;
const sc = this.props.screen || 'files';
const theme = this.props.theme === 'dark' ? 'dark' : 'light';
const share = sc === 'share', grid = sc === 'filesGrid';
const base = (share || grid) ? 'files' : sc;
const settings = base.startsWith('settings');
const s = {}; ['files','photos','activity','trash','favorites','offline','shares','recent','settings','settings2'].forEach(k => s[k] = base === k);
const RS = sel => ({ display: 'grid', gridTemplateColumns: 'minmax(0,1fr) 180px 160px 120px', alignItems: 'center', height: 52, padding: '0 12px', borderBottom: '1px solid var(--ln)', background: sel ? 'var(--acs)' : 'transparent', borderRadius: sel ? 12 : 0 });
const mk = (list, fn, o = {}) => list.map((f, i) => { const r = row(f, { tile: 30, r: 9, ico: 16, ...o }); Object.assign(r, fn(f)); r.rs = RS(o.sel === i); return r; });
let groups = [], cols = ['Name', 'Size', 'Modified'];
const split = f => { const p = (f.meta || '').split(' · '); return { a: p[0], b: p[1] || '' }; };
if (base === 'files') groups = [{ rows: mk(D.files, f => ({ a: f.size, b: f.mod, bIcon: !!f.off }), { noSwipe: 1, sel: 3 }) }];
if (base === 'favorites') groups = [{ rows: mk(D.favorites, f => ({ a: f.size, b: f.mod, bIcon: !!f.off, fav: true })) }];
if (base === 'offline') { cols = ['Name', 'Size', 'Status']; groups = [{ rows: mk(D.offline, f => ({ a: f.size, b: split(f).a, bInline: true })) }]; }
if (base === 'shares') { cols = ['Name', 'Shared by', 'Permission']; groups = [{ rows: mk(D.shares, f => ({ a: f.by, b: f.perm })) }]; }
if (base === 'recent') { cols = ['Name', 'Activity', 'Location']; groups = D.recent.map(g => ({ label: g.label, rows: mk(g.rows, f => ({ ...split(f), bIcon: !!f.off })) })); }
if (base === 'trash') { cols = ['Name', 'Original location', 'Deleted']; groups = [{ rows: mk(D.trash, f => ({ a: f.from, b: f.del })) }]; }
groups = groups.map(g => ({ ...g, hasLabel: !!g.label }));
const title = settings ? 'Settings' : D.titles[base];
const seg = on => ({ width: 34, height: 30, borderRadius: 999, display: 'flex', alignItems: 'center', justifyContent: 'center', background: on ? 'var(--acs)' : 'transparent', color: on ? 'var(--act)' : 'var(--fg3)' });
const counts = { trash: '12', offline: '1', shares: '3' };
const navItem = ([label, ico, key]) => ({ label, ico: I[18][ico], icoG: ic(ico, 18, 'var(--fg2)'), on: key === base, off: key !== base, count: counts[key] || '' });
return {
ready: true, mac, win, s, title, groups, c0: cols[0], c1: cols[1], c2: cols[2], share, notSettings: !settings,
sortable: base === 'files' || base === 'favorites',
restore: base === 'trash',
root: { ...TH[theme], width: 1280, height: 800, borderRadius: mac ? 12 : 8, overflow: 'hidden', position: 'relative', display: 'flex', flexDirection: 'column', background: 'var(--bg)', color: 'var(--fg1)', fontFamily: 'var(--ft)', border: '1px solid var(--ln)', boxSizing: 'border-box' },
i14: I[14], i16: I[16], i18: I[18], i20: I[20], i24: I[24],
showTable: groups.length > 0 && !grid, showGrid: grid,
gridRows: D.files.map(f => row(f, { noSwipe: 1, thumbH: 118 })).map(r => ({ ...r, meta: `${r.size} · ${r.mod}` })),
segL: seg(!grid), segG: seg(grid),
navA: D.pinned.map(navItem), navB: D.menu.map(navItem),
setOn: settings, setOff: !settings, setIcoG: ic('settings', 18, 'var(--fg2)'),
starSm: ic('star', 16, 'var(--act)'), moreSm: ic('moreH', 16, 'var(--fg3)'),
checkA: ic('check', 18, 'var(--act)'), chevG: ic('chevR', 16, 'var(--fg3)'), gripS: ic('grip', 16, 'var(--fg3)'),
photoGroups: [{ label: 'September 2026', count: '38 items', tiles: tiles(24, 0, [2, 9, 13, 20]) }, { label: 'August 2026', count: '112 items', tiles: tiles(16, 3, [4, 11]) }],
act: D.activity.map(g => ({ label: g.label, items: g.items.map(a => ({ ...a, av: avatar(a.col, 36), tile: tile(a.kind, 30, 9), ico: I[16][K.KIND[a.kind][2]] })) })),
tabRows: [...D.pinned.map(([label, ico]) => ({ label, ico: I[18][ico], p: 1 })), ...D.menu.map(([label, ico]) => ({ label, ico: I[18][ico], p: 0 }))].map(t => ({ ...t,
pinS: { width: 30, height: 30, borderRadius: 999, display: 'flex', alignItems: 'center', justifyContent: 'center', background: t.p ? 'var(--acs)' : 'var(--sf2)', color: t.p ? 'var(--act)' : 'var(--fg3)' },
pinI: I[14][t.p ? 'pin' : 'pinOff'] })),
bioLabel: mac ? 'Lock with Touch ID' : 'Lock with Windows Hello', bioIco: I[18][mac ? 'fingerprint' : 'face'],
syncPath: mac ? '~/Noo/rocha.family' : 'C:\\Users\\Marina\\Noo',
swipeTitle: mac ? 'Trackpad swipe on a file' : 'Touch swipe on a file',
shareTile: tile('pdf', 40, 12), avYou: avatar('#CBA5FD', 32), avAna: avatar('#DA9AC7', 32)
};
}
}
</script>
</body>
</html>
@@ -0,0 +1,433 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="./support.js"></script>
</head>
<body>
<x-dc>
<helmet>
<script src="noo-kit.js"></script>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Instrument+Sans:wght@400;500;600&family=Schibsted+Grotesk:wght@500;600&display=swap">
<style>
body{margin:0}
a{color:#8D0DE3;text-decoration:none}
a:hover{color:#6E08B3}
</style>
</helmet>
<sc-if value="{{ ready }}" hint-placeholder-val="{{ false }}">
<div style="{{ root }}">
<sc-if value="{{ ios }}">
<div style="height:54px;flex:none;display:flex;align-items:center;justify-content:space-between;padding:0 30px 0 46px;position:relative;font:600 17px/1 var(--ft);color:var(--fg1)">
<span>9:41</span>
<div style="position:absolute;left:50%;top:11px;width:124px;height:36px;margin-left:-62px;border-radius:20px;background:#000000"></div>
<div style="display:flex;gap:6px;align-items:center"><span style="{{ i16.signal }}"></span><span style="{{ i16.wifi }}"></span><span style="{{ i24.battery }}"></span></div>
</div>
</sc-if>
<sc-if value="{{ android }}">
<div style="height:40px;flex:none;display:flex;align-items:center;justify-content:space-between;padding:0 24px;font:500 14px/1 var(--ft);color:var(--fg1)">
<span>9:41</span>
<div style="display:flex;gap:6px;align-items:center"><span style="{{ i16.wifi }}"></span><span style="{{ i16.signal }}"></span><span style="{{ i16.battery }}"></span></div>
</div>
</sc-if>
<sc-if value="{{ aBar }}">
<div style="height:64px;flex:none;display:flex;align-items:center;gap:4px;padding:0 8px 0 4px;color:var(--fg1)">
<div style="width:48px;height:48px;display:flex;align-items:center;justify-content:center"><span style="{{ i24.menu }}"></span></div>
<div style="flex:1;font:500 22px/1 var(--fd);letter-spacing:-0.02em">{{ title }}</div>
<div style="width:48px;height:48px;display:flex;align-items:center;justify-content:center"><span style="{{ i24.search }}"></span></div>
<div style="width:48px;height:48px;display:flex;align-items:center;justify-content:center"><div style="width:32px;height:32px;border-radius:999px;background:var(--acs);color:var(--act);display:flex;align-items:center;justify-content:center;font:600 13px/1 var(--ft)">MR</div></div>
</div>
</sc-if>
<sc-if value="{{ aSet }}">
<div style="height:64px;flex:none;display:flex;align-items:center;gap:4px;padding:0 8px 0 4px;color:var(--fg1)">
<div style="width:48px;height:48px;display:flex;align-items:center;justify-content:center"><span style="{{ i24.back }}"></span></div>
<div style="flex:1;font:500 22px/1 var(--fd);letter-spacing:-0.02em">Settings</div>
</div>
</sc-if>
<sc-if value="{{ iBar }}">
<div style="height:44px;flex:none;display:flex;align-items:center;justify-content:space-between;padding:0 16px;color:var(--act)">
<span style="{{ i24.menu }}"></span>
<div style="display:flex;gap:18px;align-items:center"><span style="{{ i24.plus }}"></span><div style="width:30px;height:30px;border-radius:999px;background:var(--acs);color:var(--act);display:flex;align-items:center;justify-content:center;font:600 12px/1 var(--ft)">MR</div></div>
</div>
<div style="flex:none;padding:2px 16px 12px;font:600 34px/0.9 var(--fd);letter-spacing:-0.03em;color:var(--fg1)">{{ title }}</div>
<div style="flex:none;margin:0 16px 12px;height:38px;border-radius:12px;background:var(--sf);display:flex;align-items:center;gap:8px;padding:0 10px;color:var(--fg3);font:400 16px/1 var(--ft)"><span style="{{ i18.search }}"></span>Search</div>
</sc-if>
<sc-if value="{{ iSet }}">
<div style="height:44px;flex:none;display:flex;align-items:center;gap:2px;padding:0 10px;color:var(--act);font:400 17px/1 var(--ft)"><span style="{{ i24.chevL }}"></span>Files</div>
<div style="flex:none;padding:2px 16px 14px;font:600 34px/0.9 var(--fd);letter-spacing:-0.03em;color:var(--fg1)">Settings</div>
</sc-if>
<div style="flex:1;min-height:0;position:relative;overflow:hidden;display:flex;flex-direction:column">
<sc-if value="{{ s.files }}">
<div style="flex:none;display:flex;align-items:center;gap:8px;padding:4px 12px 12px 12px">
<div style="height:34px;padding:0 12px;border-radius:999px;background:var(--sf);display:flex;align-items:center;gap:6px;font:500 14px/1 var(--ft);color:var(--fg1);white-space:nowrap"><span style="{{ i16.sort }}"></span>Name<span style="{{ i14.up }}"></span></div>
<div style="height:34px;padding:0 12px;border-radius:999px;background:var(--sf);display:flex;align-items:center;gap:6px;font:500 14px/1 var(--ft);color:var(--fg1);white-space:nowrap"><span style="{{ i16.filter }}"></span><span style="white-space:nowrap">All types</span><span style="{{ i14.chevD }}"></span></div>
<div style="flex:1"></div>
<div style="display:flex;padding:3px;border-radius:999px;background:var(--sf);gap:2px">
<div style="{{ segL }}"><span style="{{ i18.list }}"></span></div>
<div style="{{ segG }}"><span style="{{ i18.grid }}"></span></div>
</div>
</div>
</sc-if>
<sc-if value="{{ s.trash }}">
<div style="flex:none;margin:0 12px 16px;padding:14px 16px;border-radius:20px;background:var(--sf);display:flex;gap:12px;align-items:center;color:var(--fg2)">
<span style="{{ i20.info }}"></span>
<div style="flex:1;font:400 14px/1.3 var(--ft);text-wrap:pretty">Items here are deleted for good after 30 days.</div>
<div style="font:600 14px/1 var(--ft);color:var(--dg);white-space:nowrap">Empty trash</div>
</div>
</sc-if>
<sc-if value="{{ s.offline }}">
<div style="flex:none;margin:0 12px 16px;padding:18px;border-radius:20px;background:var(--sf);display:flex;flex-direction:column;gap:14px">
<div style="display:flex;justify-content:space-between;align-items:flex-end;gap:12px">
<div style="display:flex;flex-direction:column;gap:6px"><div style="font:600 32px/0.9 var(--fd);letter-spacing:-0.03em">2.4 GB</div><div style="font:400 14px/1.2 var(--ft);color:var(--fg2)">of your 5 GB offline limit</div></div>
<div style="height:36px;padding:0 14px;border-radius:999px;background:var(--acs);color:var(--act);display:flex;align-items:center;gap:6px;font:600 14px/1 var(--ft);white-space:nowrap"><span style="{{ i16.sync }}"></span>Sync now</div>
</div>
<div style="height:6px;border-radius:999px;background:var(--sf3);overflow:hidden"><div style="width:48%;height:100%;background:var(--ac)"></div></div>
<div style="font:400 13px/1.2 var(--ft);color:var(--fg3)">Last synced 2 minutes ago · 1 file needs attention</div>
</div>
</sc-if>
<sc-if value="{{ s.shares }}">
<div style="flex:none;margin:0 12px 16px;display:flex;padding:3px;border-radius:999px;background:var(--sf);gap:2px">
<div style="flex:1;height:34px;border-radius:999px;background:var(--acs);color:var(--act);display:flex;align-items:center;justify-content:center;font:600 14px/1 var(--ft)">With you</div>
<div style="flex:1;height:34px;border-radius:999px;color:var(--fg2);display:flex;align-items:center;justify-content:center;font:500 14px/1 var(--ft)">By you</div>
<div style="flex:1;height:34px;border-radius:999px;color:var(--fg2);display:flex;align-items:center;justify-content:center;font:500 14px/1 var(--ft)">Links</div>
</div>
</sc-if>
<sc-if value="{{ showList }}">
<div style="display:flex;flex-direction:column;gap:18px;padding:0 12px 16px">
<sc-for list="{{ groups }}" as="g" hint-placeholder-count="1">
<div style="display:flex;flex-direction:column;gap:8px">
<sc-if value="{{ g.hasLabel }}"><div style="padding:0 8px;font:600 13px/1 var(--ft);color:var(--fg2)">{{ g.label }}</div></sc-if>
<div style="display:flex;flex-direction:column;gap:1px;background:var(--ln);border-radius:20px;overflow:hidden">
<sc-for list="{{ g.rows }}" as="f" hint-placeholder-count="6">
<div style="position:relative;background:var(--dgf)">
<sc-if value="{{ f.swiped }}"><div style="position:absolute;right:0;top:0;bottom:0;width:96px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:5px;color:#FFFFFF;font:600 12px/1 var(--ft)"><span style="{{ i20.trash }}"></span>Delete</div></sc-if>
<div style="{{ f.rs }}">
<div style="{{ f.tile }}"><span style="{{ f.ico }}"></span></div>
<div style="flex:1;min-width:0;display:flex;flex-direction:column;gap:4px">
<div style="font:500 16px/1.2 var(--ft);color:var(--fg1);white-space:nowrap;overflow:hidden;text-overflow:ellipsis">{{ f.name }}</div>
<div style="display:flex;align-items:center;gap:5px;font:400 13px/1.2 var(--ft);color:var(--fg3);white-space:nowrap;overflow:hidden">
<sc-if value="{{ f.hasBadge }}"><span style="{{ f.badge }}"></span></sc-if>
<sc-if value="{{ f.shared }}"><span style="{{ i14.users }}"></span></sc-if>
<span style="flex:0 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis">{{ f.meta }}</span>
</div>
</div>
<sc-if value="{{ f.fav }}"><span style="{{ starSm }}"></span></sc-if>
<div style="width:32px;height:40px;display:flex;align-items:center;justify-content:center"><span style="{{ trail }}"></span></div>
</div>
</div>
</sc-for>
</div>
</div>
</sc-for>
</div>
</sc-if>
<sc-if value="{{ showGrid }}">
<div style="display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:10px;padding:0 12px 16px">
<sc-for list="{{ gridRows }}" as="f" hint-placeholder-count="6">
<div style="background:var(--sf);border-radius:18px;overflow:hidden;display:flex;flex-direction:column">
<div style="{{ f.thumb }}"><span style="{{ f.icoL }}"></span></div>
<div style="padding:10px 8px 12px 12px;display:flex;flex-direction:column;gap:5px">
<div style="display:flex;align-items:center;gap:4px"><div style="flex:1;min-width:0;font:500 14px/1.2 var(--ft);white-space:nowrap;overflow:hidden;text-overflow:ellipsis">{{ f.name }}</div><span style="{{ moreSm }}"></span></div>
<div style="display:flex;align-items:center;gap:4px;font:400 12px/1.2 var(--ft);color:var(--fg3);white-space:nowrap"><sc-if value="{{ f.hasBadge }}"><span style="{{ f.badge }}"></span></sc-if><sc-if value="{{ f.fav }}"><span style="{{ starXs }}"></span></sc-if>{{ f.size }}</div>
</div>
</div>
</sc-for>
</div>
</sc-if>
<sc-if value="{{ s.photos }}">
<div style="flex:none;display:flex;gap:8px;padding:4px 12px 4px">
<div style="height:34px;padding:0 14px;border-radius:999px;background:var(--acs);color:var(--act);display:flex;align-items:center;font:600 14px/1 var(--ft);white-space:nowrap">All</div>
<div style="height:34px;padding:0 14px;border-radius:999px;background:var(--sf);display:flex;align-items:center;font:500 14px/1 var(--ft);white-space:nowrap">Photos</div>
<div style="height:34px;padding:0 14px;border-radius:999px;background:var(--sf);display:flex;align-items:center;font:500 14px/1 var(--ft);white-space:nowrap">Videos</div>
<div style="height:34px;padding:0 14px;border-radius:999px;background:var(--sf);display:flex;align-items:center;gap:6px;font:500 14px/1 var(--ft);white-space:nowrap"><span style="{{ i16.camera }}"></span>Camera</div>
</div>
<sc-for list="{{ photoGroups }}" as="g" hint-placeholder-count="2">
<div style="display:flex;justify-content:space-between;align-items:baseline;padding:16px 16px 10px"><div style="font:500 18px/1 var(--fd);letter-spacing:-0.02em">{{ g.label }}</div><div style="font:400 13px/1 var(--ft);color:var(--fg3)">{{ g.count }}</div></div>
<div style="display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:2px">
<sc-for list="{{ g.tiles }}" as="t" hint-placeholder-count="9">
<div style="{{ t.s }}"><sc-if value="{{ t.vid }}"><div style="position:absolute;right:6px;bottom:6px;height:20px;padding:0 7px 0 5px;border-radius:999px;background:rgba(14,13,11,0.7);color:#FFFFFF;display:flex;align-items:center;gap:3px;font:600 11px/1 var(--ft)"><span style="{{ i14.play }}"></span>{{ t.dur }}</div></sc-if></div>
</sc-for>
</div>
</sc-for>
</sc-if>
<sc-if value="{{ s.activity }}">
<div style="display:flex;flex-direction:column;gap:18px;padding:0 12px 16px">
<sc-for list="{{ act }}" as="g" hint-placeholder-count="2">
<div style="display:flex;flex-direction:column;gap:8px">
<div style="padding:0 8px;font:600 13px/1 var(--ft);color:var(--fg2)">{{ g.label }}</div>
<div style="display:flex;flex-direction:column;gap:1px;background:var(--ln);border-radius:20px;overflow:hidden">
<sc-for list="{{ g.items }}" as="a" hint-placeholder-count="3">
<div style="display:flex;gap:12px;padding:14px;background:var(--sf);align-items:flex-start">
<div style="{{ a.av }}">{{ a.who }}</div>
<div style="flex:1;min-width:0;display:flex;flex-direction:column;gap:5px">
<div style="font:400 15px/1.3 var(--ft);color:var(--fg1);text-wrap:pretty"><span style="font-weight:600">{{ a.name }}</span> {{ a.verb }} <span style="font-weight:600">{{ a.obj }}</span> {{ a.tail }}</div>
<div style="font:400 13px/1 var(--ft);color:var(--fg3)">{{ a.time }}</div>
</div>
<div style="{{ a.tile }}"><span style="{{ a.ico }}"></span></div>
</div>
</sc-for>
</div>
</div>
</sc-for>
</div>
</sc-if>
<sc-if value="{{ s.settings }}">
<div style="display:flex;flex-direction:column;gap:20px;padding:0 12px 16px">
<div style="padding:18px;border-radius:20px;background:var(--sf);display:flex;flex-direction:column;gap:16px">
<div style="display:flex;gap:14px;align-items:center">
<div style="width:56px;height:56px;border-radius:999px;background:var(--acs);color:var(--act);display:flex;align-items:center;justify-content:center;font:600 20px/1 var(--ft);flex:none">MR</div>
<div style="display:flex;flex-direction:column;gap:5px;flex:1;min-width:0"><div style="font:500 20px/1 var(--fd);letter-spacing:-0.02em">Marina Rocha</div><div style="font:400 14px/1 var(--ft);color:var(--fg2)">marina@rocha.family</div></div>
</div>
<div style="display:flex;gap:12px;align-items:center;padding:12px 14px;border-radius:14px;background:var(--sf2)">
<span style="{{ i18.server }}"></span>
<div style="flex:1;min-width:0;display:flex;flex-direction:column;gap:4px"><div style="font:500 15px/1.1 var(--ft)">cloud.rocha.family</div><div style="font:400 13px/1 var(--ft);color:var(--fg3)">Nextcloud 30.0.2</div></div>
<div style="height:24px;padding:0 10px;border-radius:999px;background:var(--oks);color:var(--ok);display:flex;align-items:center;font:600 12px/1 var(--ft);white-space:nowrap">Connected</div>
</div>
<div style="display:flex;flex-direction:column;gap:8px">
<div style="display:flex;justify-content:space-between;font:400 14px/1 var(--ft)"><span style="font-weight:500">Storage</span><span style="color:var(--fg2)">38.2 of 100 GB</span></div>
<div style="height:6px;border-radius:999px;background:var(--sf3);overflow:hidden"><div style="width:38%;height:100%;background:var(--ac)"></div></div>
</div>
<div style="display:flex;gap:8px">
<div style="flex:1;height:44px;border-radius:999px;background:var(--sf2);display:flex;align-items:center;justify-content:center;gap:8px;font:600 15px/1 var(--ft)"><span style="{{ i16.external }}"></span>Manage on web</div>
<div style="flex:1;height:44px;border-radius:999px;background:var(--dgs);color:var(--dg);display:flex;align-items:center;justify-content:center;gap:8px;font:600 15px/1 var(--ft)"><span style="{{ i16.logout }}"></span>Log out</div>
</div>
</div>
<div style="display:flex;flex-direction:column;gap:8px">
<div style="padding:0 8px;font:600 13px/1 var(--ft);color:var(--fg2)">Accounts</div>
<div style="display:flex;flex-direction:column;gap:1px;background:var(--ln);border-radius:20px;overflow:hidden">
<div style="display:flex;align-items:center;gap:14px;padding:0 16px;height:60px;background:var(--sf)"><div style="width:36px;height:36px;border-radius:999px;background:var(--acs);color:var(--act);display:flex;align-items:center;justify-content:center;font:600 13px/1 var(--ft)">MR</div><div style="flex:1;display:flex;flex-direction:column;gap:4px"><div style="font:500 16px/1 var(--ft)">Marina Rocha</div><div style="font:400 13px/1 var(--ft);color:var(--fg3)">cloud.rocha.family</div></div><span style="{{ checkA }}"></span></div>
<div style="display:flex;align-items:center;gap:14px;padding:0 16px;height:60px;background:var(--sf)"><div style="width:36px;height:36px;border-radius:999px;background:#85D1BD;color:#0E0D0B;display:flex;align-items:center;justify-content:center;font:600 13px/1 var(--ft)">MR</div><div style="flex:1;display:flex;flex-direction:column;gap:4px"><div style="font:500 16px/1 var(--ft)">Marina · Work</div><div style="font:400 13px/1 var(--ft);color:var(--fg3)">files.acme-studio.com</div></div></div>
<div style="display:flex;align-items:center;gap:14px;padding:0 16px;height:52px;background:var(--sf);color:var(--act);font:600 15px/1 var(--ft)"><div style="width:36px;display:flex;justify-content:center"><span style="{{ i20.plus }}"></span></div>Add account</div>
</div>
</div>
<div style="display:flex;flex-direction:column;gap:8px">
<div style="padding:0 8px;font:600 13px/1 var(--ft);color:var(--fg2)">Security</div>
<div style="display:flex;flex-direction:column;gap:1px;background:var(--ln);border-radius:20px;overflow:hidden">
<div style="display:flex;align-items:center;gap:14px;padding:12px 16px;min-height:60px;box-sizing:border-box;background:var(--sf)"><span style="{{ bioIco }}"></span><div style="flex:1;display:flex;flex-direction:column;gap:4px"><div style="font:400 16px/1.1 var(--ft)">{{ bioLabel }}</div><div style="font:400 13px/1.2 var(--ft);color:var(--fg3)">Needed every time you open the app</div></div><div style="width:48px;height:28px;border-radius:999px;background:var(--ac);padding:3px;box-sizing:border-box;display:flex;justify-content:flex-end;flex:none"><div style="width:22px;height:22px;border-radius:999px;background:#FFFFFF"></div></div></div>
<div style="display:flex;align-items:center;gap:14px;padding:0 16px;height:52px;background:var(--sf)"><span style="{{ i20.timer }}"></span><div style="flex:1;font:400 16px/1 var(--ft)">Lock after</div><div style="display:flex;align-items:center;gap:4px;font:400 15px/1 var(--ft);color:var(--fg3)">1 minute<span style="{{ i18.chevR }}"></span></div></div>
</div>
</div>
</div>
</sc-if>
<sc-if value="{{ s.settings2 }}">
<div style="display:flex;flex-direction:column;gap:20px;padding:0 12px 16px">
<div style="display:flex;flex-direction:column;gap:8px">
<div style="padding:0 8px;font:600 13px/1 var(--ft);color:var(--fg2)">File sync</div>
<div style="display:flex;flex-direction:column;gap:1px;background:var(--ln);border-radius:20px;overflow:hidden">
<div style="display:flex;align-items:center;gap:14px;padding:12px 16px;min-height:60px;box-sizing:border-box;background:var(--sf)"><span style="{{ i20.camera }}"></span><div style="flex:1;display:flex;flex-direction:column;gap:4px"><div style="font:400 16px/1.1 var(--ft)">Auto-upload photos</div><div style="font:400 13px/1.2 var(--ft);color:var(--fg3)">To /Photos/Camera</div></div><div style="width:48px;height:28px;border-radius:999px;background:var(--ac);padding:3px;box-sizing:border-box;display:flex;justify-content:flex-end;flex:none"><div style="width:22px;height:22px;border-radius:999px;background:#FFFFFF"></div></div></div>
<div style="display:flex;align-items:center;gap:14px;padding:0 16px;height:52px;background:var(--sf)"><span style="{{ i20.wifi }}"></span><div style="flex:1;font:400 16px/1 var(--ft)">Wi-Fi only</div><div style="width:48px;height:28px;border-radius:999px;background:var(--ac);padding:3px;box-sizing:border-box;display:flex;justify-content:flex-end;flex:none"><div style="width:22px;height:22px;border-radius:999px;background:#FFFFFF"></div></div></div>
<div style="display:flex;align-items:center;gap:14px;padding:0 16px;height:52px;background:var(--sf)"><span style="{{ i20.charging }}"></span><div style="flex:1;font:400 16px/1 var(--ft)">Only while charging</div><div style="width:48px;height:28px;border-radius:999px;background:var(--sf3);padding:3px;box-sizing:border-box;display:flex;justify-content:flex-start;flex:none"><div style="width:22px;height:22px;border-radius:999px;background:#FFFFFF"></div></div></div>
<div style="display:flex;align-items:center;gap:14px;padding:0 16px;height:52px;background:var(--sf)"><span style="{{ i20.sync }}"></span><div style="flex:1;font:400 16px/1 var(--ft)">Refresh offline files</div><div style="display:flex;align-items:center;gap:4px;font:400 15px/1 var(--ft);color:var(--fg3)">Every 15 min<span style="{{ i18.chevR }}"></span></div></div>
</div>
</div>
<div style="display:flex;flex-direction:column;gap:8px">
<div style="padding:0 8px;font:600 13px/1 var(--ft);color:var(--fg2)">Files cache</div>
<div style="display:flex;flex-direction:column;gap:1px;background:var(--ln);border-radius:20px;overflow:hidden">
<div style="display:flex;flex-direction:column;gap:10px;padding:14px 16px;background:var(--sf)"><div style="display:flex;justify-content:space-between;font:400 16px/1 var(--ft)"><span>Cache in use</span><span style="color:var(--fg3);font-size:15px">1.2 of 2 GB</span></div><div style="height:6px;border-radius:999px;background:var(--sf3);overflow:hidden"><div style="width:60%;height:100%;background:var(--ac)"></div></div></div>
<div style="display:flex;align-items:center;gap:14px;padding:0 16px;height:52px;background:var(--sf)"><span style="{{ i20.database }}"></span><div style="flex:1;font:400 16px/1 var(--ft)">Cache limit</div><div style="display:flex;align-items:center;gap:4px;font:400 15px/1 var(--ft);color:var(--fg3)">2 GB<span style="{{ i18.chevR }}"></span></div></div>
<div style="display:flex;align-items:center;gap:14px;padding:0 16px;height:52px;background:var(--sf);color:var(--dg)"><span style="{{ i20.trash }}"></span><div style="flex:1;font:500 16px/1 var(--ft)">Clear cache</div></div>
</div>
</div>
<div style="display:flex;flex-direction:column;gap:8px">
<div style="padding:0 8px;font:600 13px/1 var(--ft);color:var(--fg2)">Appearance</div>
<div style="display:flex;flex-direction:column;gap:1px;background:var(--ln);border-radius:20px;overflow:hidden">
<div style="display:flex;align-items:center;gap:12px;padding:0 12px 0 16px;height:56px;background:var(--sf)"><div style="flex:1;font:400 16px/1 var(--ft)">Theme</div><div style="display:flex;padding:3px;border-radius:999px;background:var(--sf2);gap:2px"><div style="height:30px;padding:0 12px;border-radius:999px;background:var(--acs);color:var(--act);display:flex;align-items:center;font:600 13px/1 var(--ft);white-space:nowrap">System</div><div style="height:30px;padding:0 12px;display:flex;align-items:center;font:500 13px/1 var(--ft);color:var(--fg2)">Light</div><div style="height:30px;padding:0 12px;display:flex;align-items:center;font:500 13px/1 var(--ft);color:var(--fg2)">Dark</div></div></div>
<div style="display:flex;align-items:center;gap:12px;padding:0 12px 0 16px;height:56px;background:var(--sf)"><div style="flex:1;font:400 16px/1 var(--ft)">Default view</div><div style="display:flex;padding:3px;border-radius:999px;background:var(--sf2);gap:2px"><div style="height:30px;padding:0 12px;border-radius:999px;background:var(--acs);color:var(--act);display:flex;align-items:center;gap:6px;font:600 13px/1 var(--ft);white-space:nowrap"><span style="{{ i14.list }}"></span>List</div><div style="height:30px;padding:0 12px;display:flex;align-items:center;gap:6px;font:500 13px/1 var(--ft);color:var(--fg2)"><span style="{{ i14.grid }}"></span>Grid</div></div></div>
<div style="display:flex;align-items:center;gap:14px;padding:0 16px;height:52px;background:var(--sf)"><div style="flex:1;font:400 16px/1 var(--ft)">Show hidden files</div><div style="width:48px;height:28px;border-radius:999px;background:var(--sf3);padding:3px;box-sizing:border-box;display:flex;justify-content:flex-start;flex:none"><div style="width:22px;height:22px;border-radius:999px;background:#FFFFFF"></div></div></div>
</div>
</div>
</div>
</sc-if>
<sc-if value="{{ s.settings3 }}">
<div style="display:flex;flex-direction:column;gap:20px;padding:0 12px 16px">
<div style="display:flex;flex-direction:column;gap:8px">
<div style="padding:0 8px;display:flex;justify-content:space-between;font:600 13px/1 var(--ft);color:var(--fg2)"><span>Tabs</span><span style="font-weight:500;color:var(--fg3)">5 of 5 pinned</span></div>
<div style="display:flex;flex-direction:column;gap:1px;background:var(--ln);border-radius:20px;overflow:hidden">
<sc-for list="{{ tabRows }}" as="t" hint-placeholder-count="8">
<div style="display:flex;align-items:center;gap:12px;padding:0 10px 0 10px;height:52px;background:var(--sf)">
<span style="{{ gripS }}"></span>
<span style="{{ t.ico }}"></span>
<div style="flex:1;font:400 16px/1 var(--ft)">{{ t.label }}</div>
<div style="{{ t.pinS }}"><span style="{{ t.pinI }}"></span></div>
</div>
</sc-for>
</div>
<div style="padding:0 8px;font:400 13px/1.3 var(--ft);color:var(--fg3);text-wrap:pretty">Pinned tabs sit in the bottom bar. The rest live in the menu. Drag to reorder.</div>
</div>
<div style="display:flex;flex-direction:column;gap:8px">
<div style="padding:0 8px;font:600 13px/1 var(--ft);color:var(--fg2)">Swipe on a file</div>
<div style="display:flex;flex-direction:column;gap:1px;background:var(--ln);border-radius:20px;overflow:hidden">
<div style="display:flex;align-items:center;gap:14px;padding:0 16px;height:52px;background:var(--sf)"><span style="{{ i20.swipe }}"></span><div style="flex:1;font:400 16px/1 var(--ft)">Swipe left</div><div style="height:28px;padding:0 10px;border-radius:999px;background:var(--dgs);color:var(--dg);display:flex;align-items:center;gap:5px;font:600 13px/1 var(--ft);white-space:nowrap"><span style="{{ i14.trash }}"></span>Delete</div><span style="{{ chevG }}"></span></div>
<div style="display:flex;align-items:center;gap:14px;padding:0 16px;height:52px;background:var(--sf)"><span style="{{ i20.swipe }}"></span><div style="flex:1;font:400 16px/1 var(--ft)">Swipe right</div><div style="height:28px;padding:0 10px;border-radius:999px;background:var(--acs);color:var(--act);display:flex;align-items:center;gap:5px;font:600 13px/1 var(--ft);white-space:nowrap"><span style="{{ i14.star }}"></span>Favorite</div><span style="{{ chevG }}"></span></div>
</div>
</div>
</div>
</sc-if>
<sc-if value="{{ fab }}">
<div style="position:absolute;right:16px;bottom:16px;height:56px;padding:0 20px 0 16px;border-radius:999px;background:var(--ac);color:#FFFFFF;display:flex;align-items:center;gap:10px;font:600 16px/1 var(--ft)"><span style="{{ i24.plus }}"></span>Upload</div>
</sc-if>
</div>
<sc-if value="{{ aNav }}">
<div style="flex:none;background:var(--sf);display:flex;flex-direction:column">
<div style="display:flex;height:80px;align-items:center">
<sc-for list="{{ nav }}" as="n" hint-placeholder-count="5">
<div style="flex:1;display:flex;flex-direction:column;align-items:center;gap:4px">
<sc-if value="{{ n.on }}"><div style="width:56px;height:32px;border-radius:999px;background:var(--acs);color:var(--act);display:flex;align-items:center;justify-content:center"><span style="{{ n.ico }}"></span></div><div style="font:600 12px/1 var(--ft);color:var(--fg1)">{{ n.label }}</div></sc-if>
<sc-if value="{{ n.off }}"><div style="width:56px;height:32px;color:var(--fg2);display:flex;align-items:center;justify-content:center"><span style="{{ n.ico }}"></span></div><div style="font:500 12px/1 var(--ft);color:var(--fg2)">{{ n.label }}</div></sc-if>
</div>
</sc-for>
</div>
<div style="height:20px;display:flex;align-items:center;justify-content:center"><div style="width:108px;height:4px;border-radius:999px;background:var(--fg3)"></div></div>
</div>
</sc-if>
<sc-if value="{{ iNav }}">
<div style="flex:none;background:var(--sf);border-top:1px solid var(--ln);display:flex;flex-direction:column">
<div style="display:flex;padding-top:8px;height:50px;box-sizing:border-box">
<sc-for list="{{ nav }}" as="n" hint-placeholder-count="5">
<sc-if value="{{ n.on }}"><div style="flex:1;display:flex;flex-direction:column;align-items:center;gap:4px;color:var(--act);font:600 10px/1 var(--ft)"><span style="{{ n.ico }}"></span>{{ n.label }}</div></sc-if>
<sc-if value="{{ n.off }}"><div style="flex:1;display:flex;flex-direction:column;align-items:center;gap:4px;color:var(--fg3);font:500 10px/1 var(--ft)"><span style="{{ n.ico }}"></span>{{ n.label }}</div></sc-if>
</sc-for>
</div>
<div style="height:34px;display:flex;align-items:flex-end;justify-content:center;padding-bottom:8px;box-sizing:border-box"><div style="width:134px;height:5px;border-radius:999px;background:var(--fg1)"></div></div>
</div>
</sc-if>
<sc-if value="{{ aGesture }}"><div style="height:24px;flex:none;display:flex;align-items:center;justify-content:center"><div style="width:108px;height:4px;border-radius:999px;background:var(--fg3)"></div></div></sc-if>
<sc-if value="{{ iGesture }}"><div style="height:34px;flex:none;display:flex;align-items:flex-end;justify-content:center;padding-bottom:8px;box-sizing:border-box"><div style="width:134px;height:5px;border-radius:999px;background:var(--fg1)"></div></div></sc-if>
<sc-if value="{{ drawer }}">
<div style="position:absolute;inset:0;background:var(--scrim)"></div>
<div style="position:absolute;left:0;top:0;bottom:0;width:316px;background:var(--sf);border-radius:0 28px 28px 0;display:flex;flex-direction:column;padding:64px 12px 28px;box-sizing:border-box;gap:4px">
<div style="display:flex;gap:12px;align-items:center;padding:0 12px 14px">
<div style="width:48px;height:48px;border-radius:999px;background:var(--acs);color:var(--act);display:flex;align-items:center;justify-content:center;font:600 17px/1 var(--ft);flex:none">MR</div>
<div style="flex:1;min-width:0;display:flex;flex-direction:column;gap:5px"><div style="font:500 18px/1 var(--fd);letter-spacing:-0.02em">Marina Rocha</div><div style="font:400 13px/1 var(--ft);color:var(--fg3)">cloud.rocha.family</div></div>
<div style="width:36px;height:36px;border-radius:999px;background:var(--sf2);display:flex;align-items:center;justify-content:center"><span style="{{ i18.chevD }}"></span></div>
</div>
<div style="display:flex;flex-direction:column;gap:8px;padding:0 12px 16px">
<div style="height:6px;border-radius:999px;background:var(--sf3);overflow:hidden"><div style="width:38%;height:100%;background:var(--ac)"></div></div>
<div style="font:400 13px/1 var(--ft);color:var(--fg3)">38.2 GB of 100 GB used</div>
</div>
<div style="height:1px;background:var(--ln);margin:0 12px 8px"></div>
<div style="padding:8px 16px;font:600 13px/1 var(--ft);color:var(--fg2)">More</div>
<sc-for list="{{ menuRows }}" as="m" hint-placeholder-count="3">
<div style="height:52px;border-radius:999px;padding:0 16px;display:flex;align-items:center;gap:14px;font:500 16px/1 var(--ft)"><span style="{{ m.ico }}"></span><div style="flex:1">{{ m.label }}</div><div style="font:500 14px/1 var(--ft);color:var(--fg3)">{{ m.count }}</div></div>
</sc-for>
<div style="height:1px;background:var(--ln);margin:8px 12px"></div>
<div style="height:52px;border-radius:999px;padding:0 16px;display:flex;align-items:center;gap:14px;font:500 16px/1 var(--ft)"><span style="{{ i24.settings }}"></span>Settings</div>
<div style="flex:1"></div>
<div style="padding:0 16px;display:flex;align-items:center;gap:8px;font:600 14px/1 var(--ft);color:var(--act)"><span style="{{ i16.pencil }}"></span>Edit tabs</div>
</div>
</sc-if>
<sc-if value="{{ share }}">
<div style="position:absolute;inset:0;background:var(--scrim)"></div>
<div style="position:absolute;left:0;right:0;bottom:0;background:var(--sf);border-radius:28px 28px 0 0;padding:10px 20px 40px;display:flex;flex-direction:column;gap:22px">
<div style="align-self:center;width:36px;height:5px;border-radius:999px;background:var(--sf3)"></div>
<div style="display:flex;gap:12px;align-items:center">
<div style="{{ shareTile }}"><span style="{{ i24.doc }}"></span></div>
<div style="flex:1;min-width:0;display:flex;flex-direction:column;gap:5px"><div style="font:500 19px/1 var(--fd);letter-spacing:-0.02em">Q3 Planning.pdf</div><div style="font:400 13px/1 var(--ft);color:var(--fg3)">2.4 MB · Documents</div></div>
<div style="width:36px;height:36px;border-radius:999px;background:var(--sf2);display:flex;align-items:center;justify-content:center"><span style="{{ i18.x }}"></span></div>
</div>
<div style="display:flex;flex-direction:column;gap:12px">
<div style="font:500 17px/1 var(--fd);letter-spacing:-0.02em">Share with people</div>
<div style="height:48px;border-radius:14px;background:var(--sf2);display:flex;align-items:center;gap:10px;padding:0 14px;color:var(--fg3);font:400 15px/1 var(--ft)"><span style="{{ i20.userPlus }}"></span>Name, email or group</div>
<div style="display:flex;flex-direction:column;gap:14px;padding-top:4px">
<div style="display:flex;align-items:center;gap:12px"><div style="{{ avYou }}">MR</div><div style="flex:1;display:flex;flex-direction:column;gap:4px"><div style="font:500 15px/1 var(--ft)">You</div><div style="font:400 13px/1 var(--ft);color:var(--fg3)">marina@rocha.family</div></div><div style="font:500 13px/1 var(--ft);color:var(--fg3);padding-right:4px">Owner</div></div>
<div style="display:flex;align-items:center;gap:12px"><div style="{{ avAna }}">AS</div><div style="flex:1;display:flex;flex-direction:column;gap:4px"><div style="font:500 15px/1 var(--ft)">Ana Souza</div><div style="font:400 13px/1 var(--ft);color:var(--fg3)">ana@rocha.family</div></div><div style="height:30px;padding:0 8px 0 12px;border-radius:999px;border:1px solid var(--ln);display:flex;align-items:center;gap:4px;font:500 13px/1 var(--ft);white-space:nowrap">Can edit<span style="{{ i14.chevD }}"></span></div></div>
<div style="display:flex;align-items:center;gap:12px"><div style="width:36px;height:36px;border-radius:999px;background:var(--sf2);color:var(--fg2);display:flex;align-items:center;justify-content:center;flex:none"><span style="{{ i18.users }}"></span></div><div style="flex:1;display:flex;flex-direction:column;gap:4px"><div style="font:500 15px/1 var(--ft)">Design team</div><div style="font:400 13px/1 var(--ft);color:var(--fg3)">Group · 6 people</div></div><div style="height:30px;padding:0 8px 0 12px;border-radius:999px;border:1px solid var(--ln);display:flex;align-items:center;gap:4px;font:500 13px/1 var(--ft);white-space:nowrap">Can view<span style="{{ i14.chevD }}"></span></div></div>
</div>
</div>
<div style="height:1px;background:var(--ln)"></div>
<div style="display:flex;flex-direction:column;gap:12px">
<div style="display:flex;justify-content:space-between;align-items:center"><div style="display:flex;flex-direction:column;gap:5px"><div style="font:500 17px/1 var(--fd);letter-spacing:-0.02em">Share link</div><div style="font:400 13px/1 var(--ft);color:var(--fg3)">Anyone with the link can view</div></div><div style="width:48px;height:28px;border-radius:999px;background:var(--ac);padding:3px;box-sizing:border-box;display:flex;justify-content:flex-end;flex:none"><div style="width:22px;height:22px;border-radius:999px;background:#FFFFFF"></div></div></div>
<div style="border-radius:16px;background:var(--sf2);padding:6px 6px 6px 14px;display:flex;gap:10px;align-items:center;color:var(--fg2)"><span style="{{ i18.link }}"></span><div style="flex:1;min-width:0;font:400 14px/1 ui-monospace,Menlo,monospace;color:var(--fg1);white-space:nowrap;overflow:hidden;text-overflow:ellipsis">cloud.rocha.family/s/k7Pq2xW</div><div style="height:40px;padding:0 16px;border-radius:999px;background:var(--ac);color:#FFFFFF;display:flex;align-items:center;gap:6px;font:600 14px/1 var(--ft);white-space:nowrap"><span style="{{ i16.copy }}"></span>Copy link</div></div>
<div style="display:flex;gap:8px;flex-wrap:wrap">
<div style="height:32px;padding:0 12px;border-radius:999px;border:1px solid var(--ln);display:flex;align-items:center;gap:6px;font:500 13px/1 var(--ft);white-space:nowrap"><span style="{{ i14.eye }}"></span>Can view<span style="{{ i14.chevD }}"></span></div>
<div style="height:32px;padding:0 12px;border-radius:999px;background:var(--acs);color:var(--act);display:flex;align-items:center;gap:6px;font:600 13px/1 var(--ft);white-space:nowrap"><span style="{{ i14.calendar }}"></span>Expires Oct 24</div>
<div style="height:32px;padding:0 12px;border-radius:999px;border:1px solid var(--ln);display:flex;align-items:center;gap:6px;font:500 13px/1 var(--ft);white-space:nowrap"><span style="{{ i14.lock }}"></span>Add password</div>
</div>
</div>
<div style="display:flex;flex-direction:column;gap:10px">
<div style="height:52px;border-radius:999px;border:1.5px solid var(--sf3);display:flex;align-items:center;justify-content:center;gap:10px;font:600 16px/1 var(--ft)"><span style="{{ i20.send }}"></span>Send file directly</div>
<div style="font:400 13px/1.3 var(--ft);color:var(--fg3);text-align:center">Sends a copy through another app. Link settings don’t apply.</div>
</div>
</div>
</sc-if>
</div>
</sc-if>
</x-dc>
<script type="text/x-dc" data-dc-script data-props="{&quot;$preview&quot;:{&quot;width&quot;:390,&quot;height&quot;:844},&quot;platform&quot;:{&quot;editor&quot;:&quot;enum&quot;,&quot;options&quot;:[&quot;ios&quot;,&quot;android&quot;],&quot;default&quot;:&quot;ios&quot;,&quot;tsType&quot;:&quot;'ios'|'android'&quot;},&quot;screen&quot;:{&quot;editor&quot;:&quot;enum&quot;,&quot;options&quot;:[&quot;files&quot;,&quot;filesGrid&quot;,&quot;photos&quot;,&quot;favorites&quot;,&quot;offline&quot;,&quot;shares&quot;,&quot;recent&quot;,&quot;activity&quot;,&quot;trash&quot;,&quot;drawer&quot;,&quot;share&quot;,&quot;settings&quot;,&quot;settings2&quot;,&quot;settings3&quot;],&quot;default&quot;:&quot;files&quot;,&quot;tsType&quot;:&quot;string&quot;},&quot;theme&quot;:{&quot;editor&quot;:&quot;enum&quot;,&quot;options&quot;:[&quot;light&quot;,&quot;dark&quot;],&quot;default&quot;:&quot;light&quot;,&quot;tsType&quot;:&quot;'light'|'dark'&quot;}}">
class Component extends DCLogic {
componentDidMount() {
if (!window.NK) this._t = setInterval(() => { if (window.NK) { clearInterval(this._t); this.forceUpdate(); } }, 40);
}
componentWillUnmount() { clearInterval(this._t); }
renderVals() {
const K = window.NK;
if (!K) return { ready: false };
const { I, ic, D, row, TH, tile, tiles, avatar } = K;
const ios = this.props.platform !== 'android', android = !ios;
const sc = this.props.screen || 'files';
const theme = this.props.theme === 'dark' ? 'dark' : 'light';
const drawer = sc === 'drawer', share = sc === 'share';
const grid = sc === 'filesGrid';
const base = (drawer || share || grid) ? 'files' : sc;
const settings = base.startsWith('settings');
const s = {}; ['files','photos','activity','trash','favorites','offline','shares','recent','settings','settings2','settings3'].forEach(k => s[k] = base === k);
const ROW = { display: 'flex', alignItems: 'center', gap: 14, padding: '0 6px 0 14px', height: 64, background: 'var(--sf)', position: 'relative' };
const mk = (list, o = {}) => list.map(f => { const r = row(f, o); r.rs = { ...ROW, transform: r.swiped ? 'translateX(-96px)' : 'none' }; return r; });
const noSwipe = drawer || share;
let groups = [], trail = ic(ios ? 'moreH' : 'moreV', 20, 'var(--fg3)');
if (base === 'files') groups = [{ rows: mk(D.files, { noSwipe }) }];
if (base === 'favorites') { groups = [{ rows: mk(D.favorites, { noFav: 1 }) }]; trail = ic('star', 20, 'var(--act)'); }
if (base === 'offline') groups = [{ rows: mk(D.offline) }];
if (base === 'shares') groups = [{ rows: mk(D.shares) }];
if (base === 'recent') groups = D.recent.map(g => ({ label: g.label, rows: mk(g.rows) }));
if (base === 'trash') { groups = [{ rows: mk(D.trash) }]; trail = ic('restore', 20, 'var(--act)'); }
groups = groups.map(g => ({ ...g, hasLabel: !!g.label }));
const title = settings ? 'Settings' : D.titles[base];
const seg = on => ({ width: 36, height: 28, borderRadius: 999, display: 'flex', alignItems: 'center', justifyContent: 'center', background: on ? 'var(--acs)' : 'transparent', color: on ? 'var(--act)' : 'var(--fg3)' });
return {
ready: true, ios, android, s, title, groups, trail, drawer, share,
root: { ...TH[theme], width: 390, height: 844, borderRadius: ios ? 48 : 36, overflow: 'hidden', position: 'relative', display: 'flex', flexDirection: 'column', background: 'var(--bg)', color: 'var(--fg1)', fontFamily: 'var(--ft)' },
i14: I[14], i16: I[16], i18: I[18], i20: I[20], i24: I[24],
aBar: android && !settings, aSet: android && settings, iBar: ios && !settings, iSet: ios && settings,
aNav: android && !settings, iNav: ios && !settings, aGesture: android && settings, iGesture: ios && settings,
showList: groups.length > 0 && !grid, showGrid: grid,
gridRows: D.files.slice(0, 8).map(f => row(f, { noSwipe: 1 })),
segL: seg(!grid), segG: seg(grid),
fab: android && (base === 'files' || base === 'photos'),
starSm: ic('star', 16, 'var(--act)'), starXs: ic('star', 12, 'var(--act)'), moreSm: ic(ios ? 'moreH' : 'moreV', 16, 'var(--fg3)'),
checkA: ic('check', 20, 'var(--act)'), chevG: ic('chevR', 18, 'var(--fg3)'), gripS: ic('grip', 18, 'var(--fg3)'),
photoGroups: [{ label: 'September 2026', count: '38 items', tiles: tiles(18, 0, [2, 9, 13]) }, { label: 'August 2026', count: '112 items', tiles: tiles(9, 3, [4]) }],
act: D.activity.map(g => ({ label: g.label, items: g.items.map(a => ({ ...a, av: avatar(a.col, 36), tile: tile(a.kind, 32, 10), ico: I[16][K.KIND[a.kind][2]] })) })),
nav: D.pinned.map(([label, ico, key]) => ({ label, ico: I[24][ico], on: key === base, off: key !== base })),
menuRows: D.menu.map(([label, ico, key, count]) => ({ label, ico: I[24][ico], count })),
tabRows: [...D.pinned.map(([label, ico]) => ({ label, ico: I[20][ico], p: 1 })), ...D.menu.map(([label, ico]) => ({ label, ico: I[20][ico], p: 0 }))].map(t => ({ ...t,
pinS: { width: 36, height: 36, borderRadius: 999, display: 'flex', alignItems: 'center', justifyContent: 'center', background: t.p ? 'var(--acs)' : 'var(--sf2)', color: t.p ? 'var(--act)' : 'var(--fg3)' },
pinI: I[16][t.p ? 'pin' : 'pinOff'] })),
bioLabel: ios ? 'Lock with Face ID' : 'Lock with fingerprint', bioIco: I[20][ios ? 'face' : 'fingerprint'],
shareTile: tile('pdf', 44, 12), avYou: { ...avatar('#CBA5FD', 36) }, avAna: avatar('#DA9AC7', 36)
};
}
}
</script>
</body>
</html>
@@ -0,0 +1,202 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="./support.js"></script>
</head>
<body>
<x-dc>
<helmet>
<script src="noo-kit.js"></script>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Instrument+Sans:wght@400;500;600&family=Schibsted+Grotesk:wght@500;600&display=swap">
<style>
body{margin:0;background:#F3EFE6}
a{color:#8D0DE3;text-decoration:none}
a:hover{color:#6E08B3}
</style>
</helmet>
<div style="max-width:1440px;margin:0 auto;padding:64px 40px 96px;display:flex;flex-direction:column;gap:48px;font-family:'Instrument Sans',system-ui,sans-serif;color:#0E0D0B">
<div style="display:flex;flex-direction:column;gap:16px">
<div style="font:600 64px/0.9 'Schibsted Grotesk',system-ui,sans-serif;letter-spacing:-0.03em">Noo design system</div>
<div style="font:400 18px/1.35 'Instrument Sans',system-ui,sans-serif;color:#5A5852;max-width:760px;text-wrap:pretty">Every component is shown in light and dark. The full rules for code are in <a href="DESIGN_SYSTEM.md">DESIGN_SYSTEM.md</a>. You can see every screen in <a href="Noo Screens.dc.html">Noo Screens</a>.</div>
</div>
<sc-if value="{{ ready }}" hint-placeholder-val="{{ false }}">
<div style="display:flex;flex-direction:column;gap:14px">
<div style="font:500 28px/1 'Schibsted Grotesk',system-ui,sans-serif;letter-spacing:-0.02em">Type</div>
<div style="background:#FFFFFF;border-radius:20px;padding:8px 24px">
<sc-for list="{{ type }}" as="t" hint-placeholder-count="9">
<div style="display:grid;grid-template-columns:200px minmax(0,1fr);gap:24px;align-items:center;padding:16px 0;border-bottom:1px solid #EAE8E2">
<div style="font:400 13px/1.3 ui-monospace,Menlo,monospace;color:#7C7A72">{{ t.spec }}</div>
<div style="{{ t.s }}">{{ t.sample }}</div>
</div>
</sc-for>
</div>
</div>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,600px),1fr));gap:24px">
<sc-for list="{{ themes }}" as="th" hint-placeholder-count="2">
<div style="{{ th.vars }}">
<div style="display:flex;flex-direction:column;gap:28px;padding:28px;border-radius:28px;background:var(--bg);color:var(--fg1);font-family:var(--ft)">
<div style="font:600 32px/0.9 var(--fd);letter-spacing:-0.03em">{{ th.label }}</div>
<div style="display:flex;flex-direction:column;gap:12px">
<div style="font:600 13px/1 var(--ft);color:var(--fg2)">Color</div>
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(120px,1fr));gap:8px">
<sc-for list="{{ th.swatches }}" as="c" hint-placeholder-count="16">
<div style="display:flex;flex-direction:column;gap:6px;padding:6px;border-radius:14px;background:var(--sf)">
<div style="{{ c.s }}"></div>
<div style="padding:0 4px 4px;display:flex;flex-direction:column;gap:3px"><div style="font:600 12px/1 var(--ft)">{{ c.name }}</div><div style="font:400 11px/1 ui-monospace,Menlo,monospace;color:var(--fg3)">{{ c.hex }}</div></div>
</div>
</sc-for>
</div>
</div>
<div style="display:flex;flex-direction:column;gap:12px">
<div style="font:600 13px/1 var(--ft);color:var(--fg2)">File-type tiles</div>
<div style="display:flex;gap:14px;flex-wrap:wrap">
<sc-for list="{{ kinds }}" as="k" hint-placeholder-count="7">
<div style="display:flex;flex-direction:column;align-items:center;gap:6px"><div style="{{ k.tile }}"><span style="{{ k.ico }}"></span></div><div style="font:500 12px/1 var(--ft);color:var(--fg2)">{{ k.name }}</div></div>
</sc-for>
</div>
</div>
<div style="display:flex;flex-direction:column;gap:12px">
<div style="font:600 13px/1 var(--ft);color:var(--fg2)">Buttons</div>
<div style="display:flex;gap:10px;flex-wrap:wrap;align-items:center">
<div style="height:44px;padding:0 20px 0 16px;border-radius:999px;background:var(--ac);color:#FFFFFF;display:flex;align-items:center;gap:8px;font:600 15px/1 var(--ft);white-space:nowrap"><span style="{{ i18.plus }}"></span>Primary</div>
<div style="height:44px;padding:0 20px;border-radius:999px;background:var(--acs);color:var(--act);display:flex;align-items:center;gap:8px;font:600 15px/1 var(--ft);white-space:nowrap"><span style="{{ i16.sync }}"></span>Tonal</div>
<div style="height:44px;padding:0 20px;border-radius:999px;background:var(--sf2);display:flex;align-items:center;font:600 15px/1 var(--ft);white-space:nowrap">Secondary</div>
<div style="height:44px;padding:0 20px;border-radius:999px;background:var(--dgs);color:var(--dg);display:flex;align-items:center;gap:8px;font:600 15px/1 var(--ft);white-space:nowrap"><span style="{{ i16.logout }}"></span>Danger</div>
<div style="height:44px;padding:0 20px;border-radius:999px;border:1.5px solid var(--sf3);display:flex;align-items:center;gap:8px;font:600 15px/1 var(--ft);white-space:nowrap"><span style="{{ i16.send }}"></span>Outline</div>
<div style="height:56px;padding:0 20px 0 16px;border-radius:999px;background:var(--ac);color:#FFFFFF;display:flex;align-items:center;gap:10px;font:600 16px/1 var(--ft);white-space:nowrap"><span style="{{ i24.plus }}"></span>FAB · Android</div>
</div>
</div>
<div style="display:flex;flex-direction:column;gap:12px">
<div style="font:600 13px/1 var(--ft);color:var(--fg2)">Chips, segmented, toggles, search</div>
<div style="display:flex;gap:10px;flex-wrap:wrap;align-items:center">
<div style="height:34px;padding:0 12px;border-radius:999px;background:var(--sf);display:flex;align-items:center;gap:6px;font:500 14px/1 var(--ft);white-space:nowrap"><span style="{{ i16.sort }}"></span>Name<span style="{{ i14.up }}"></span></div>
<div style="height:34px;padding:0 12px;border-radius:999px;border:1px solid var(--ln);display:flex;align-items:center;gap:6px;font:500 14px/1 var(--ft);white-space:nowrap">Modified<span style="{{ i14.chevD }}"></span></div>
<div style="height:34px;padding:0 12px;border-radius:999px;background:var(--acs);color:var(--act);display:flex;align-items:center;gap:6px;font:600 14px/1 var(--ft);white-space:nowrap">Available offline<span style="{{ i14.x }}"></span></div>
<div style="display:flex;padding:3px;border-radius:999px;background:var(--sf);gap:2px"><div style="width:36px;height:28px;border-radius:999px;background:var(--acs);color:var(--act);display:flex;align-items:center;justify-content:center"><span style="{{ i18.list }}"></span></div><div style="width:36px;height:28px;color:var(--fg3);display:flex;align-items:center;justify-content:center"><span style="{{ i18.grid }}"></span></div></div>
<div style="display:flex;padding:3px;border-radius:999px;background:var(--sf);gap:2px"><div style="height:30px;padding:0 14px;border-radius:999px;background:var(--acs);color:var(--act);display:flex;align-items:center;font:600 13px/1 var(--ft)">With you</div><div style="height:30px;padding:0 14px;display:flex;align-items:center;font:500 13px/1 var(--ft);color:var(--fg2)">By you</div><div style="height:30px;padding:0 14px;display:flex;align-items:center;font:500 13px/1 var(--ft);color:var(--fg2)">Links</div></div>
<div style="width:48px;height:28px;border-radius:999px;background:var(--ac);padding:3px;box-sizing:border-box;display:flex;justify-content:flex-end"><div style="width:22px;height:22px;border-radius:999px;background:#FFFFFF"></div></div>
<div style="width:48px;height:28px;border-radius:999px;background:var(--sf3);padding:3px;box-sizing:border-box;display:flex;justify-content:flex-start"><div style="width:22px;height:22px;border-radius:999px;background:#FFFFFF"></div></div>
</div>
<div style="height:38px;border-radius:12px;background:var(--sf);display:flex;align-items:center;gap:8px;padding:0 12px;color:var(--fg3);font:400 16px/1 var(--ft)"><span style="{{ i18.search }}"></span>Search</div>
</div>
<div style="display:flex;flex-direction:column;gap:12px">
<div style="font:600 13px/1 var(--ft);color:var(--fg2)">Grouped list · file rows · swipe</div>
<div style="display:flex;flex-direction:column;gap:1px;background:var(--ln);border-radius:20px;overflow:hidden">
<sc-for list="{{ rows }}" as="f" hint-placeholder-count="4">
<div style="position:relative;background:var(--dgf)">
<sc-if value="{{ f.swiped }}"><div style="position:absolute;right:0;top:0;bottom:0;width:96px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:5px;color:#FFFFFF;font:600 12px/1 var(--ft)"><span style="{{ i20.trash }}"></span>Delete</div></sc-if>
<div style="{{ f.rs }}">
<div style="{{ f.tile }}"><span style="{{ f.ico }}"></span></div>
<div style="flex:1;min-width:0;display:flex;flex-direction:column;gap:4px">
<div style="font:500 16px/1.2 var(--ft);white-space:nowrap;overflow:hidden;text-overflow:ellipsis">{{ f.name }}</div>
<div style="display:flex;align-items:center;gap:5px;font:400 13px/1.2 var(--ft);color:var(--fg3);white-space:nowrap"><sc-if value="{{ f.hasBadge }}"><span style="{{ f.badge }}"></span></sc-if><sc-if value="{{ f.shared }}"><span style="{{ i14.users }}"></span></sc-if>{{ f.meta }}</div>
</div>
<sc-if value="{{ f.fav }}"><span style="{{ starSm }}"></span></sc-if>
<div style="width:32px;display:flex;justify-content:center"><span style="{{ moreT }}"></span></div>
</div>
</div>
</sc-for>
</div>
</div>
<div style="display:flex;flex-direction:column;gap:12px">
<div style="font:600 13px/1 var(--ft);color:var(--fg2)">Settings rows · progress · badges · avatars</div>
<div style="display:flex;flex-direction:column;gap:1px;background:var(--ln);border-radius:20px;overflow:hidden">
<div style="display:flex;align-items:center;gap:14px;padding:12px 16px;min-height:60px;box-sizing:border-box;background:var(--sf)"><span style="{{ i20.fingerprint }}"></span><div style="flex:1;display:flex;flex-direction:column;gap:4px"><div style="font:400 16px/1.1 var(--ft)">Toggle row</div><div style="font:400 13px/1.2 var(--ft);color:var(--fg3)">With a subtitle</div></div><div style="width:48px;height:28px;border-radius:999px;background:var(--ac);padding:3px;box-sizing:border-box;display:flex;justify-content:flex-end;flex:none"><div style="width:22px;height:22px;border-radius:999px;background:#FFFFFF"></div></div></div>
<div style="display:flex;align-items:center;gap:14px;padding:0 16px;height:52px;background:var(--sf)"><span style="{{ i20.timer }}"></span><div style="flex:1;font:400 16px/1 var(--ft)">Value row</div><div style="display:flex;align-items:center;gap:4px;font:400 15px/1 var(--ft);color:var(--fg3)">1 minute<span style="{{ i18.chevR }}"></span></div></div>
<div style="display:flex;flex-direction:column;gap:10px;padding:14px 16px;background:var(--sf)"><div style="display:flex;justify-content:space-between;font:400 16px/1 var(--ft)"><span>Progress row</span><span style="color:var(--fg3);font-size:15px">1.2 of 2 GB</span></div><div style="height:6px;border-radius:999px;background:var(--sf3);overflow:hidden"><div style="width:60%;height:100%;background:var(--ac)"></div></div></div>
<div style="display:flex;align-items:center;gap:14px;padding:0 16px;height:52px;background:var(--sf);color:var(--dg)"><span style="{{ i20.trash }}"></span><div style="flex:1;font:500 16px/1 var(--ft)">Destructive row</div></div>
</div>
<div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap">
<div style="height:24px;padding:0 10px;border-radius:999px;background:var(--oks);color:var(--ok);display:flex;align-items:center;font:600 12px/1 var(--ft)">Connected</div>
<div style="height:24px;padding:0 10px;border-radius:999px;background:var(--dgs);color:var(--dg);display:flex;align-items:center;font:600 12px/1 var(--ft)">Couldn’t sync</div>
<div style="display:flex;align-items:center;gap:5px;font:400 13px/1 var(--ft);color:var(--fg3)"><span style="{{ bOk }}"></span>Synced</div>
<div style="display:flex;align-items:center;gap:5px;font:400 13px/1 var(--ft);color:var(--fg3)"><span style="{{ bSync }}"></span>Syncing</div>
<div style="display:flex;align-items:center;gap:5px;font:400 13px/1 var(--ft);color:var(--fg3)"><span style="{{ bErr }}"></span>Error</div>
<div style="flex:1"></div>
<div style="width:36px;height:36px;border-radius:999px;background:var(--acs);color:var(--act);display:flex;align-items:center;justify-content:center;font:600 13px/1 var(--ft)">MR</div>
<div style="{{ avA }}">AS</div>
<div style="{{ avB }}">LP</div>
</div>
</div>
<div style="display:flex;flex-direction:column;gap:12px">
<div style="font:600 13px/1 var(--ft);color:var(--fg2)">Navigation · iOS tab bar / Android navigation bar / desktop sidebar</div>
<div style="display:flex;background:var(--sf);border-radius:20px;padding:10px 0">
<sc-for list="{{ nav }}" as="n" hint-placeholder-count="5">
<sc-if value="{{ n.on }}"><div style="flex:1;display:flex;flex-direction:column;align-items:center;gap:4px;color:var(--act);font:600 10px/1 var(--ft)"><span style="{{ n.ico }}"></span>{{ n.label }}</div></sc-if>
<sc-if value="{{ n.off }}"><div style="flex:1;display:flex;flex-direction:column;align-items:center;gap:4px;color:var(--fg3);font:500 10px/1 var(--ft)"><span style="{{ n.ico }}"></span>{{ n.label }}</div></sc-if>
</sc-for>
</div>
<div style="display:flex;background:var(--sf);border-radius:20px;padding:14px 0">
<sc-for list="{{ nav }}" as="n" hint-placeholder-count="5">
<div style="flex:1;display:flex;flex-direction:column;align-items:center;gap:4px">
<sc-if value="{{ n.on }}"><div style="width:56px;height:32px;border-radius:999px;background:var(--acs);color:var(--act);display:flex;align-items:center;justify-content:center"><span style="{{ n.ico }}"></span></div><div style="font:600 12px/1 var(--ft)">{{ n.label }}</div></sc-if>
<sc-if value="{{ n.off }}"><div style="width:56px;height:32px;color:var(--fg2);display:flex;align-items:center;justify-content:center"><span style="{{ n.ico }}"></span></div><div style="font:500 12px/1 var(--ft);color:var(--fg2)">{{ n.label }}</div></sc-if>
</div>
</sc-for>
</div>
<div style="display:flex;gap:4px;flex-wrap:wrap">
<div style="height:38px;border-radius:12px;padding:0 12px;display:flex;align-items:center;gap:12px;background:var(--acs);color:var(--act);font:600 14px/1 var(--ft);width:220px;box-sizing:border-box"><span style="{{ i18.folder }}"></span>Files</div>
<div style="height:38px;border-radius:12px;padding:0 12px;display:flex;align-items:center;gap:12px;font:500 14px/1 var(--ft);width:220px;box-sizing:border-box"><span style="{{ i18.trash }}"></span><div style="flex:1">Trash</div><div style="font:500 12px/1 var(--ft);color:var(--fg3)">12</div></div>
</div>
</div>
</div>
</div>
</sc-for>
</div>
</sc-if>
</div>
</x-dc>
<script type="text/x-dc" data-dc-script>
class Component extends DCLogic {
componentDidMount() {
if (!window.NK) this._t = setInterval(() => { if (window.NK) { clearInterval(this._t); this.forceUpdate(); } }, 40);
}
componentWillUnmount() { clearInterval(this._t); }
renderVals() {
const K = window.NK;
if (!K) return { ready: false };
const { I, ic, D, row, TH, tile, avatar } = K;
const FD = "'Schibsted Grotesk',system-ui,sans-serif", FT = "'Instrument Sans',system-ui,sans-serif";
const t = (spec, sample, s) => ({ spec, sample, s: { ...s, color: '#0E0D0B' } });
const names = [['bg','--bg'],['surface','--sf'],['surface-2','--sf2'],['surface-3','--sf3'],['line','--ln'],['fg-1','--fg1'],['fg-2','--fg2'],['fg-3','--fg3'],['accent','--ac'],['accent-text','--act'],['accent-soft','--acs'],['danger','--dg'],['danger-soft','--dgs'],['success','--ok'],['success-soft','--oks'],['warning','--wn'],['warning-soft','--wns'],['info','--in'],['info-soft','--ins']];
const ROW = { display: 'flex', alignItems: 'center', gap: 14, padding: '0 6px 0 14px', height: 64, background: 'var(--sf)', position: 'relative' };
const demo = [D.files[2], D.files[3], D.offline[2], D.offline[4], { ...D.files[4] }].map(f => { const r = row(f); r.rs = { ...ROW, transform: r.swiped ? 'translateX(-96px)' : 'none' }; return r; });
return {
ready: true,
i14: I[14], i16: I[16], i18: I[18], i20: I[20], i24: I[24],
type: [
t('Display 600 · 34/0.9 · −3%', 'Large title', { font: `600 34px/0.9 ${FD}`, letterSpacing: '-0.03em' }),
t('Display 600 · 32/0.9 · −3%', '2.4 GB', { font: `600 32px/0.9 ${FD}`, letterSpacing: '-0.03em' }),
t('Display 500 · 22/1 · −2%', 'Top bar title', { font: `500 22px/1 ${FD}`, letterSpacing: '-0.02em' }),
t('Display 500 · 18/1 · −2%', 'Card title · September 2026', { font: `500 18px/1 ${FD}`, letterSpacing: '-0.02em' }),
t('Text 500 · 16/1.2', 'Q3 Planning.pdf', { font: `500 16px/1.2 ${FT}` }),
t('Text 400 · 15/1.3', 'Ana Souza shared Q3 Planning.pdf with you', { font: `400 15px/1.3 ${FT}` }),
t('Text 600 · 13/1', 'Section label', { font: `600 13px/1 ${FT}` }),
t('Text 400 · 13/1.2 · fg-3', '2.4 MB · Yesterday', { font: `400 13px/1.2 ${FT}`, opacity: 0.62 }),
t('Mono 400 · 14', 'cloud.rocha.family/s/k7Pq2xW', { font: '400 14px/1 ui-monospace,Menlo,monospace' })
],
themes: ['light', 'dark'].map(id => ({ label: id === 'light' ? 'Light' : 'Dark', vars: TH[id],
swatches: names.map(([name, v]) => ({ name, hex: TH[id][v], s: { height: 44, borderRadius: 10, background: `var(${v})`, boxShadow: 'inset 0 0 0 1px var(--ln)' } })) })),
kinds: Object.keys(K.KIND).map(k => ({ name: k, tile: tile(k, 40, 12), ico: I[20][K.KIND[k][2]] })),
rows: demo, starSm: ic('star', 16, 'var(--act)'), moreT: ic('moreH', 20, 'var(--fg3)'),
bOk: ic('ok', 14, 'var(--ok)'), bSync: ic('sync', 14, 'var(--act)'), bErr: ic('alert', 14, 'var(--dg)'),
avA: avatar('#DA9AC7', 36), avB: avatar('#8DCDE2', 36),
nav: D.pinned.map(([label, ico], i) => ({ label, ico: I[24][ico], on: i === 0, off: i !== 0 }))
};
}
}
</script>
</body>
</html>
@@ -0,0 +1,93 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="./support.js"></script>
</head>
<body>
<x-dc>
<helmet>
<meta name="design_doc_mode" content="canvas">
<script src="noo-kit.js"></script>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Instrument+Sans:wght@400;500;600&family=Schibsted+Grotesk:wght@500;600&display=swap">
<style>
body{margin:0;background:#EAE8E2}
a{color:#8D0DE3;text-decoration:none}
a:hover{color:#6E08B3}
</style>
</helmet>
<div style="padding:96px;display:flex;flex-direction:column;gap:120px;font-family:'Instrument Sans',system-ui,sans-serif;color:#0E0D0B;width:max-content">
<div style="display:flex;flex-direction:column;gap:20px;max-width:1100px">
<div style="font:600 96px/0.9 'Schibsted Grotesk',system-ui,sans-serif;letter-spacing:-0.03em">Noo</div>
<div style="font:400 28px/1.3 'Instrument Sans',system-ui,sans-serif;color:#5A5852;text-wrap:pretty">A Nextcloud client for iOS, Android, macOS and Windows. One brand language, navigation adapted per platform. Light and dark shown for every screen.</div>
<div style="display:flex;gap:12px;font:600 20px/1 'Instrument Sans',system-ui,sans-serif"><a href="Noo Design System.dc.html" style="height:52px;padding:0 24px;border-radius:999px;background:#8D0DE3;color:#FFFFFF;display:flex;align-items:center;white-space:nowrap">Open the design system</a><a href="DESIGN_SYSTEM.md" style="height:52px;padding:0 24px;border-radius:999px;background:#FFFFFF;display:flex;align-items:center;white-space:nowrap">DESIGN_SYSTEM.md</a></div>
</div>
<sc-for list="{{ mobile }}" as="p" hint-placeholder-count="2">
<section style="display:flex;flex-direction:column;gap:40px">
<div style="display:flex;align-items:baseline;gap:24px;border-bottom:2px solid #0E0D0B;padding-bottom:20px"><div style="font:600 72px/0.9 'Schibsted Grotesk',system-ui,sans-serif;letter-spacing:-0.03em">{{ p.name }}</div><div style="font:400 24px/1.2 'Instrument Sans',system-ui,sans-serif;color:#5A5852;white-space:nowrap">{{ p.note }}</div></div>
<div style="display:flex;gap:120px">
<sc-for list="{{ p.themes }}" as="t" hint-placeholder-count="2">
<div style="display:flex;flex-direction:column;gap:32px">
<div style="font:500 36px/1 'Schibsted Grotesk',system-ui,sans-serif;letter-spacing:-0.02em">{{ t.label }}</div>
<div style="display:grid;grid-template-columns:repeat(7,390px);gap:72px 48px">
<sc-for list="{{ screensM }}" as="sc" hint-placeholder-count="14">
<div style="display:flex;flex-direction:column;gap:14px">
<div style="display:flex;gap:10px;align-items:baseline;font:500 18px/1 'Instrument Sans',system-ui,sans-serif"><span style="color:#7C7A72">{{ sc.n }}</span><span style="white-space:nowrap">{{ sc.label }}</span></div>
<div style="width:390px;height:844px;border-radius:48px;box-shadow:0 0 0 1px rgba(14,13,11,0.08),0 24px 48px rgba(30,0,47,0.10)">
<dc-import name="Mobile Screen" platform="{{ p.id }}" screen="{{ sc.id }}" theme="{{ t.id }}" hint-size="390px,844px"></dc-import>
</div>
</div>
</sc-for>
</div>
</div>
</sc-for>
</div>
</section>
</sc-for>
<sc-for list="{{ desktop }}" as="p" hint-placeholder-count="2">
<section style="display:flex;flex-direction:column;gap:40px">
<div style="display:flex;align-items:baseline;gap:24px;border-bottom:2px solid #0E0D0B;padding-bottom:20px"><div style="font:600 72px/0.9 'Schibsted Grotesk',system-ui,sans-serif;letter-spacing:-0.03em">{{ p.name }}</div><div style="font:400 24px/1.2 'Instrument Sans',system-ui,sans-serif;color:#5A5852;white-space:nowrap">{{ p.note }}</div></div>
<div style="display:flex;gap:160px">
<sc-for list="{{ p.themes }}" as="t" hint-placeholder-count="2">
<div style="display:flex;flex-direction:column;gap:32px">
<div style="font:500 36px/1 'Schibsted Grotesk',system-ui,sans-serif;letter-spacing:-0.02em">{{ t.label }}</div>
<div style="display:grid;grid-template-columns:repeat(3,1280px);gap:72px 64px">
<sc-for list="{{ screensD }}" as="sc" hint-placeholder-count="12">
<div style="display:flex;flex-direction:column;gap:14px">
<div style="display:flex;gap:10px;align-items:baseline;font:500 20px/1 'Instrument Sans',system-ui,sans-serif"><span style="color:#7C7A72">{{ sc.n }}</span><span style="white-space:nowrap">{{ sc.label }}</span></div>
<div style="width:1280px;height:800px;border-radius:12px;box-shadow:0 24px 48px rgba(30,0,47,0.12)">
<dc-import name="Desktop Screen" os="{{ p.id }}" screen="{{ sc.id }}" theme="{{ t.id }}" hint-size="1280px,800px"></dc-import>
</div>
</div>
</sc-for>
</div>
</div>
</sc-for>
</div>
</section>
</sc-for>
</div>
</x-dc>
<script type="text/x-dc" data-dc-script data-props="{&quot;platforms&quot;:{&quot;editor&quot;:&quot;enum&quot;,&quot;options&quot;:[&quot;all&quot;,&quot;ios&quot;,&quot;android&quot;,&quot;mac&quot;,&quot;windows&quot;],&quot;default&quot;:&quot;all&quot;,&quot;tsType&quot;:&quot;string&quot;},&quot;themes&quot;:{&quot;editor&quot;:&quot;enum&quot;,&quot;options&quot;:[&quot;both&quot;,&quot;light&quot;,&quot;dark&quot;],&quot;default&quot;:&quot;both&quot;,&quot;tsType&quot;:&quot;string&quot;}}">
class Component extends DCLogic {
renderVals() {
const pf = this.props.platforms || 'all';
const th = this.props.themes || 'both';
const themes = [{ id: 'light', label: 'Light' }, { id: 'dark', label: 'Dark' }].filter(t => th === 'both' || th === t.id);
const M = [['files','Files · list'],['filesGrid','Files · grid'],['photos','Photos'],['favorites','Favorites'],['offline','Offline'],['shares','Shares'],['recent','Recent'],['activity','Activity'],['trash','Trash'],['drawer','Menu (unpinned tabs)'],['share','Share sheet'],['settings','Settings · account & security'],['settings2','Settings · sync, cache, appearance'],['settings3','Settings · tabs & swipe']];
const Dk = [['files','Files · list'],['filesGrid','Files · grid'],['photos','Photos'],['favorites','Favorites'],['offline','Offline'],['shares','Shares'],['recent','Recent'],['activity','Activity'],['trash','Trash'],['share','Share dialog'],['settings','Settings · account, security, sync'],['settings2','Settings · cache, appearance, tabs, swipe']];
const num = a => a.map(([id, label], i) => ({ id, label, n: String(i + 1).padStart(2, '0') }));
const keep = id => pf === 'all' || pf === id;
return {
screensM: num(M), screensD: num(Dk),
mobile: [{ id: 'ios', name: 'iOS', note: 'Large titles, tab bar, “+” upload in the nav bar.', themes }, { id: 'android', name: 'Android', note: 'Top app bar, navigation bar with pill indicator, Upload FAB.', themes }].filter(p => keep(p.id)),
desktop: [{ id: 'mac', name: 'macOS', note: 'Traffic lights in the sidebar, back/forward in the toolbar.', themes }, { id: 'windows', name: 'Windows', note: 'Title bar with caption buttons, same sidebar and toolbar.', themes }].filter(p => keep(p.id))
};
}
}
</script>
</body>
</html>
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
+39 -7
View File
@@ -73,6 +73,15 @@ rather than needing a rewrite for multi-account support.
there is no WebDAV client dependency. `_parseDavDate`/`_davPath` in this there is no WebDAV client dependency. `_parseDavDate`/`_davPath` in this
file exist because WebDAV responses use RFC 1123 dates and either bare file exist because WebDAV responses use RFC 1123 dates and either bare
paths or full URLs for `href`; reuse them rather than re-deriving. paths or full URLs for `href`; reuse them rather than re-deriving.
`creationdate` specifically needs `_parseDavCreationDate`, not
`_parseDavDate` directly - Nextcloud has no real per-file creation-time
tracking for most setups, so that property routinely comes back as a
placeholder Unix-epoch date rather than being omitted, which
`_parseDavDate` alone parses "successfully" into a real (if bogus)
January 1970 `DateTime`. `_parseDavCreationDate` treats that placeholder
as absent instead, so `NextcloudItem.dateCreated` falls back to
`lastModified` (its constructor's default) the same as it would for a
missing/unparseable value.
- **Everything else** (shares, activity, trash, favorites, quota, user info, - **Everything else** (shares, activity, trash, favorites, quota, user info,
file versions) goes through Nextcloud's OCS APIs (`/ocs/v2.php/...`), JSON file versions) goes through Nextcloud's OCS APIs (`/ocs/v2.php/...`), JSON
in, with the `OCS-APIRequest: true` header required on every OCS call. in, with the `OCS-APIRequest: true` header required on every OCS call.
@@ -197,13 +206,26 @@ rather than needing a rewrite for multi-account support.
Dio from a separate process lifecycle - `UploadService.startUpload` passes Dio from a separate process lifecycle - `UploadService.startUpload` passes
everything the Kotlin side needs (the pre-built `Authorization` header everything the Kotlin side needs (the pre-built `Authorization` header
from `NextcloudService.authHeaders`, not the raw password) as Intent from `NextcloudService.authHeaders`, not the raw password) as Intent
extras, a one-way handoff with no channel back to Dart afterward. Keep extras, a one-way handoff for the start of the upload - the Kotlin side
the two upload implementations in sync manually if upload semantics never asks Dart anything mid-upload, since the app may not even be
change. Progress/cancellation is entirely notification-driven (one running by then. Keep the two upload implementations in sync manually if
ongoing, updatable notification for the whole batch; its Cancel action upload semantics change. Progress/cancellation is entirely notification-
re-delivers an Intent to the same running service instance, which an driven (one ongoing, updatable notification for the whole batch; its
`AtomicBoolean` the copy/upload loops poll) - there's no plumbing back to Cancel action re-delivers an Intent to the same running service instance,
the Dart UI, by design, since the app may not even be running. which an `AtomicBoolean` the copy/upload loops poll) - the notification is
the only UI a closed app gets.
There is one thing that *does* come back, when the app is still running:
once a batch finishes with at least one success, `ShareUploadService.kt`
publishes into `UploadEventBus` (an in-process pub/sub, same shape as
`SyncStatusBus` below), which `MainActivity.kt` forwards to Dart over the
`dev.ayushya.noo/upload_service/status` `EventChannel` -
`UploadService.completions`. `FilesController` subscribes in its own
constructor and calls `refreshData()` when the event's folder matches
`currentFolderPath`, so a file uploaded into the folder currently on
screen shows up without a manual pull-to-refresh. Follows
`SyncService.statusStream`'s "one shared `static final` stream" rule (see
its own doc comment) - a second `receiveBroadcastStream()` subscriber
would silently steal the single native-side listener from the first.
## Being picked by other apps (photo/file picker) ## Being picked by other apps (photo/file picker)
@@ -779,3 +801,13 @@ Flutter's own default to already be high enough.
`isRestoringSession` still gates the splash screen until the above resolves `isRestoringSession` still gates the splash screen until the above resolves
— see `standards.md` for why widget tests must mock both storage channels — see `standards.md` for why widget tests must mock both storage channels
rather than relying on this async path throwing naturally. rather than relying on this async path throwing naturally.
- `putBytes(path, bytes)` does a WebDAV `PUT` to overwrite a file; used by the text/markdown editor (`MediaTextPreview`).
## Changing a share's permissions
`NextcloudService.updateSharePermissions` does `PUT
/ocs/v2.php/apps/files_sharing/api/v1/shares/{id}` with a `permissions`
bitmask (1 read, 2 update, 4 create, 8 delete, 16 reshare). The share
sheet's per-person pill opens a menu with Can view (1) / Can edit (3 for
files, 15 for folders), keeping the existing reshare bit, plus Remove access.
+48 -9
View File
@@ -75,6 +75,19 @@ class/method already makes obvious.
never completes) and the app stays on `_SplashView`'s indeterminate never completes) and the app stays on `_SplashView`'s indeterminate
spinner, which makes `pumpAndSettle()` hang until its own timeout instead spinner, which makes `pumpAndSettle()` hang until its own timeout instead
of failing fast. See `test/widget_test.dart` for the reference setup. of failing fast. See `test/widget_test.dart` for the reference setup.
- Design-system components (`lib/widgets/noo/`) are tested in
`test/widgets/noo/`, one file per component folder. Use the helpers in
`noo_test_utils.dart`:
- `setUpNooTests()` turns off font fetching.
- `testNooWidgets(...)` runs a body once per theme, light and dark, built
through the real `AppTheme`, and hands it the `NooColors`.
- `pumpNoo(...)` mounts the widget.
Build themes inside the test body, not at file level, because `AppTheme`
touches Google Fonts before the test binding exists. `pumpNoo` centers
the child, which loosens its constraints. To catch a widget stretching to
fill its parent, put it in a bounded box (for example, `SizedBox` +
`Align`).
- Run with `flutter test`. - Run with `flutter test`.
## Local install/deploy ## Local install/deploy
@@ -99,15 +112,18 @@ on the device. `android/app/build.gradle.kts` picks a release signing key
in this order: a local `android/key.properties` (gitignored — points at a in this order: a local `android/key.properties` (gitignored — points at a
gitignored keystore file, e.g. `android/app/release-keystore.jks`), then gitignored keystore file, e.g. `android/app/release-keystore.jks`), then
CI env vars (`RELEASE_KEYSTORE_PATH`/`_PASSWORD`, `RELEASE_KEY_ALIAS`/ CI env vars (`RELEASE_KEYSTORE_PATH`/`_PASSWORD`, `RELEASE_KEY_ALIAS`/
`_PASSWORD`, set by `.gitea/workflows/build.yml` from repo secrets), then `_PASSWORD`, set from the same repo secrets by both `.gitea/workflows/
falls back to the debug key if neither is configured. As long as the same build.yml`, triggered by `RC*` tags and producing a sideloadable APK, and
dedicated release keystore backs both `key.properties` locally and the `.gitea/workflows/release.yml`, triggered by `Release-*` tags and producing
Gitea secrets, local release builds and CI-built release APKs share one the `.aab` Play Console wants), then falls back to the debug key if neither
signature, so `adb install -r` works cleanly either way. A local checkout is configured. As long as the same dedicated release keystore backs both
with no `key.properties` set up falls back to the (per-machine, ungitted) `key.properties` locally and the Gitea secrets, local release builds and
debug key, which won't match a CI-signed APK — installing one over the CI-built release APKs share one signature, so `adb install -r` works
other still forces a full uninstall, since there's no way around Android's cleanly either way. A local checkout with no `key.properties` set up falls
signature check from the tooling side. back to the (per-machine, ungitted) debug key, which won't match a
CI-signed APK — installing one over the other still forces a full
uninstall, since there's no way around Android's signature check from the
tooling side.
## Dependencies ## Dependencies
@@ -115,3 +131,26 @@ Networking is deliberately split: `package:http` for simple JSON/XML
request-response calls, `package:dio` only where streaming/progress is request-response calls, `package:dio` only where streaming/progress is
needed (downloads). Don't introduce a third HTTP client — extend the needed (downloads). Don't introduce a third HTTP client — extend the
existing split instead. existing split instead.
## Play Store upload (CI)
`.gitea/workflows/release.yml` (on `Release-*` tags) first checks the tag
equals `Release-<pubspec version>`, builds the signed `.aab`, attaches it to a
Gitea release, then uploads it with `r0adkll/upload-google-play` to the
`internal` track with `status: completed`, so it rolls out to internal
testers automatically (promote to other tracks in Play Console). Requires the `PLAY_SERVICE_ACCOUNT_JSON`
repo secret (service account with release permissions; the app's first
release must be uploaded manually). Play rejects a repeated `versionCode`, so
bump the `+N` in `pubspec.yaml` for every tag - re-tagging the same version
will fail the upload. Release notes come from
`distribution/whatsnew/whatsnew-en-US` (max 500 chars) - update per release.
## Community PR flow
Gitea is the source of truth; GitHub is a push mirror (never commit to GitHub
`main`) and the public issue tracker. `scripts/sync-github-prs.sh` (cron on the
home server) imports open GitHub PRs into Gitea via Agit
(`refs/for/main`, topic `gh-pr-N`), where `.gitea/workflows/pr.yml` runs
analyze + tests. That workflow must never use secrets; signing and Play
credentials stay on the tag-triggered `build.yml`/`release.yml`. Issue
templates are in `.github/ISSUE_TEMPLATE/`.
+302 -55
View File
@@ -1,5 +1,21 @@
# Styling # Styling
**Migration in progress:** the target look is
[`design-system/DESIGN_SYSTEM.md`](design-system/DESIGN_SYSTEM.md) — warm
neutrals, one violet accent, pill controls, Schibsted Grotesk/Instrument
Sans, no gradients/shadows — built from the `widgets/noo/` component kit
(see "Noo design-system components" below). Rebuilt so far: the app shell
(top/bottom bars, drawer, sidebar, toolbar), Files/Offline, Photos,
Favorites, Recent, Activity, Trash, Shares, Settings, the lock screen,
login, `ShareUploadView`, `MoveCopyDestinationPicker`,
`MoveCopyConflictSheet`, the shared `Breadcrumbs` widget, and the file
details/share bottom sheets/dialogs (`DetailsSheet`/`ShareSheet`). **Still on
the pre-rework Material 3 theme** documented in "Theme" and "Reusable
chrome" below: `FileViewerScreen` (the media viewer), `SearchView`, and
`LoginWebviewView`. Update this file to describe each area as it gets
reworked, rather than leaving stale Material 3 guidance next to a design
system that's already superseded it.
## Theme ## Theme
All theming goes through [`AppTheme`](../../lib/theme/app_theme.dart) All theming goes through [`AppTheme`](../../lib/theme/app_theme.dart)
@@ -14,10 +30,12 @@ widgets. Key points:
OS-provided `ColorScheme` wins over the seed color — always thread both OS-provided `ColorScheme` wins over the seed color — always thread both
`dynamicScheme` and `useDynamicColor` through when adding a theme knob. `dynamicScheme` and `useDynamicColor` through when adding a theme knob.
- **Font**: Inter via `google_fonts`, applied through - **Font**: Inter via `google_fonts`, applied through
`GoogleFonts.interTextTheme(...)`. In tests, set `GoogleFonts.interTextTheme(...)`. Font files ship in
`GoogleFonts.config.allowRuntimeFetching = false` in `setUpAll` — without `assets/google_fonts/` (Instrument Sans, Schibsted Grotesk) and
it, the font-fetch call to Google's CDN can stall `pumpAndSettle` `main()` sets `GoogleFonts.config.allowRuntimeFetching = false`, so the
indefinitely (see `standards.md`). app never contacts Google (the privacy policy on the website relies on
this — keep it true; add new weights as bundled files). Tests set the
same flag in `setUpAll` (see `standards.md`).
- **Cards**: flat (`elevation: 0`), 20px rounded corners, - **Cards**: flat (`elevation: 0`), 20px rounded corners,
`surfaceContainerLow`. `surfaceContainerLow`.
- **App bars**: flat, not centered, `surface` background. - **App bars**: flat, not centered, `surface` background.
@@ -34,62 +52,263 @@ widgets. Key points:
and no `scrollbarTheme` override (tried once, explicitly reverted). Don't and no `scrollbarTheme` override (tried once, explicitly reverted). Don't
reintroduce one without being asked. reintroduce one without being asked.
## Noo design-system components
The rebuilt UI is assembled from `lib/widgets/noo/`, which implements
`DESIGN_SYSTEM.md` §2–3. Build new screens from these rather than from raw
Material widgets or the pre-rework chrome below.
- **Tokens** live in [`design_tokens.dart`](../../lib/theme/design_tokens.dart):
colors via `context.nooColors` (a `NooColors` `ThemeExtension`), plus
`NooText`, `NooSpace`, `NooRadii`, `NooSizes`, `NooMotion` and
`nooDialogShadow`. `NooText` styles set no color; callers add it with
`copyWith(color: ...)`. Flutter's `TextStyle.height` is a multiple of font
size, so the spec's CSS line-height maps to it directly (for example,
0.9 → `height: 0.9`).
- **Icons are Lucide** (`lucide_icons_flutter`, `LucideIcons.*`) inside
`noo/`, never Material `Icons.*`.
- **Components are data-agnostic.** They take strings, icons, colors and
callbacks, not `NextcloudItem`/`AppTab`, so the screen layer maps models
onto them.
- **Platform differences** are chosen with a flag rather than by reading the
platform inside the widget: `NooNavStyle` (`ios`/`android`, with
`NooNavStyle.fromPlatform`) for nav chrome, and `iosStyle` on rows and
cards for the ellipsis vs. vertical-ellipsis overflow icon.
Catalog:
| Folder | Components |
|---|---|
| `core/` | `NooButton`, `NooFab`, `NooChip`, `NooSegmentedControl`, `NooToggle`, `NooSearchField`, `NooAvatar`, `NooBadge`, `NooProgressBar` |
| `lists/` | `NooGroupedList`, `NooSettingsRow`, `NooTabOrderRow`, `NooBanner`, `NooInfoNote` (tinted info callout; `SettingsSection(notice:)`), `NooSummaryCard`, `NooSelectionBar` |
| `files/` | `NooFileKind` (spec §1.2 tiles; `NooFileKind.from(name:, mimeType:, isDirectory:)`), `NooFileTile`, `NooStatusIcon`/`NooSyncStatus`, `NooFileRow` (mobile 64px), `NooFileTableHeader`/`NooFileTableRow` (desktop), `NooSwipeAction` |
| `media/` | `NooGridCard`, `NooPhotoTile` (video badge, selection), `NooPhotoGroupHeader`/`NooPhotoGrid` (sliver, or `.box`), `NooActivityItem`, `NooStatCard` |
| `nav/` | `NooBottomBar`, `NooTopBar` (a `PreferredSizeWidget`) with `NooTopBarButton`/`NooTopBarBack`, `NooDrawer` with its `Account`/`Storage`/`Item`/`Link` parts, `NooSidebar` with `NooSidebarItem`/`Divider`/`Account`/`Storage`, `NooToolbar` (floating rounded card with margin; `NooToolbar.outerHeight` for `appBar` sizing) |
| `overlays/` | `showNooSheet`, `showNooDialog`/`NooDialog`, `NooOverlayHeader`, `NooTextField`, and the share parts `NooShareSection`, `NooPersonAccessRow`, `NooPermissionPill` |
Gotchas:
- `NooDrawer` can't set its own scrim. The host `Scaffold` needs
`drawerScrimColor: context.nooColors.scrim`.
- `NooGroupedList` draws dividers by showing `line` through 1px gaps, so each
child must paint its own surface (`NooSettingsRow` and `NooTabOrderRow`
do). Its `collapsible`/`initiallyExpanded` params (off by default) make
`label` a tap target that shows/hides the card - no current caller opts
in (Settings' mobile sections used to, when every section rendered
inline in one long column; now each section is its own pushed screen -
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
built yet. `NooSidebar.windowControls` is the slot for it.
- Always read colors through `context.nooColors`, never
`Theme.of(context).colorScheme` - the latter is Flutter's own Material 3
scheme, reseeded by the user's accent color choice (`AppTheme.light`/
`.dark`), so anything painted from it carries a faint hue of whatever
accent is picked instead of the app's neutral palette. `sticky_header_delegate.dart`
shipped with this bug once already (a pinned controls-row header tinted
by the accent instead of matching its screen's plain `colors.bg`).
- `NooSegmentedControl` defaults to `onSurface: false` (`colors.surface`
fill) - right when it's sitting directly on `colors.bg` (List/Grid
toggle, Shares' scope switcher), but pass `onSurface: true` for one
placed inside a sheet or dialog (already `colors.surface`), or its pill
track blends invisibly into the sheet instead of reading as a grouped
control (Files'/Photos' sort and type-filter sheets do this).
- Any `RefreshIndicator` needs `physics: const AlwaysScrollableScrollPhysics()`
on its scrollable child, or pull-to-refresh silently can't be triggered
once the list is short enough to fit the viewport (empty, or one item) -
every tab's `CustomScrollView` sets this explicitly for exactly that
reason.
## Reusable chrome ## Reusable chrome
- `MediaGridTile` (`lib/widgets/media_grid_tile.dart`): the full-bleed image/video **Rebuilt on the Noo design system** (Files/Offline, Photos, Favorites,
grid card with name/size scrim, shared by Files (server preview) and Offline Recent, Activity, Trash, Shares, Settings, the app shell, lock screen,
(local `FileImage`, images only - no video frame-extraction plugin, so offline login, `ShareUploadView`, `MoveCopyDestinationPicker`, `DetailsSheet` and
videos use the plain icon card). `ItemThumbnail` takes an optional `localFile` `ShareSheet`): these no longer use the pieces below. Their own building
for the same offline-image case in list tiles. blocks are noted where they matter:
- `FilesControlsRow` (`lib/widgets/files_controls_row.dart`): the
sort/hidden/scope/type-filter/view-mode row, shared by the Files and
Offline tabs (`showStorageScope: false` for Offline).
- `FilesControlsRow` (`lib/widgets/files_controls_row.dart`) — now built from
`NooChip`/`NooSegmentedControl`; still the sort/hidden/scope/type-filter/
view-mode row shared by Files and Offline (`showStorageScope: false` for
Offline), and reused as-is by Favorites.
- `lib/widgets/files/file_breadcrumb_row.dart` — the noo-styled breadcrumb
trail Files uses in place of the shared
[`Breadcrumbs`](../../lib/widgets/breadcrumbs.dart) widget. `Breadcrumbs`
is now also noo-styled (same tokens, private-widget-turned-shared) — it's
used only by `ShareUploadView`/`MoveCopyDestinationPicker`, which is why
it was safe to restyle directly instead of forking another
`FileBreadcrumbRow`-style copy; don't move Files back onto it.
`lib/widgets/tabs/` (`tab_state_slivers.dart`, `tab_day_groups.dart`,
`tab_location.dart`) — the loading/error/empty-state slivers and
day/month grouping helpers shared by Recent/Activity/Trash/Shares.
`tab_state_slivers.dart` also has `topBarSliver`, which every regular
tab's view uses to plant its own `AppTopBar` instance as the first sliver
in its `CustomScrollView` (a thin wrapper around the framework's
`SliverFloatingHeader`) instead of the shell passing one shared instance
to `Scaffold.appBar` - see its doc comment and `architecture.md`'s
"Mobile" bullet for why.
`lib/widgets/settings/` — Settings' 8 section widgets plus
`settings_section.dart`'s `SettingsSection`/`showSettingsPicker` and
`settings_dialogs.dart`'s `confirmRemoveAccount`.
`lib/widgets/shell/shell_common.dart` — account/storage formatting,
`openSettings`/`openSearch`, `showAccountSwitcher`, `ShellAvatarButton`,
`ShellSearchLauncher`, shared by the mobile and desktop shell chrome.
- [`getItemIcon`/`getIconColor`/`ItemThumbnail`](../../lib/widgets/item_icon.dart) - [`getItemIcon`/`getIconColor`/`ItemThumbnail`](../../lib/widgets/item_icon.dart)
— the icon/color/thumbnail treatment for a file or folder, shared by any — `getItemIcon`/`getIconColor` are superseded by `NooFileKind` in
screen that lists `NextcloudItem`s the way the Files tab does (currently rebuilt screens; `ItemThumbnail` is still reused as-is, fed into
`files_view.dart` and `share_upload_view.dart`'s destination picker). `NooFileTile`/`NooFileRow`/`NooFileTableRow`/`NooGridCard`'s `thumbnail`
Extend this rather than re-deriving per-type icons/colors at a new call slot. `ShareUploadView`/`MoveCopyDestinationPicker` don't use any of the
site. three any more - both destination pickers now list folders only (see
- [`DetailsHeader`](../../lib/widgets/details/details_sheet.dart) — the below), and a folder never gets a real thumbnail (only image/video do),
icon-box/name/meta row every per-item bottom sheet opens on so a plain `NooFileTile(kind: NooFileKind.folder)` covers every row.
(`DetailsSheet`, the media viewer's collapsed peek state, and - `DetailsSheet`/`ShareSheet` (`lib/widgets/details/details_sheet.dart`,
`ShareSheet`). Takes a `padding` override for callers whose own scroll `lib/widgets/share_sheet.dart`) are rebuilt: `showNooSheet`/`showNooDialog`
view already applies horizontal insets (`ShareSheet`'s `ListView`), so it per `NooLayout.isDesktop`, with the header built from `detailsFileTile`
doesn't get doubled up. Reuse this instead of a bare title `Text` for any (a `NooFileTile` keyed by `NooFileKind.from`) and `detailsMetaLine` -
new per-item sheet - a plain title reads as under-designed next to the shared top-level helpers in `details_sheet.dart` so both sheets open on
other sheets (a real instance: `ShareSheet` used to be just that). the same header, replacing the old `DetailsHeader` widget. Neither sheet
uses `showGradualBottomSheet`'s drag-to-resize any more: `DetailsSheet`
swaps Info/Versions/Activity with a `NooSegmentedControl` (there's no
tab-strip component in the noo kit) instead of a `TabBar`/`TabBarView`,
and both sheets' content sits in one `Column` so `showNooSheet`'s/
`showNooDialog`'s own `SingleChildScrollView` handles overflow - a fixed
page per tab no longer needs a resizable sheet to see the rest.
`showGradualBottomSheet` (`lib/widgets/gradual_bottom_sheet.dart`) has no
remaining callers as a result.
`ShareSheet`'s "Share with people"/"Share link"/"Send file directly"
sections follow `DESIGN_SYSTEM.md` §4 via `NooShareSection`/
`NooPersonAccessRow`/`NooPermissionPill`; the permission pill and the
link's permission/expiry chips are read-only display (no
`ItemOperations` call updates a share's permission/expiry/password/
hide-download yet) - promote those to real controls once that exists.
Removing a person/group/email share is reachable by tapping their
permission pill, which opens a small "Remove access" menu.
[`MoveCopyConflictSheet`](../../lib/widgets/move_copy_conflict_sheet.dart)
is rebuilt: `showNooDialog`/`showNooSheet` (per `NooLayout.isDesktop`), a
`NooGroupedList` of file-tile rows with an inline
`NooSegmentedControl<ConflictChoice>` once "Decide per item" is picked,
and `NooButton`s for overwrite-all/keep-both/decide-per-item/confirm. Its
`show(BuildContext, List<MoveCopyConflict>)` API is unchanged.
- [`FrostedGlassContainer`](../../lib/widgets/frosted_glass_container.dart) — - [`FrostedGlassContainer`](../../lib/widgets/frosted_glass_container.dart) —
the blurred/translucent pill background shared by all floating chrome the blurred/translucent pill background for the media viewer's top/bottom
(bottom nav bar, media-viewer top/bottom bars and video transport bars and video transport controls (its other former user, the floating
controls). Reuse this for any new floating overlay instead of building a bottom nav bar, is gone - see below). Reuse this for any new floating
new blur/shadow combo. overlay instead of building a new blur/shadow combo.
- [`FloatingBottomNavBar`](../../lib/widgets/floating_bottom_bar.dart) — the - Upload FAB size: `SettingsController.fabStyle` (`FabStyle.auto/mini/expanded`,
main tab bar; opacity/blur are user-adjustable settings Settings > Appearance > "Upload button") drives `NooFab.collapsed` in
(`SettingsController.bottomBarOpacity`/`bottomBarBlur`), not constants — `main.dart` - auto collapses off Files/Photos, mini always icon-only,
pull new adjustable visual knobs from `SettingsController` the same way expanded always labelled. Phone/Android layout only.
rather than hardcoding them. - PDF viewer (`media_pdf_preview.dart`): pdfx's InteractiveViewer won't zoom
out past `viewport.height / doc.height`, so for short documents (<= 3
pages, measured up front) the full-size `PdfViewPinch` is wrapped in a
`MediaQuery` with extra bottom padding (pdfx's child is a `SafeArea`) so
the boundary height reaches the viewport and fit-width stays reachable
after pinch-zooming, without shrinking the canvas. `topInset` (status bar
+ top bar height, 0 when the controls are hidden) animates the viewport
below the overlaid top bar.
- `NooGroupedList.notice`: optional widget (e.g. `NooInfoNote`) rendered
under the label, above the card; `SettingsSection` uses it for its
`notice` on phones.
- [`MediaDetailsPanel`](../../lib/widgets/viewer/media_details_panel.dart) —
the media viewer's bottom bar as a draggable panel (mobile/phone only;
tablet-class still uses the `DetailsSheet` dialog). Dragging the bar, the
Details button, or swiping up on swipeable media (raw `Listener` in
`file_viewer_screen.dart`) grows it upward so the action row rides on top
of the details; tapping the media collapses it.
- `SettingsController.bottomBarOpacity`/`bottomBarBlur` and
`lib/widgets/floating_bottom_bar.dart`/`media_grid_tile.dart`/
`swipeable_item.dart`/`sync_status_badge.dart`/`selectable_thumbnail.dart`
are gone: the bottom bar is now the flat, non-blurred `NooBottomBar` (no
opacity/blur knob - flat surfaces per the design system), grid tiles are
`NooGridCard`, swipe actions are `NooSwipeAction`, and per-item sync
status is `NooFileRow`/`NooFileTableRow`'s built-in `NooStatusIcon` list
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. In `avatarMenu` mode `main.dart` also sets
`Scaffold.drawerEnableOpenDragGesture: false` so the edge swipe can't open
the drawer. The menu header has a chevron that expands an account section
(other saved accounts to switch to, "Add Account", "Manage Accounts")
above the hidden tabs/Settings. `showAvatarMenu` is this app's first use of
`showGeneralDialog` directly (`barrierColor: Colors.transparent` +
`barrierDismissible: true` for a non-dimming click-outside-to-close menu,
not a modal flow) - there's no existing anchored-popup primitive here
(`PopupMenuButton`'s own width doesn't stretch to a full content column),
so don't reach for `showNooSheet`/`showNooDialog` for something shaped
like this. It's positioned just past the status bar (`SafeArea`'s own
inset, not the top bar's full height on top of that) so it covers the
top bar - including the tab title - rather than sitting below it, and
its card carries two stacked `boxShadow`s rather than just
`nooDialogShadow` alone: that one shadow's blur is wide and soft enough
to read as basically invisible on a small card over a dark theme's
near-black `bg` (a dark, diffuse shadow needs real density close to the
edge to be visible against an already-dark backdrop), so a second,
tighter, more opaque contact shadow underneath it gives real elevation
in both themes. The card's border can go missing wherever an opaque row
sits against it too (every corner but the header's, which has no
full-bleed fill of its own) if a `Container` combines `border` with its
own `clipBehavior` - that paints the border as part of the *outer*
decoration, then the clipped child on top right up to the same boundary,
with no gap for the border's own stroke to show through. Fixed the same
way any bordered-and-clipped `Container` should be: no `clipBehavior` on
the bordered `Container` itself, and a 1px-inset `ClipRRect` (radius
reduced by that same 1px) around the filled, clipped content instead, so
it never paints over the border. Also added
`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 shared by all 8 tabs (see the pull-to-sync `CustomScrollView` header with the persistent sync-status
`architecture.md`); also where the pull-to-refresh gesture thresholds and chip and pull-to-refresh gesture/spinner. Every screen (including
the classic Material refresh spinner live. Its persistent compact chip `ShareUploadView`/`MoveCopyDestinationPicker`, its last two users) has
(icon + "Sync off"/"Syncing…"/"Synced"/"Sync issue") reflects device-sync dropped it for a plain `RefreshIndicator` + `CustomScrollView`
status (`SyncStatusController.syncHeaderStatus`), not the WebDAV-refresh (device-sync status now shows per-row via `NooStatusIcon`/the Offline
loading state the pull gesture itself triggers - that has its own, `NooSummaryCard`, not a shared header chip), so the `SyncedHeaderScaffold`
separate floating spinner bubble, so nothing was lost by handing the class itself is now dead code - kept only because the same file's
persistent text/icon over. The expanded panel's headline is a separate, top-level `formatBytes` helper is still widely used
more detailed string (`_syncSummary` in `synced_header_scaffold.dart`) - (`files_view.dart`/`favorites_view.dart`/`shell_common.dart`/
counts of what's actually configured to sync ("2 folders & 1 file `widgets/details/*`).
synced") rather than just repeating the chip's generic label. - [`SegmentedIconGroup`/`ToggleIconButton`](../../lib/widgets/segmented_icon_toggle.dart)
Deliberately doesn't add up the individual files inside a synced folder and [`SortMenuButton`](../../lib/widgets/sort_menu_button.dart) — the
("1 folder synced", not "1 folder & 4 items synced") - once a folder's Material sort/filter-chip pieces `ShareUploadView`/
synced, its file count is an implementation detail, not something the `MoveCopyDestinationPicker` used to mirror Files' old controls row with.
user picked. Both destination pickers dropped that whole row (they only ever browse
- [`SyncStatusBadge`](../../lib/widgets/sync_status_badge.dart) — the small folders, so sort/hidden/scope/type-filter/grid controls don't apply), so
corner badge over a thumbnail showing per-item device-sync status these two files are now dead code too - `Breadcrumbs` is the only shared
(`cloud_done`/`sync`, nothing for not-synced/conflict); used in Files' widget promoted to noo styling instead of removed, since Files' own
list and grid tiles today. Reuse this rather than a new ad hoc badge if `FileBreadcrumbRow` proved the same trail is still wanted elsewhere.
another view starts showing sync status per item.
- [`SeekBarPainter`/`SeekBarPreview`](../../lib/widgets/seek_bar_painter.dart) - [`SeekBarPainter`/`SeekBarPreview`](../../lib/widgets/seek_bar_painter.dart)
— the four `MediaProgressBarStyle` presets (Default/Wavy/Slim/Squiggly) — the four `MediaProgressBarStyle` presets (Default/Wavy/Slim/Squiggly)
for the video player's seek bar, plus a perpetually-animated for the video player's seek bar, plus a perpetually-animated
@@ -127,4 +346,32 @@ widgets. Key points:
ColorFilter.mode(colorScheme.onSurface, BlendMode.srcIn), ...)` so it ColorFilter.mode(colorScheme.onSurface, BlendMode.srcIn), ...)` so it
reads correctly in both light and dark mode. Never the full-color reads correctly in both light and dark mode. Never the full-color
`app_icon.png`/adaptive-icon assets for in-app UI — those are for the `app_icon.png`/adaptive-icon assets for in-app UI — those are for the
launcher icon only (`flutter_launcher_icons` in `pubspec.yaml`). launcher icon only. The launcher icon is maintained by hand from an
IconKitchen export (`mipmap-*` in `android/app/src/main/res`); there is no
generator step. `assets/icon/app_icon_monochrome.png` is deliberately a
tightly-cropped glyph (unlike the launcher's safe-zone-padded monochrome
layer), so it renders at a sensible size at 72-80px.
## Text/markdown viewer
`MediaTextPreview` is an editable monospace `TextField` padded clear of the status bar, top bar and action bar; a Save button appears when dirty (read-only for offline copies). `NooPersonAccessRow.trailing` replaces the owner label/permission pill (used by share-search results). Sheets whose close button lives in `showNooSheet` children must pop via a `Builder` context, not the caller's.
`showNooSheet` insets its body by the keyboard (`viewInsets.bottom`) so focused fields stay visible; the body's widget structure must not change when the keyboard opens, or the sheet content is rebuilt and loses focus.
Markdown files (`.md`/`.markdown`) in `MediaTextPreview` open rendered via `flutter_markdown_plus` (`Markdown`, styled from Noo tokens in `_markdownStyle`), with a top-right Edit/Preview toggle (hidden for read-only offline copies). Other text files go straight to the editor.
`ShareSheet`: focusing the people search field does not scroll; once the user types, `_revealPeopleSection` animates the "Share with people" section (keyed by `_peopleKey` on the `NooShareSection`, not the inner column) to just below the sheet's top edge with a small gap.
## Desktop/tablet layout notes
- The desktop branch in `main.dart` is wrapped in `SafeArea` so tablets keep
system bars clear of the sidebar; the sidebar has no divider between pinned
and hidden tabs.
- Desktop Settings is the same two-level menu as mobile (centered 640px column, back button in the floating toolbar); the
pushed category screens render the desktop card layout.
- Settings that don't apply in wide-tablet (Appearance nav options,
Swipe on a file) show a `NooInfoNote` via `SettingsSection.notice`.
- Mobile-layout grids (Files, Favorites, move/copy picker, share upload,
Photos) take their column count from `NooLayout.gridColumns` (min tile
width, never below the phone count), so a tablet in portrait gets more
columns rather than a few huge tiles.
+1
View File
@@ -0,0 +1 @@
*.sh text eol=lf
+26
View File
@@ -0,0 +1,26 @@
name: Check PR
# Runs analyze + tests on pull requests. Deliberately uses no secrets, so it
# is safe against imported contributor code. Keep it that way: signing keys
# and the Play credentials are only available to the tag-triggered workflows.
on:
pull_request:
branches: [main]
permissions:
contents: read
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Flutter
uses: subosito/flutter-action@v2
with:
channel: stable
- run: flutter pub get
- run: flutter analyze
- run: flutter test
+105
View File
@@ -0,0 +1,105 @@
name: Build App Bundle
# Deliberately not "on every push" - only when a real release tag (Release-1.0.0,
# Release-1.2.3, ...) is pushed. Tag the commit you want built:
# git tag Release-1.0.0 && git push origin Release-1.0.0
# Unlike the RC* flow (build.yml), this builds the .aab Play Console wants,
# not a sideloadable .apk.
on:
push:
tags:
- "Release-*"
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@v4
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "17"
- name: Set up Flutter
uses: subosito/flutter-action@v2
with:
channel: stable
- name: Set up Android SDK
uses: android-actions/setup-android@v3
with:
# Google removed the legacy standalone "tools" package from the
# SDK repository (~Sep 2026); the action's own default packages
# list ("tools platform-tools") now makes its internal sdkmanager
# call fail for everyone. platform-tools is all this step needs
# to install itself - flutter build fetches whatever
# platform/build-tools versions it actually needs on its own.
packages: "platform-tools"
- name: Check tag matches pubspec version
# Play rejects a repeated versionCode and a mismatched tag would
# publish the wrong version name, so fail fast before building.
run: |
VERSION=$(grep '^version:' pubspec.yaml | sed 's/version: //' | cut -d'+' -f1)
if [ "${GITHUB_REF_NAME}" != "Release-${VERSION}" ]; then
echo "Tag ${GITHUB_REF_NAME} does not match pubspec version ${VERSION}" >&2
exit 1
fi
- name: Install dependencies
run: flutter pub get
- name: Analyze
run: flutter analyze
- name: Decode release keystore
# Same key as the RC* APK builds, so a Play Store upload's signature
# matches anything sideloaded from a release build. See
# android/app/build.gradle.kts.
run: echo "$RELEASE_KEYSTORE_BASE64" | base64 -d > "${{ runner.temp }}/release-keystore.jks"
env:
RELEASE_KEYSTORE_BASE64: ${{ secrets.RELEASE_KEYSTORE_BASE64 }}
- name: Build release App Bundle
run: flutter build appbundle --release
env:
RELEASE_KEYSTORE_PATH: ${{ runner.temp }}/release-keystore.jks
RELEASE_KEYSTORE_PASSWORD: ${{ secrets.RELEASE_KEYSTORE_PASSWORD }}
RELEASE_KEY_ALIAS: ${{ secrets.RELEASE_KEY_ALIAS }}
RELEASE_KEY_PASSWORD: ${{ secrets.RELEASE_KEY_PASSWORD }}
- name: Rename bundle to Noo-<version>.aab
run: |
VERSION=$(grep '^version:' pubspec.yaml | sed 's/version: //' | cut -d'+' -f1)
mkdir -p release
cp build/app/outputs/bundle/release/app-release.aab "release/Noo-${VERSION}.aab"
echo "AAB_PATH=release/Noo-${VERSION}.aab" >> "$GITHUB_ENV"
echo "VERSION=${VERSION}" >> "$GITHUB_ENV"
- name: Publish Gitea release with App Bundle attached
# Gitea-native release action (not GitHub's) - authenticates and
# talks to this same Gitea instance automatically via the
# `github.token`/`github.server_url` context Gitea Actions
# provides for compatibility. Release is named/tagged after
# whatever tag triggered this run (e.g. "Release-1.0.0").
uses: akkuman/gitea-release-action@v1
with:
files: ${{ env.AAB_PATH }}
- name: Upload to Google Play (internal track, auto-publish)
# Rolls the build out to internal testers automatically
# (`status: completed`); promote it to other tracks in Play Console.
# Needs the PLAY_SERVICE_ACCOUNT_JSON repo secret, and the app's
# first release must already have been uploaded by hand.
uses: r0adkll/upload-google-play@v1
with:
serviceAccountJsonPlainText: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
packageName: dev.ayushya.noo
releaseFiles: ${{ env.AAB_PATH }}
releaseName: ${{ env.VERSION }}
track: internal
status: completed
whatsNewDirectory: distribution/whatsnew
+33
View File
@@ -0,0 +1,33 @@
name: Bug report
description: Something isn't working as expected
labels: [bug]
body:
- type: textarea
id: what
attributes:
label: What happened?
description: What you did, what you expected, and what happened instead.
validations:
required: true
- type: input
id: version
attributes:
label: Noo version
description: Settings → About
validations:
required: true
- type: input
id: device
attributes:
label: Device and OS version
validations:
required: true
- type: input
id: server
attributes:
label: Nextcloud server version
- type: textarea
id: extra
attributes:
label: Screenshots or logs
description: Remove any personal data or server addresses you don't want public.
+5
View File
@@ -0,0 +1,5 @@
blank_issues_enabled: false
contact_links:
- name: Security problem
url: https://github.com/ayushyamitabh/noo/blob/main/SECURITY.md
about: Please report security issues privately, not as public issues.
@@ -0,0 +1,22 @@
name: Feature request
description: Suggest an improvement
labels: [enhancement]
body:
- type: markdown
attributes:
value: >
Noo aims to be a simple, just-works Nextcloud client with reasonable
customization, not a replacement for every Nextcloud feature. Requests
that fit that goal are most likely to be accepted.
- type: textarea
id: problem
attributes:
label: What problem are you trying to solve?
validations:
required: true
- type: textarea
id: idea
attributes:
label: What would you like to see?
validations:
required: true
+27
View File
@@ -0,0 +1,27 @@
## Summary
<!-- What does this change, and why? Link the issue it closes, e.g. "Closes #123". -->
## Changes
-
## Screenshots / recordings
<!-- Required for UI changes: before/after, light + dark, phone + tablet width where relevant. Delete if not applicable. -->
## Testing
<!-- How did you verify this? Device/emulator and OS version for anything platform-specific. -->
- [ ] `flutter analyze` is clean
- [ ] `flutter test` passes
- [ ] Tried it on a device or emulator (UI / platform changes)
- [ ] Tested against a real Nextcloud server (server / sync changes)
## Checklist
- [ ] New or changed behavior has tests where practical
- [ ] Docs in `.claude/context/` updated if architecture, server integration, styling, or standards changed
- [ ] No secrets, server URLs, credentials, or personal data in code, tests, or screenshots
- [ ] Commits follow the commit message template (`.gitmessage`)
+12
View File
@@ -44,3 +44,15 @@ app.*.map.json
/android/app/debug /android/app/debug
/android/app/profile /android/app/profile
/android/app/release /android/app/release
# Local Claude Code state
.claude/worktrees/
.claude/settings.local.json
.claude/scheduled_tasks.lock
# Signing / secrets (never commit)
*.keystore
*.jks
key.properties
.env
.env.*
+17
View File
@@ -0,0 +1,17 @@
# <Summary: imperative, sentence case, no trailing period, <= 72 chars>
#
# Why is this change needed? What does it do? Wrap lines at 72 chars.
# Explain the "why"; the diff already shows the "what".
#
# Examples of good summaries:
# Fix PDF zoom not returning to fit-width
# Add Upload button size setting
# Bump to 1.0.2+5
#
# Closes #<issue>
#
# Trailers (keep the ones that apply):
# Co-Authored-By: Name <email>
# ----------------------------------------------------------------------
# Lines starting with '#' are ignored. Enable this template once with:
# git config commit.template .gitmessage
+5
View File
@@ -15,6 +15,11 @@ relevant file(s) before working in that area rather than loading all of them:
conventions, fonts, reusable chrome widgets conventions, fonts, reusable chrome widgets
- [`standards.md`](.claude/context/standards.md) — code style, comment - [`standards.md`](.claude/context/standards.md) — code style, comment
conventions, linting, testing conventions, linting, testing
- [`design-system/DESIGN_SYSTEM.md`](.claude/context/design-system/DESIGN_SYSTEM.md)
— the target visual/component spec (tokens, components, platform
mapping, screen recipes) for the in-progress UI rework; the sibling
`.dc.html`/`noo-kit.js` files it references are the visual/token
reference builds, not something to read directly
## Common commands ## Common commands
+78
View File
@@ -0,0 +1,78 @@
# Contributing to Noo
Thanks for your interest in improving Noo. Bug reports, ideas, and pull
requests are welcome.
## Before you start
- For anything bigger than a small fix, open an issue first so we can agree on
the approach before you spend time on it.
- Read [`CLAUDE.md`](CLAUDE.md) and the topic files in
[`.claude/context/`](.claude/context): architecture, server integration,
styling, and code standards. They apply to human and AI contributors alike.
- Never include real server URLs, usernames, app passwords, or personal files
in issues, tests, or screenshots.
## Setup
```bash
flutter pub get
flutter run
```
You need a Nextcloud server to sign in to. A throwaway local instance is best
for testing anything destructive (delete, move, trash).
## Making a change
1. Branch from `main`.
2. Keep the change focused; unrelated cleanup belongs in its own PR.
3. Run the checks:
```bash
flutter analyze
flutter test
```
4. Format only the files you touched (`dart format <files>`), not the whole
tree.
5. If you changed architecture, server integration, styling conventions, or
standards, update the matching file in `.claude/context/` in the same PR.
6. Open a pull request and fill in the template.
## How PRs and issues are handled
Issues and pull requests live on GitHub. The maintainer's source of truth is
a self-hosted Gitea instance: open PRs are imported there, reviewed, and
merged there, and `main` is then mirrored back to GitHub. Two things follow:
- Keep discussion on the GitHub PR; that is where I'll reply.
- A merged change may show up on GitHub as the PR being closed with a link to
the commit rather than as "Merged". Your authorship is preserved.
- Imported PRs run `flutter analyze` and `flutter test` on my server, so
please run both locally first.
## Commit messages
Use the repository's template so history stays consistent:
```bash
git config commit.template .gitmessage
```
Write an imperative, sentence-case summary of 72 characters or fewer, then a
blank line and a short explanation of *why* when it isn't obvious.
## License of contributions
Noo is licensed under the [PolyForm Shield License 1.0.0](LICENSE). By
submitting a contribution you confirm that you wrote it (or have the right to
submit it), and you license it to the maintainer and everyone who receives the
project under those same terms. You also grant the maintainer the right to
relicense the project, including your contribution, in the future.
## Security
Please do not file security problems as public issues. Email
[hello@ayushya.dev](mailto:hello@ayushya.dev) with the details and I'll
respond as soon as I can.
+91
View File
@@ -0,0 +1,91 @@
Required Notice: Copyright (c) 2026 Ayushya (https://ayushya.dev)
# PolyForm Shield License 1.0.0
<https://polyformproject.org/licenses/shield/1.0.0>
## Acceptance
In order to get any license under these terms, you must agree to them as both strict obligations and conditions to all your licenses.
## Copyright License
The licensor grants you a copyright license for the software to do everything you might do with the software that would otherwise infringe the licensor's copyright in it for any permitted purpose. However, you may only distribute the software according to [Distribution License](#distribution-license) and make changes or new works based on the software according to [Changes and New Works License](#changes-and-new-works-license).
## Distribution License
The licensor grants you an additional copyright license to distribute copies of the software. Your license to distribute covers distributing the software with changes and new works permitted by [Changes and New Works License](#changes-and-new-works-license).
## Notices
You must ensure that anyone who gets a copy of any part of the software from you also gets a copy of these terms or the URL for them above, as well as copies of any plain-text lines beginning with `Required Notice:` that the licensor provided with the software. For example:
> Required Notice: Copyright Yoyodyne, Inc. (http://example.com)
## Changes and New Works License
The licensor grants you an additional copyright license to make changes and new works based on the software for any permitted purpose.
## Patent License
The licensor grants you a patent license for the software that covers patent claims the licensor can license, or becomes able to license, that you would infringe by using the software.
## Noncompete
Any purpose is a permitted purpose, except for providing any product that competes with the software or any product the licensor or any of its affiliates provides using the software.
## Competition
Goods and services compete even when they provide functionality through different kinds of interfaces or for different technical platforms. Applications can compete with services, libraries with plugins, frameworks with development tools, and so on, even if they're written in different programming languages or for different computer architectures. Goods and services compete even when provided free of charge. If you market a product as a practical substitute for the software or another product, it definitely competes.
## New Products
If you are using the software to provide a product that does not compete, but the licensor or any of its affiliates brings your product into competition by providing a new version of the software or another product using the software, you may continue using versions of the software available under these terms beforehand to provide your competing product, but not any later versions.
## Discontinued Products
You may begin using the software to compete with a product or service that the licensor or any of its affiliates has stopped providing, unless the licensor includes a plain-text line beginning with `Licensor Line of Business:` with the software that mentions that line of business. For example:
> Licensor Line of Business: YoyodyneCMS Content Management System (http://example.com/cms)
## Sales of Business
If the licensor or any of its affiliates sells a line of business developing the software or using the software to provide a product, the buyer can also enforce [Noncompete](#noncompete) for that product.
## Fair Use
You may have "fair use" rights for the software under the law. These terms do not limit them.
## No Other Rights
These terms do not allow you to sublicense or transfer any of your licenses to anyone else, or prevent the licensor from granting licenses to anyone else. These terms do not imply any other licenses.
## Patent Defense
If you make any written claim that the software infringes or contributes to infringement of any patent, your patent license for the software granted under these terms ends immediately. If your company makes such a claim, your patent license ends immediately for work on behalf of your company.
## Violations
The first time you are notified in writing that you have violated any of these terms, or done anything with the software not covered by your licenses, your licenses can nonetheless continue if you come into full compliance with these terms, and take practical steps to correct past violations, within 32 days of receiving notice. Otherwise, all your licenses end immediately.
## No Liability
***As far as the law allows, the software comes as is, without any warranty or condition, and the licensor will not be liable to you for any damages arising out of these terms or the use or nature of the software, under any kind of legal claim.***
## Definitions
The **licensor** is the individual or entity offering these terms, and the **software** is the software the licensor makes available under these terms.
A **product** can be a good or service, or a combination of them.
**You** refers to the individual or entity agreeing to these terms.
**Your company** is any legal entity, sole proprietorship, or other kind of organization that you work for, plus all its affiliates.
**Affiliates** means the other organizations than an organization has control over, is under the control of, or is under common control with.
**Control** means ownership of substantially all the assets of an entity, or the power to direct its management and policies by vote, contract, or otherwise. Control can be direct or indirect.
**Your licenses** are all the licenses granted to you for the software under these terms.
**Use** means anything you do with the software requiring one of your licenses.
+87 -32
View File
@@ -1,36 +1,46 @@
# Noo # Noo
A fast, native [Nextcloud](https://nextcloud.com) client built with Flutter A fast, native [Nextcloud](https://nextcloud.com) client built with Flutter
and Material You. and Material 3.
Noo gives you a clean, modern way to browse and manage the files on your own Noo is a clean, modern way to browse and manage the files on your own
Nextcloud server — files, photos, shares, activity, and trash, all in one Nextcloud server: files, photos, shares, activity, and trash in one app,
app, themed to match your device. themed to match your device and laid out for both phones and tablets.
Website: <https://noo.ayushya.dev>
> Noo is an independent, unofficial client. It is not affiliated with or
> endorsed by Nextcloud GmbH.
## Features ## Features
- **Files** — browse, upload, move, rename, delete, and favorite files and - **Files**: browse, upload, move, copy, rename, delete, and favorite files
folders; sort and filter per folder and folders; sort and filter per folder; list or grid view
- **Photos** — a dedicated media gallery with a full-screen viewer for images - **Photos**: a media gallery with a full-screen viewer for images and video
and video - **Viewer**: images, video, PDF (pinch to zoom), Markdown and text, with a
- **Activity** — a feed of what's changed on your server pull-up details panel (info, activity, sharing)
- **Shares** — see and manage what you've shared and what's shared with you - **Shares, Recent, Favorites, Activity, Trash**: see and manage what you
- **Trash** — restore or permanently delete recently removed items have shared, what changed, and what you deleted
- **Material You theming** — dynamic color on supported devices, five - **Search** across your server
built-in accent colors, light/dark/AMOLED - **Offline and sync**: keep files available offline and sync chosen folders
- **Secure sign-in** — authenticates via Nextcloud's Login Flow v2: you sign in the background
in through your browser, and Noo never sees or stores your password, - **Multiple accounts**: switch between saved accounts from the avatar menu
only a scoped app token - **App lock**: optionally require device authentication to open the app,
switch accounts, or reveal hidden files
## Screenshots - **Share to Noo**: upload from any app's share sheet
- **Make it yours**: Material You dynamic color, five accent colors,
_Coming soon._ light/dark/AMOLED, attached or floating bottom bar, hamburger or avatar
navigation, adjustable upload button, and a tablet layout with a sidebar
- **Secure sign-in**: authenticates through Nextcloud's Login Flow v2. You
sign in through your browser, and Noo never sees or stores your password,
only a scoped app password
## Getting started ## Getting started
Prerequisites: Prerequisites:
- [Flutter](https://docs.flutter.dev/get-started/install) (SDK `^3.12.2`) - [Flutter](https://docs.flutter.dev/get-started/install) (SDK `^3.12.2`)
- An Android device or emulator, an iOS device, or Windows
- A Nextcloud server with Login Flow v2 enabled (on by default since - A Nextcloud server with Login Flow v2 enabled (on by default since
Nextcloud 15) Nextcloud 15)
@@ -39,38 +49,83 @@ flutter pub get
flutter run flutter run
``` ```
On first launch, enter your server's address — Noo opens your browser to On first launch, enter your server's address. Noo opens your browser to
finish signing in. finish signing in.
## Supported platforms
Android, iOS, and Windows.
## Building ## Building
```bash ```bash
flutter build apk # Android flutter build apk # Android (debug-signed unless you configure a key)
flutter build ios # iOS flutter build ios # iOS
flutter build windows # Windows flutter build windows # Windows
``` ```
Release builds read signing details from `android/key.properties` or from
`RELEASE_KEYSTORE_*` environment variables (see `android/app/build.gradle.kts`).
Neither the keystore nor `key.properties` is, or should ever be, committed.
Without them, release builds fall back to debug signing.
## Tech stack ## Tech stack
Flutter with Material 3, [`provider`](https://pub.dev/packages/provider) for Flutter with Material 3, [`provider`](https://pub.dev/packages/provider) for
state management, and `http`/`dio` talking directly to Nextcloud's WebDAV state management, and `http`/`dio` talking directly to Nextcloud's WebDAV and
and OCS APIs — no bundled Nextcloud SDK. OCS APIs, with no bundled Nextcloud SDK. Android has native workers for
transfers and background sync.
## Contributing ## Contributing
Project context for contributors (human or AI) lives in Contributions are welcome. Please read [`CONTRIBUTING.md`](CONTRIBUTING.md)
[`CLAUDE.md`](CLAUDE.md) and `.claude/context/` — architecture, the server first. In short:
integration, styling conventions, and code standards.
```bash ```bash
flutter test # run tests flutter test # run tests
flutter analyze # static analysis / lints flutter analyze # static analysis / lints
``` ```
Architecture, server integration, styling conventions, and code standards are
documented in [`CLAUDE.md`](CLAUDE.md) and `.claude/context/`, written for
both human and AI contributors.
## Privacy
Noo talks only to the Nextcloud server you point it at. It has no analytics
and no third-party tracking. Read the full [privacy policy](https://noo.ayushya.dev/privacy).
## Acknowledgements
Typefaces [Instrument Sans](https://fonts.google.com/specimen/Instrument+Sans)
and [Schibsted Grotesk](https://fonts.google.com/specimen/Schibsted+Grotesk)
are bundled under the SIL Open Font License, and icons are from
[Lucide](https://lucide.dev). Those keep their own licenses; the license
below covers Noo's own code and assets.
## License ## License
Not yet licensed for redistribution — license TBD. Noo is source-available under the
[PolyForm Shield License 1.0.0](LICENSE). In plain terms: you may read, build,
run, and modify the code, and contribute changes back, but you may not use it
to make a product or service that competes with Noo. Please do not republish
it as your own app.
Contributions are welcome under the terms in
[`CONTRIBUTING.md`](CONTRIBUTING.md).
### Why PolyForm Shield?
I want the code to be open to read, learn from, build, and improve, while
keeping anyone from lifting it and shipping a competing copy of the app.
Permissive licenses (MIT, Apache) allow exactly that, and copyleft ones
(GPL) still allow a rebranded fork. PolyForm Shield sits in between: it is
not an OSI "open source" license, and I'm not claiming it is, but it keeps
the code available while protecting the project.
## Where development happens
The source of truth is a self-hosted Gitea instance, and this GitHub
repository is a read-only mirror plus the public issue tracker. I keep it
that way so that I own my hosting, builds, and release credentials rather
than depending on a third party for them. You don't have to use Gitea:
open issues and pull requests here as usual. Open PRs are imported to my
instance, reviewed and merged there, and `main` is mirrored back here, so a
merged PR may show as closed with a link to the commit instead of "Merged".
See [`CONTRIBUTING.md`](CONTRIBUTING.md) for details.
+8
View File
@@ -0,0 +1,8 @@
# Security policy
If you find a security problem in Noo, please email
[hello@ayushya.dev](mailto:hello@ayushya.dev) instead of opening a public
issue. Include steps to reproduce and the version (`pubspec.yaml`). I'll
acknowledge reports as soon as I can and credit you in the fix if you'd like.
Never include real server URLs, credentials, or personal files in a report.
@@ -56,6 +56,7 @@ class MainActivity : FlutterFragmentActivity() {
private val newPickChannelName = "dev.ayushya.noo/pick_intent/new" private val newPickChannelName = "dev.ayushya.noo/pick_intent/new"
private val syncServiceChannelName = "dev.ayushya.noo/sync_service" private val syncServiceChannelName = "dev.ayushya.noo/sync_service"
private val syncStatusChannelName = "dev.ayushya.noo/sync_service/status" private val syncStatusChannelName = "dev.ayushya.noo/sync_service/status"
private val uploadStatusChannelName = "dev.ayushya.noo/upload_service/status"
private val notificationPermissionRequestCode = 4202 private val notificationPermissionRequestCode = 4202
// Lazy, not a field initializer - `packageName` reads through the // Lazy, not a field initializer - `packageName` reads through the
// Activity's base Context, which isn't attached yet while this class's // Activity's base Context, which isn't attached yet while this class's
@@ -66,6 +67,7 @@ class MainActivity : FlutterFragmentActivity() {
private var newShareSink: EventChannel.EventSink? = null private var newShareSink: EventChannel.EventSink? = null
private var newPickSink: EventChannel.EventSink? = null private var newPickSink: EventChannel.EventSink? = null
private var syncStatusListener: ((SyncStatusBus.Status) -> Unit)? = null private var syncStatusListener: ((SyncStatusBus.Status) -> Unit)? = null
private var uploadCompletedListener: ((UploadEventBus.Completed) -> Unit)? = null
override fun configureFlutterEngine(flutterEngine: FlutterEngine) { override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine) super.configureFlutterEngine(flutterEngine)
@@ -157,6 +159,29 @@ class MainActivity : FlutterFragmentActivity() {
syncStatusListener = null syncStatusListener = null
} }
}) })
EventChannel(flutterEngine.dartExecutor.binaryMessenger, uploadStatusChannelName)
.setStreamHandler(object : EventChannel.StreamHandler {
override fun onListen(arguments: Any?, events: EventChannel.EventSink) {
val listener: (UploadEventBus.Completed) -> Unit = { completed ->
mainHandler.post {
events.success(
mapOf(
"remoteFolder" to completed.remoteFolder,
"succeeded" to completed.succeeded,
"failed" to completed.failed,
),
)
}
}
uploadCompletedListener = listener
UploadEventBus.subscribe(listener)
}
override fun onCancel(arguments: Any?) {
uploadCompletedListener?.let { UploadEventBus.unsubscribe(it) }
uploadCompletedListener = null
}
})
} }
/// [accountIdOverride] is the account Dart is asking about - needed /// [accountIdOverride] is the account Dart is asking about - needed
@@ -185,6 +185,13 @@ class ShareUploadService : Service() {
else -> "Uploaded $succeeded of ${batch.files.size} files - $failed failed" else -> "Uploaded $succeeded of ${batch.files.size} files - $failed failed"
} }
manager().notify(NOTIFICATION_ID, buildFinalNotification(finalText)) manager().notify(NOTIFICATION_ID, buildFinalNotification(finalText))
// batch.remoteFolder, not the locally-normalized cleanFolder - Dart's
// FilesController.currentFolderPath never carries the trailing
// slash cleanFolder adds, so publishing the original value lets the
// Dart side compare them directly with no reformatting of its own.
if (succeeded > 0) {
UploadEventBus.publishCompleted(batch.remoteFolder, succeeded, failed)
}
if (queue.pendingCount == 0) { if (queue.pendingCount == 0) {
stopForeground(STOP_FOREGROUND_DETACH) stopForeground(STOP_FOREGROUND_DETACH)
@@ -0,0 +1,39 @@
package dev.ayushya.noo
/**
* In-memory, in-process pub/sub for "an upload batch just finished" -
* [ShareUploadService] publishes into this, `MainActivity`'s
* `dev.ayushya.noo/upload_service/status` EventChannel forwards it to Dart
* so `FilesController` can refresh the destination folder if it's the one
* currently on screen, without a manual pull-to-refresh. See
* [SyncStatusBus]'s identical shape/rationale - the service and the
* Activity always run in the same process, so plain in-memory pub/sub is
* enough. Unlike that bus, this only ever carries discrete "just finished"
* events, not ongoing state, so there's nothing to replay to a listener
* that subscribes late.
*/
object UploadEventBus {
data class Completed(
val remoteFolder: String,
val succeeded: Int,
val failed: Int,
)
private val listeners = mutableListOf<(Completed) -> Unit>()
@Synchronized
fun subscribe(listener: (Completed) -> Unit) {
listeners.add(listener)
}
@Synchronized
fun unsubscribe(listener: (Completed) -> Unit) {
listeners.remove(listener)
}
@Synchronized
fun publishCompleted(remoteFolder: String, succeeded: Int, failed: Int) {
val event = Completed(remoteFolder, succeeded, failed)
listeners.toList().forEach { it(event) }
}
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 37 KiB

+1
View File
@@ -0,0 +1 @@
Improved tablet layout: sidebar navigation, floating title bars, and adaptive grids. Long-press selection and Settings submenus now work on tablets.
+284 -77
View File
@@ -1,6 +1,8 @@
import 'dart:async'; import 'dart:async';
import 'package:dynamic_color/dynamic_color.dart'; import 'package:dynamic_color/dynamic_color.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:permission_handler/permission_handler.dart'; import 'package:permission_handler/permission_handler.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
@@ -22,15 +24,29 @@ import 'providers/trash_controller.dart';
import 'services/pick_intent_service.dart'; import 'services/pick_intent_service.dart';
import 'services/share_intent_service.dart'; import 'services/share_intent_service.dart';
import 'theme/app_theme.dart'; import 'theme/app_theme.dart';
import 'theme/design_tokens.dart';
import 'views/lock_screen_view.dart'; import 'views/lock_screen_view.dart';
import 'views/login_view.dart'; import 'views/login_view.dart';
import 'views/search_view.dart';
import 'views/share_upload_view.dart'; import 'views/share_upload_view.dart';
import 'widgets/app_drawer.dart';
import 'widgets/app_tab_view_builder.dart'; import 'widgets/app_tab_view_builder.dart';
import 'widgets/floating_bottom_bar.dart'; import 'widgets/app_top_bar.dart';
import 'widgets/bottom_nav_bar.dart';
import 'widgets/create_menu.dart';
import 'widgets/noo/core/noo_avatar.dart';
import 'widgets/noo/core/noo_button.dart';
import 'widgets/noo/core/noo_fab.dart';
import 'widgets/noo/nav/noo_nav_style.dart';
import 'widgets/noo/nav/noo_sidebar.dart';
import 'widgets/noo/nav/noo_toolbar.dart';
import 'widgets/noo/noo_layout.dart';
import 'widgets/noo/overlays/noo_dialog.dart';
import 'widgets/shell/shell_common.dart';
void main() { void main() {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
// Fonts ship in assets/google_fonts; never fetch them from Google at runtime.
GoogleFonts.config.allowRuntimeFetching = false;
runApp( runApp(
MultiProvider( MultiProvider(
// Split out of the former single `ServerProvider` god object - see // Split out of the former single `ServerProvider` god object - see
@@ -61,8 +77,7 @@ void main() {
SyncStatusController(context.read(), context.read()), SyncStatusController(context.read(), context.read()),
), ),
ChangeNotifierProvider( ChangeNotifierProvider(
create: (context) => create: (context) => PhotosController(context.read(), context.read()),
PhotosController(context.read(), context.read()),
), ),
ChangeNotifierProvider( ChangeNotifierProvider(
create: (context) => create: (context) =>
@@ -143,21 +158,19 @@ class _SplashView extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme; final colors = context.nooColors;
return Scaffold( return Scaffold(
backgroundColor: colors.bg,
body: Center( body: Center(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
// The monochrome adaptive-icon layer is a plain white silhouette // The monochrome adaptive-icon layer is a plain white silhouette
// on transparent, meant to be tinted rather than shown as-is - // on transparent, meant to be tinted rather than shown as-is -
// srcIn recolors it to the theme's foreground so it reads // srcIn recolors it to fg-1 so it reads correctly in both light
// correctly in both light and dark mode. // and dark mode.
ColorFiltered( ColorFiltered(
colorFilter: ColorFilter.mode( colorFilter: ColorFilter.mode(colors.fg1, BlendMode.srcIn),
colorScheme.onSurface,
BlendMode.srcIn,
),
child: Image.asset( child: Image.asset(
'assets/icon/app_icon_monochrome.png', 'assets/icon/app_icon_monochrome.png',
width: 72, width: 72,
@@ -170,7 +183,7 @@ class _SplashView extends StatelessWidget {
height: 22, height: 22,
child: CircularProgressIndicator( child: CircularProgressIndicator(
strokeWidth: 2.5, strokeWidth: 2.5,
color: colorScheme.primary, color: colors.accent,
), ),
), ),
], ],
@@ -246,26 +259,34 @@ class _MainShellViewState extends State<MainShellView> {
if (status.isGranted || status.isPermanentlyDenied) return; if (status.isGranted || status.isPermanentlyDenied) return;
if (!mounted) return; if (!mounted) return;
final shouldRequest = await showDialog<bool>( final colors = context.nooColors;
context: context, final shouldRequest = await showNooDialog<bool>(
context,
title: 'Allow notifications?',
barrierDismissible: false, barrierDismissible: false,
builder: (context) => AlertDialog( children: [
title: const Text('Allow notifications?'), Text(
content: const Text(
'Noo shows upload and download progress as a notification, so ' 'Noo shows upload and download progress as a notification, so '
'you can track transfers that keep running in the background.', 'you can track transfers that keep running in the background.',
style: NooText.body.copyWith(color: colors.fg2),
), ),
actions: [ Row(
TextButton( mainAxisAlignment: MainAxisAlignment.end,
onPressed: () => Navigator.of(context).pop(false), spacing: 10,
children: [
NooButton(
variant: NooButtonVariant.secondary,
onTap: () => Navigator.of(context).pop(false),
child: const Text('Not now'), child: const Text('Not now'),
), ),
FilledButton( NooButton(
onPressed: () => Navigator.of(context).pop(true), variant: NooButtonVariant.primary,
onTap: () => Navigator.of(context).pop(true),
child: const Text('Allow'), child: const Text('Allow'),
), ),
], ],
), ),
],
); );
if (shouldRequest == true) { if (shouldRequest == true) {
await Permission.notification.request(); await Permission.notification.request();
@@ -294,9 +315,15 @@ class _MainShellViewState extends State<MainShellView> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colors = context.nooColors;
final settings = context.watch<SettingsController>(); final settings = context.watch<SettingsController>();
final pick = context.watch<PickController>(); final pick = context.watch<PickController>();
final connectivity = context.watch<ConnectivityController>(); final connectivity = context.watch<ConnectivityController>();
final session = context.watch<SessionController>();
final quota = context.watch<FilesController>().quota;
// Cheap and already loaded once the Trash tab has been visited this
// session - not worth adding a fetch just to populate a nav badge.
final trashCount = context.watch<TrashController>().items.length;
// A one-shot request (e.g. tapping a search result) to switch tabs - // A one-shot request (e.g. tapping a search result) to switch tabs -
// consumed here so it only fires once, then cleared after this frame // consumed here so it only fires once, then cleared after this frame
@@ -317,58 +344,230 @@ class _MainShellViewState extends State<MainShellView> {
// user's own hidden/reordered tab settings rather than respecting // user's own hidden/reordered tab settings rather than respecting
// them, since picking is a separate mode from normal browsing. With // them, since picking is a separate mode from normal browsing. With
// no network at all, every other tab would just show its own loading // no network at all, every other tab would just show its own loading
// spinner forever/an error state, so the bottom nav collapses to just // spinner forever/an error state, so the nav collapses to just Offline
// Offline - the one tab that works without a connection (see // - the one tab that works without a connection (see
// `FilesView(offline: true)`/`ConnectivityController`). // `FilesView(offline: true)`/`ConnectivityController`).
final visible = pickRequest != null final overrideActive = pickRequest != null || connectivity.isOffline;
final pinnedTabs = pickRequest != null
? [AppTab.files, AppTab.photos] ? [AppTab.files, AppTab.photos]
: connectivity.isOffline : connectivity.isOffline
? [AppTab.offline] ? [AppTab.offline]
: settings.visibleTabs; : settings.visibleTabs;
final selectedTab = visible.contains(_currentTab) // The drawer/sidebar's "More" section - hidden while an override above
? _currentTab // is active, same as the drawer itself being unavailable during pick.
: (visible.isNotEmpty ? visible.first : AppTab.files); final hiddenTabs = overrideActive
final selectedIndex = visible ? const <AppTab>[]
.indexOf(selectedTab) : settings.tabOrder
.clamp(0, visible.isEmpty ? 0 : visible.length - 1); .where((t) => settings.hiddenTabs.contains(t))
final navItems = visible
.map((tab) => FloatingNavItem(label: tab.label, icon: tab.icon))
.toList(); .toList();
return PopScope( // A tab opened from "More" isn't one of the pinned destinations, but
// While picking, system back cancels the pick (and tells the caller // should stay shown and selected (with no destination highlighted in
// nothing was chosen) instead of exiting the app from under it - // the bottom bar/sidebar) until the user taps a pinned or another
// there's no Navigator route to pop at this, the root screen. // "More" tab.
canPop: pickRequest == null, final displayTabs = !overrideActive && !pinnedTabs.contains(_currentTab)
onPopInvokedWithResult: (didPop, result) { ? [...pinnedTabs, _currentTab]
if (!didPop && pickRequest != null) pick.cancelPick(); : pinnedTabs;
}, final selectedTab = displayTabs.contains(_currentTab)
child: Scaffold( ? _currentTab
body: Stack( : (displayTabs.isNotEmpty ? displayTabs.first : AppTab.files);
final selectedIndex = displayTabs
.indexOf(selectedTab)
.clamp(0, displayTabs.isEmpty ? 0 : displayTabs.length - 1);
final pinnedIndex = pinnedTabs.indexOf(selectedTab);
void selectTab(AppTab tab) {
if (tab == _currentTab) return;
setState(() => _currentTab = tab);
}
String? trashBadge(AppTab tab) =>
tab == AppTab.trash && trashCount > 0 ? '$trashCount' : null;
final isDesktop = NooLayout.isDesktop(context);
final navStyle = NooLayout.navStyle(context);
// Picking/offline already override the tab set itself (see pinnedTabs
// above) - Search doesn't belong in either: there's nothing to search
// for a file-picking flow, and Search needs the network Offline mode
// doesn't have.
final showBottomBarSearch = !overrideActive && settings.searchInBottomBar;
// Each tab renders its own sticky selection toolbar inline (right under
// its sort/filter row) instead of the shell swapping in a shared one,
// so the nav chrome stays put and usable regardless of selection state.
//
// Each tab also builds its own [AppTopBar] (labelled for that tab, not
// just whichever is currently selected) and plants it as that tab's own
// first sliver - see `buildAppTabView`'s doc comment and `topBarSliver`
// in `tabs/tab_state_slivers.dart` - instead of one shared instance
// living in `Scaffold.appBar`. That's what gives each tab's top bar
// Material's native floating-away-on-scroll-down/reappear-on-scroll-up
// behavior, tied to that tab's own `ScrollController`: a fixed
// `Scaffold.appBar` can't do that (no per-tab scroll signal reaches
// it), and a shared single instance can't show 7 different tab labels
// at once now that every tab keeps its own independent scroll state.
// Null on desktop (which shows `NooToolbar` instead - embedding it here
// too, since `tabStack` is shared by both layouts below, would double
// up the top chrome there) and while picking (no top bar at all, same
// as this screen's old `Scaffold.appBar: pickRequest == null ? ... :
// null`).
final tabStack = Stack(
children: [ children: [
IndexedStack( IndexedStack(
index: selectedIndex, index: selectedIndex,
children: visible 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(),
// Each tab now renders its own sticky selection toolbar inline ],
// (right under its sort/filter row) instead of this shell );
// swapping in a shared floating bar - the bottom nav stays put
// and usable regardless of selection state. final canUpload =
FloatingBottomNavBar( pickRequest == null &&
selectedIndex: selectedIndex, (selectedTab == AppTab.files || selectedTab == AppTab.photos);
items: navItems,
opacity: settings.bottomBarOpacity, Widget scaffold;
blurSigma: settings.bottomBarBlur, if (isDesktop) {
scaffold = Scaffold(
backgroundColor: colors.bg,
// Tablets keep the system bars, so inset for them (desktop's
// insets are zero).
body: SafeArea(
child: Row(
children: [
NooSidebar(
account: NooSidebarAccount(
avatar: NooAvatar(
initials: accountInitial(session.username),
current: true,
size: 32,
),
name: session.username,
subtitle: serverHost(session.serverUrl),
onTap: () => showAccountSwitcher(context),
),
items: [
for (final tab in pinnedTabs)
NooSidebarItem(
icon: tab.icon,
label: tab.label,
count: trashBadge(tab),
selected: tab == selectedTab,
onTap: () => selectTab(tab),
),
for (final tab in hiddenTabs)
NooSidebarItem(
icon: tab.icon,
label: tab.label,
count: trashBadge(tab),
selected: tab == selectedTab,
onTap: () => selectTab(tab),
),
],
storage: NooSidebarStorage(
value: quotaFraction(quota),
detail: quotaDetail(quota),
),
settings: NooSidebarItem(
icon: LucideIcons.settings,
label: 'Settings',
onTap: () => openSettings(context),
),
),
Expanded(
child: Column(
children: [
NooToolbar(
title: selectedTab.label,
search: const ShellSearchLauncher(onSurface: true),
actions: [
if (canUpload)
NooButton(
icon: LucideIcons.upload,
onTap: () => showCreateMenu(context),
child: const Text('Upload'),
),
],
),
Expanded(
child: ColoredBox(color: colors.surface, child: tabStack),
),
],
),
),
],
),
),
);
} else {
final bottomBarStyle = settings.bottomBarStyle;
scaffold = Scaffold(
backgroundColor: colors.bg,
drawerScrimColor: colors.scrim,
// The top bar itself no longer lives here - see `tabStack` above.
drawer: pickRequest == null ? const AppDrawer() : null,
// The avatar menu replaces the drawer, so the edge swipe must not
// open it either.
drawerEnableOpenDragGesture:
settings.navMenuStyle == NooNavMenuStyle.drawer,
// Floating needs the body to draw behind the bar's own transparent
// margin (see NooBottomBarStyle's doc comment) instead of stopping
// short of it like attached does.
extendBody: bottomBarStyle == NooBottomBarStyle.floating,
// Android-only extended Upload FAB - iOS uses the top bar's `plus`
// instead (see AppTopBar). Stays mounted across every tab (picking
// aside) and collapses to an icon-only circle off Files/Photos,
// rather than the Scaffold popping it fully in/out on every tab
// switch - see NooFab's [collapsed].
floatingActionButton:
pickRequest == null && navStyle == NooNavStyle.android
? NooFab(
collapsed: switch (settings.fabStyle) {
FabStyle.auto => !canUpload,
FabStyle.mini => true,
FabStyle.expanded => false,
},
barStyle: bottomBarStyle,
onTap: () => showCreateMenu(context),
)
: null,
body: tabStack,
bottomNavigationBar: BottomNavBar(
style: navStyle,
barStyle: bottomBarStyle,
tabs: pinnedTabs,
selectedIndex: pinnedIndex,
onSearchTap: showBottomBarSearch ? () => openSearch(context) : null,
onDestinationSelected: (index) { onDestinationSelected: (index) {
final tappedTab = visible[index]; final tappedTab = pinnedTabs[index];
if (tappedTab == _currentTab) { if (tappedTab == _currentTab) {
if (settings.tapTabToScrollTop) { if (settings.tapTabToScrollTop) {
final controller = _scrollControllers[tappedTab]; final controller = _scrollControllers[tappedTab];
if (controller != null && controller.hasClients) { // `hasClients` only means a position is attached - not that
// it's finished its first layout. Calling animateTo before
// that throws deep inside Flutter's ballistic-scroll code
// (min/maxScrollExtent are still null), and since this
// fires synchronously from the nav bar's tap handler, the
// exception corrupts that tab's scroll view instead of
// just being logged - a real crash seen in the wild
// (Files rendering empty after re-tapping its own tab).
if (controller != null &&
controller.hasClients &&
controller.position.hasContentDimensions) {
controller.animateTo( controller.animateTo(
0, 0,
duration: const Duration(milliseconds: 300), duration: const Duration(milliseconds: 300),
@@ -378,19 +577,21 @@ class _MainShellViewState extends State<MainShellView> {
} }
return; return;
} }
setState(() { selectTab(tappedTab);
_currentTab = tappedTab;
});
},
onSearchTap: () {
Navigator.of(
context,
).push(MaterialPageRoute(builder: (_) => const SearchView()));
}, },
), ),
], );
), }
),
return PopScope(
// While picking, system back cancels the pick (and tells the caller
// nothing was chosen) instead of exiting the app from under it -
// there's no Navigator route to pop at this, the root screen.
canPop: pickRequest == null,
onPopInvokedWithResult: (didPop, result) {
if (!didPop && pickRequest != null) pick.cancelPick();
},
child: scaffold,
); );
} }
} }
@@ -403,21 +604,27 @@ class _PickingProgressOverlay extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colors = context.nooColors;
return Positioned.fill( return Positioned.fill(
child: ColoredBox( child: ColoredBox(
color: Colors.black.withValues(alpha: 0.35), color: colors.scrim,
child: const Center( child: Center(
child: Card( child: Container(
child: Padding( padding: const EdgeInsets.all(24),
padding: EdgeInsets.all(24), decoration: BoxDecoration(
color: colors.surface,
borderRadius: BorderRadius.circular(NooRadii.dialog),
),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
CircularProgressIndicator(), CircularProgressIndicator(color: colors.accent),
SizedBox(height: 16), const SizedBox(height: 16),
Text('Preparing file...'), Text(
], 'Preparing file…',
style: NooText.body.copyWith(color: colors.fg1),
), ),
],
), ),
), ),
), ),
+18 -11
View File
@@ -1,5 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
/// Every destination the bottom nav bar can show. Order here is only the /// Every destination the bottom nav bar can show. Order here is only the
/// fallback default — actual order/visibility/default-tab are user /// fallback default — actual order/visibility/default-tab are user
@@ -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 {
@@ -41,24 +46,26 @@ extension AppTabInfo on AppTab {
} }
} }
// Lucide icon mapping per DESIGN_SYSTEM.md 1.5 - the Lucide icon slug is
// named in a comment where it doesn't match the Dart identifier.
IconData get icon { IconData get icon {
switch (this) { switch (this) {
case AppTab.files: case AppTab.files:
return Icons.folder_rounded; return LucideIcons.folder;
case AppTab.photos: case AppTab.photos:
return Icons.photo_library_rounded; return LucideIcons.images;
case AppTab.favorites: case AppTab.favorites:
return Icons.favorite_rounded; return LucideIcons.star;
case AppTab.activity: case AppTab.activity:
return Icons.electric_bolt_rounded; return LucideIcons.activity;
case AppTab.trash: case AppTab.trash:
return Icons.delete_outline_rounded; return LucideIcons.trash2; // trash-2
case AppTab.shares: case AppTab.shares:
return Icons.groups_rounded; return LucideIcons.share2; // share-2
case AppTab.recent: case AppTab.recent:
return Symbols.search_activity_rounded; return LucideIcons.clock;
case AppTab.offline: case AppTab.offline:
return Icons.offline_pin_rounded; return LucideIcons.hardDriveDownload; // hard-drive-download
} }
} }
} }
+16
View File
@@ -39,4 +39,20 @@ class NextcloudShare {
}); });
bool get isFolder => itemType == NextcloudItemType.folder; bool get isFolder => itemType == NextcloudItemType.folder;
NextcloudShare withPermissions(int permissions) => NextcloudShare(
id: id,
path: path,
name: name,
itemType: itemType,
shareType: shareType,
ownerDisplayName: ownerDisplayName,
sharedWithDisplayName: sharedWithDisplayName,
sharedAt: sharedAt,
sharedWithMe: sharedWithMe,
url: url,
permissions: permissions,
token: token,
expireDate: expireDate,
);
} }
+75
View File
@@ -1,16 +1,91 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
/// A bulk action's identity, stable across the dynamic icon/label a
/// [SelectionAction] actually renders (e.g. `favorite` covers both
/// "Favorite" and "Remove from favorites"). This is what the user's
/// configured [order] - Settings' "Action bar" reorder list - and
/// [NooSelectionBar]'s inline/overflow split key off, since the live
/// [SelectionAction] list is rebuilt fresh on every selection change and
/// can't be compared by icon/label alone.
///
/// Not every screen offers every kind (e.g. Favorites has no `rename` or
/// `sync`) - [orderSelectionActions] just skips whatever kind a screen
/// didn't build.
enum SelectionActionKind {
favorite,
share,
download,
delete,
copy,
move,
rename,
sync,
details;
/// Generic label for the Settings reorder row - the live bar uses each
/// screen's own contextual label (e.g. "Remove from favorites") instead.
String get label => switch (this) {
SelectionActionKind.favorite => 'Favorite',
SelectionActionKind.share => 'Share',
SelectionActionKind.download => 'Download',
SelectionActionKind.delete => 'Delete',
SelectionActionKind.copy => 'Copy',
SelectionActionKind.move => 'Move',
SelectionActionKind.rename => 'Rename',
SelectionActionKind.sync => 'Sync to device',
SelectionActionKind.details => 'Details',
};
IconData get icon => switch (this) {
SelectionActionKind.favorite => LucideIcons.star,
SelectionActionKind.share => LucideIcons.share2,
SelectionActionKind.download => LucideIcons.download,
SelectionActionKind.delete => LucideIcons.trash2,
SelectionActionKind.copy => LucideIcons.copy,
SelectionActionKind.move => LucideIcons.folderInput,
SelectionActionKind.rename => LucideIcons.filePen,
SelectionActionKind.sync => LucideIcons.hardDriveDownload,
SelectionActionKind.details => LucideIcons.info,
};
}
/// One bulk action available for the current multi-selection (e.g. favorite, /// One bulk action available for the current multi-selection (e.g. favorite,
/// download, delete), shown as an icon button in the sticky selection /// download, delete), shown as an icon button in the sticky selection
/// toolbar each tab (Files, Photos, ...) renders inline in its own content. /// toolbar each tab (Files, Photos, ...) renders inline in its own content.
class SelectionAction { class SelectionAction {
final SelectionActionKind kind;
final IconData icon; final IconData icon;
final String label; final String label;
final VoidCallback onTap; final VoidCallback onTap;
const SelectionAction({ const SelectionAction({
required this.kind,
required this.icon, required this.icon,
required this.label, required this.label,
required this.onTap, required this.onTap,
}); });
} }
/// Reorders [actions] to match the user's configured [order] (Settings'
/// "Action bar" section, [SelectionActionKind] values) - this is what makes
/// that setting take effect, since it's [order] that decides which actions
/// land in `NooSelectionBar`'s inline slots vs. behind "More". A kind in
/// [order] the caller didn't build (e.g. `rename` on a multi-item
/// selection) is just skipped; a kind [actions] has that [order] doesn't
/// mention (e.g. a kind added in a later app update, before the saved
/// order is migrated - see [SettingsController.selectionActionOrder]) is
/// appended at the end in its original position.
List<SelectionAction> orderSelectionActions(
List<SelectionAction> actions,
List<SelectionActionKind> order,
) {
final byKind = {for (final a in actions) a.kind: a};
final ordered = <SelectionAction>[];
for (final kind in order) {
final action = byKind.remove(kind);
if (action != null) ordered.add(action);
}
ordered.addAll(byKind.values);
return ordered;
}
+52 -4
View File
@@ -3,6 +3,7 @@ import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import '../models/nextcloud_item.dart'; import '../models/nextcloud_item.dart';
import '../services/upload_service.dart';
import 'folder_browser.dart'; import 'folder_browser.dart';
import 'session_controller.dart'; import 'session_controller.dart';
@@ -100,6 +101,7 @@ class FilesController extends ChangeNotifier
NextcloudUserQuota? _quota; NextcloudUserQuota? _quota;
List<NextcloudActivity> _activities = []; List<NextcloudActivity> _activities = [];
bool _isLoading = false; bool _isLoading = false;
bool _activating = false;
String? _errorMessage; String? _errorMessage;
FilesController(this.session) { FilesController(this.session) {
@@ -107,6 +109,22 @@ class FilesController extends ChangeNotifier
session.addAccountClearedListener(_onAccountCleared); session.addAccountClearedListener(_onAccountCleared);
session.addAccountActivatedListener(_onAccountActivated); session.addAccountActivatedListener(_onAccountActivated);
session.addAccountReadyListener(_restoreDisplayPrefs); session.addAccountReadyListener(_restoreDisplayPrefs);
// Refreshes automatically once an upload finishes, so a file shared/
// uploaded into the folder currently on screen shows up without a
// manual pull-to-refresh - uploads run in an Android foreground
// service (see UploadService's doc comment) with no other way back to
// this controller.
_uploadSub = UploadService.completions.listen(_onUploadCompleted);
}
StreamSubscription<UploadCompletion>? _uploadSub;
void _onUploadCompleted(UploadCompletion completion) {
// refreshData() always hits the network regardless of cache freshness
// (see its own doc comment), so there's nothing to invalidate first -
// it overwrites this folder's cache entry once the fetch lands.
if (completion.remoteFolder != _currentFolderPath) return;
refreshData();
} }
// Getters // Getters
@@ -130,7 +148,10 @@ class FilesController extends ChangeNotifier
CachePolicy get cachePolicy => _cachePolicy; CachePolicy get cachePolicy => _cachePolicy;
int get cacheIntervalMinutes => _cacheIntervalMinutes; int get cacheIntervalMinutes => _cacheIntervalMinutes;
@override @override
bool get isLoading => _isLoading; // Also true while a freshly activated account is still restoring prefs
// and while the session verifies it, so a switch shows a spinner instead
// of briefly flashing the empty-folder message.
bool get isLoading => _isLoading || _activating || session.isLoading;
@override @override
String? get errorMessage => _errorMessage; String? get errorMessage => _errorMessage;
NextcloudUserQuota? get quota => _quota; NextcloudUserQuota? get quota => _quota;
@@ -266,7 +287,8 @@ class FilesController extends ChangeNotifier
/// sort, cache policy) - hooked to `addAccountReadyListener` as well as /// sort, cache policy) - hooked to `addAccountReadyListener` as well as
/// activation so the Offline tab, which reads these same prefs, respects /// activation so the Offline tab, which reads these same prefs, respects
/// them even on a provisional/offline login where activation never fires. /// them even on a provisional/offline login where activation never fires.
Future<void> _restoreDisplayPrefs() => _prefsRestore ??= _doRestoreDisplayPrefs(); Future<void> _restoreDisplayPrefs() =>
_prefsRestore ??= _doRestoreDisplayPrefs();
/// Completes once this account's cache policy/display prefs have been /// Completes once this account's cache policy/display prefs have been
/// loaded from storage - lets `SyncStatusController` schedule background /// loaded from storage - lets `SyncStatusController` schedule background
@@ -334,15 +356,24 @@ class FilesController extends ChangeNotifier
defaultCacheIntervalMinutes; defaultCacheIntervalMinutes;
notifyListeners(); notifyListeners();
} catch (e) { } catch (e) {
debugPrint('[FilesController] Account-activation prefs restore failed: $e'); debugPrint(
'[FilesController] Account-activation prefs restore failed: $e',
);
} }
} }
} }
Future<void> _onAccountActivated() async { Future<void> _onAccountActivated() async {
_activating = true;
notifyListeners();
try {
await _restoreDisplayPrefs(); await _restoreDisplayPrefs();
_startCacheRefreshTimerIfNeeded(); _startCacheRefreshTimerIfNeeded();
await refreshData(); await refreshData();
} finally {
_activating = false;
notifyListeners();
}
} }
@override @override
@@ -401,7 +432,9 @@ class FilesController extends ChangeNotifier
notifyListeners(); notifyListeners();
} }
debugPrint('[FilesController] Refreshing data for path: $_currentFolderPath'); debugPrint(
'[FilesController] Refreshing data for path: $_currentFolderPath',
);
try { try {
final items = await service.fetchDirectory(_currentFolderPath); final items = await service.fetchDirectory(_currentFolderPath);
@@ -549,6 +582,20 @@ class FilesController extends ChangeNotifier
return session.service?.fetchDirectory(path) ?? Future.value([]); return session.service?.fetchDirectory(path) ?? Future.value([]);
} }
/// Finds the single item at [path] by listing its parent folder and
/// matching on the exact path - there's no WebDAV call here for stat'ing
/// one path directly outside a directory PROPFIND. Used by the Shares tab
/// to open the full Share sheet for a [NextcloudShare], which only carries
/// enough metadata for its own row, not the size/dates `ShareSheet`'s
/// header needs.
Future<NextcloudItem?> fetchItemAtPath(String path) async {
final normalized = path.startsWith('/') ? path : '/$path';
final lastSlash = normalized.lastIndexOf('/');
final parent = lastSlash <= 0 ? '/' : normalized.substring(0, lastSlash);
final items = await fetchFolderListing(parent);
return items.where((i) => i.path == normalized).firstOrNull;
}
void invalidateCache() => _directoryCache.clear(); void invalidateCache() => _directoryCache.clear();
/// Persists a per-account browsing pref under its `acct_<id>_`-namespaced /// Persists a per-account browsing pref under its `acct_<id>_`-namespaced
@@ -692,6 +739,7 @@ class FilesController extends ChangeNotifier
void dispose() { void dispose() {
WidgetsBinding.instance.removeObserver(this); WidgetsBinding.instance.removeObserver(this);
_cacheRefreshTimer?.cancel(); _cacheRefreshTimer?.cancel();
_uploadSub?.cancel();
super.dispose(); super.dispose();
} }
} }
+17 -3
View File
@@ -168,9 +168,9 @@ class ItemOperations {
(c) => choices[c.item.id] == ConflictChoice.keepBoth, (c) => choices[c.item.id] == ConflictChoice.keepBoth,
); );
final existingNames = needsKeepBoth final existingNames = needsKeepBoth
? (await files.fetchFolderListing(destFolderPath)) ? (await files.fetchFolderListing(
.map((i) => i.name) destFolderPath,
.toSet() )).map((i) => i.name).toSet()
: <String>{}; : <String>{};
var succeeded = 0; var succeeded = 0;
@@ -303,6 +303,20 @@ class ItemOperations {
} }
} }
Future<bool> updateSharePermissions(
NextcloudShare share,
int permissions,
) async {
final service = session.service;
if (service == null) return false;
try {
return await service.updateSharePermissions(share.id, permissions);
} catch (e) {
debugPrint('[ItemOperations] updateSharePermissions failed: $e');
return false;
}
}
/// Deletes a share (from the file-details/Share sheet, not the Shares /// Deletes a share (from the file-details/Share sheet, not the Shares
/// tab itself - see `SharesController.deleteShare` for that one, which /// tab itself - see `SharesController.deleteShare` for that one, which
/// additionally patches the tab's own list). /// additionally patches the tab's own list).
+26 -7
View File
@@ -27,8 +27,7 @@ enum LoginFlowStatus { idle, initiating, awaitingBrowser, error }
/// full rationale. /// full rationale.
class SessionController extends ChangeNotifier with WidgetsBindingObserver { class SessionController extends ChangeNotifier with WidgetsBindingObserver {
final ConnectivityController connectivity; final ConnectivityController connectivity;
final Future<SharedPreferences> prefsFuture = final Future<SharedPreferences> prefsFuture = SharedPreferences.getInstance();
SharedPreferences.getInstance();
final AccountStore accountStore = AccountStore(); final AccountStore accountStore = AccountStore();
// True from the moment a saved session is restored (or a network // True from the moment a saved session is restored (or a network
@@ -96,10 +95,32 @@ class SessionController extends ChangeNotifier with WidgetsBindingObserver {
final List<VoidCallback> _accountReadyListeners = []; final List<VoidCallback> _accountReadyListeners = [];
void addAccountClearedListener(VoidCallback cb) => void addAccountClearedListener(VoidCallback cb) =>
_accountClearedListeners.add(cb); _accountClearedListeners.add(cb);
void addAccountActivatedListener(VoidCallback cb) =>
// Both `activated` and `ready` are one-shot, fire-and-forget calls, not a
// replayable stream - so a controller isn't guaranteed to be *listening*
// yet when the real event fires. Most controllers are registered with
// `lazy: false` in main.dart specifically so they exist before that can
// happen, but one that isn't (built lazily, on first read, like
// FilesController) can lose the race if nothing reads it until after
// login already finished verifying - e.g. `ConnectivityController`
// misreporting offline right at cold start collapses the bottom nav to
// just the Offline tab (see `main.dart`), so Files' tab, and the
// `FilesController` it lazily creates, never gets built during that
// window. Calling back immediately here if the account is *already* in
// the state being subscribed to closes that race for every current and
// future subscriber, without each one needing its own view-level
// fallback reload - see `.claude/context/architecture.md`'s "State
// management" section for the guardrail this exists to enforce.
void addAccountActivatedListener(VoidCallback cb) {
_accountActivatedListeners.add(cb); _accountActivatedListeners.add(cb);
void addAccountReadyListener(VoidCallback cb) => if (_isLoggedIn && !_isProvisionalLogin) cb();
}
void addAccountReadyListener(VoidCallback cb) {
_accountReadyListeners.add(cb); _accountReadyListeners.add(cb);
if (_isLoggedIn) cb();
}
void _notifyAccountCleared() { void _notifyAccountCleared() {
for (final cb in _accountClearedListeners) { for (final cb in _accountClearedListeners) {
cb(); cb();
@@ -141,9 +162,7 @@ class SessionController extends ChangeNotifier with WidgetsBindingObserver {
// one-time unlock at cold start would give the feature no real // one-time unlock at cold start would give the feature no real
// security value, since the realistic threat is someone else picking // security value, since the realistic threat is someone else picking
// up an already-running, unlocked phone. // up an already-running, unlocked phone.
if (state == AppLifecycleState.paused && if (state == AppLifecycleState.paused && _loginLockEnabled && _isUnlocked) {
_loginLockEnabled &&
_isUnlocked) {
_isUnlocked = false; _isUnlocked = false;
notifyListeners(); notifyListeners();
} }
+125 -22
View File
@@ -1,12 +1,19 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import '../models/app_tab.dart'; import '../models/app_tab.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.
enum SwipeAction { none, favorite, delete, share } enum SwipeAction { none, favorite, delete, share }
/// How the Android Upload FAB is sized: [auto] expands on Files/Photos and
/// shrinks to an icon elsewhere, [mini] is always icon-only, [expanded]
/// always shows the label.
enum FabStyle { auto, mini, expanded }
/// Thumb/track presets for the video player's seek bar, matching the four /// Thumb/track presets for the video player's seek bar, matching the four
/// combinations offered by other Material You media players: a Material 3 /// combinations offered by other Material You media players: a Material 3
/// slider-style thumb ([classic]), an animated travelling wave with a round /// slider-style thumb ([classic]), an animated travelling wave with a round
@@ -23,13 +30,16 @@ class SettingsController extends ChangeNotifier {
static const _prefThemeMode = 'ui_theme_mode'; static const _prefThemeMode = 'ui_theme_mode';
static const _prefUseDynamicColor = 'ui_use_dynamic_color'; static const _prefUseDynamicColor = 'ui_use_dynamic_color';
static const _prefSeedColor = 'ui_seed_color'; static const _prefSeedColor = 'ui_seed_color';
static const _prefBottomBarOpacity = 'ui_bottom_bar_opacity';
static const _prefBottomBarBlur = 'ui_bottom_bar_blur';
static const _prefTabOrder = 'ui_tab_order'; static const _prefTabOrder = 'ui_tab_order';
static const _prefSelectionActionOrder = 'ui_selection_action_order';
static const _prefHiddenTabs = 'ui_hidden_tabs'; static const _prefHiddenTabs = 'ui_hidden_tabs';
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 _prefFabStyle = 'ui_fab_style';
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';
@@ -37,22 +47,27 @@ class SettingsController extends ChangeNotifier {
final Future<SharedPreferences> _prefsFuture = final Future<SharedPreferences> _prefsFuture =
SharedPreferences.getInstance(); SharedPreferences.getInstance();
Color _seedColor = AppTheme.defaultNextcloudBlue; 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;
double _bottomBarOpacity = 0.55;
double _bottomBarBlur = 28;
bool _tapTabToScrollTop = true; bool _tapTabToScrollTop = true;
List<AppTab> _tabOrder = AppTab.values.toList(); List<AppTab> _tabOrder = AppTab.values.toList();
Set<AppTab> _hiddenTabs = {}; Set<AppTab> _hiddenTabs = {};
AppTab _defaultTab = AppTab.files; AppTab _defaultTab = AppTab.files;
List<SelectionActionKind> _selectionActionOrder = SelectionActionKind.values
.toList();
SwipeAction _swipeLeftAction = SwipeAction.delete; SwipeAction _swipeLeftAction = SwipeAction.delete;
SwipeAction _swipeRightAction = SwipeAction.favorite; SwipeAction _swipeRightAction = SwipeAction.favorite;
FabStyle _fabStyle = FabStyle.auto;
// A one-shot request for the shell to switch its active bottom-nav tab // A one-shot request for the shell to switch its active bottom-nav tab
// (e.g. a search result landing on Files) - consumed and cleared by // (e.g. a search result landing on Files) - consumed and cleared by
@@ -79,25 +94,41 @@ 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;
double get bottomBarOpacity => _bottomBarOpacity;
double get bottomBarBlur => _bottomBarBlur;
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;
Set<AppTab> get hiddenTabs => _hiddenTabs; Set<AppTab> get hiddenTabs => _hiddenTabs;
AppTab get defaultTab => _defaultTab; AppTab get defaultTab => _defaultTab;
/// The priority order bulk actions (favorite, share, download, ...) show
/// in on the multi-select action bar - see [orderSelectionActions]. Every
/// [SelectionActionKind] is always present here (nothing is hidden, only
/// reordered), so [NooSelectionBar]'s fixed inline slots are always full.
List<SelectionActionKind> get selectionActionOrder => _selectionActionOrder;
/// The tabs the bottom nav bar should actually show, in order. /// The tabs the bottom nav bar should actually show, in order.
List<AppTab> get visibleTabs => List<AppTab> get visibleTabs =>
_tabOrder.where((t) => !_hiddenTabs.contains(t)).toList(); _tabOrder.where((t) => !_hiddenTabs.contains(t)).toList();
SwipeAction get swipeLeftAction => _swipeLeftAction; SwipeAction get swipeLeftAction => _swipeLeftAction;
SwipeAction get swipeRightAction => _swipeRightAction; SwipeAction get swipeRightAction => _swipeRightAction;
FabStyle get fabStyle => _fabStyle;
Future<void> _load() async { Future<void> _load() async {
try { try {
@@ -110,6 +141,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;
@@ -122,9 +169,6 @@ class SettingsController extends ChangeNotifier {
} }
final seedColorValue = prefs.getInt(_prefSeedColor); final seedColorValue = prefs.getInt(_prefSeedColor);
if (seedColorValue != null) _seedColor = Color(seedColorValue); if (seedColorValue != null) _seedColor = Color(seedColorValue);
_bottomBarOpacity =
prefs.getDouble(_prefBottomBarOpacity) ?? _bottomBarOpacity;
_bottomBarBlur = prefs.getDouble(_prefBottomBarBlur) ?? _bottomBarBlur;
_tapTabToScrollTop = _tapTabToScrollTop =
prefs.getBool(_prefTapTabToScrollTop) ?? _tapTabToScrollTop; prefs.getBool(_prefTapTabToScrollTop) ?? _tapTabToScrollTop;
@@ -143,6 +187,25 @@ class SettingsController extends ChangeNotifier {
_tabOrder = order; _tabOrder = order;
} }
final savedActionOrderNames = prefs.getStringList(
_prefSelectionActionOrder,
);
if (savedActionOrderNames != null) {
final order = <SelectionActionKind>[];
for (final name in savedActionOrderNames) {
final match = SelectionActionKind.values
.where((k) => k.name == name)
.firstOrNull;
if (match != null) order.add(match);
}
// Forward-compat: a kind added in a later app update won't be in an
// older saved order yet, so append anything missing.
for (final kind in SelectionActionKind.values) {
if (!order.contains(kind)) order.add(kind);
}
_selectionActionOrder = order;
}
final savedHiddenNames = prefs.getStringList(_prefHiddenTabs); final savedHiddenNames = prefs.getStringList(_prefHiddenTabs);
if (savedHiddenNames != null) { if (savedHiddenNames != null) {
_hiddenTabs = savedHiddenNames _hiddenTabs = savedHiddenNames
@@ -187,6 +250,14 @@ class SettingsController extends ChangeNotifier {
); );
} }
final fabName = prefs.getString(_prefFabStyle);
if (fabName != null) {
_fabStyle = FabStyle.values.firstWhere(
(f) => f.name == fabName,
orElse: () => _fabStyle,
);
}
notifyListeners(); notifyListeners();
} catch (e) { } catch (e) {
debugPrint('[SettingsController] Preference restore failed: $e'); debugPrint('[SettingsController] Preference restore failed: $e');
@@ -223,18 +294,6 @@ class SettingsController extends ChangeNotifier {
); );
} }
void setBottomBarOpacity(double value) {
_bottomBarOpacity = value;
notifyListeners();
_prefsFuture.then((p) => p.setDouble(_prefBottomBarOpacity, value));
}
void setBottomBarBlur(double value) {
_bottomBarBlur = value;
notifyListeners();
_prefsFuture.then((p) => p.setDouble(_prefBottomBarBlur, value));
}
void setTapTabToScrollTop(bool value) { void setTapTabToScrollTop(bool value) {
_tapTabToScrollTop = value; _tapTabToScrollTop = value;
notifyListeners(); notifyListeners();
@@ -247,6 +306,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();
@@ -255,6 +340,17 @@ class SettingsController extends ChangeNotifier {
); );
} }
void setSelectionActionOrder(List<SelectionActionKind> order) {
_selectionActionOrder = order;
notifyListeners();
_prefsFuture.then(
(p) => p.setStringList(
_prefSelectionActionOrder,
order.map((k) => k.name).toList(),
),
);
}
/// Shows or hides a tab in the bottom nav bar. Refuses to hide the last /// Shows or hides a tab in the bottom nav bar. Refuses to hide the last
/// remaining visible tab. If the tab being hidden is the current default, /// remaining visible tab. If the tab being hidden is the current default,
/// the default falls back to the next visible tab. /// the default falls back to the next visible tab.
@@ -324,4 +420,11 @@ class SettingsController extends ChangeNotifier {
notifyListeners(); notifyListeners();
_prefsFuture.then((p) => p.setString(_prefSwipeRightAction, action.name)); _prefsFuture.then((p) => p.setString(_prefSwipeRightAction, action.name));
} }
void setFabStyle(FabStyle style) {
if (_fabStyle == style) return;
_fabStyle = style;
notifyListeners();
_prefsFuture.then((p) => p.setString(_prefFabStyle, style.name));
}
} }
+37 -8
View File
@@ -8,7 +8,16 @@ import 'session_controller.dart';
class SharesController extends ChangeNotifier { class SharesController extends ChangeNotifier {
final SessionController session; final SessionController session;
List<NextcloudShare> _shares = []; /// Each scope's own cache - switching `sharedWithMe` no longer refetches
/// on every switch, only the first time a scope is visited (or on a
/// pull-to-refresh/retry), since `By you`/`Links` (see shares_view.dart's
/// client-side split of this same list) and `With you` used to hit the
/// network again on every single toggle, even switching straight back to
/// a scope just fetched seconds ago.
List<NextcloudShare> _withMeShares = [];
List<NextcloudShare> _byMeShares = [];
bool _withMeLoaded = false;
bool _byMeLoaded = false;
bool _isLoading = false; bool _isLoading = false;
String? _errorMessage; String? _errorMessage;
bool _sharedWithMe = false; bool _sharedWithMe = false;
@@ -18,13 +27,17 @@ class SharesController extends ChangeNotifier {
session.addAccountActivatedListener(_onAccountActivated); session.addAccountActivatedListener(_onAccountActivated);
} }
List<NextcloudShare> get shares => _shares; List<NextcloudShare> get shares =>
_sharedWithMe ? _withMeShares : _byMeShares;
bool get isLoading => _isLoading; bool get isLoading => _isLoading;
String? get errorMessage => _errorMessage; String? get errorMessage => _errorMessage;
bool get sharedWithMe => _sharedWithMe; bool get sharedWithMe => _sharedWithMe;
void _onAccountCleared() { void _onAccountCleared() {
_shares = []; _withMeShares = [];
_byMeShares = [];
_withMeLoaded = false;
_byMeLoaded = false;
_isLoading = false; _isLoading = false;
_errorMessage = null; _errorMessage = null;
_sharedWithMe = false; _sharedWithMe = false;
@@ -35,19 +48,29 @@ class SharesController extends ChangeNotifier {
unawaited(fetchAll()); unawaited(fetchAll());
} }
/// Fetches the *current* scope fresh, ignoring its cache - the initial
/// load, pull-to-refresh, and the error-retry button all want a real
/// round-trip regardless of whether this scope was already loaded.
Future<void> fetchAll() async { Future<void> fetchAll() async {
final service = session.service; final service = session.service;
if (!session.isLoggedIn || service == null) return; if (!session.isLoggedIn || service == null) return;
final gen = session.sessionGeneration; final gen = session.sessionGeneration;
final wantsWithMe = _sharedWithMe;
_isLoading = true; _isLoading = true;
_errorMessage = null; _errorMessage = null;
notifyListeners(); notifyListeners();
try { try {
final shares = await service.fetchShares(sharedWithMe: _sharedWithMe); final shares = await service.fetchShares(sharedWithMe: wantsWithMe);
if (gen != session.sessionGeneration) return; if (gen != session.sessionGeneration) return;
_shares = shares; if (wantsWithMe) {
_withMeShares = shares;
_withMeLoaded = true;
} else {
_byMeShares = shares;
_byMeLoaded = true;
}
} catch (e) { } catch (e) {
if (gen != session.sessionGeneration) return; if (gen != session.sessionGeneration) return;
debugPrint('[SharesController] Error fetching shares: $e'); debugPrint('[SharesController] Error fetching shares: $e');
@@ -60,12 +83,14 @@ class SharesController extends ChangeNotifier {
} }
} }
/// Switches between "shared by me" and "shared with me" and refetches. /// Switches between "shared by me" and "shared with me". Only fetches
/// when the target scope hasn't been loaded yet - switching back to one
/// already cached just swaps which list [shares] reads from.
void setSharedWithMe(bool value) { void setSharedWithMe(bool value) {
if (_sharedWithMe == value) return; if (_sharedWithMe == value) return;
_sharedWithMe = value; _sharedWithMe = value;
notifyListeners(); notifyListeners();
fetchAll(); if (!(value ? _withMeLoaded : _byMeLoaded)) fetchAll();
} }
Future<bool> deleteShare(NextcloudShare share) async { Future<bool> deleteShare(NextcloudShare share) async {
@@ -73,7 +98,11 @@ class SharesController extends ChangeNotifier {
if (service == null) return false; if (service == null) return false;
final success = await service.deleteShare(share.id); final success = await service.deleteShare(share.id);
if (success) { if (success) {
_shares = _shares.where((s) => s.id != share.id).toList(); if (_sharedWithMe) {
_withMeShares = _withMeShares.where((s) => s.id != share.id).toList();
} else {
_byMeShares = _byMeShares.where((s) => s.id != share.id).toList();
}
notifyListeners(); notifyListeners();
} }
return success; return success;
+57 -26
View File
@@ -44,6 +44,19 @@ DateTime? _parseDavDate(String? raw) {
} }
} }
/// Nextcloud's WebDAV server has no real per-file creation-time tracking
/// for most setups, so its `creationdate` property routinely comes back as
/// a placeholder Unix-epoch date ("Thu, 01 Jan 1970 00:00:00 GMT") instead
/// of being omitted - [_parseDavDate] parses that "successfully" into a
/// real (if bogus) `DateTime`, so every item's created date read January
/// 1970. Treat it as absent instead, the same as a missing/unparseable
/// value: `NextcloudItem`'s constructor then falls back to `lastModified`.
DateTime? _parseDavCreationDate(String? raw) {
final parsed = _parseDavDate(raw);
if (parsed != null && parsed.millisecondsSinceEpoch <= 0) return null;
return parsed;
}
/// The OCS Activity API reports each event's time as an ISO 8601 string in /// The OCS Activity API reports each event's time as an ISO 8601 string in
/// a `datetime` field (e.g. "2025-09-15T12:34:56+00:00") - there is no /// a `datetime` field (e.g. "2025-09-15T12:34:56+00:00") - there is no
/// numeric `timestamp` field despite that being a very easy name to guess. /// numeric `timestamp` field despite that being a very easy name to guess.
@@ -134,6 +147,19 @@ class NextcloudService {
return response.bodyBytes; return response.bodyBytes;
} }
/// Overwrites the file at [itemPath] with [bytes] (WebDAV PUT) - used by
/// the in-app text editor. Returns whether the server accepted it.
Future<bool> putBytes(String itemPath, List<int> bytes) async {
final response = await http.put(
Uri.parse(fileUrl(itemPath)),
headers: _headers,
body: bytes,
);
return response.statusCode == 200 ||
response.statusCode == 201 ||
response.statusCode == 204;
}
Future<bool> testConnection() async { Future<bool> testConnection() async {
try { try {
final davPath = '$_cleanServerUrl/remote.php/dav/files/$username/'; final davPath = '$_cleanServerUrl/remote.php/dav/files/$username/';
@@ -356,7 +382,7 @@ class NextcloudService {
final size = int.tryParse(sizeStr ?? '0') ?? 0; final size = int.tryParse(sizeStr ?? '0') ?? 0;
final lastMod = _parseDavDate(lastModStr) ?? DateTime.now(); final lastMod = _parseDavDate(lastModStr) ?? DateTime.now();
final created = _parseDavDate(createdStr); final created = _parseDavCreationDate(createdStr);
final itemType = NextcloudItem.deduceType(name, isCollection, mimeType); final itemType = NextcloudItem.deduceType(name, isCollection, mimeType);
final validId = (fileId != null && fileId.isNotEmpty) ? fileId : name; final validId = (fileId != null && fileId.isNotEmpty) ? fileId : name;
@@ -671,7 +697,7 @@ class NextcloudService {
final size = int.tryParse(sizeStr ?? '0') ?? 0; final size = int.tryParse(sizeStr ?? '0') ?? 0;
final lastMod = _parseDavDate(lastModStr) ?? DateTime.now(); final lastMod = _parseDavDate(lastModStr) ?? DateTime.now();
final created = _parseDavDate(createdStr); final created = _parseDavCreationDate(createdStr);
final itemType = NextcloudItem.deduceType(name, false, mimeType); final itemType = NextcloudItem.deduceType(name, false, mimeType);
final validId = (fileId != null && fileId.isNotEmpty) ? fileId : name; final validId = (fileId != null && fileId.isNotEmpty) ? fileId : name;
@@ -835,7 +861,7 @@ class NextcloudService {
final size = int.tryParse(sizeStr ?? '0') ?? 0; final size = int.tryParse(sizeStr ?? '0') ?? 0;
final lastMod = _parseDavDate(lastModStr) ?? DateTime.now(); final lastMod = _parseDavDate(lastModStr) ?? DateTime.now();
final created = _parseDavDate(createdStr); final created = _parseDavCreationDate(createdStr);
final itemType = NextcloudItem.deduceType(name, isCollection, mimeType); final itemType = NextcloudItem.deduceType(name, isCollection, mimeType);
final validId = (fileId != null && fileId.isNotEmpty) ? fileId : name; final validId = (fileId != null && fileId.isNotEmpty) ? fileId : name;
@@ -1000,7 +1026,7 @@ class NextcloudService {
final size = int.tryParse(sizeStr ?? '0') ?? 0; final size = int.tryParse(sizeStr ?? '0') ?? 0;
final lastMod = _parseDavDate(lastModStr) ?? DateTime.now(); final lastMod = _parseDavDate(lastModStr) ?? DateTime.now();
final created = _parseDavDate(createdStr); final created = _parseDavCreationDate(createdStr);
final itemType = NextcloudItem.deduceType(name, false, mimeType); final itemType = NextcloudItem.deduceType(name, false, mimeType);
final validId = (fileId != null && fileId.isNotEmpty) ? fileId : name; final validId = (fileId != null && fileId.isNotEmpty) ? fileId : name;
@@ -1458,28 +1484,17 @@ class NextcloudService {
final rawList = data['ocs']?['data']; final rawList = data['ocs']?['data'];
if (rawList is! List) return []; if (rawList is! List) return [];
return rawList.map<NextcloudShare>((raw) { // Shares out to `_shareFromJson` (the same parser `fetchSharesForPath`/
final path = (raw['path'] ?? '/').toString(); // `fetchInheritedShares` use) rather than its own duplicated, thinner
final segments = path.split('/').where((s) => s.isNotEmpty).toList(); // inline parsing - that copy never set `url`/`token`/`permissions`/
final name = segments.isNotEmpty ? segments.last : path; // `expireDate` at all, so a public-link share always came back with
final isFolder = (raw['item_type'] ?? '').toString() == 'folder'; // `url: null` here and the Shares tab's own "Copy link" action could
final mimeType = raw['mimetype'] as String?; // never show up for it.
return rawList
return NextcloudShare( .map<NextcloudShare>(
id: (raw['id'] ?? '').toString(), (raw) => _shareFromJson(raw, sharedWithMe: sharedWithMe),
path: path, )
name: name, .toList();
itemType: NextcloudItem.deduceType(name, isFolder, mimeType),
shareType: _mapShareType((raw['share_type'] as num?)?.toInt() ?? -1),
ownerDisplayName: (raw['displayname_owner'] ?? raw['uid_owner'] ?? '')
.toString(),
sharedWithDisplayName: raw['share_with_displayname'] as String?,
sharedAt: DateTime.fromMillisecondsSinceEpoch(
((raw['stime'] as num?)?.toInt() ?? 0) * 1000,
),
sharedWithMe: sharedWithMe,
);
}).toList();
} }
/// Creates a public link share for [path] and returns its share URL. /// Creates a public link share for [path] and returns its share URL.
@@ -1523,6 +1538,22 @@ class NextcloudService {
return response.statusCode == 200; return response.statusCode == 200;
} }
/// Changes a share's permission bitmask (1 read, 2 update, 4 create,
/// 8 delete, 16 reshare).
Future<bool> updateSharePermissions(String shareId, int permissions) async {
final url =
'$_cleanServerUrl/ocs/v2.php/apps/files_sharing/api/v1/shares/$shareId?format=json';
debugPrint('[Nextcloud OCS] Updating share $shareId permissions');
final response = await http.put(
Uri.parse(url),
headers: _headers,
body: {'permissions': '$permissions'},
);
debugPrint('[Nextcloud OCS] Update share status: ${response.statusCode}');
return response.statusCode == 200;
}
NextcloudShare _shareFromJson(Map raw, {required bool sharedWithMe}) { NextcloudShare _shareFromJson(Map raw, {required bool sharedWithMe}) {
final path = (raw['path'] ?? '/').toString(); final path = (raw['path'] ?? '/').toString();
final segments = path.split('/').where((s) => s.isNotEmpty).toList(); final segments = path.split('/').where((s) => s.isNotEmpty).toList();
+37 -1
View File
@@ -5,6 +5,27 @@ import '../providers/session_controller.dart';
import 'native_channel.dart'; import 'native_channel.dart';
import 'share_intent_service.dart'; import 'share_intent_service.dart';
/// One upload batch finishing - see [UploadService.completions].
class UploadCompletion {
final String remoteFolder;
final int succeeded;
final int failed;
const UploadCompletion({
required this.remoteFolder,
required this.succeeded,
required this.failed,
});
factory UploadCompletion.fromMap(Map<dynamic, dynamic> map) {
return UploadCompletion(
remoteFolder: map['remoteFolder'] as String? ?? '/',
succeeded: (map['succeeded'] as num?)?.toInt() ?? 0,
failed: (map['failed'] as num?)?.toInt() ?? 0,
);
}
}
/// Hands a "Share to Noo" upload off to `ShareUploadService.kt`'s Android /// Hands a "Share to Noo" upload off to `ShareUploadService.kt`'s Android
/// foreground service, which does the actual prepare+upload entirely on its /// foreground service, which does the actual prepare+upload entirely on its
/// own from there - including surviving the app being closed - with a /// own from there - including surviving the app being closed - with a
@@ -14,11 +35,15 @@ import 'share_intent_service.dart';
/// real Android Service does. /// real Android Service does.
class UploadService { class UploadService {
static const _channel = MethodChannel('dev.ayushya.noo/upload_service'); static const _channel = MethodChannel('dev.ayushya.noo/upload_service');
static const _statusChannel = EventChannel(
'dev.ayushya.noo/upload_service/status',
);
/// Starts uploading [files] into the active account's current folder. /// Starts uploading [files] into the active account's current folder.
/// Fire-and-forget from Dart's perspective - once this returns, the /// Fire-and-forget from Dart's perspective - once this returns, the
/// service owns the rest and reports progress/completion/cancellation /// service owns the rest and reports progress/completion/cancellation
/// through its own notification, not back to the app. /// through its own notification, not back to the app. [completions] is
/// the one thing it does report back, for `FilesController` to act on.
static Future<void> startUpload( static Future<void> startUpload(
SessionController session, SessionController session,
FilesController files, FilesController files,
@@ -37,4 +62,15 @@ class UploadService {
'remoteFolder': files.currentFolderPath, 'remoteFolder': files.currentFolderPath,
}); });
} }
/// Fires once per finished batch (at least one file uploaded) from
/// `UploadEventBus` (Kotlin) - lets `FilesController` refresh the
/// destination folder if it's the one currently on screen, instead of the
/// list only updating on a manual pull-to-refresh. One shared stream,
/// deliberately: see `SyncService.statusStream`'s identical doc comment -
/// `EventChannel.receiveBroadcastStream()` opens its own native
/// subscription per call, and the native side only keeps the latest one.
static final Stream<UploadCompletion> completions = _statusChannel
.receiveBroadcastStream()
.map((event) => UploadCompletion.fromMap(event as Map<dynamic, dynamic>));
} }
+67 -26
View File
@@ -1,9 +1,19 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart'; import 'package:google_fonts/google_fonts.dart';
import 'design_tokens.dart';
class AppTheme { class AppTheme {
static const Color defaultNextcloudBlue = Color(0xFF0082C9); static const Color defaultNextcloudBlue = Color(0xFF0082C9);
/// The design system's own fixed violet - [NooColors.light]/`.dark`'s
/// `accent`, and the accent picker's "Default" swatch. Also
/// [SettingsController]'s initial [SettingsController.seedColor], so a
/// fresh install (or any device without Material You dynamic color) keeps
/// today's violet look rather than picking up the leftover
/// [defaultNextcloudBlue] default from before seed colors actually did
/// anything to the Noo-styled UI (see [NooColors.fromSeed]).
static const Color defaultAccent = Color(0xFF8D0DE3);
// Flutter's Material 3 default (ZoomPageTransitionsBuilder) doesn't // Flutter's Material 3 default (ZoomPageTransitionsBuilder) doesn't
// implement Android's predictive-back gesture at all. Opting into // implement Android's predictive-back gesture at all. Opting into
// PredictiveBackPageTransitionsBuilder here (Android only) is required — // PredictiveBackPageTransitionsBuilder here (Android only) is required —
@@ -23,6 +33,7 @@ class AppTheme {
static const _sliderTheme = SliderThemeData(year2023: false); static const _sliderTheme = SliderThemeData(year2023: false);
static const List<Color> seedColors = [ static const List<Color> seedColors = [
defaultAccent, // Default (Noo Violet)
Color(0xFF0082C9), // Nextcloud Blue Color(0xFF0082C9), // Nextcloud Blue
Color(0xFF009688), // Ocean Teal Color(0xFF009688), // Ocean Teal
Color(0xFF6750A4), // Deep Purple Color(0xFF6750A4), // Deep Purple
@@ -30,6 +41,49 @@ class AppTheme {
Color(0xFF2E7D32), // Emerald Green Color(0xFF2E7D32), // Emerald Green
]; ];
// Design-system typefaces (DESIGN_SYSTEM.md 1.3): Schibsted Grotesk for
// headings, Instrument Sans for UI/body text. Applied over Flutter's
// default TextTheme slots app-wide - screens not yet reworked to the
// design system's own named type scale (`NooText`) still benefit from
// the new typefaces immediately instead of staying on Inter until their
// turn comes.
static TextTheme _textTheme(Brightness brightness) {
final base = brightness == Brightness.light
? ThemeData.light().textTheme
: ThemeData.dark().textTheme;
return GoogleFonts.instrumentSansTextTheme(base).copyWith(
displayLarge: GoogleFonts.schibstedGrotesk(
textStyle: base.displayLarge,
fontWeight: FontWeight.w600,
),
displayMedium: GoogleFonts.schibstedGrotesk(
textStyle: base.displayMedium,
fontWeight: FontWeight.w600,
),
displaySmall: GoogleFonts.schibstedGrotesk(
textStyle: base.displaySmall,
fontWeight: FontWeight.w600,
),
headlineLarge: GoogleFonts.schibstedGrotesk(
textStyle: base.headlineLarge,
fontWeight: FontWeight.w600,
),
headlineMedium: GoogleFonts.schibstedGrotesk(
fontWeight: FontWeight.w600,
letterSpacing: -0.5,
),
headlineSmall: GoogleFonts.schibstedGrotesk(
textStyle: base.headlineSmall,
fontWeight: FontWeight.w600,
),
titleLarge: GoogleFonts.schibstedGrotesk(
fontWeight: FontWeight.w600,
letterSpacing: -0.2,
),
titleMedium: GoogleFonts.schibstedGrotesk(fontWeight: FontWeight.w500),
);
}
static ThemeData light( static ThemeData light(
Color seedColor, { Color seedColor, {
ColorScheme? dynamicScheme, ColorScheme? dynamicScheme,
@@ -41,26 +95,19 @@ class AppTheme {
seedColor: seedColor, seedColor: seedColor,
brightness: Brightness.light, brightness: Brightness.light,
); );
final nooColors = (useDynamicColor && dynamicScheme != null)
? NooColors.fromDynamicScheme(dynamicScheme)
: NooColors.fromSeed(seedColor, Brightness.light);
return ThemeData( return ThemeData(
useMaterial3: true, useMaterial3: true,
brightness: Brightness.light, brightness: Brightness.light,
colorScheme: colorScheme, colorScheme: colorScheme,
extensions: [nooColors],
scaffoldBackgroundColor: colorScheme.surface, scaffoldBackgroundColor: colorScheme.surface,
pageTransitionsTheme: _pageTransitionsTheme, pageTransitionsTheme: _pageTransitionsTheme,
sliderTheme: _sliderTheme, sliderTheme: _sliderTheme,
textTheme: GoogleFonts.interTextTheme(ThemeData.light().textTheme) textTheme: _textTheme(Brightness.light),
.copyWith(
headlineMedium: GoogleFonts.inter(
fontWeight: FontWeight.w600,
letterSpacing: -0.5,
),
titleLarge: GoogleFonts.inter(
fontWeight: FontWeight.w600,
letterSpacing: -0.2,
),
titleMedium: GoogleFonts.inter(fontWeight: FontWeight.w500),
),
cardTheme: CardThemeData( cardTheme: CardThemeData(
elevation: 0, elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
@@ -71,7 +118,7 @@ class AppTheme {
elevation: 0, elevation: 0,
scrolledUnderElevation: 0, scrolledUnderElevation: 0,
centerTitle: false, centerTitle: false,
titleTextStyle: GoogleFonts.inter( titleTextStyle: GoogleFonts.schibstedGrotesk(
fontSize: 22, fontSize: 22,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: colorScheme.onSurface, color: colorScheme.onSurface,
@@ -92,6 +139,9 @@ class AppTheme {
seedColor: seedColor, seedColor: seedColor,
brightness: Brightness.dark, brightness: Brightness.dark,
); );
var nooColors = (useDynamicColor && dynamicScheme != null)
? NooColors.fromDynamicScheme(dynamicScheme)
: NooColors.fromSeed(seedColor, Brightness.dark);
if (amoled) { if (amoled) {
// True OLED black across every surface tone Material 3 hands out - // True OLED black across every surface tone Material 3 hands out -
@@ -106,27 +156,18 @@ class AppTheme {
surfaceContainerHighest: Colors.black, surfaceContainerHighest: Colors.black,
surfaceDim: Colors.black, surfaceDim: Colors.black,
); );
nooColors = nooColors.withAmoled();
} }
return ThemeData( return ThemeData(
useMaterial3: true, useMaterial3: true,
brightness: Brightness.dark, brightness: Brightness.dark,
colorScheme: colorScheme, colorScheme: colorScheme,
extensions: [nooColors],
scaffoldBackgroundColor: colorScheme.surface, scaffoldBackgroundColor: colorScheme.surface,
pageTransitionsTheme: _pageTransitionsTheme, pageTransitionsTheme: _pageTransitionsTheme,
sliderTheme: _sliderTheme, sliderTheme: _sliderTheme,
textTheme: GoogleFonts.interTextTheme(ThemeData.dark().textTheme) textTheme: _textTheme(Brightness.dark),
.copyWith(
headlineMedium: GoogleFonts.inter(
fontWeight: FontWeight.w600,
letterSpacing: -0.5,
),
titleLarge: GoogleFonts.inter(
fontWeight: FontWeight.w600,
letterSpacing: -0.2,
),
titleMedium: GoogleFonts.inter(fontWeight: FontWeight.w500),
),
cardTheme: CardThemeData( cardTheme: CardThemeData(
elevation: 0, elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
@@ -137,7 +178,7 @@ class AppTheme {
elevation: 0, elevation: 0,
scrolledUnderElevation: 0, scrolledUnderElevation: 0,
centerTitle: false, centerTitle: false,
titleTextStyle: GoogleFonts.inter( titleTextStyle: GoogleFonts.schibstedGrotesk(
fontSize: 22, fontSize: 22,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: colorScheme.onSurface, color: colorScheme.onSurface,
+460
View File
@@ -0,0 +1,460 @@
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
/// Semantic color tokens from `.claude/context/design-system/DESIGN_SYSTEM.md`
/// section 1.1 - read that file before changing any value here. Registered
/// on [ThemeData.extensions], so call sites reach these via
/// `Theme.of(context).extension<NooColors>()!` (or a `context.nooColors`
/// getter, once one exists) instead of the Material [ColorScheme] roles.
///
/// Three sources feed this, matching the app's color settings: the fixed
/// [light]/[dark] palettes below (the design system's own default look,
/// also what the accent picker's "Default" violet swatch selects),
/// [NooColors.fromDynamicScheme] when the user has Material You dynamic
/// color turned on, or [NooColors.fromSeed] for a custom accent-picker
/// swatch. [withAmoled] layers on top of any of the three when the user's
/// AMOLED toggle is on.
@immutable
class NooColors extends ThemeExtension<NooColors> {
final Color bg;
final Color surface;
final Color surface2;
final Color surface3;
final Color line;
final Color fg1;
final Color fg2;
final Color fg3;
final Color accent;
final Color accentText;
final Color accentSoft;
final Color danger;
final Color dangerSoft;
final Color dangerFill;
final Color success;
final Color successSoft;
final Color warning;
final Color warningSoft;
final Color info;
final Color infoSoft;
final Color scrim;
const NooColors({
required this.bg,
required this.surface,
required this.surface2,
required this.surface3,
required this.line,
required this.fg1,
required this.fg2,
required this.fg3,
required this.accent,
required this.accentText,
required this.accentSoft,
required this.danger,
required this.dangerSoft,
required this.dangerFill,
required this.success,
required this.successSoft,
required this.warning,
required this.warningSoft,
required this.info,
required this.infoSoft,
required this.scrim,
});
static const light = NooColors(
bg: Color(0xFFF3EFE6),
surface: Color(0xFFFFFFFF),
surface2: Color(0xFFF6F5F2),
surface3: Color(0xFFEAE8E2),
line: Color(0xFFEAE8E2),
fg1: Color(0xFF0E0D0B),
fg2: Color(0xFF5A5852),
fg3: Color(0xFF7C7A72),
accent: Color(0xFF8D0DE3),
accentText: Color(0xFF8D0DE3),
accentSoft: Color(0xFFECDFFF),
danger: Color(0xFFA8202A),
dangerSoft: Color(0xFFFDE7E7),
dangerFill: Color(0xFFA8202A),
success: Color(0xFF3E7A23),
successSoft: Color(0xFFEAF5DF),
warning: Color(0xFF8C5A0E),
warningSoft: Color(0xFFFBEFD0),
info: Color(0xFF2940A8),
infoSoft: Color(0xFFE6ECFE),
scrim: Color(0x730E0D0B),
);
static const dark = NooColors(
bg: Color(0xFF0E0D0B),
surface: Color(0xFF25241F),
surface2: Color(0xFF3D3C38),
surface3: Color(0xFF5A5852),
line: Color(0xFF3D3C38),
fg1: Color(0xFFF3EFE6),
fg2: Color(0xFFBDBAB1),
fg3: Color(0xFF9E9B92),
accent: Color(0xFF8D0DE3),
accentText: Color(0xFFCBA5FD),
accentSoft: Color(0xFF2F0454),
danger: Color(0xFFF49E9E),
dangerSoft: Color(0xFF560F18),
dangerFill: Color(0xFFA8202A),
success: Color(0xFFB7DD9F),
successSoft: Color(0xFF1E3F10),
warning: Color(0xFFF0C26C),
warningSoft: Color(0xFF4A3208),
info: Color(0xFFB6C3FB),
infoSoft: Color(0xFF1C2B73),
scrim: Color(0x99000000),
);
/// Avatar/photo-placeholder fills (`DESIGN_SYSTEM.md` 1.1) - a fixed
/// secondary palette, same in light and dark, always paired with
/// [avatarTextColor]. Content-identity colors, not theme, so they don't
/// change with brightness or dynamic color (same reasoning as file-type
/// tinting in `styling.md`).
static const avatarPalette = [
Color(0xFF8DCDE2),
Color(0xFF96B0FD),
Color(0xFFE5D8BD),
Color(0xFFEEEDB3),
Color(0xFFDA9AC7),
Color(0xFFA7D296),
Color(0xFFE9A6A7),
Color(0xFF85D1BD),
Color(0xFFCBA5FD),
];
static const avatarTextColor = Color(0xFF0E0D0B);
/// Maps a Material You dynamic [ColorScheme] onto these roles for users
/// who've turned dynamic color on, instead of the fixed [light]/[dark]
/// palettes. `success`/`successSoft`/`warning`/`warningSoft`/`info`/
/// `infoSoft` have no Material dynamic equivalent and are content-identity
/// colors regardless (file-type tiles) rather than brand colors, so they
/// always come from the fixed palette matching [cs]'s brightness. Same
/// for `scrim`: Material 3's `ColorScheme.scrim` role is fully *opaque*
/// black (`0xFF000000`), meant to be used at reduced opacity by whoever
/// paints it, not painted directly - using it as-is for a sheet/dialog's
/// `barrierColor` hid the screen behind completely instead of dimming it,
/// so this uses the fixed palette's already-translucent scrim instead.
factory NooColors.fromDynamicScheme(ColorScheme cs) {
final fixed = cs.brightness == Brightness.dark ? dark : light;
return NooColors(
bg: cs.surface,
surface: cs.surfaceContainerLow,
surface2: cs.surfaceContainerHigh,
surface3: cs.surfaceContainerHighest,
line: cs.outlineVariant,
fg1: cs.onSurface,
fg2: cs.onSurfaceVariant,
fg3: cs.onSurfaceVariant,
accent: cs.primary,
accentText: cs.primary,
accentSoft: cs.primaryContainer,
danger: cs.error,
dangerSoft: cs.errorContainer,
dangerFill: cs.error,
success: fixed.success,
successSoft: fixed.successSoft,
warning: fixed.warning,
warningSoft: fixed.warningSoft,
info: fixed.info,
infoSoft: fixed.infoSoft,
scrim: fixed.scrim,
);
}
/// Derives an accent-only variant of the fixed [light]/[dark] palette for
/// a user-chosen custom seed color (Settings' accent-color picker) -
/// unlike [fromDynamicScheme], only the accent roles change; bg/surface/
/// fg etc. stay the Noo warm-neutral palette, since picking a named
/// accent ("Ocean Teal") is meant to retint the brand color, not reshape
/// the whole surface to match a wallpaper the way dynamic color does.
/// [seedColor] equal to the fixed palette's own accent (the picker's
/// "Default" violet swatch) returns the untouched fixed palette, so the
/// out-of-box look stays pixel-identical rather than going through
/// [ColorScheme.fromSeed]'s tonal-palette math for no reason.
factory NooColors.fromSeed(Color seedColor, Brightness brightness) {
final fixed = brightness == Brightness.dark ? dark : light;
if (seedColor == light.accent) return fixed;
final cs = ColorScheme.fromSeed(
seedColor: seedColor,
brightness: brightness,
);
return fixed.copyWith(
accent: seedColor,
// Light accentText matches accent exactly, same as the fixed
// palette - dark needs a lighter, desaturated tint for contrast
// against a dark surface, which is exactly what Material's tonal
// primary (tone ~80 in dark mode) is designed to give.
accentText: brightness == Brightness.dark ? cs.primary : seedColor,
accentSoft: cs.primaryContainer,
);
}
/// True OLED black across every surface tone - not just [bg] - so cards,
/// bars and sheets all go black too instead of the dark palette's warm
/// dark-grey tones. Mirrors the AMOLED handling this replaces in
/// `AppTheme.dark` (see git history), just against these tokens instead
/// of Material [ColorScheme] roles.
NooColors withAmoled() => copyWith(
bg: Colors.black,
surface: Colors.black,
surface2: Colors.black,
surface3: Colors.black,
);
@override
NooColors copyWith({
Color? bg,
Color? surface,
Color? surface2,
Color? surface3,
Color? line,
Color? fg1,
Color? fg2,
Color? fg3,
Color? accent,
Color? accentText,
Color? accentSoft,
Color? danger,
Color? dangerSoft,
Color? dangerFill,
Color? success,
Color? successSoft,
Color? warning,
Color? warningSoft,
Color? info,
Color? infoSoft,
Color? scrim,
}) {
return NooColors(
bg: bg ?? this.bg,
surface: surface ?? this.surface,
surface2: surface2 ?? this.surface2,
surface3: surface3 ?? this.surface3,
line: line ?? this.line,
fg1: fg1 ?? this.fg1,
fg2: fg2 ?? this.fg2,
fg3: fg3 ?? this.fg3,
accent: accent ?? this.accent,
accentText: accentText ?? this.accentText,
accentSoft: accentSoft ?? this.accentSoft,
danger: danger ?? this.danger,
dangerSoft: dangerSoft ?? this.dangerSoft,
dangerFill: dangerFill ?? this.dangerFill,
success: success ?? this.success,
successSoft: successSoft ?? this.successSoft,
warning: warning ?? this.warning,
warningSoft: warningSoft ?? this.warningSoft,
info: info ?? this.info,
infoSoft: infoSoft ?? this.infoSoft,
scrim: scrim ?? this.scrim,
);
}
@override
NooColors lerp(ThemeExtension<NooColors>? other, double t) {
if (other is! NooColors) return this;
return NooColors(
bg: Color.lerp(bg, other.bg, t)!,
surface: Color.lerp(surface, other.surface, t)!,
surface2: Color.lerp(surface2, other.surface2, t)!,
surface3: Color.lerp(surface3, other.surface3, t)!,
line: Color.lerp(line, other.line, t)!,
fg1: Color.lerp(fg1, other.fg1, t)!,
fg2: Color.lerp(fg2, other.fg2, t)!,
fg3: Color.lerp(fg3, other.fg3, t)!,
accent: Color.lerp(accent, other.accent, t)!,
accentText: Color.lerp(accentText, other.accentText, t)!,
accentSoft: Color.lerp(accentSoft, other.accentSoft, t)!,
danger: Color.lerp(danger, other.danger, t)!,
dangerSoft: Color.lerp(dangerSoft, other.dangerSoft, t)!,
dangerFill: Color.lerp(dangerFill, other.dangerFill, t)!,
success: Color.lerp(success, other.success, t)!,
successSoft: Color.lerp(successSoft, other.successSoft, t)!,
warning: Color.lerp(warning, other.warning, t)!,
warningSoft: Color.lerp(warningSoft, other.warningSoft, t)!,
info: Color.lerp(info, other.info, t)!,
infoSoft: Color.lerp(infoSoft, other.infoSoft, t)!,
scrim: Color.lerp(scrim, other.scrim, t)!,
);
}
}
/// Reads the app's [NooColors] - registered on every [ThemeData] built by
/// `AppTheme`, so this is never actually null in a running app.
extension NooColorsContext on BuildContext {
NooColors get nooColors => Theme.of(this).extension<NooColors>()!;
}
/// The 4px spacing scale from the Noo Design System project's
/// `tokens/spacing.css` (claude.ai/design, project id
/// bb69c00d-f400-445c-9683-355668c0bfe1 - the authoritative source now;
/// `.claude/context/design-system/` is the earlier, less precise mockup
/// export).
class NooSpace {
const NooSpace._();
static const double xxs = 4;
static const double xs = 8;
static const double sm = 12;
static const double smd = 14;
static const double md = 16;
static const double lg = 20;
static const double xl = 24;
static const double xxl = 32;
}
/// Corner radii from `tokens/spacing.css`. `pill` covers every button,
/// chip, segmented control, toggle, search field and badge.
class NooRadii {
const NooRadii._();
static const double pill = 999;
static const double card = 20;
static const double gridCard = 18;
static const double input = 14;
static const double sidebarItem = 12;
static const double sheetTop = 28;
static const double dialog = 24;
static const double dropdown = 10;
static const double screenCornerAndroid = 36;
static const double screenCornerIOS = 48;
static const double windowMac = 12;
static const double windowWin = 8;
}
/// Fixed heights/widths from `tokens/spacing.css` - button sizes, row
/// heights, the desktop sidebar width. Named to match the component that
/// uses each one (`NooButton`'s `size` picks from the `button*` set).
class NooSizes {
const NooSizes._();
static const double buttonCta = 52;
static const double buttonCard = 44;
static const double buttonField = 40;
static const double buttonToolbar = 36;
static const double buttonCompact = 32;
static const double buttonXs = 28;
static const double rowMobile = 64;
static const double rowDesktop = 52;
static const double settingsRow = 52;
static const double sidebarItem = 38;
static const double toolbar = 64;
static const double sidebarWidth = 256;
}
/// Motion constants from `tokens/spacing.css`'s `--ease`/`--dur-*`/
/// `--press-scale`.
class NooMotion {
const NooMotion._();
static const Curve ease = Cubic(0.7, 0, 0.15, 1);
static const Duration fast = Duration(milliseconds: 180);
static const Duration base = Duration(milliseconds: 280);
static const Duration slow = Duration(milliseconds: 480);
static const double pressScale = 0.98;
}
/// The one shadow the design system allows, from `tokens/spacing.css`'s
/// `--shadow-dialog` - desktop dialogs, and [NooBottomBarStyle.floating]'s
/// bar/search satellite (the only other thing in the app that's genuinely
/// floating above other content). Nothing else should use a `BoxShadow`.
const nooDialogShadow = BoxShadow(
color: Color(0x241E002F), // rgba(30,0,47,.14)
blurRadius: 48,
offset: Offset(0, 24),
);
/// The named type scale from `DESIGN_SYSTEM.md` 1.3 - mobile sizes only for
/// now (desktop rework hasn't started). Schibsted Grotesk for headings,
/// Instrument Sans for UI text, per the spec. Color is deliberately not set
/// here - callers pull it from [NooColors] since the same style (e.g.
/// `meta`) is reused across surfaces with different foreground colors.
class NooText {
const NooText._();
static TextStyle get largeTitle => GoogleFonts.schibstedGrotesk(
fontSize: 34,
height: 0.9,
fontWeight: FontWeight.w600,
letterSpacing: -34 * 0.03,
);
static TextStyle get stat => GoogleFonts.schibstedGrotesk(
fontSize: 32,
height: 0.9,
fontWeight: FontWeight.w600,
letterSpacing: -32 * 0.03,
);
static TextStyle get title => GoogleFonts.schibstedGrotesk(
fontSize: 22,
fontWeight: FontWeight.w500,
letterSpacing: -22 * 0.02,
);
static TextStyle get cardTitle => GoogleFonts.schibstedGrotesk(
fontSize: 20,
fontWeight: FontWeight.w500,
letterSpacing: -20 * 0.02,
);
/// Desktop `GroupedList` title / share dialog headings - 17px, between
/// [cardTitle] and [groupHeading].
static TextStyle get sectionTitle => GoogleFonts.schibstedGrotesk(
fontSize: 17,
fontWeight: FontWeight.w500,
letterSpacing: -17 * 0.02,
);
static TextStyle get groupHeading => GoogleFonts.schibstedGrotesk(
fontSize: 18,
fontWeight: FontWeight.w500,
letterSpacing: -18 * 0.02,
);
static TextStyle get bodyL => GoogleFonts.instrumentSans(
fontSize: 16,
height: 1.2,
fontWeight: FontWeight.w400,
);
static TextStyle get body => GoogleFonts.instrumentSans(
fontSize: 15,
height: 1.3,
fontWeight: FontWeight.w400,
);
static TextStyle get label =>
GoogleFonts.instrumentSans(fontSize: 13, fontWeight: FontWeight.w600);
static TextStyle get meta => GoogleFonts.instrumentSans(
fontSize: 13,
height: 1.2,
fontWeight: FontWeight.w400,
);
/// Bottom-nav label, idle state (Android: 12/500 fg-2 - see
/// `Mobile Screen.dc.html`'s `aNav` block). Use [navLabelActive] for the
/// selected destination.
static TextStyle get navLabel =>
GoogleFonts.instrumentSans(fontSize: 12, fontWeight: FontWeight.w500);
/// Bottom-nav label, active state (Android: 12/600 fg-1, not accent-text -
/// only the icon's pill uses accent-soft/accent-text).
static TextStyle get navLabelActive =>
GoogleFonts.instrumentSans(fontSize: 12, fontWeight: FontWeight.w600);
static TextStyle get button =>
GoogleFonts.instrumentSans(fontSize: 15, fontWeight: FontWeight.w600);
static TextStyle get buttonSm =>
GoogleFonts.instrumentSans(fontSize: 14, fontWeight: FontWeight.w600);
static const mono = TextStyle(
fontFamily: 'monospace',
fontFamilyFallback: ['ui-monospace', 'Menlo'],
fontSize: 14,
height: 1,
);
}
+194 -1468
View File
File diff suppressed because it is too large Load Diff
+107 -110
View File
@@ -1,141 +1,138 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../models/nextcloud_item.dart';
import '../providers/files_controller.dart'; import '../providers/files_controller.dart';
import '../widgets/more_tabs_button.dart'; import '../providers/session_controller.dart';
import '../widgets/profile_avatar_button.dart'; import '../theme/design_tokens.dart';
import '../widgets/synced_header_scaffold.dart'; import '../widgets/noo/lists/noo_grouped_list.dart';
import '../widgets/noo/media/noo_activity_item.dart';
import '../widgets/noo/media/noo_photo_tile.dart';
import '../widgets/noo/noo_layout.dart';
import '../widgets/tabs/tab_day_groups.dart';
import '../widgets/tabs/tab_state_slivers.dart';
/// The whole-account activity feed - lives on [FilesController] (fetched
/// alongside the current Files folder) rather than its own controller, see
/// `FilesController.refreshData`. Grouped by calendar day per
/// `DESIGN_SYSTEM.md` §4; desktop centers the feed at 760px.
///
/// [NextcloudActivity] only carries a rendered `title`/`subject` string
/// pair plus an `author` username - the server response has no structured
/// actor/verb/object breakdown or file reference, so the bold "actor"
/// segment is the author and the rest of the sentence (already fully
/// formed server-side) rides as one plain run rather than being split
/// further, and there's no reliable file to show in the trailing file
/// tile slot - see this screen's rebuild report for what a richer feed
/// would need from the service layer.
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) {
final theme = Theme.of(context); final colors = context.nooColors;
final colorScheme = theme.colorScheme;
final files = context.watch<FilesController>(); final files = context.watch<FilesController>();
final session = context.watch<SessionController>();
final activities = files.activities; final activities = files.activities;
final isDesktop = NooLayout.isDesktop(context);
final groups = groupByCalendarDay<NextcloudActivity>(
activities,
(a) => a.timestamp,
);
final List<Widget> contentSlivers = [ Widget feed;
const SliverToBoxAdapter(child: SizedBox(height: 16)), if (files.isLoading && activities.isEmpty) {
feed = tabLoadingSliver;
if (files.isLoading && activities.isEmpty) } else if (activities.isEmpty) {
const SliverFillRemaining( feed = tabEmptySliver(
hasScrollBody: false, context,
child: Center(child: CircularProgressIndicator()), icon: LucideIcons.activity,
) message: 'No recent activity',
else if (activities.isEmpty) );
SliverFillRemaining( } else {
hasScrollBody: false, feed = SliverPadding(
child: Center( padding: EdgeInsets.symmetric(horizontal: NooLayout.gutter(context)),
child: Text(
'No recent activity',
style: theme.textTheme.titleMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
)
else
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 20),
sliver: SliverList( sliver: SliverList(
delegate: SliverChildBuilderDelegate((context, index) { delegate: SliverChildBuilderDelegate((context, index) {
final act = activities[index]; final group = groups[index];
return Padding( return Padding(
padding: const EdgeInsets.only(bottom: 12), padding: const EdgeInsets.only(bottom: 18),
child: Container( child: NooGroupedList(
padding: const EdgeInsets.all(16), label: group.label,
decoration: BoxDecoration(
color: colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: colorScheme.outlineVariant.withValues(alpha: 0.2),
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Container( for (final act in group.items)
padding: const EdgeInsets.all(10), Material(
decoration: BoxDecoration( color: colors.surface,
color: colorScheme.primaryContainer, child: NooActivityItem(
shape: BoxShape.circle, actor: act.author,
verb: act.title,
tail: act.subject.isNotEmpty && act.subject != act.title
? act.subject
: null,
time: DateFormat.jm().format(act.timestamp),
avatarColor: NooPhotoTile.paletteColor(
act.author.hashCode,
), ),
child: Icon( currentUser:
act.icon, act.author.toLowerCase() ==
color: colorScheme.onPrimaryContainer, session.username.toLowerCase(),
size: 20,
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Text(
act.title,
style: theme.textTheme.titleMedium
?.copyWith(
fontWeight: FontWeight.w600,
fontSize: 14,
),
),
),
const SizedBox(width: 8),
Text(
DateFormat.jm().format(act.timestamp),
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontSize: 11,
), ),
), ),
], ],
), ),
const SizedBox(height: 4),
Text(
act.subject,
style: theme.textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
fontSize: 13,
),
),
const SizedBox(height: 6),
Text(
'By ${act.author} • ${DateFormat.MMMd().format(act.timestamp)}',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.primary,
fontWeight: FontWeight.w500,
fontSize: 11,
),
),
],
),
),
],
),
),
); );
}, childCount: activities.length), }, childCount: groups.length),
),
), ),
);
}
const SliverToBoxAdapter(child: SizedBox(height: 100)), final List<Widget> contentSlivers = [
const SliverFillRemaining(hasScrollBody: false, child: SizedBox()), if (topBar != null) topBarSliver(topBar!),
const SliverToBoxAdapter(child: SizedBox(height: NooSpace.md)),
feed,
...tabBottomInsetSlivers(context),
]; ];
return SyncedHeaderScaffold( // See files_view.dart's identical fix - without this, the sticky
scrollController: scrollController, // controls row rides up under the status bar once the floating top bar
actions: const [MoreTabsButton(), ProfileAvatarButton()], // above it fully collapses.
contentSlivers: contentSlivers, final scrollView = SafeArea(
top: true,
bottom: false,
child: CustomScrollView(
controller: scrollController,
// See files_view.dart's identical fix - without this, pull-to-
// refresh can't be triggered on an empty or single-item feed.
physics: const AlwaysScrollableScrollPhysics(),
slivers: contentSlivers,
),
);
return ColoredBox(
color: colors.bg,
child: RefreshIndicator(
color: colors.accent,
backgroundColor: colors.surface,
onRefresh: () => context.read<FilesController>().refreshData(),
// "Desktop limits it to 760px wide" (DESIGN_SYSTEM.md §4) - centered
// rather than left-aligned, so a wide window doesn't stretch the
// feed's short sentences edge to edge.
child: isDesktop
? Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 760),
child: scrollView,
),
)
: scrollView,
),
); );
} }
} }
+276 -367
View File
@@ -1,7 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:share_plus/share_plus.dart'; import 'package:share_plus/share_plus.dart';
import '../models/app_tab.dart'; import '../models/app_tab.dart';
@@ -13,16 +13,24 @@ import '../providers/item_operations.dart';
import '../providers/session_controller.dart'; import '../providers/session_controller.dart';
import '../providers/settings_controller.dart'; import '../providers/settings_controller.dart';
import '../services/download_service.dart'; import '../services/download_service.dart';
import '../theme/design_tokens.dart';
import '../widgets/details/details_sheet.dart'; import '../widgets/details/details_sheet.dart';
import '../widgets/item_icon.dart'; import '../widgets/files_controls_row.dart';
import '../widgets/more_tabs_button.dart'; import '../widgets/noo/files/noo_file_kind.dart';
import '../widgets/profile_avatar_button.dart'; import '../widgets/noo/files/noo_file_row.dart';
import '../widgets/segmented_icon_toggle.dart'; import '../widgets/noo/files/noo_file_table.dart';
import '../widgets/selectable_thumbnail.dart'; import '../widgets/noo/files/noo_file_tile.dart';
import '../widgets/noo/lists/noo_grouped_list.dart';
import '../widgets/noo/lists/noo_selection_bar.dart';
import '../widgets/noo/lists/noo_settings_row.dart';
import '../widgets/noo/media/noo_grid_card.dart';
import '../widgets/noo/noo_layout.dart';
import '../widgets/noo/overlays/noo_sheet.dart';
import '../widgets/noo/core/noo_button.dart';
import '../widgets/share_sheet.dart'; import '../widgets/share_sheet.dart';
import '../widgets/sort_menu_button.dart';
import '../widgets/sticky_header_delegate.dart'; import '../widgets/sticky_header_delegate.dart';
import '../widgets/synced_header_scaffold.dart'; import '../widgets/tabs/tab_state_slivers.dart';
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';
@@ -35,97 +43,41 @@ import 'move_copy_destination_picker.dart';
/// wide list also means the breadcrumb-vs-content mismatch a filter /// wide list also means the breadcrumb-vs-content mismatch a filter
/// toggle had - the trail showing wherever Files was last browsing while /// toggle had - the trail showing wherever Files was last browsing while
/// the content showed something else entirely - simply can't happen). /// the content showed something else entirely - simply can't happen).
/// Shares Files' own sort/hidden/storage-scope/grid-list display prefs /// Shares Files' own sort/hidden/type-filter/storage-scope/grid-list
/// (via `FilesController.applyFilesDisplayPrefs`) rather than a separate /// display prefs (via `FilesController.applyFilesDisplayPrefs`, and
/// parallel settings dimension. /// `FilesControlsRow` for the controls themselves) rather than a separate
/// parallel settings dimension. Renders content only - the shell owns the
/// top bar, bottom bar, drawer and FAB.
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();
} }
class _FavoritesViewState extends State<FavoritesView> class _FavoritesViewState extends State<FavoritesView> {
with SingleTickerProviderStateMixin {
bool _requested = false; bool _requested = false;
final Set<String> _selectedIds = {}; final Set<String> _selectedIds = {};
final ScrollController _selectionActionsScrollController = ScrollController();
final List<AnimationController> _scrollHintControllers = [];
bool get _isSelecting => _selectedIds.isNotEmpty; bool get _isSelecting => _selectedIds.isNotEmpty;
void _toggleSelection(NextcloudItem item) { void _toggleSelection(NextcloudItem item) {
HapticFeedback.selectionClick(); HapticFeedback.selectionClick();
final enteringSelection = _selectedIds.isEmpty;
setState(() { setState(() {
if (!_selectedIds.remove(item.id)) _selectedIds.add(item.id); if (!_selectedIds.remove(item.id)) _selectedIds.add(item.id);
}); });
if (enteringSelection && _isSelecting) {
WidgetsBinding.instance.addPostFrameCallback(
(_) => _playScrollHint(_selectionActionsScrollController),
);
}
} }
void _clearSelection() { void _clearSelection() {
setState(_selectedIds.clear); setState(_selectedIds.clear);
} }
/// Mirrors `FilesView`/`PhotosView`'s identical scroll-hint - see their
/// doc comment: nudges the selection actions row right and back, once,
/// the first time a selection starts. No-ops if there's nothing to
/// scroll (row already fits).
Future<void> _playScrollHint(ScrollController scrollController) async {
await Future.delayed(const Duration(milliseconds: 500));
if (!mounted || !scrollController.hasClients) return;
final maxExtent = scrollController.position.maxScrollExtent;
if (maxExtent <= 0) return;
final double peak = maxExtent < 36 ? maxExtent : 36;
final controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 900),
);
_scrollHintControllers.add(controller);
final hint = TweenSequence<double>([
TweenSequenceItem(
tween: Tween(
begin: 0.0,
end: peak,
).chain(CurveTween(curve: Curves.easeInOutSine)),
weight: 50,
),
TweenSequenceItem(
tween: Tween(
begin: peak,
end: 0.0,
).chain(CurveTween(curve: Curves.easeInOutSine)),
weight: 50,
),
]).animate(controller);
void onTick() {
if (scrollController.hasClients) {
scrollController.jumpTo(hint.value);
}
}
hint.addListener(onTick);
await controller.forward();
hint.removeListener(onTick);
_scrollHintControllers.remove(controller);
controller.dispose();
}
@override
void dispose() {
for (final controller in _scrollHintControllers) {
controller.dispose();
}
_selectionActionsScrollController.dispose();
super.dispose();
}
/// A favorited folder switches to the Files tab, navigated there; a /// A favorited folder switches to the Files tab, navigated there; a
/// favorited file just opens directly from here, like Files/Photos do - /// favorited file just opens directly from here, like Files/Photos do -
/// no need to reposition Files first since dismissing the viewer lands /// no need to reposition Files first since dismissing the viewer lands
@@ -149,46 +101,57 @@ class _FavoritesViewState extends State<FavoritesView>
/// The bulk actions shown in the sticky selection toolbar for the /// The bulk actions shown in the sticky selection toolbar for the
/// currently-selected items - same set Files/Photos offer. /// currently-selected items - same set Files/Photos offer.
List<SelectionAction> _buildSelectionActions(List<NextcloudItem> selected) { List<SelectionAction> _buildSelectionActions(List<NextcloudItem> selected) {
return [ final actions = [
SelectionAction( SelectionAction(
icon: Icons.favorite_border_rounded, kind: SelectionActionKind.favorite,
icon: LucideIcons.starOff,
label: 'Remove from favorites', label: 'Remove from favorites',
onTap: () => _unfavoriteSelected(selected), onTap: () => _unfavoriteSelected(selected),
), ),
SelectionAction( SelectionAction(
icon: Icons.share_rounded, kind: SelectionActionKind.share,
icon: LucideIcons.share2,
label: 'Share', label: 'Share',
onTap: () => selected.length == 1 onTap: () => selected.length == 1
? ShareSheet.show(context, selected.single) ? ShareSheet.show(context, selected.single)
: _shareSelected(context, selected), : _shareSelected(context, selected),
), ),
SelectionAction( SelectionAction(
icon: Icons.download_rounded, kind: SelectionActionKind.download,
icon: LucideIcons.download,
label: 'Download', label: 'Download',
onTap: () => _downloadSelected(context, selected), onTap: () => _downloadSelected(context, selected),
), ),
SelectionAction( SelectionAction(
icon: Icons.delete_outline_rounded, kind: SelectionActionKind.delete,
icon: LucideIcons.trash2,
label: 'Delete', label: 'Delete',
onTap: () => _confirmDeleteSelected(context, selected), onTap: () => _confirmDeleteSelected(context, selected),
), ),
SelectionAction( SelectionAction(
icon: Icons.copy_rounded, kind: SelectionActionKind.copy,
icon: LucideIcons.copy,
label: 'Copy', label: 'Copy',
onTap: () => _moveOrCopySelected(selected, copy: true), onTap: () => _moveOrCopySelected(selected, copy: true),
), ),
SelectionAction( SelectionAction(
icon: Icons.drive_file_move_rounded, kind: SelectionActionKind.move,
icon: LucideIcons.folderInput,
label: 'Move', label: 'Move',
onTap: () => _moveOrCopySelected(selected, copy: false), onTap: () => _moveOrCopySelected(selected, copy: false),
), ),
if (selected.length == 1) if (selected.length == 1)
SelectionAction( SelectionAction(
icon: Icons.info_outline_rounded, kind: SelectionActionKind.details,
icon: LucideIcons.info,
label: 'Details', label: 'Details',
onTap: () => DetailsSheet.show(context, selected.single), onTap: () => DetailsSheet.show(context, selected.single),
), ),
]; ];
return orderSelectionActions(
actions,
context.read<SettingsController>().selectionActionOrder,
);
} }
Future<void> _unfavoriteSelected(List<NextcloudItem> items) async { Future<void> _unfavoriteSelected(List<NextcloudItem> items) async {
@@ -348,98 +311,87 @@ class _FavoritesViewState extends State<FavoritesView>
); );
} }
Widget _buildControlsRow(FilesController files) { /// A single item's actions (the same list the multi-select bar offers,
return SizedBox( /// just for one item) - mirrors `FilesView._showItemActionsSheet`.
height: 44, void _showItemActionsSheet(BuildContext context, NextcloudItem item) {
child: SingleChildScrollView( final actions = _buildSelectionActions([item]);
scrollDirection: Axis.horizontal, showNooSheet(
child: Row( context,
children: [ children: [
IconButton( NooGroupedList(
icon: Icon(
files.filesSortAscending
? Icons.arrow_upward_rounded
: Icons.arrow_downward_rounded,
size: 20,
),
visualDensity: VisualDensity.compact,
tooltip: files.filesSortAscending ? 'Ascending' : 'Descending',
onPressed: files.toggleFilesSortOrder,
),
SizedBox(
width: 130,
child: SortMenuButton(
field: files.filesSortField,
onChanged: files.setFilesSortField,
),
),
ToggleIconButton(
icon: files.showHiddenFiles
? Icons.visibility_rounded
: Icons.visibility_off_rounded,
isSelected: files.showHiddenFiles,
onTap: () => files.toggleShowHiddenFiles(),
tooltip: 'Show hidden files',
),
const SizedBox(width: 4),
SegmentedIconGroup(
children: [ children: [
ToggleIconButton( for (final action in actions)
icon: Symbols.circles_rounded, NooSettingsRow(
isSelected: files.storageScope == StorageScope.cloud, icon: action.icon,
onTap: () => files.setStorageScope(StorageScope.cloud), label: Text(action.label),
tooltip: 'Cloud storage', onTap: () {
), Navigator.pop(context);
ToggleIconButton( action.onTap();
icon: Symbols.hard_drive_rounded, },
isSelected: files.storageScope == StorageScope.external,
onTap: () => files.setStorageScope(StorageScope.external),
tooltip: 'External storage',
),
],
),
const SizedBox(width: 8),
SegmentedIconGroup(
children: [
ToggleIconButton(
icon: Icons.view_list_rounded,
isSelected: !files.isGridView,
onTap: () => files.setGridView(false),
tooltip: 'List view',
),
ToggleIconButton(
icon: Icons.grid_view_rounded,
isSelected: files.isGridView,
onTap: () => files.setGridView(true),
tooltip: 'Grid view',
), ),
], ],
), ),
], ],
),
),
); );
} }
Widget _buildListTile( /// A real image/video thumbnail for [item]'s file tile/card, sized so
/// `NooFileTile`'s `FittedBox` (which needs a concretely-sized child to
/// scale) always has one to work with; null when there's nothing to show
/// (falls back to the kind's soft-color tile).
Widget? _thumbnailFor(
BuildContext context,
NextcloudItem item, {
required double extent,
}) {
final isMedia =
(item.type == NextcloudItemType.image ||
item.type == NextcloudItemType.video) &&
item.previewUrl != null;
if (!isMedia) return null;
final session = context.watch<SessionController>();
return Image.network(
item.previewUrl!,
width: extent,
height: extent,
headers: session.service?.authHeaders,
fit: BoxFit.cover,
filterQuality: FilterQuality.low,
gaplessPlayback: true,
errorBuilder: (ctx, err, stack) => const SizedBox.shrink(),
);
}
String _metaFor(NextcloudItem item) {
return item.isFolder
? 'Folder'
: '${formatBytes(item.size)} · ${DateFormat.yMMMd().format(item.lastModified)}';
}
Widget _buildRow(
BuildContext context, BuildContext context,
NextcloudItem item, NextcloudItem item,
List<NextcloudItem> siblings, List<NextcloudItem> siblings,
) { ) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final session = context.watch<SessionController>();
final isSelected = _selectedIds.contains(item.id); final isSelected = _selectedIds.contains(item.id);
final kind = NooFileKind.from(
name: item.name,
mimeType: item.mimeType,
isDirectory: item.isFolder,
);
return Padding( return Padding(
padding: const EdgeInsets.only(bottom: 8), padding: const EdgeInsets.only(bottom: 8),
child: ClipRRect( child: ClipRRect(
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(NooRadii.card),
child: Material( child: NooFileRow(
color: isSelected kind: kind,
? colorScheme.primaryContainer.withValues(alpha: 0.5) name: item.name,
: colorScheme.surfaceContainerLow, meta: _metaFor(item),
child: InkWell( favorite: item.isFavorite,
iosStyle: NooLayout.iosStyle(context),
selected: isSelected,
thumbnail: _thumbnailFor(context, item, extent: NooSizes.rowMobile),
onTap: () { onTap: () {
if (_isSelecting) { if (_isSelecting) {
_toggleSelection(item); _toggleSelection(item);
@@ -448,52 +400,47 @@ class _FavoritesViewState extends State<FavoritesView>
} }
}, },
onLongPress: () => _toggleSelection(item), onLongPress: () => _toggleSelection(item),
child: Padding( onMore: () => _showItemActionsSheet(context, item),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
SelectableThumbnail(
isSelected: isSelected,
size: 44,
checkmarkSize: 24,
child: ItemThumbnail(
item: item,
service: session.service,
size: 44,
borderRadius: 12,
iconSize: 22,
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.name,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
fontSize: 14,
),
),
const SizedBox(height: 2),
Text(
item.isFolder
? 'Folder'
: '${formatBytes(item.size)} • ${DateFormat.yMMMd().format(item.lastModified)}',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
],
),
),
), ),
), ),
);
}
Widget _buildTableRow(
BuildContext context,
NextcloudItem item,
List<NextcloudItem> siblings,
) {
final isSelected = _selectedIds.contains(item.id);
final kind = NooFileKind.from(
name: item.name,
mimeType: item.mimeType,
isDirectory: item.isFolder,
);
return NooFileTableRow(
kind: kind,
name: item.name,
col2: item.isFolder ? null : DateFormat.yMMMd().format(item.lastModified),
col3: item.isFolder ? null : formatBytes(item.size),
favorite: item.isFavorite,
selected: isSelected,
thumbnail: _thumbnailFor(
context,
item,
extent: NooFileTileSize.desktop.extent,
), ),
onTap: () {
if (_isSelecting) {
_toggleSelection(item);
} else {
_openFavorite(context, item, siblings);
}
},
// Desktop has no long-press gesture; right-click is this row's
// equivalent entry point into multi-select.
onSecondaryTap: () => _toggleSelection(item),
onMore: () => _showItemActionsSheet(context, item),
); );
} }
@@ -501,21 +448,27 @@ class _FavoritesViewState extends State<FavoritesView>
BuildContext context, BuildContext context,
NextcloudItem item, NextcloudItem item,
List<NextcloudItem> siblings, List<NextcloudItem> siblings,
bool isDesktop,
) { ) {
final theme = Theme.of(context); final colors = context.nooColors;
final session = context.watch<SessionController>();
final isSelected = _selectedIds.contains(item.id); final isSelected = _selectedIds.contains(item.id);
final isMedia = final kind = NooFileKind.from(
(item.type == NextcloudItemType.image || name: item.name,
item.type == NextcloudItemType.video) && mimeType: item.mimeType,
item.previewUrl != null; isDirectory: item.isFolder,
final iconColor = getIconColor(context, item.type); );
final thumbnailHeight = isDesktop ? 118.0 : 104.0;
return ClipRRect( return NooGridCard(
borderRadius: BorderRadius.circular(20), name: item.name,
child: Material( meta: item.isFolder ? 'Folder' : formatBytes(item.size),
color: theme.colorScheme.surfaceContainerLow, placeholderColor: kind.background(colors),
child: InkWell( icon: kind.icon,
iconColor: kind.foreground(colors),
thumbnailHeight: thumbnailHeight,
thumbnail: _thumbnailFor(context, item, extent: thumbnailHeight),
selected: isSelected,
verticalOverflowIcon: !NooLayout.iosStyle(context),
onTap: () { onTap: () {
if (_isSelecting) { if (_isSelecting) {
_toggleSelection(item); _toggleSelection(item);
@@ -524,66 +477,14 @@ class _FavoritesViewState extends State<FavoritesView>
} }
}, },
onLongPress: () => _toggleSelection(item), onLongPress: () => _toggleSelection(item),
child: SelectableThumbnail( onMore: () => _showItemActionsSheet(context, item),
isSelected: isSelected,
checkmarkSize: 32,
child: isMedia
? Image.network(
item.previewUrl!,
headers: session.service?.authHeaders,
fit: BoxFit.cover,
filterQuality: FilterQuality.low,
gaplessPlayback: true,
errorBuilder: (ctx, err, stack) => Container(
color: theme.colorScheme.secondaryContainer,
child: Icon(
getItemIcon(item.type),
color: theme.colorScheme.onSecondaryContainer,
size: 32,
),
),
)
: Padding(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: iconColor.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(12),
),
child: Icon(
getItemIcon(item.type),
color: iconColor,
size: 24,
),
),
Text(
item.name,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
fontSize: 13,
),
),
],
),
),
),
),
),
); );
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final colors = context.nooColors;
final colorScheme = theme.colorScheme; final isDesktop = NooLayout.isDesktop(context);
final favoritesController = context.watch<FavoritesController>(); final favoritesController = context.watch<FavoritesController>();
final filesController = context.watch<FilesController>(); final filesController = context.watch<FilesController>();
@@ -600,18 +501,27 @@ class _FavoritesViewState extends State<FavoritesView>
.where((i) => _selectedIds.contains(i.id)) .where((i) => _selectedIds.contains(i.id))
.toList(); .toList();
final controlsRow = Padding(
padding: const EdgeInsets.fromLTRB(
NooSpace.sm,
NooSpace.sm,
NooSpace.sm,
NooSpace.xs,
),
child: FilesControlsRow(folderPath: filesController.currentFolderPath),
);
final contentSlivers = <Widget>[ final contentSlivers = <Widget>[
if (widget.topBar != null) topBarSliver(widget.topBar!),
SliverPersistentHeader( SliverPersistentHeader(
pinned: !_isSelecting, pinned: !_isSelecting,
delegate: StickyHeaderDelegate( delegate: StickyHeaderDelegate(
height: 60, height: _isSelecting ? 56 : 64,
child: Padding( child: _isSelecting
padding: const EdgeInsets.fromLTRB(16, 16, 16, 0), ? _buildSelectionBar(context, selectedItems)
child: _buildControlsRow(filesController), : controlsRow,
), ),
), ),
),
const SliverToBoxAdapter(child: SizedBox(height: 8)),
if (favoritesController.isLoading && favorites.isEmpty) if (favoritesController.isLoading && favorites.isEmpty)
const SliverFillRemaining( const SliverFillRemaining(
hasScrollBody: false, hasScrollBody: false,
@@ -626,32 +536,25 @@ class _FavoritesViewState extends State<FavoritesView>
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Icon( Icon(LucideIcons.circleAlert, size: 56, color: colors.danger),
Icons.error_outline_rounded,
size: 64,
color: colorScheme.error,
),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
'Could not load favorites', 'Could not load favorites',
style: theme.textTheme.titleLarge?.copyWith( style: NooText.cardTitle.copyWith(color: colors.danger),
color: colorScheme.error,
fontWeight: FontWeight.bold,
),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
favoritesController.errorMessage!, favoritesController.errorMessage!,
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium?.copyWith( style: NooText.body.copyWith(color: colors.fg3),
color: colorScheme.onSurfaceVariant,
),
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
FilledButton.icon( NooButton(
onPressed: favoritesController.fetchAll, variant: NooButtonVariant.secondary,
icon: const Icon(Icons.refresh_rounded), size: NooButtonSize.field,
label: const Text('Retry'), icon: LucideIcons.refreshCw,
onTap: favoritesController.fetchAll,
child: const Text('Retry'),
), ),
], ],
), ),
@@ -665,17 +568,11 @@ class _FavoritesViewState extends State<FavoritesView>
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Icon( Icon(LucideIcons.star, size: 56, color: colors.fg3),
Icons.favorite_border_rounded,
size: 64,
color: colorScheme.outlineVariant,
),
const SizedBox(height: 12), const SizedBox(height: 12),
Text( Text(
'No favorites yet', 'No favorites yet',
style: theme.textTheme.titleMedium?.copyWith( style: NooText.cardTitle.copyWith(color: colors.fg2),
color: colorScheme.onSurfaceVariant,
),
), ),
], ],
), ),
@@ -683,30 +580,73 @@ class _FavoritesViewState extends State<FavoritesView>
) )
else if (filesController.isGridView) else if (filesController.isGridView)
SliverPadding( SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), padding: EdgeInsets.symmetric(
horizontal: NooLayout.gutter(context),
vertical: NooSpace.xs,
),
sliver: SliverGrid( sliver: SliverGrid(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2, crossAxisCount: isDesktop
childAspectRatio: 1.1, ? 5
crossAxisSpacing: 12, : NooLayout.gridColumns(context, phone: 2, minTile: 180),
mainAxisSpacing: 12, crossAxisSpacing: isDesktop ? 16 : 10,
mainAxisSpacing: isDesktop ? 16 : 10,
childAspectRatio: isDesktop ? 0.92 : 0.85,
), ),
delegate: SliverChildBuilderDelegate((context, index) { delegate: SliverChildBuilderDelegate((context, index) {
return _buildGridCard(context, favorites[index], siblings); return _buildGridCard(
context,
favorites[index],
siblings,
isDesktop,
);
}, childCount: favorites.length), }, childCount: favorites.length),
), ),
) )
else if (isDesktop)
SliverPadding(
padding: EdgeInsets.symmetric(horizontal: NooLayout.gutter(context)),
sliver: SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
if (index == 0) {
return NooFileTableHeader(
col2Label: 'Modified',
col3Label: 'Size',
sortColumn: switch (filesController.filesSortField) {
FileSortField.name => 0,
FileSortField.dateModified => 1,
FileSortField.size => 2,
FileSortField.dateCreated => null,
},
sortAscending: filesController.filesSortAscending,
onSort: (column) {
final field = [
FileSortField.name,
FileSortField.dateModified,
FileSortField.size,
][column];
if (field == filesController.filesSortField) {
filesController.toggleFilesSortOrder();
} else {
filesController.setFilesSortField(field);
}
},
);
}
return _buildTableRow(context, favorites[index - 1], siblings);
}, childCount: favorites.length + 1),
),
)
else else
SliverPadding( SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), padding: EdgeInsets.symmetric(horizontal: NooLayout.gutter(context)),
sliver: SliverList( sliver: SliverList(
delegate: SliverChildBuilderDelegate((context, index) { delegate: SliverChildBuilderDelegate((context, index) {
return _buildListTile(context, favorites[index], siblings); return _buildRow(context, favorites[index], siblings);
}, childCount: favorites.length), }, childCount: favorites.length),
), ),
), ),
const SliverToBoxAdapter(child: SizedBox(height: 100)), SliverToBoxAdapter(child: SizedBox(height: bottomBarClearance(context))),
const SliverFillRemaining(hasScrollBody: false, child: SizedBox()),
]; ];
return PopScope( return PopScope(
@@ -714,74 +654,43 @@ class _FavoritesViewState extends State<FavoritesView>
onPopInvokedWithResult: (didPop, result) { onPopInvokedWithResult: (didPop, result) {
if (!didPop && _isSelecting) _clearSelection(); if (!didPop && _isSelecting) _clearSelection();
}, },
child: SyncedHeaderScaffold( child: ColoredBox(
scrollController: widget.scrollController, color: colors.bg,
actions: const [MoreTabsButton(), ProfileAvatarButton()], child: RefreshIndicator(
color: colors.accent,
backgroundColor: colors.surface,
onRefresh: favoritesController.fetchAll, onRefresh: favoritesController.fetchAll,
selectionBar: _isSelecting // See files_view.dart's identical fix - without this, the sticky
? _buildSelectionBar(context, theme, selectedItems) // controls row rides up under the status bar once the floating
: null, // top bar above it fully collapses.
contentSlivers: contentSlivers, child: SafeArea(
top: true,
bottom: false,
child: CustomScrollView(
controller: widget.scrollController,
// See files_view.dart's identical fix - without this, pull-to-
// refresh can't be triggered on an empty or single-item list.
physics: const AlwaysScrollableScrollPhysics(),
slivers: contentSlivers,
),
),
),
), ),
); );
} }
/// Replaces the top bar entirely while selecting (see /// Replaces the controls row's own sticky slot while selecting - mirrors
/// `SyncedHeaderScaffold.selectionBar`) - mirrors Files/Photos' identical /// Files/Photos' identical selection bar.
/// selection bar.
Widget _buildSelectionBar( Widget _buildSelectionBar(
BuildContext context, BuildContext context,
ThemeData theme,
List<NextcloudItem> selectedItems, List<NextcloudItem> selectedItems,
) { ) {
return Padding( return NooSelectionBar(
padding: const EdgeInsets.symmetric(horizontal: 16), count: selectedItems.length,
child: Row( actions: _buildSelectionActions(selectedItems),
children: [ onClose: _clearSelection,
SizedBox( isDesktop: NooLayout.isDesktop(context),
width: MediaQuery.of(context).size.width * 0.5, iosStyle: NooLayout.iosStyle(context),
child: Align(
alignment: Alignment.centerLeft,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.close_rounded, size: 20),
tooltip: 'Cancel selection',
onPressed: _clearSelection,
visualDensity: VisualDensity.compact,
),
const SizedBox(width: 8),
Text(
'${selectedItems.length} selected',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
],
),
),
),
Expanded(
child: SingleChildScrollView(
controller: _selectionActionsScrollController,
scrollDirection: Axis.horizontal,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
for (final action in _buildSelectionActions(selectedItems))
IconButton(
icon: Icon(action.icon, size: 20),
tooltip: action.label,
onPressed: action.onTap,
visualDensity: VisualDensity.compact,
),
],
),
),
),
],
),
); );
} }
} }
File diff suppressed because it is too large Load Diff
+706 -580
View File
File diff suppressed because it is too large Load Diff
+15 -28
View File
@@ -1,6 +1,9 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../providers/session_controller.dart'; import '../providers/session_controller.dart';
import '../theme/design_tokens.dart';
import '../widgets/noo/core/noo_button.dart';
/// Shown in place of the main shell whenever [SessionController.needsUnlock] is /// Shown in place of the main shell whenever [SessionController.needsUnlock] is
/// true - a fresh app launch with login lock configured, or returning to the /// true - a fresh app launch with login lock configured, or returning to the
@@ -48,10 +51,10 @@ class _LockScreenViewState extends State<LockScreenView>
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final colors = context.nooColors;
final colorScheme = theme.colorScheme;
return Scaffold( return Scaffold(
backgroundColor: colors.bg,
body: Center( body: Center(
child: Padding( child: Padding(
padding: const EdgeInsets.all(32), padding: const EdgeInsets.all(32),
@@ -63,10 +66,7 @@ class _LockScreenViewState extends State<LockScreenView>
// silhouette on transparent, meant to be recolored rather // silhouette on transparent, meant to be recolored rather
// than shown as-is. // than shown as-is.
ColorFiltered( ColorFiltered(
colorFilter: ColorFilter.mode( colorFilter: ColorFilter.mode(colors.fg1, BlendMode.srcIn),
colorScheme.onSurface,
BlendMode.srcIn,
),
child: Image.asset( child: Image.asset(
'assets/icon/app_icon_monochrome.png', 'assets/icon/app_icon_monochrome.png',
width: 80, width: 80,
@@ -77,35 +77,22 @@ class _LockScreenViewState extends State<LockScreenView>
Text( Text(
'Noo is locked', 'Noo is locked',
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: theme.textTheme.headlineSmall?.copyWith( style: NooText.title.copyWith(color: colors.fg1),
fontWeight: FontWeight.w700,
),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
'Unlock with your PIN or biometrics to continue', 'Unlock with your PIN or biometrics to continue',
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium?.copyWith( style: NooText.body.copyWith(color: colors.fg3),
color: colorScheme.onSurfaceVariant,
),
), ),
const SizedBox(height: 32), const SizedBox(height: 32),
SizedBox( NooButton(
height: 54, variant: NooButtonVariant.primary,
child: FilledButton.icon( size: NooButtonSize.cta,
onPressed: _authenticating ? null : _attemptUnlock, icon: LucideIcons.lockOpen,
icon: _authenticating disabled: _authenticating,
? SizedBox( onTap: _attemptUnlock,
width: 20, child: Text(_authenticating ? 'Unlocking…' : 'Unlock'),
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2.5,
color: colorScheme.onPrimary,
),
)
: const Icon(Icons.lock_open_rounded),
label: Text(_authenticating ? 'Unlocking…' : 'Unlock'),
),
), ),
], ],
), ),
+132 -202
View File
@@ -1,13 +1,22 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../models/saved_account.dart'; import '../models/saved_account.dart';
import '../providers/session_controller.dart'; import '../providers/session_controller.dart';
import '../theme/design_tokens.dart';
import '../widgets/noo/core/noo_avatar.dart';
import '../widgets/noo/core/noo_button.dart';
import '../widgets/noo/nav/noo_top_bar.dart';
import '../widgets/noo/noo_layout.dart';
import '../widgets/noo/overlays/noo_text_field.dart';
import '../widgets/settings/settings_dialogs.dart';
import '../widgets/shell/shell_common.dart';
import 'login_webview_view.dart'; import 'login_webview_view.dart';
class LoginView extends StatefulWidget { class LoginView extends StatefulWidget {
/// True when this is pushed from Settings ("Add account") on top of an /// True when this is pushed from Settings ("Add account") on top of an
/// already-logged-in session, rather than shown as the app's root screen /// already-logged-in session, rather than shown as the app's root screen
/// with no account yet. Adds an AppBar/back affordance and auto-pops once /// with no account yet. Adds a top bar/back affordance and auto-pops once
/// the new account becomes active. /// the new account becomes active.
final bool isAddingAccount; final bool isAddingAccount;
@@ -18,9 +27,9 @@ class LoginView extends StatefulWidget {
} }
class _LoginViewState extends State<LoginView> { class _LoginViewState extends State<LoginView> {
final _formKey = GlobalKey<FormState>();
final _urlController = TextEditingController(); final _urlController = TextEditingController();
String? _originalActiveAccountId; String? _originalActiveAccountId;
String? _localError;
bool _popped = false; bool _popped = false;
bool _webViewPushed = false; bool _webViewPushed = false;
@@ -39,12 +48,14 @@ class _LoginViewState extends State<LoginView> {
} }
void _handleContinue() { void _handleContinue() {
if (!_formKey.currentState!.validate()) return; final url = _urlController.text.trim();
if (url.isEmpty) {
setState(() => _localError = 'Please enter your Nextcloud server address');
return;
}
setState(() => _localError = null);
FocusScope.of(context).unfocus(); FocusScope.of(context).unfocus();
context.read<SessionController>().startLoginFlow( context.read<SessionController>().startLoginFlow(url, addAccount: widget.isAddingAccount);
_urlController.text.trim(),
addAccount: widget.isAddingAccount,
);
} }
/// Resumes a saved account with one tap (see [SessionController.switchAccount]). /// Resumes a saved account with one tap (see [SessionController.switchAccount]).
@@ -68,47 +79,25 @@ class _LoginViewState extends State<LoginView> {
); );
} }
/// Mirrors AccountView's `_AccountsCard._confirmRemove` - same /// Mirrors Settings' own account removal - same confirm/remove gate, just
/// destructive-action gate, just reached from the login screen instead of /// reached from the login screen instead of the saved-accounts list.
/// Settings.
Future<void> _confirmRemoveAccount(SavedAccount account) async { Future<void> _confirmRemoveAccount(SavedAccount account) async {
final host = Uri.tryParse(account.serverUrl)?.host ?? account.serverUrl; await confirmRemoveAccount(
final confirmed = await showDialog<bool>( context,
context: context, context.read<SessionController>(),
builder: (dialogContext) { accountId: account.id,
return AlertDialog( username: account.username,
title: const Text('Remove Account'), host: serverHost(account.serverUrl),
content: Text( isActive: false,
'Remove ${account.username} ($host)? You can add it again later.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext, false),
child: const Text('Cancel'),
),
FilledButton(
style: FilledButton.styleFrom(
backgroundColor: Theme.of(dialogContext).colorScheme.error,
),
onPressed: () => Navigator.pop(dialogContext, true),
child: const Text('Remove'),
),
],
); );
},
);
if (confirmed != true || !mounted) return;
await context.read<SessionController>().removeAccount(account.id);
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final colors = context.nooColors;
final colorScheme = theme.colorScheme;
final session = context.watch<SessionController>(); final session = context.watch<SessionController>();
final isAwaitingBrowser = final isAwaitingBrowser = session.loginFlowStatus == LoginFlowStatus.awaitingBrowser;
session.loginFlowStatus == LoginFlowStatus.awaitingBrowser;
final isInitiating = session.loginFlowStatus == LoginFlowStatus.initiating; final isInitiating = session.loginFlowStatus == LoginFlowStatus.initiating;
// A new/refreshed account has just become active - pop back to // A new/refreshed account has just become active - pop back to
@@ -128,9 +117,7 @@ class _LoginViewState extends State<LoginView> {
// SessionController.startLoginFlow's doc comment for why. Push it the // SessionController.startLoginFlow's doc comment for why. Push it the
// moment there's a URL to show; _webViewPushed resets once that route // moment there's a URL to show; _webViewPushed resets once that route
// pops (cancelled or done) so a retry after cancelling pushes it again. // pops (cancelled or done) so a retry after cancelling pushes it again.
if (!_webViewPushed && if (!_webViewPushed && isAwaitingBrowser && session.pendingLoginUrl != null) {
isAwaitingBrowser &&
session.pendingLoginUrl != null) {
_webViewPushed = true; _webViewPushed = true;
final url = session.pendingLoginUrl!; final url = session.pendingLoginUrl!;
WidgetsBinding.instance.addPostFrameCallback((_) async { WidgetsBinding.instance.addPostFrameCallback((_) async {
@@ -154,9 +141,17 @@ class _LoginViewState extends State<LoginView> {
}); });
} }
final errorMessage =
_localError ?? (session.loginFlowStatus == LoginFlowStatus.error ? session.errorMessage : null);
final body = Scaffold( final body = Scaffold(
backgroundColor: colors.bg,
appBar: widget.isAddingAccount appBar: widget.isAddingAccount
? AppBar(title: const Text('Add Account')) ? NooTopBar(
style: NooLayout.navStyle(context),
title: 'Add account',
leading: const NooTopBarBack(),
)
: null, : null,
body: SafeArea( body: SafeArea(
child: Center( child: Center(
@@ -172,8 +167,7 @@ class _LoginViewState extends State<LoginView> {
// deleting it, specifically so it can be resumed // deleting it, specifically so it can be resumed
// from here with one tap - no need to repeat // from here with one tap - no need to repeat
// Login Flow v2. // Login Flow v2.
if (!widget.isAddingAccount && if (!widget.isAddingAccount && session.accounts.isNotEmpty) ...[
session.accounts.isNotEmpty) ...[
_SavedAccountsSection( _SavedAccountsSection(
accounts: session.accounts, accounts: session.accounts,
onSelect: (account) => _continueAsAccount(account), onSelect: (account) => _continueAsAccount(account),
@@ -182,33 +176,21 @@ class _LoginViewState extends State<LoginView> {
const SizedBox(height: 28), const SizedBox(height: 28),
Row( Row(
children: [ children: [
const Expanded(child: Divider()), Expanded(child: Divider(color: colors.line)),
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 12), padding: const EdgeInsets.symmetric(horizontal: 12),
child: Text( child: Text('or', style: NooText.meta.copyWith(color: colors.fg3)),
'or',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
), ),
), Expanded(child: Divider(color: colors.line)),
),
const Expanded(child: Divider()),
], ],
), ),
const SizedBox(height: 28), const SizedBox(height: 28),
], ],
_ServerForm( _ServerForm(
formKey: _formKey,
urlController: _urlController, urlController: _urlController,
isLoading: isLoading: isInitiating || session.isLoading || isAwaitingBrowser,
isInitiating || session.isLoading || isAwaitingBrowser, errorMessage: errorMessage,
errorMessage:
session.loginFlowStatus == LoginFlowStatus.error
? session.errorMessage
: null,
onContinue: _handleContinue, onContinue: _handleContinue,
theme: theme,
colorScheme: colorScheme,
), ),
], ],
), ),
@@ -236,29 +218,23 @@ class _LoginViewState extends State<LoginView> {
} }
class _ServerForm extends StatelessWidget { class _ServerForm extends StatelessWidget {
final GlobalKey<FormState> formKey;
final TextEditingController urlController; final TextEditingController urlController;
final bool isLoading; final bool isLoading;
final String? errorMessage; final String? errorMessage;
final VoidCallback onContinue; final VoidCallback onContinue;
final ThemeData theme;
final ColorScheme colorScheme;
const _ServerForm({ const _ServerForm({
required this.formKey,
required this.urlController, required this.urlController,
required this.isLoading, required this.isLoading,
required this.errorMessage, required this.errorMessage,
required this.onContinue, required this.onContinue,
required this.theme,
required this.colorScheme,
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Form( final colors = context.nooColors;
key: formKey,
child: Column( return Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
@@ -269,33 +245,17 @@ class _ServerForm extends StatelessWidget {
// shown as-is. Used everywhere the app shows its own icon // shown as-is. Used everywhere the app shows its own icon
// in-app, rather than the full-color launcher icon. // in-app, rather than the full-color launcher icon.
child: ColorFiltered( child: ColorFiltered(
colorFilter: ColorFilter.mode( colorFilter: ColorFilter.mode(colors.fg1, BlendMode.srcIn),
Theme.of(context).colorScheme.onSurface, child: Image.asset('assets/icon/app_icon_monochrome.png', width: 80, height: 80),
BlendMode.srcIn,
),
child: Image.asset(
'assets/icon/app_icon_monochrome.png',
width: 80,
height: 80,
),
), ),
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
Text( Text('Noo', textAlign: TextAlign.center, style: NooText.largeTitle.copyWith(color: colors.fg1)),
'Noo',
textAlign: TextAlign.center,
style: theme.textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.w800,
letterSpacing: -0.5,
),
),
const SizedBox(height: 6), const SizedBox(height: 6),
Text( Text(
'Connect to your self-hosted server', 'Connect to your self-hosted server',
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium?.copyWith( style: NooText.body.copyWith(color: colors.fg3),
color: colorScheme.onSurfaceVariant,
),
), ),
const SizedBox(height: 36), const SizedBox(height: 36),
if (errorMessage != null) if (errorMessage != null)
@@ -303,90 +263,49 @@ class _ServerForm extends StatelessWidget {
margin: const EdgeInsets.only(bottom: 20), margin: const EdgeInsets.only(bottom: 20),
padding: const EdgeInsets.all(14), padding: const EdgeInsets.all(14),
decoration: BoxDecoration( decoration: BoxDecoration(
color: colorScheme.errorContainer, color: colors.dangerSoft,
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(NooRadii.input),
), ),
child: Row( child: Row(
children: [ children: [
Icon( Icon(LucideIcons.circleAlert, color: colors.danger, size: 20),
Icons.error_outline_rounded,
color: colorScheme.onErrorContainer,
size: 20,
),
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded( Expanded(
child: Text( child: Text(
errorMessage!, errorMessage!,
style: theme.textTheme.bodySmall?.copyWith( style: NooText.body.copyWith(
color: colorScheme.onErrorContainer, fontSize: 13,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
color: colors.danger,
), ),
), ),
), ),
], ],
), ),
), ),
TextFormField( NooTextField(
controller: urlController, controller: urlController,
placeholder: 'cloud.example.com',
leadingIcon: LucideIcons.server,
keyboardType: TextInputType.url, keyboardType: TextInputType.url,
autofillHints: const [AutofillHints.url], onSubmitted: (_) => onContinue(),
decoration: InputDecoration(
labelText: 'Server Address',
hintText: 'cloud.example.com',
prefixIcon: const Icon(Icons.dns_outlined),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
),
filled: true,
fillColor: colorScheme.surfaceContainerLow,
),
validator: (val) {
if (val == null || val.trim().isEmpty) {
return 'Please enter your Nextcloud server address';
}
return null;
},
onFieldSubmitted: (_) => onContinue(),
), ),
const SizedBox(height: 28), const SizedBox(height: 28),
SizedBox( NooButton(
height: 54, variant: NooButtonVariant.primary,
child: FilledButton( size: NooButtonSize.cta,
onPressed: isLoading ? null : onContinue, fullWidth: true,
style: FilledButton.styleFrom( disabled: isLoading,
shape: RoundedRectangleBorder( onTap: onContinue,
borderRadius: BorderRadius.circular(27), child: Text(isLoading ? 'Connecting…' : 'Continue'),
),
),
child: isLoading
? SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
strokeWidth: 2.5,
color: colorScheme.onPrimary,
),
)
: const Text(
'Continue',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
),
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
Text( Text(
"You'll finish signing in on the page that opens. This app never sees your password.", "You'll finish signing in on the page that opens. This app never sees your password.",
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: theme.textTheme.bodySmall?.copyWith( style: NooText.meta.copyWith(color: colors.fg3, fontSize: 11),
color: colorScheme.onSurfaceVariant,
fontSize: 11,
),
), ),
], ],
),
); );
} }
} }
@@ -398,46 +317,37 @@ class _SavedAccountsSection extends StatelessWidget {
final ValueChanged<SavedAccount> onSelect; final ValueChanged<SavedAccount> onSelect;
final ValueChanged<SavedAccount> onRemove; final ValueChanged<SavedAccount> onRemove;
const _SavedAccountsSection({ const _SavedAccountsSection({required this.accounts, required this.onSelect, required this.onRemove});
required this.accounts,
required this.onSelect,
required this.onRemove,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final colors = context.nooColors;
final colorScheme = theme.colorScheme;
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Padding( Padding(
padding: const EdgeInsets.only(left: 4, bottom: 8), padding: const EdgeInsets.only(left: 4, bottom: 8),
child: Text( child: Text('Continue as', style: NooText.label.copyWith(color: colors.fg2)),
'Continue as',
style: theme.textTheme.labelLarge?.copyWith(
color: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
), ),
), ClipRRect(
), borderRadius: BorderRadius.circular(NooRadii.card),
Card( child: DecoratedBox(
margin: EdgeInsets.zero, decoration: BoxDecoration(color: colors.line),
child: Column( child: Column(
children: [ children: [
for (final account in accounts) ...[ for (var i = 0; i < accounts.length; i++) ...[
if (i > 0) const SizedBox(height: 1),
_SavedAccountRow( _SavedAccountRow(
account: account, account: accounts[i],
onTap: () => onSelect(account), onTap: () => onSelect(accounts[i]),
onRemove: () => onRemove(account), onRemove: () => onRemove(accounts[i]),
), ),
if (account != accounts.last)
const Divider(height: 1, indent: 16, endIndent: 16),
], ],
], ],
), ),
), ),
),
], ],
); );
} }
@@ -448,40 +358,60 @@ class _SavedAccountRow extends StatelessWidget {
final VoidCallback onTap; final VoidCallback onTap;
final VoidCallback onRemove; final VoidCallback onRemove;
const _SavedAccountRow({ const _SavedAccountRow({required this.account, required this.onTap, required this.onRemove});
required this.account,
required this.onTap,
required this.onRemove,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme; final colors = context.nooColors;
final host = Uri.tryParse(account.serverUrl)?.host ?? account.serverUrl;
final initial = account.username.isNotEmpty
? account.username[0].toUpperCase()
: '?';
return ListTile( return Material(
leading: CircleAvatar( color: colors.surface,
backgroundColor: colorScheme.primary, child: InkWell(
child: Text(
initial,
style: TextStyle(
color: colorScheme.onPrimary,
fontWeight: FontWeight.bold,
),
),
),
title: Text(account.username),
subtitle: Text(host),
trailing: IconButton(
icon: const Icon(Icons.close_rounded),
tooltip: 'Remove account',
visualDensity: VisualDensity.compact,
onPressed: onRemove,
),
onTap: onTap, onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: NooSpace.md, vertical: 10),
child: Row(
spacing: 12,
children: [
NooAvatar(initials: accountInitial(account.username), size: 36),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 2,
children: [
Text(
account.username,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: NooText.bodyL.copyWith(
height: 1.1,
fontWeight: FontWeight.w500,
color: colors.fg1,
),
),
Text(
serverHost(account.serverUrl),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: NooText.meta.copyWith(color: colors.fg3),
),
],
),
),
GestureDetector(
onTap: onRemove,
child: Container(
width: 36,
height: 36,
alignment: Alignment.center,
decoration: BoxDecoration(color: colors.surface2, shape: BoxShape.circle),
child: Icon(LucideIcons.x, size: 16, color: colors.fg2),
),
),
],
),
),
),
); );
} }
} }
+17 -6
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:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:webview_flutter/webview_flutter.dart'; import 'package:webview_flutter/webview_flutter.dart';
import '../providers/session_controller.dart'; import '../providers/session_controller.dart';
import '../theme/design_tokens.dart';
import '../widgets/noo/nav/noo_top_bar.dart';
import '../widgets/noo/noo_layout.dart';
/// Every Login Flow v2 page - first login or an additional account - /// Every Login Flow v2 page - first login or an additional account -
/// rendered in Flutter's own WebView rather than a Chrome Custom Tab or /// rendered in Flutter's own WebView rather than a Chrome Custom Tab or
@@ -63,6 +67,7 @@ class _LoginWebViewViewState extends State<LoginWebViewView> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final session = context.watch<SessionController>(); final session = context.watch<SessionController>();
final colors = context.nooColors;
// The login flow finished (success or error) or was cancelled // The login flow finished (success or error) or was cancelled
// elsewhere - close this screen automatically rather than leaving it // elsewhere - close this screen automatically rather than leaving it
@@ -81,18 +86,24 @@ class _LoginWebViewViewState extends State<LoginWebViewView> {
if (!didPop) _cancel(); if (!didPop) _cancel();
}, },
child: Scaffold( child: Scaffold(
appBar: AppBar( backgroundColor: colors.bg,
title: const Text('Sign in'), appBar: NooTopBar(
leading: IconButton( style: NooLayout.navStyle(context),
icon: const Icon(Icons.close_rounded), title: 'Log in',
leading: NooTopBarButton(
icon: LucideIcons.x,
tooltip: 'Cancel', tooltip: 'Cancel',
onPressed: _cancel, onTap: _cancel,
), ),
), ),
body: Stack( body: Stack(
children: [ children: [
WebViewWidget(controller: _controller), WebViewWidget(controller: _controller),
if (_pageLoading) const LinearProgressIndicator(), if (_pageLoading)
LinearProgressIndicator(
color: colors.accent,
backgroundColor: colors.surface3,
),
], ],
), ),
), ),
+182 -282
View File
@@ -1,21 +1,24 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:intl/intl.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../models/move_copy_result.dart'; import '../models/move_copy_result.dart';
import '../models/nextcloud_item.dart'; import '../models/nextcloud_item.dart';
import '../providers/files_controller.dart'; import '../providers/files_controller.dart';
import '../providers/item_operations.dart'; import '../providers/item_operations.dart';
import '../providers/session_controller.dart'; import '../theme/design_tokens.dart';
import '../widgets/breadcrumbs.dart'; import '../widgets/breadcrumbs.dart';
import '../widgets/item_icon.dart'; import '../widgets/files_controls_row.dart';
import '../widgets/more_tabs_button.dart'; import '../widgets/more_tabs_button.dart';
import '../widgets/move_copy_conflict_sheet.dart'; import '../widgets/move_copy_conflict_sheet.dart';
import '../widgets/profile_avatar_button.dart'; import '../widgets/noo/core/noo_button.dart';
import '../widgets/segmented_icon_toggle.dart'; import '../widgets/noo/files/noo_file_kind.dart';
import '../widgets/sort_menu_button.dart'; import '../widgets/noo/files/noo_file_row.dart';
import '../widgets/sticky_header_delegate.dart'; import '../widgets/noo/files/noo_file_table.dart';
import '../widgets/synced_header_scaffold.dart'; import '../widgets/noo/media/noo_grid_card.dart';
import '../widgets/noo/nav/noo_top_bar.dart';
import '../widgets/noo/nav/noo_toolbar.dart';
import '../widgets/noo/noo_layout.dart';
import '../widgets/tabs/tab_state_slivers.dart';
/// Destination-folder browser for moving/copying [items] (a multi-select /// Destination-folder browser for moving/copying [items] (a multi-select
/// batch from Files or Photos). Visually mirrors `ShareUploadView`'s /// batch from Files or Photos). Visually mirrors `ShareUploadView`'s
@@ -85,9 +88,7 @@ class _MoveCopyDestinationPickerState extends State<MoveCopyDestinationPicker> {
Future<void> _fetch(String path) async { Future<void> _fetch(String path) async {
setState(() => _isLoading = true); setState(() => _isLoading = true);
final raw = await context.read<FilesController>().fetchFolderListing( final raw = await context.read<FilesController>().fetchFolderListing(path);
path,
);
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_rawItems = raw; _rawItems = raw;
@@ -175,230 +176,87 @@ class _MoveCopyDestinationPickerState extends State<MoveCopyDestinationPicker> {
).pop(MoveCopyResult(succeeded: succeeded, failed: failed)); ).pop(MoveCopyResult(succeeded: succeeded, failed: failed));
} }
Widget _buildControlsRow(FilesController files) { // Only folders are valid Move/Copy destinations, so the listing - unlike
return SizedBox( // Files' own - never shows plain files at all.
height: 44, Widget _buildRow(NextcloudItem item, int index, int count) {
child: SingleChildScrollView( final colors = context.nooColors;
scrollDirection: Axis.horizontal, final row = NooFileRow(
child: Row( kind: NooFileKind.folder,
name: item.name,
meta: 'Folder',
iosStyle: NooLayout.iosStyle(context),
onTap: () => _navigateToFolder(item.path),
);
final isFirst = index == 0;
final isLast = index == count - 1;
return Column(
children: [ children: [
IconButton( ClipRRect(
icon: Icon( borderRadius: BorderRadius.vertical(
files.filesSortAscending top: isFirst ? const Radius.circular(NooRadii.card) : Radius.zero,
? Icons.arrow_upward_rounded bottom: isLast ? const Radius.circular(NooRadii.card) : Radius.zero,
: Icons.arrow_downward_rounded,
size: 20,
), ),
visualDensity: VisualDensity.compact, child: row,
tooltip: files.filesSortAscending ? 'Ascending' : 'Descending',
onPressed: files.toggleFilesSortOrder,
),
SizedBox(
width: 130,
child: SortMenuButton(
field: files.filesSortField,
onChanged: files.setFilesSortField,
),
),
ToggleIconButton(
icon: files.showHiddenFiles
? Icons.visibility_rounded
: Icons.visibility_off_rounded,
isSelected: files.showHiddenFiles,
onTap: () => files.toggleShowHiddenFiles(),
tooltip: 'Show hidden files',
),
const SizedBox(width: 4),
SegmentedIconGroup(
children: [
ToggleIconButton(
icon: Symbols.circles_rounded,
isSelected: files.storageScope == StorageScope.cloud,
onTap: () => files.setStorageScope(StorageScope.cloud),
tooltip: 'Cloud storage',
),
ToggleIconButton(
icon: Symbols.hard_drive_rounded,
isSelected: files.storageScope == StorageScope.external,
onTap: () => files.setStorageScope(StorageScope.external),
tooltip: 'External storage',
), ),
if (!isLast) Container(height: 1, color: colors.line),
], ],
),
const SizedBox(width: 8),
SegmentedIconGroup(
children: [
ToggleIconButton(
icon: Icons.view_list_rounded,
isSelected: !files.isGridView,
onTap: () => files.setGridView(false),
tooltip: 'List view',
),
ToggleIconButton(
icon: Icons.grid_view_rounded,
isSelected: files.isGridView,
onTap: () => files.setGridView(true),
tooltip: 'Grid view',
),
],
),
],
),
),
); );
} }
// Only folders are valid Move/Copy destinations - files still show (so Widget _buildDesktopRow(NextcloudItem item) {
// the listing matches what the Files tab itself would show for this return NooFileTableRow(
// folder) but are visually dimmed and inert, same treatment as kind: NooFileKind.folder,
// ShareUploadView's own destination browser. name: item.name,
Widget _buildListTile(NextcloudItem item) { onTap: () => _navigateToFolder(item.path),
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final session = context.watch<SessionController>();
final isFolder = item.isFolder;
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Opacity(
opacity: isFolder ? 1 : 0.5,
child: Material(
color: colorScheme.surfaceContainerLow,
child: InkWell(
onTap: isFolder ? () => _navigateToFolder(item.path) : null,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
child: Row(
children: [
ItemThumbnail(
item: item,
service: session.service,
size: 44,
borderRadius: 12,
iconSize: 22,
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.name,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
fontSize: 14,
),
),
const SizedBox(height: 2),
Text(
isFolder
? 'Folder'
: '${formatBytes(item.size)} • ${DateFormat.yMMMd().format(item.lastModified)}',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
if (isFolder) const Icon(Icons.chevron_right_rounded),
],
),
),
),
),
),
),
); );
} }
Widget _buildGridCard(NextcloudItem item) { Widget _buildGridCard(NextcloudItem item) {
final theme = Theme.of(context); final colors = context.nooColors;
final colorScheme = theme.colorScheme; return NooGridCard(
final isFolder = item.isFolder; name: item.name,
final iconColor = getIconColor(context, item.type); meta: 'Folder',
placeholderColor: colors.accentSoft,
return Opacity( icon: LucideIcons.folder,
opacity: isFolder ? 1 : 0.5, iconColor: colors.accentText,
child: Material( thumbnailHeight: NooLayout.isDesktop(context) ? 118 : 104,
color: colorScheme.surfaceContainerLow, onTap: () => _navigateToFolder(item.path),
borderRadius: BorderRadius.circular(20),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: isFolder ? () => _navigateToFolder(item.path) : null,
child: Padding(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: iconColor.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(12),
),
child: Icon(
getItemIcon(item.type),
color: iconColor,
size: 24,
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.name,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
fontSize: 13,
),
),
const SizedBox(height: 2),
Text(
isFolder ? 'Folder' : formatBytes(item.size),
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontSize: 11,
),
),
],
),
],
),
),
),
),
); );
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final colors = context.nooColors;
final colorScheme = theme.colorScheme; final desktop = NooLayout.isDesktop(context);
final files = context.watch<FilesController>(); final files = context.watch<FilesController>();
final ops = context.read<ItemOperations>(); final ops = context.read<ItemOperations>();
final hasBreadcrumbs = _pathStack.length > 1; final hasBreadcrumbs = _pathStack.length > 1;
final currentLabel = _currentPath == '/' final folders = files
? 'Home' .applyFilesDisplayPrefs(_rawItems)
: _currentPath.split('/').where((s) => s.isNotEmpty).last; .where((item) => item.isFolder)
final items = files.applyFilesDisplayPrefs(_rawItems); .toList();
final invalidDestination = _destinationIsInvalid(); final invalidDestination = _destinationIsInvalid();
final gutter = NooLayout.gutter(context);
final verb = widget.copy ? 'Copying' : 'Moving';
final itemCount = widget.items.length;
final controlsColumn = Padding( final slivers = <Widget>[
padding: const EdgeInsets.fromLTRB(16, 16, 16, 12), SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.fromLTRB(
gutter,
NooSpace.md,
gutter,
NooSpace.sm,
),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_buildControlsRow(files), SizedBox(
height: 44,
child: FilesControlsRow(folderPath: _currentPath),
),
if (hasBreadcrumbs) ...[ if (hasBreadcrumbs) ...[
const SizedBox(height: 10), const SizedBox(height: 10),
SizedBox( SizedBox(
@@ -411,116 +269,158 @@ class _MoveCopyDestinationPickerState extends State<MoveCopyDestinationPicker> {
], ],
], ],
), ),
);
final contentSlivers = <Widget>[
SliverPersistentHeader(
pinned: true,
delegate: StickyHeaderDelegate(
height: hasBreadcrumbs ? 114 : 72,
child: controlsColumn,
), ),
), ),
if (_isLoading) if (_isLoading)
const SliverFillRemaining( tabLoadingSliver
hasScrollBody: false, else if (folders.isEmpty)
child: Center(child: CircularProgressIndicator()), tabEmptySliver(
) context,
else if (items.isEmpty) icon: LucideIcons.folder,
SliverFillRemaining( message: 'No folders here',
hasScrollBody: false,
child: Center(
child: Text(
'Folder is empty',
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
),
) )
else if (files.isGridView) else if (files.isGridView)
SliverPadding( SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), padding: EdgeInsets.symmetric(horizontal: gutter),
sliver: SliverGrid( sliver: SliverGrid(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2, crossAxisCount: desktop
childAspectRatio: 1.1, ? 5
crossAxisSpacing: 12, : NooLayout.gridColumns(context, phone: 2, minTile: 180),
mainAxisSpacing: 12, childAspectRatio: desktop ? 1.05 : 0.92,
crossAxisSpacing: desktop ? 16 : 10,
mainAxisSpacing: desktop ? 16 : 10,
),
delegate: SliverChildBuilderDelegate(
(context, index) => _buildGridCard(folders[index]),
childCount: folders.length,
),
),
)
else if (desktop)
SliverPadding(
padding: EdgeInsets.symmetric(horizontal: gutter),
sliver: SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) => _buildDesktopRow(folders[index]),
childCount: folders.length,
), ),
delegate: SliverChildBuilderDelegate((context, index) {
return _buildGridCard(items[index]);
}, childCount: items.length),
), ),
) )
else else
SliverPadding( SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), padding: EdgeInsets.symmetric(horizontal: gutter),
sliver: SliverList( sliver: SliverList(
delegate: SliverChildBuilderDelegate((context, index) { delegate: SliverChildBuilderDelegate(
return _buildListTile(items[index]); (context, index) =>
}, childCount: items.length), _buildRow(folders[index], index, folders.length),
childCount: folders.length,
), ),
), ),
const SliverToBoxAdapter(child: SizedBox(height: 24)), ),
const SliverToBoxAdapter(child: SizedBox(height: NooSpace.xl)),
]; ];
final title = widget.copy ? 'Copy to...' : 'Move to...';
return PopScope( return PopScope(
canPop: _pathStack.length <= 1, canPop: _pathStack.length <= 1,
onPopInvokedWithResult: (didPop, result) { onPopInvokedWithResult: (didPop, result) {
if (!didPop) _navigateUp(); if (!didPop) _navigateUp();
}, },
child: Scaffold( child: Scaffold(
body: SyncedHeaderScaffold( backgroundColor: colors.bg,
scrollController: _scrollController, appBar: desktop
actions: const [MoreTabsButton(), ProfileAvatarButton()], ? NooToolbar(
contentSlivers: contentSlivers, title: title,
actions: [
const MoreTabsButton(),
const SizedBox(width: NooSpace.xs),
NooButton(
variant: NooButtonVariant.secondary,
size: NooButtonSize.toolbar,
onTap: () => Navigator.maybePop(context),
child: const Text('Cancel'),
), ),
bottomNavigationBar: Material( ],
color: colorScheme.surfaceContainerHigh, )
elevation: 8, : NooTopBar(
shape: const RoundedRectangleBorder( style: NooLayout.navStyle(context),
borderRadius: BorderRadius.vertical(top: Radius.circular(24)), title: title,
leading: const NooTopBarBack(label: 'Cancel'),
// Stacks a hidden tab's own screen on top rather than
// jumping the shell there, so it doesn't abandon this
// picker - safe to keep, unlike ShareUploadView's chrome.
actions: const [MoreTabsButton()],
),
body: SafeArea(
top: false,
child: RefreshIndicator(
color: colors.accent,
backgroundColor: colors.surface,
onRefresh: () => _fetch(_currentPath),
child: CustomScrollView(
controller: _scrollController,
// See files_view.dart's identical fix - without this, pull-
// to-refresh can't be triggered on an empty or single-folder
// listing.
physics: const AlwaysScrollableScrollPhysics(),
slivers: slivers,
),
),
),
bottomNavigationBar: DecoratedBox(
decoration: BoxDecoration(
color: colors.surface,
border: Border(top: BorderSide(color: colors.line)),
), ),
clipBehavior: Clip.antiAlias,
child: SafeArea( child: SafeArea(
top: false, top: false,
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 16), padding: const EdgeInsets.fromLTRB(
NooSpace.lg,
NooSpace.md,
NooSpace.lg,
NooSpace.md,
),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
if (invalidDestination) ...[
Text( Text(
"Can't ${widget.copy ? 'copy' : 'move'} a folder into " invalidDestination
"itself or one of its own subfolders", ? "Can't ${widget.copy ? 'copy' : 'move'} a folder into "
style: theme.textTheme.bodySmall?.copyWith( "itself or one of its own subfolders"
color: colorScheme.error, : '$verb $itemCount item${itemCount == 1 ? '' : 's'}',
textAlign: TextAlign.center,
style: NooText.meta.copyWith(
color: invalidDestination ? colors.danger : colors.fg3,
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: NooSpace.sm),
], NooButton(
SizedBox( variant: NooButtonVariant.primary,
width: double.infinity, size: NooButtonSize.cta,
child: FilledButton.icon( fullWidth: true,
onPressed: invalidDestination || _isSubmitting disabled: invalidDestination || _isSubmitting,
icon: _isSubmitting
? null
: (widget.copy
? LucideIcons.copy
: LucideIcons.folderInput),
onTap: invalidDestination || _isSubmitting
? null ? null
: () => _confirm(ops), : () => _confirm(ops),
icon: _isSubmitting child: _isSubmitting
? const SizedBox( ? const SizedBox(
width: 18, width: 18,
height: 18, height: 18,
child: CircularProgressIndicator(strokeWidth: 2), child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
) )
: Icon( : Text(widget.copy ? 'Copy here' : 'Move here'),
widget.copy
? Icons.copy_rounded
: Icons.drive_file_move_rounded,
),
label: Text(
'${widget.copy ? 'Copy' : 'Move'} ${widget.items.length} '
'item(s) to $currentLabel',
),
),
), ),
], ],
), ),
+434 -336
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:intl/intl.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:share_plus/share_plus.dart'; import 'package:share_plus/share_plus.dart';
import '../models/nextcloud_item.dart'; import '../models/nextcloud_item.dart';
@@ -10,107 +11,82 @@ import '../providers/item_operations.dart';
import '../providers/photos_controller.dart'; import '../providers/photos_controller.dart';
import '../providers/pick_controller.dart'; import '../providers/pick_controller.dart';
import '../providers/session_controller.dart'; import '../providers/session_controller.dart';
import '../providers/settings_controller.dart';
import '../services/download_service.dart'; import '../services/download_service.dart';
import '../theme/design_tokens.dart';
import '../widgets/details/details_sheet.dart'; import '../widgets/details/details_sheet.dart';
import '../widgets/more_tabs_button.dart'; import '../widgets/noo/core/noo_button.dart';
import '../widgets/profile_avatar_button.dart'; import '../widgets/noo/core/noo_chip.dart';
import '../widgets/segmented_icon_toggle.dart'; import '../widgets/noo/core/noo_segmented_control.dart';
import '../widgets/selectable_thumbnail.dart'; import '../widgets/noo/core/noo_toggle.dart';
import '../widgets/noo/files/noo_file_kind.dart';
import '../widgets/noo/lists/noo_grouped_list.dart';
import '../widgets/noo/lists/noo_selection_bar.dart';
import '../widgets/noo/lists/noo_settings_row.dart';
import '../widgets/noo/media/noo_photo_group.dart';
import '../widgets/noo/media/noo_photo_tile.dart';
import '../widgets/noo/noo_layout.dart';
import '../widgets/noo/overlays/noo_sheet.dart';
import '../widgets/share_sheet.dart'; import '../widgets/share_sheet.dart';
import '../widgets/sort_menu_button.dart'; import '../widgets/sort_menu_button.dart' show sortFieldLabel;
import '../widgets/sticky_header_delegate.dart'; import '../widgets/sticky_header_delegate.dart';
import '../widgets/synced_header_scaffold.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';
/// The type chips PhotosController actually has data for (DESIGN_SYSTEM.md
/// section 4 also lists "Camera", but there's no camera-upload/EXIF source
/// to distinguish that from any other photo - see the view's doc comment).
enum _PhotoTypeFilter { all, image, video }
/// A contiguous run of [PhotosController.items] that shares a calendar
/// month, in whatever order the controller already sorted them - see
/// [_PhotosViewState._groupByMonth].
class _MonthGroup {
final DateTime month;
final List<NextcloudItem> items;
_MonthGroup(this.month, this.items);
}
/// The Photos tab: every image/video across the account, grouped by month
/// (DESIGN_SYSTEM.md section 4). Renders content only - the shell
/// (`MainShellView`) owns the top bar, bottom bar, drawer and FAB.
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();
} }
class _PhotosViewState extends State<PhotosView> class _PhotosViewState extends State<PhotosView> {
with SingleTickerProviderStateMixin {
bool _requested = false; bool _requested = false;
final Set<String> _selectedIds = {}; final Set<String> _selectedIds = {};
final ScrollController _selectionActionsScrollController = ScrollController();
final List<AnimationController> _scrollHintControllers = []; // Session-local only (not one of PhotosController's persisted display
// prefs) - a pure narrowing of the already-fetched/filtered/sorted list,
// same way the type chips narrow Files' listing without touching the
// controller.
_PhotoTypeFilter _typeFilter = _PhotoTypeFilter.all;
bool get _isSelecting => _selectedIds.isNotEmpty; bool get _isSelecting => _selectedIds.isNotEmpty;
void _toggleSelection(NextcloudItem item) { void _toggleSelection(NextcloudItem item) {
HapticFeedback.selectionClick(); HapticFeedback.selectionClick();
final enteringSelection = _selectedIds.isEmpty;
setState(() { setState(() {
if (!_selectedIds.remove(item.id)) _selectedIds.add(item.id); if (!_selectedIds.remove(item.id)) _selectedIds.add(item.id);
}); });
if (enteringSelection && _isSelecting) {
WidgetsBinding.instance.addPostFrameCallback(
(_) => _playScrollHint(_selectionActionsScrollController),
);
}
} }
void _clearSelection() { void _clearSelection() {
setState(_selectedIds.clear); setState(_selectedIds.clear);
} }
/// A one-shot hint that the selection actions row actually scrolls -
/// mirrors `FilesView`'s identical controls-row hint (see its doc
/// comment): nudges it right and back, once, the first time a selection
/// starts. No-ops if there's nothing to scroll (row already fits).
Future<void> _playScrollHint(ScrollController scrollController) async {
await Future.delayed(const Duration(milliseconds: 500));
if (!mounted || !scrollController.hasClients) return;
final maxExtent = scrollController.position.maxScrollExtent;
if (maxExtent <= 0) return;
final double peak = maxExtent < 36 ? maxExtent : 36;
final controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 900),
);
_scrollHintControllers.add(controller);
final hint = TweenSequence<double>([
TweenSequenceItem(
tween: Tween(
begin: 0.0,
end: peak,
).chain(CurveTween(curve: Curves.easeInOutSine)),
weight: 50,
),
TweenSequenceItem(
tween: Tween(
begin: peak,
end: 0.0,
).chain(CurveTween(curve: Curves.easeInOutSine)),
weight: 50,
),
]).animate(controller);
void onTick() {
if (scrollController.hasClients) {
scrollController.jumpTo(hint.value);
}
}
hint.addListener(onTick);
await controller.forward();
hint.removeListener(onTick);
_scrollHintControllers.remove(controller);
controller.dispose();
}
@override
void dispose() {
for (final controller in _scrollHintControllers) {
controller.dispose();
}
_selectionActionsScrollController.dispose();
super.dispose();
}
/// Mirrors `FilesView._handlePickTap` - Photos has no folders, so this is /// Mirrors `FilesView._handlePickTap` - Photos has no folders, so this is
/// just the matching/toggle/immediate-confirm branch. /// just the matching/toggle/immediate-confirm branch.
void _handlePickTap( void _handlePickTap(
@@ -143,62 +119,103 @@ class _PhotosViewState extends State<PhotosView>
if (pick.isPicking) { if (pick.isPicking) {
return [ return [
SelectionAction( SelectionAction(
icon: Icons.check_rounded, kind: SelectionActionKind.favorite,
icon: LucideIcons.check,
label: 'Use ${selected.length} item(s)', label: 'Use ${selected.length} item(s)',
onTap: () => pick.confirmPick(selected), onTap: () => pick.confirmPick(selected),
), ),
]; ];
} }
return [ // Matches the star/star-off convention Files' own selection toolbar
// uses for favorite/unfavorite (see `files_view.dart`) rather than a
// filled heart.
final allFavorited = selected.every((i) => i.isFavorite);
final actions = [
SelectionAction( SelectionAction(
icon: selected.every((i) => i.isFavorite) kind: SelectionActionKind.favorite,
? Icons.favorite_rounded icon: allFavorited ? LucideIcons.starOff : LucideIcons.star,
: Icons.favorite_border_rounded, label: allFavorited ? 'Remove from favorites' : 'Favorite',
label: selected.every((i) => i.isFavorite)
? 'Remove from favorites'
: 'Favorite',
onTap: () => _favoriteSelected(selected), onTap: () => _favoriteSelected(selected),
), ),
SelectionAction( SelectionAction(
icon: Icons.share_rounded, kind: SelectionActionKind.share,
icon: LucideIcons.share2,
label: 'Share', label: 'Share',
onTap: () => selected.length == 1 onTap: () => selected.length == 1
? ShareSheet.show(context, selected.single) ? ShareSheet.show(context, selected.single)
: _shareSelected(context, selected), : _shareSelected(context, selected),
), ),
SelectionAction( SelectionAction(
icon: Icons.download_rounded, kind: SelectionActionKind.download,
icon: LucideIcons.download,
label: 'Download', label: 'Download',
onTap: () => _downloadSelected(context, selected), onTap: () => _downloadSelected(context, selected),
), ),
SelectionAction( SelectionAction(
icon: Icons.delete_outline_rounded, kind: SelectionActionKind.delete,
icon: LucideIcons.trash2,
label: 'Delete', label: 'Delete',
onTap: () => _confirmDeleteSelected(context, selected), onTap: () => _confirmDeleteSelected(context, selected),
), ),
SelectionAction( SelectionAction(
icon: Icons.copy_rounded, kind: SelectionActionKind.copy,
icon: LucideIcons.copy,
label: 'Copy', label: 'Copy',
onTap: () => _moveOrCopySelected(selected, copy: true), onTap: () => _moveOrCopySelected(selected, copy: true),
), ),
SelectionAction( SelectionAction(
icon: Icons.drive_file_move_rounded, kind: SelectionActionKind.move,
icon: LucideIcons.folderInput,
label: 'Move', label: 'Move',
onTap: () => _moveOrCopySelected(selected, copy: false), onTap: () => _moveOrCopySelected(selected, copy: false),
), ),
if (selected.length == 1) if (selected.length == 1)
SelectionAction( SelectionAction(
icon: Icons.info_outline_rounded, kind: SelectionActionKind.details,
icon: LucideIcons.info,
label: 'Details', label: 'Details',
onTap: () => DetailsSheet.show(context, selected.single), onTap: () => DetailsSheet.show(context, selected.single),
), ),
]; ];
return orderSelectionActions(
actions,
context.read<SettingsController>().selectionActionOrder,
);
}
/// Splits an already-filtered/sorted list into contiguous month runs
/// (`dateCreated`) in list order - deliberately not a global re-sort by
/// date, so a "Name"/"Size" sort chip still governs the order shown
/// (see the group header row it feeds).
List<_MonthGroup> _groupByMonth(List<NextcloudItem> items) {
final groups = <_MonthGroup>[];
for (final item in items) {
final d = item.dateCreated;
final month = DateTime(d.year, d.month);
if (groups.isNotEmpty && groups.last.month == month) {
groups.last.items.add(item);
} else {
groups.add(_MonthGroup(month, [item]));
}
}
return groups;
}
bool _matchesTypeFilter(NextcloudItem item) {
switch (_typeFilter) {
case _PhotoTypeFilter.all:
return true;
case _PhotoTypeFilter.image:
return item.type == NextcloudItemType.image;
case _PhotoTypeFilter.video:
return item.type == NextcloudItemType.video;
}
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final colors = context.nooColors;
final colorScheme = theme.colorScheme; final isDesktop = NooLayout.isDesktop(context);
final photosController = context.watch<PhotosController>(); final photosController = context.watch<PhotosController>();
final filesController = context.watch<FilesController>(); final filesController = context.watch<FilesController>();
final pick = context.watch<PickController>(); final pick = context.watch<PickController>();
@@ -210,89 +227,45 @@ class _PhotosViewState extends State<PhotosView>
); );
} }
final photos = photosController.items; final allPhotos = photosController.items;
final selectedItems = photos final visiblePhotos = allPhotos.where(_matchesTypeFilter).toList();
// Selection tracks the full (favorites/hidden-filtered) list, not just
// the type-filtered view, so switching the type chip mid-selection
// never silently drops a selected item from the bulk actions.
final selectedItems = allPhotos
.where((i) => _selectedIds.contains(i.id)) .where((i) => _selectedIds.contains(i.id))
.toList(); .toList();
final groups = _groupByMonth(visiblePhotos);
final controlsRow = Padding( final controlsRow = _buildControlsRow(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 0), context,
child: SizedBox( photosController,
height: 44, filesController,
child: Row(
children: [
IconButton(
icon: Icon(
photosController.sortAscending
? Icons.arrow_upward_rounded
: Icons.arrow_downward_rounded,
size: 20,
),
visualDensity: VisualDensity.compact,
tooltip: photosController.sortAscending
? 'Ascending'
: 'Descending',
onPressed: photosController.toggleSortOrder,
),
Expanded(
child: SortMenuButton(
field: photosController.sortField,
onChanged: photosController.setSortField,
),
),
ToggleIconButton(
icon: photosController.showFavoritesOnly
? Icons.favorite_rounded
: Icons.favorite_border_rounded,
isSelected: photosController.showFavoritesOnly,
onTap: photosController.toggleFavoritesFilter,
tooltip: 'Favorites only',
),
const SizedBox(width: 4),
ToggleIconButton(
icon: photosController.showHidden
? Icons.visibility_rounded
: Icons.visibility_off_rounded,
isSelected: photosController.showHidden,
onTap: () => photosController.toggleShowHidden(),
tooltip: 'Show hidden files',
),
const SizedBox(width: 4),
SegmentedIconGroup(
children: [
ToggleIconButton(
icon: Symbols.circles_rounded,
isSelected: filesController.storageScope == StorageScope.cloud,
onTap: () =>
filesController.setStorageScope(StorageScope.cloud),
tooltip: 'Cloud storage',
),
ToggleIconButton(
icon: Symbols.hard_drive_rounded,
isSelected:
filesController.storageScope == StorageScope.external,
onTap: () =>
filesController.setStorageScope(StorageScope.external),
tooltip: 'External storage',
),
],
),
],
),
),
); );
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 very top of the screen instead (see `selectionBar` below), so // the same slot instead.
// this is free to scroll away rather than staying pinned under it.
SliverPersistentHeader( SliverPersistentHeader(
pinned: !_isSelecting, pinned: !_isSelecting,
delegate: StickyHeaderDelegate(height: 60, child: controlsRow), delegate: StickyHeaderDelegate(
height: _isSelecting ? 56 : 64,
child: _isSelecting
? _buildSelectionBar(context, pick, selectedItems)
: Padding(
padding: const EdgeInsets.fromLTRB(
NooSpace.sm,
NooSpace.sm,
NooSpace.sm,
NooSpace.xs,
),
child: controlsRow,
),
),
), ),
const SliverToBoxAdapter(child: SizedBox(height: 8)),
if (photosController.isLoading && photos.isEmpty) if (photosController.isLoading && allPhotos.isEmpty)
const SliverFillRemaining( const SliverFillRemaining(
hasScrollBody: false, hasScrollBody: false,
child: Center(child: CircularProgressIndicator()), child: Center(child: CircularProgressIndicator()),
@@ -306,85 +279,87 @@ class _PhotosViewState extends State<PhotosView>
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Icon( Icon(LucideIcons.circleAlert, size: 56, color: colors.danger),
Icons.error_outline_rounded,
size: 64,
color: colorScheme.error,
),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
'Could not load photos', 'Could not load photos',
style: theme.textTheme.titleLarge?.copyWith( style: NooText.cardTitle.copyWith(color: colors.danger),
color: colorScheme.error,
fontWeight: FontWeight.bold,
),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
photosController.errorMessage!, photosController.errorMessage!,
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium?.copyWith( style: NooText.body.copyWith(color: colors.fg3),
color: colorScheme.onSurfaceVariant,
),
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
FilledButton.icon( NooButton(
onPressed: photosController.fetchAllMedia, variant: NooButtonVariant.secondary,
icon: const Icon(Icons.refresh_rounded), size: NooButtonSize.field,
label: const Text('Retry'), icon: LucideIcons.refreshCw,
onTap: photosController.fetchAllMedia,
child: const Text('Retry'),
), ),
], ],
), ),
), ),
), ),
) )
else if (photos.isEmpty) else if (visiblePhotos.isEmpty)
SliverFillRemaining( SliverFillRemaining(
hasScrollBody: false, hasScrollBody: false,
child: Center( child: Center(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Icon( Icon(LucideIcons.image, size: 56, color: colors.fg3),
Icons.photo_library_outlined,
size: 64,
color: colorScheme.outlineVariant,
),
const SizedBox(height: 12), const SizedBox(height: 12),
Text( Text(
'No photos found in Nextcloud library', allPhotos.isEmpty
style: theme.textTheme.titleMedium?.copyWith( ? 'No photos found in Nextcloud library'
color: colorScheme.onSurfaceVariant, : 'No photos match this filter',
), style: NooText.cardTitle.copyWith(color: colors.fg2),
), ),
], ],
), ),
), ),
) )
else else
SliverPadding( for (final group in groups) ...[
padding: const EdgeInsets.symmetric(horizontal: 16), SliverToBoxAdapter(
sliver: SliverGrid( child: NooPhotoGroupHeader(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( title: DateFormat('MMMM yyyy').format(group.month),
crossAxisCount: 3, count:
crossAxisSpacing: 8, '${group.items.length} ${group.items.length == 1 ? 'item' : 'items'}',
mainAxisSpacing: 8, padding: EdgeInsets.fromLTRB(
childAspectRatio: 1.0, NooLayout.gutter(context),
), NooSpace.lg,
delegate: SliverChildBuilderDelegate((context, index) { NooLayout.gutter(context),
final photo = photos[index]; NooSpace.sm,
return _buildPhotoTile(context, photo);
}, childCount: photos.length),
), ),
), ),
),
NooPhotoGrid(
itemCount: group.items.length,
columns: isDesktop
? NooPhotoGrid.desktopColumns
: NooLayout.gridColumns(
context,
phone: NooPhotoGrid.mobileColumns,
minTile: 130,
),
gap: isDesktop ? NooPhotoGrid.desktopGap : NooPhotoGrid.mobileGap,
padding: EdgeInsets.symmetric(
horizontal: isDesktop ? NooLayout.gutter(context) : 0,
),
itemBuilder: (context, index) =>
_buildPhotoTile(context, group.items[index], visiblePhotos),
),
],
// 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.
// For a short grid this stretches the white card's background down to SliverToBoxAdapter(child: SizedBox(height: bottomBarClearance(context))),
// the screen edge (matching the empty state above); for a long grid
// that already fills the viewport it contributes nothing extra.
const SliverFillRemaining(hasScrollBody: false, child: SizedBox()),
]; ];
return PopScope( return PopScope(
@@ -392,121 +367,279 @@ class _PhotosViewState extends State<PhotosView>
onPopInvokedWithResult: (didPop, result) { onPopInvokedWithResult: (didPop, result) {
if (!didPop && _isSelecting) _clearSelection(); if (!didPop && _isSelecting) _clearSelection();
}, },
child: SyncedHeaderScaffold( child: ColoredBox(
scrollController: widget.scrollController, color: colors.bg,
actions: const [MoreTabsButton(), ProfileAvatarButton()], child: RefreshIndicator(
color: colors.accent,
backgroundColor: colors.surface,
onRefresh: photosController.fetchAllMedia, onRefresh: photosController.fetchAllMedia,
selectionBar: _isSelecting // See files_view.dart's identical fix - without this, the sticky
? _buildSelectionBar(context, theme, pick, selectedItems) // controls row rides up under the status bar once the floating
: null, // top bar above it fully collapses.
contentSlivers: contentSlivers, child: SafeArea(
top: true,
bottom: false,
child: CustomScrollView(
controller: widget.scrollController,
// See files_view.dart's identical fix - without this, pull-to-
// refresh can't be triggered on an empty or single-item list.
physics: const AlwaysScrollableScrollPhysics(),
slivers: contentSlivers,
),
),
),
), ),
); );
} }
/// Replaces the top bar entirely while selecting (see /// Type chips (All/Photos/Videos - "Camera" from the spec has no data
/// `SyncedHeaderScaffold.selectionBar`) - a close button, the "N /// source, see the view's doc comment), a sort chip and a filter chip for
/// selected" count, and the horizontally-scrollable bulk actions. /// favorites-only/hidden/external-storage, mirroring `FilesControlsRow`'s
/// layout/pattern.
Widget _buildControlsRow(
BuildContext context,
PhotosController photos,
FilesController files,
) {
final filtersActive =
_typeFilter != _PhotoTypeFilter.all ||
photos.showFavoritesOnly ||
photos.showHidden ||
files.storageScope != StorageScope.cloud;
// A plain Row, not a horizontally-scrolling one - see
// `FilesControlsRow`'s identical fix/doc comment: a `SingleChildScrollView`
// gives its child unbounded width for no benefit here (two chips never
// need to scroll), and it's actively harmful for a row with a trailing
// `Spacer`/flex child.
return SizedBox(
height: 44,
child: Row(
children: [
NooChip(
icon: photos.sortAscending
? LucideIcons.arrowUp
: LucideIcons.arrowDown,
onTap: () => _showSortSheet(context, photos),
child: Text(sortFieldLabel(photos.sortField)),
),
const SizedBox(width: 8),
NooChip(
icon: LucideIcons.filter,
trailing: NooChipTrailing.menu,
selected: filtersActive,
onTap: () => _showFilterSheet(context, photos, files),
child: const Text('Filters'),
),
],
),
);
}
void _showSortSheet(BuildContext context, PhotosController photos) {
showNooSheet(
context,
children: [
// `showNooSheet`'s `children` are built once, up front - a bare
// checkmark here would freeze at whatever it was when the sheet
// opened, since tapping a row calls `photos.set...`/notifies the
// controller, not this already-built widget tree. `ListenableBuilder`
// re-runs its `builder` on every notification instead, so the
// selection updates live.
ListenableBuilder(
listenable: photos,
builder: (context, _) => Column(
mainAxisSize: MainAxisSize.min,
children: [
NooSegmentedControl<bool>(
fill: true,
onSurface: true,
value: photos.sortAscending,
onChanged: (asc) {
if (asc != photos.sortAscending) photos.toggleSortOrder();
},
options: const [
NooSegmentOption(
value: true,
icon: LucideIcons.arrowUp,
label: 'Ascending',
),
NooSegmentOption(
value: false,
icon: LucideIcons.arrowDown,
label: 'Descending',
),
],
),
const SizedBox(height: 22),
NooGroupedList(
children: [
for (final field in FileSortField.values)
NooSettingsRow(
label: Text(sortFieldLabel(field)),
trailing: field == photos.sortField
? Icon(
LucideIcons.check,
size: 18,
color: context.nooColors.accentText,
)
: null,
onTap: () {
photos.setSortField(field);
Navigator.pop(context);
},
),
],
),
],
),
),
],
);
}
void _showFilterSheet(
BuildContext context,
PhotosController photos,
FilesController files,
) {
showNooSheet(
context,
children: [
// `_typeFilter` lives on this State, not a ChangeNotifier, so
// `Listenable.merge([photos, files])` alone doesn't rebuild this
// sheet when it changes - it used to only visibly move once the
// sheet was closed and reopened. `StatefulBuilder` gives it a
// rebuild trigger of its own; see `_showSortSheet`'s comment for
// why the two controllers still need `ListenableBuilder`.
StatefulBuilder(
builder: (context, setSheetState) => ListenableBuilder(
listenable: Listenable.merge([photos, files]),
builder: (context, _) => Column(
mainAxisSize: MainAxisSize.min,
children: [
// Same "icon always, label only when selected" pill as
// Files' own type filter - not a fully-labelled track, so
// the two screens' filter sheets look and behave the same.
NooSegmentedControl<_PhotoTypeFilter>(
fill: true,
onSurface: true,
labelOnlySelected: true,
value: _typeFilter,
onChanged: (filter) => setState(() {
_typeFilter = filter;
setSheetState(() {});
}),
options: const [
NooSegmentOption(
value: _PhotoTypeFilter.all,
icon: LucideIcons.layoutGrid,
label: 'All',
),
NooSegmentOption(
value: _PhotoTypeFilter.image,
icon: LucideIcons.image,
label: 'Photos',
),
NooSegmentOption(
value: _PhotoTypeFilter.video,
icon: LucideIcons.film,
label: 'Videos',
),
],
),
const SizedBox(height: 22),
NooGroupedList(
children: [
NooSettingsRow(
icon: LucideIcons.heart,
label: const Text('Favorites only'),
trailing: NooToggle(
checked: photos.showFavoritesOnly,
onChanged: (_) => photos.toggleFavoritesFilter(),
),
),
NooSettingsRow(
icon: LucideIcons.eye,
label: const Text('Show hidden files'),
trailing: NooToggle(
checked: photos.showHidden,
onChanged: (_) => photos.toggleShowHidden(),
),
),
NooSettingsRow(
icon: LucideIcons.hardDrive,
label: const Text('External storage'),
trailing: NooToggle(
checked: files.storageScope == StorageScope.external,
onChanged: (external) => files.setStorageScope(
external ? StorageScope.external : StorageScope.cloud,
),
),
),
],
),
],
),
),
),
],
);
}
/// Replaces the controls row's own sticky slot while selecting - a close
/// button, the "N selected" count, and the bulk actions. Mirrors
/// `FilesView`'s identical selection bar.
Widget _buildSelectionBar( Widget _buildSelectionBar(
BuildContext context, BuildContext context,
ThemeData theme,
PickController pick, PickController pick,
List<NextcloudItem> selectedItems, List<NextcloudItem> selectedItems,
) { ) {
return Padding( return NooSelectionBar(
padding: const EdgeInsets.symmetric(horizontal: 16), count: selectedItems.length,
child: Row( actions: _buildSelectionActions(pick, selectedItems),
children: [ onClose: _clearSelection,
SizedBox( isDesktop: NooLayout.isDesktop(context),
width: MediaQuery.of(context).size.width * 0.5, iosStyle: NooLayout.iosStyle(context),
child: Align(
alignment: Alignment.centerLeft,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.close_rounded, size: 20),
tooltip: 'Cancel selection',
onPressed: _clearSelection,
visualDensity: VisualDensity.compact,
),
const SizedBox(width: 8),
Text(
'${selectedItems.length} selected',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
],
),
),
),
Expanded(
child: SingleChildScrollView(
controller: _selectionActionsScrollController,
scrollDirection: Axis.horizontal,
// Left-aligned (not anchored to the trailing edge) so the
// first action's left edge sits at a fixed spot - lining up
// with the controls row's own first icon directly below it -
// regardless of how many actions there are.
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
for (final action in _buildSelectionActions(
pick,
selectedItems,
))
IconButton(
icon: Icon(action.icon, size: 20),
tooltip: action.label,
onPressed: action.onTap,
visualDensity: VisualDensity.compact,
),
],
),
),
),
],
),
); );
} }
Widget _buildPhotoTile(BuildContext context, NextcloudItem photo) { Widget _buildPhotoTile(
final colorScheme = Theme.of(context).colorScheme; BuildContext context,
NextcloudItem photo,
List<NextcloudItem> siblings,
) {
final pick = context.watch<PickController>(); final pick = context.watch<PickController>();
final session = context.watch<SessionController>(); final session = context.watch<SessionController>();
final isSelected = _selectedIds.contains(photo.id); final isSelected = _selectedIds.contains(photo.id);
final isVideo = photo.type == NextcloudItemType.video;
// No border radius on the tile itself — the outer ClipRRect below is return NooPhotoTile(
// the only place that rounds it, so a swipe doesn't reveal a stray selected: isSelected,
// floating rounded corner mid-drag (see the same fix in files_view). selectionMode: _isSelecting,
final tile = GestureDetector( // No real duration source (no video-metadata extraction anywhere in
// the app) - an empty string still marks it as a video and shows the
// icon-only badge (DESIGN_SYSTEM.md: "else icon-only").
videoDuration: isVideo ? '' : null,
placeholderColor: NooPhotoTile.paletteColor(photo.id.hashCode),
onTap: () { onTap: () {
if (pick.isPicking) { if (pick.isPicking) {
_handlePickTap(context, pick, photo); _handlePickTap(context, pick, photo);
} else if (_isSelecting) { } else if (_isSelecting) {
_toggleSelection(photo); _toggleSelection(photo);
} else { } else {
_openLightbox(context, photo); _openLightbox(context, photo, siblings);
} }
}, },
onLongPress: pick.isPicking && !pick.pickRequest!.allowMultiple onLongPress: pick.isPicking && !pick.pickRequest!.allowMultiple
? null ? null
: () => _toggleSelection(photo), : () => _toggleSelection(photo),
child: ColoredBox( child: photo.previewUrl != null
color: colorScheme.surfaceContainerHigh,
child: SelectableThumbnail(
isSelected: isSelected,
checkmarkSize: 32,
child: Stack(
fit: StackFit.expand,
children: [
photo.previewUrl != null
? LayoutBuilder( ? LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
// Decode at the tile's actual rendered size rather // Decode at the tile's actual rendered size rather than the
// than the full 500x500 preview the server returns — // full 500x500 preview the server returns - cheaper to
// cheaper to decode/cache while scrolling a grid. // decode/cache while scrolling a grid.
final cachePixels = final cachePixels =
(constraints.maxWidth * (constraints.maxWidth *
MediaQuery.of(context).devicePixelRatio) MediaQuery.of(context).devicePixelRatio)
@@ -525,63 +658,28 @@ class _PhotosViewState extends State<PhotosView>
}, },
) )
: _buildFallbackTile(context, photo), : _buildFallbackTile(context, photo),
if (photo.type == NextcloudItemType.video)
Positioned(
bottom: 8,
right: 8,
child: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.6),
shape: BoxShape.circle,
),
child: const Icon(
Icons.play_arrow_rounded,
color: Colors.white,
size: 16,
),
),
),
if (photo.isFavorite)
const Positioned(
top: 8,
right: 8,
child: Icon(
Icons.favorite_rounded,
color: Colors.red,
size: 18,
),
),
],
),
),
),
); );
return ClipRRect(borderRadius: BorderRadius.circular(16), child: tile);
} }
Widget _buildFallbackTile(BuildContext context, NextcloudItem photo) { Widget _buildFallbackTile(BuildContext context, NextcloudItem photo) {
final colorScheme = Theme.of(context).colorScheme; final colors = context.nooColors;
return Container( final kind = NooFileKind.from(name: photo.name, mimeType: photo.mimeType);
color: colorScheme.secondaryContainer, return ColoredBox(
color: kind.background(colors),
child: Center( child: Center(
child: Icon( child: Icon(kind.icon, color: kind.foreground(colors), size: 32),
photo.type == NextcloudItemType.video
? Icons.movie_rounded
: Icons.image_rounded,
color: colorScheme.onSecondaryContainer,
size: 32,
),
), ),
); );
} }
void _openLightbox(BuildContext context, NextcloudItem photo) { void _openLightbox(
final photos = context.read<PhotosController>().items; BuildContext context,
NextcloudItem photo,
List<NextcloudItem> siblings,
) {
Navigator.push( Navigator.push(
context, context,
FileViewerScreen.route(item: photo, siblings: photos), FileViewerScreen.route(item: photo, siblings: siblings),
); );
} }
+180 -185
View File
@@ -1,58 +1,37 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../models/nextcloud_item.dart'; import '../models/nextcloud_item.dart';
import '../providers/recent_controller.dart'; import '../providers/recent_controller.dart';
import '../widgets/more_tabs_button.dart'; import '../theme/design_tokens.dart';
import '../widgets/profile_avatar_button.dart'; import '../widgets/details/details_sheet.dart';
import '../widgets/synced_header_scaffold.dart'; import '../widgets/noo/files/noo_file_kind.dart';
import '../widgets/noo/files/noo_file_row.dart';
import '../widgets/noo/files/noo_file_table.dart';
import '../widgets/noo/lists/noo_grouped_list.dart';
import '../widgets/noo/lists/noo_settings_row.dart';
import '../widgets/noo/noo_layout.dart';
import '../widgets/noo/overlays/noo_sheet.dart';
import '../widgets/share_sheet.dart';
import '../widgets/tabs/tab_day_groups.dart';
import '../widgets/tabs/tab_location.dart';
import '../widgets/tabs/tab_state_slivers.dart';
import 'file_viewer_screen.dart'; import 'file_viewer_screen.dart';
IconData _recentItemIcon(NextcloudItemType type) {
switch (type) {
case NextcloudItemType.folder:
return Icons.folder_rounded;
case NextcloudItemType.image:
return Icons.image_rounded;
case NextcloudItemType.video:
return Icons.movie_rounded;
case NextcloudItemType.audio:
return Icons.audiotrack_rounded;
case NextcloudItemType.document:
return Icons.description_rounded;
case NextcloudItemType.archive:
return Icons.folder_zip_rounded;
case NextcloudItemType.file:
return Icons.insert_drive_file_rounded;
}
}
Color _recentItemIconColor(BuildContext context, NextcloudItemType type) {
final colorScheme = Theme.of(context).colorScheme;
switch (type) {
case NextcloudItemType.folder:
return colorScheme.primary;
case NextcloudItemType.image:
return Colors.amber.shade700;
case NextcloudItemType.video:
return Colors.deepOrange.shade600;
case NextcloudItemType.audio:
return Colors.purple.shade600;
case NextcloudItemType.document:
return Colors.blue.shade700;
case NextcloudItemType.archive:
return Colors.teal.shade700;
case NextcloudItemType.file:
return colorScheme.outline;
}
}
/// Recently modified files across the whole account (not folders), newest /// Recently modified files across the whole account (not folders), newest
/// first — see [RecentController.fetchAll]. /// first - see [RecentController.fetchAll]. Grouped into Today/Yesterday/
/// This week/Earlier per `DESIGN_SYSTEM.md` §4; meta is "Modified {time} ·
/// {location}" since the server only ever reports a modification, not a
/// distinct action per entry.
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();
@@ -63,8 +42,7 @@ class _RecentViewState extends State<RecentView> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final colors = context.nooColors;
final colorScheme = theme.colorScheme;
final recent = context.watch<RecentController>(); final recent = context.watch<RecentController>();
if (!_requested) { if (!_requested) {
@@ -73,174 +51,180 @@ class _RecentViewState extends State<RecentView> {
} }
final items = recent.items; final items = recent.items;
final isDesktop = NooLayout.isDesktop(context);
final groups = groupByRecentBucket<NextcloudItem>(
items,
(item) => item.lastModified,
);
final List<Widget> contentSlivers = [ final List<Widget> contentSlivers = [
const SliverToBoxAdapter(child: SizedBox(height: 16)), if (widget.topBar != null) topBarSliver(widget.topBar!),
const SliverToBoxAdapter(child: SizedBox(height: NooSpace.md)),
if (recent.isLoading && items.isEmpty) if (recent.isLoading && items.isEmpty)
const SliverFillRemaining( tabLoadingSliver
hasScrollBody: false,
child: Center(child: CircularProgressIndicator()),
)
else if (recent.errorMessage != null) else if (recent.errorMessage != null)
SliverFillRemaining( tabErrorSliver(
hasScrollBody: false, context,
child: Center( title: 'Could not load recent files',
child: Padding( message: recent.errorMessage!,
padding: const EdgeInsets.all(24), onRetry: recent.fetchAll,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline_rounded,
size: 64,
color: colorScheme.error,
),
const SizedBox(height: 16),
Text(
'Could not load recent files',
style: theme.textTheme.titleLarge?.copyWith(
color: colorScheme.error,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
recent.errorMessage!,
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 20),
FilledButton.icon(
onPressed: recent.fetchAll,
icon: const Icon(Icons.refresh_rounded),
label: const Text('Retry'),
),
],
),
),
),
) )
else if (items.isEmpty) else if (items.isEmpty)
SliverFillRemaining( tabEmptySliver(
hasScrollBody: false, context,
child: Center( icon: LucideIcons.clock,
child: Column( message: 'No recent files',
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.history_rounded,
size: 64,
color: colorScheme.outlineVariant,
),
const SizedBox(height: 12),
Text(
'No recent files',
style: theme.textTheme.titleMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
) )
else if (isDesktop)
_buildDesktopTable(context, groups)
else else
SliverPadding( _buildMobileGroups(context, groups),
padding: const EdgeInsets.symmetric(horizontal: 20), ...tabBottomInsetSlivers(context),
sliver: SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
final item = items[index];
return _buildTile(context, item);
}, childCount: items.length),
),
),
const SliverToBoxAdapter(child: SizedBox(height: 100)),
const SliverFillRemaining(hasScrollBody: false, child: SizedBox()),
]; ];
return SyncedHeaderScaffold( return ColoredBox(
scrollController: widget.scrollController, color: colors.bg,
actions: const [MoreTabsButton(), ProfileAvatarButton()], child: RefreshIndicator(
color: colors.accent,
backgroundColor: colors.surface,
onRefresh: recent.fetchAll, onRefresh: recent.fetchAll,
contentSlivers: contentSlivers, // See files_view.dart's identical fix - without this, the sticky
// controls row rides up under the status bar once the floating top
// bar above it fully collapses.
child: SafeArea(
top: true,
bottom: false,
child: CustomScrollView(
controller: widget.scrollController,
// See files_view.dart's identical fix - without this, pull-to-
// refresh can't be triggered on an empty or single-item list.
physics: const AlwaysScrollableScrollPhysics(),
slivers: contentSlivers,
),
),
),
); );
} }
Widget _buildTile(BuildContext context, NextcloudItem item) { Widget _buildMobileGroups(
final theme = Theme.of(context); BuildContext context,
final colorScheme = theme.colorScheme; List<TabDayGroup<NextcloudItem>> groups,
final iconColor = _recentItemIconColor(context, item.type); ) {
final folderPath = item.path.substring( return SliverPadding(
0, padding: const EdgeInsets.symmetric(horizontal: NooSpace.sm),
item.path.length - item.name.length, sliver: SliverList(
); delegate: SliverChildBuilderDelegate((context, index) {
final group = groups[index];
return Padding( return Padding(
padding: const EdgeInsets.only(bottom: 8), padding: EdgeInsets.only(
child: Material( bottom: index == groups.length - 1 ? 0 : 18,
color: colorScheme.surfaceContainerLow, ),
borderRadius: BorderRadius.circular(16), child: NooGroupedList(
child: InkWell( label: group.label,
borderRadius: BorderRadius.circular(16),
onTap: () => _openFile(context, item),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [ children: [
Container( for (final item in group.items) _buildRow(context, item),
width: 44, ],
height: 44,
decoration: BoxDecoration(
color: iconColor.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(12),
), ),
child: Icon( );
_recentItemIcon(item.type), }, childCount: groups.length),
color: iconColor,
size: 22,
), ),
);
}
Widget _buildDesktopTable(
BuildContext context,
List<TabDayGroup<NextcloudItem>> groups,
) {
return SliverPadding(
padding: const EdgeInsets.symmetric(
horizontal: NooSpace.xl,
vertical: NooSpace.xs,
), ),
const SizedBox(width: 14), sliver: SliverList(
Expanded( delegate: SliverChildBuilderDelegate((context, index) {
final group = groups[index];
return Padding(
padding: const EdgeInsets.only(bottom: 18),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( TabGroupLabel(group.label),
item.name, const NooFileTableHeader(
style: theme.textTheme.titleMedium?.copyWith( col2Label: 'Modified',
fontWeight: FontWeight.w600, col3Label: 'Location',
fontSize: 14,
),
),
const SizedBox(height: 2),
Text(
folderPath.isEmpty ? '/' : folderPath,
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 2),
Text(
DateFormat.yMMMd().add_jm().format(item.lastModified),
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.primary,
fontWeight: FontWeight.w500,
),
), ),
for (final item in group.items) _buildDesktopRow(context, item),
], ],
), ),
), );
], }, childCount: groups.length),
),
),
),
), ),
); );
} }
Widget _buildRow(BuildContext context, NextcloudItem item) {
return NooFileRow(
kind: NooFileKind.from(
name: item.name,
mimeType: item.mimeType,
isDirectory: item.isFolder,
),
name: item.name,
meta: 'Modified ${_metaTime(item.lastModified)} · ${_location(item)}',
favorite: item.isFavorite,
iosStyle: NooLayout.iosStyle(context),
onTap: () => _openFile(context, item),
onMore: () => _showItemSheet(context, item),
);
}
Widget _buildDesktopRow(BuildContext context, NextcloudItem item) {
return NooFileTableRow(
kind: NooFileKind.from(
name: item.name,
mimeType: item.mimeType,
isDirectory: item.isFolder,
),
name: item.name,
col2: _metaTime(item.lastModified),
col3: _location(item),
favorite: item.isFavorite,
onTap: () => _openFile(context, item),
onMore: () => _showItemSheet(context, item),
);
}
void _showItemSheet(BuildContext context, NextcloudItem item) {
showNooSheet(
context,
children: [
NooGroupedList(
children: [
NooSettingsRow(
icon: LucideIcons.info,
label: const Text('Details'),
onTap: () {
Navigator.pop(context);
DetailsSheet.show(context, item);
},
),
NooSettingsRow(
icon: LucideIcons.share2,
label: const Text('Share'),
onTap: () {
Navigator.pop(context);
ShareSheet.show(context, item);
},
),
],
),
],
);
}
/// Preserves the original screen's exact tap behavior: always opens the
/// media viewer (the server's recent-files search only ever returns
/// files, never folders, so there's no folder-navigation case to handle).
void _openFile(BuildContext context, NextcloudItem item) { void _openFile(BuildContext context, NextcloudItem item) {
final recent = context.read<RecentController>(); final recent = context.read<RecentController>();
final siblings = recent.items.where((i) => i.isMedia).toList(); final siblings = recent.items.where((i) => i.isMedia).toList();
@@ -250,3 +234,14 @@ class _RecentViewState extends State<RecentView> {
); );
} }
} }
String _metaTime(DateTime dt) {
final now = DateTime.now();
if (dt.year == now.year && dt.month == now.month && dt.day == now.day) {
return DateFormat.jm().format(dt);
}
if (dt.year == now.year) return DateFormat.MMMd().format(dt);
return DateFormat.yMMMd().format(dt);
}
String _location(NextcloudItem item) => tabLocationLabel(item.path, item.name);
+219 -72
View File
@@ -1,14 +1,29 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../models/app_tab.dart'; import '../models/app_tab.dart';
import '../models/nextcloud_item.dart'; import '../models/nextcloud_item.dart';
import '../providers/files_controller.dart'; import '../providers/files_controller.dart';
import '../providers/settings_controller.dart'; import '../providers/settings_controller.dart';
import '../theme/design_tokens.dart';
import '../widgets/noo/files/noo_file_kind.dart';
import '../widgets/noo/files/noo_file_row.dart';
import '../widgets/noo/files/noo_file_table.dart';
import '../widgets/noo/core/noo_search_field.dart';
import '../widgets/noo/nav/noo_top_bar.dart';
import '../widgets/noo/nav/noo_toolbar.dart';
import '../widgets/noo/noo_layout.dart';
import 'file_viewer_screen.dart'; import 'file_viewer_screen.dart';
/// Full-text file search, pushed from the shell's search entry point
/// (`openSearch` in `widgets/shell/shell_common.dart`). Debounces input,
/// then lists matches as file rows - tapping one jumps to the result on the
/// Files tab (see [_openResult]). Per DESIGN_SYSTEM.md §4 "Search".
class SearchView extends StatefulWidget { class SearchView extends StatefulWidget {
const SearchView({super.key}); const SearchView({super.key});
@@ -74,25 +89,6 @@ class _SearchViewState extends State<SearchView> {
} }
} }
IconData _iconFor(NextcloudItemType type) {
switch (type) {
case NextcloudItemType.folder:
return Icons.folder_rounded;
case NextcloudItemType.image:
return Icons.image_rounded;
case NextcloudItemType.video:
return Icons.movie_rounded;
case NextcloudItemType.audio:
return Icons.audiotrack_rounded;
case NextcloudItemType.document:
return Icons.description_rounded;
case NextcloudItemType.archive:
return Icons.folder_zip_rounded;
case NextcloudItemType.file:
return Icons.insert_drive_file_rounded;
}
}
/// Navigates the Files tab to [item] (its own folder if it's a folder, /// Navigates the Files tab to [item] (its own folder if it's a folder,
/// its parent folder otherwise - rebuilding the whole breadcrumb trail /// its parent folder otherwise - rebuilding the whole breadcrumb trail
/// from root rather than assuming it's under wherever the user was /// from root rather than assuming it's under wherever the user was
@@ -113,98 +109,249 @@ class _SearchViewState extends State<SearchView> {
} }
} }
void _clear() {
_controller.clear();
_focusNode.requestFocus();
_onChanged('');
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final colors = context.nooColors;
final colorScheme = theme.colorScheme; final desktop = NooLayout.isDesktop(context);
return Scaffold( return Scaffold(
backgroundColor: colors.bg,
appBar: desktop
? NooToolbar(
titleWidget: _buildField(onSurface: true),
actions: [
IconTheme.merge(
data: IconThemeData(color: colors.fg1, size: 24),
child: NooTopBarButton(
icon: LucideIcons.x,
tooltip: 'Close',
onTap: () => Navigator.pop(context),
),
),
],
)
: null,
body: SafeArea( body: SafeArea(
child: Column( top: !desktop,
child: desktop
? _buildBody(context, desktop)
: Column(
children: [ children: [
Padding( Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 4), padding: const EdgeInsets.fromLTRB(
child: SearchBar( NooSpace.xs,
controller: _controller, NooSpace.xs,
focusNode: _focusNode, NooSpace.md,
autoFocus: true, NooSpace.xs,
hintText: 'Search your files...',
elevation: const WidgetStatePropertyAll(0),
onChanged: _onChanged,
leading: IconButton(
icon: const Icon(Icons.arrow_back_rounded),
onPressed: () => Navigator.pop(context),
), ),
trailing: [ child: Row(
if (_controller.text.isNotEmpty) children: [
IconButton( IconTheme.merge(
icon: const Icon(Icons.close_rounded), data: IconThemeData(color: colors.fg1, size: 24),
onPressed: () { child: NooTopBarButton(
_controller.clear(); icon: LucideIcons.arrowLeft,
_onChanged(''); tooltip: 'Back',
}, onTap: () => Navigator.pop(context),
), ),
),
Expanded(child: _buildField(onSurface: false)),
], ],
), ),
), ),
Expanded(child: _buildBody(context, colorScheme, theme)), Expanded(child: _buildBody(context, desktop)),
], ],
), ),
), ),
); );
} }
Widget _buildBody( Widget _buildField({required bool onSurface}) {
BuildContext context, // The pill `NooSearchField`, not the inset-panel `NooTextField` - this
ColorScheme colorScheme, // is a search field (DESIGN_SYSTEM.md's "Search field" component,
ThemeData theme, // radius 999 outside the one iOS-inline exception), not a form input.
) { // `ios: false` matches the Android shape we're building against now;
// revisit once the iOS large-title/inline-field entry point is built.
return NooSearchField(
controller: _controller,
focusNode: _focusNode,
autofocus: true,
placeholder: 'Search files',
onChanged: _onChanged,
onSurface: onSurface,
ios: false,
trailing: _controller.text.isEmpty
? null
: _ClearFieldButton(onTap: _clear),
);
}
Widget _buildBody(BuildContext context, bool desktop) {
final colors = context.nooColors;
if (_isSearching) { if (_isSearching) {
return const Center(child: CircularProgressIndicator()); return Center(child: CircularProgressIndicator(color: colors.accent));
} }
if (_error != null) { if (_error != null) {
return Center( return _buildMessage(
child: Padding( context,
padding: const EdgeInsets.all(24), icon: LucideIcons.circleAlert,
child: Text( iconColor: colors.danger,
_error!, title: 'Couldn\'t search',
textAlign: TextAlign.center, message: _error,
style: TextStyle(color: colorScheme.error),
),
),
); );
} }
if (!_hasSearched) { if (!_hasSearched) {
return const SizedBox.shrink(); return _buildMessage(
context,
icon: LucideIcons.search,
iconColor: colors.fg3,
title: 'Search your files',
message: 'Find files and folders by name.',
);
} }
if (_results.isEmpty) { if (_results.isEmpty) {
return _buildMessage(
context,
icon: LucideIcons.searchX,
iconColor: colors.fg3,
title: 'No results found',
);
}
return desktop ? _buildDesktopResults(context) : _buildMobileResults(context);
}
Widget _buildMessage(
BuildContext context, {
required IconData icon,
required Color iconColor,
required String title,
String? message,
}) {
final colors = context.nooColors;
return Center( return Center(
child: Text( child: Padding(
'No results found', padding: const EdgeInsets.all(NooSpace.xl),
style: theme.textTheme.bodyMedium?.copyWith( child: Column(
color: colorScheme.onSurfaceVariant, mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 56, color: iconColor),
const SizedBox(height: NooSpace.sm),
Text(
title,
textAlign: TextAlign.center,
style: NooText.cardTitle.copyWith(color: colors.fg2),
),
if (message != null) ...[
const SizedBox(height: NooSpace.xxs),
Text(
message,
textAlign: TextAlign.center,
style: NooText.body.copyWith(color: colors.fg3),
),
],
],
), ),
), ),
); );
} }
Widget _buildMobileResults(BuildContext context) {
return ListView.builder( return ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), padding: const EdgeInsets.fromLTRB(
NooSpace.sm,
NooSpace.xs,
NooSpace.sm,
NooSpace.xl,
),
physics: const BouncingScrollPhysics(),
itemCount: _results.length, itemCount: _results.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final item = _results[index]; final item = _results[index];
return ListTile( return Padding(
leading: Icon(_iconFor(item.type), color: colorScheme.primary), padding: const EdgeInsets.only(bottom: 8),
title: Text(item.name, maxLines: 1, overflow: TextOverflow.ellipsis), child: ClipRRect(
subtitle: Text( borderRadius: BorderRadius.circular(NooRadii.card),
item.isFolder child: NooFileRow(
? item.path kind: NooFileKind.from(
: DateFormat.yMMMd().format(item.lastModified), name: item.name,
maxLines: 1, mimeType: item.mimeType,
overflow: TextOverflow.ellipsis, isDirectory: item.isFolder,
), ),
name: item.name,
meta: _mobileMeta(item),
favorite: item.isFavorite,
iosStyle: NooLayout.iosStyle(context),
onTap: () => _openResult(item), onTap: () => _openResult(item),
),
),
); );
}, },
); );
} }
Widget _buildDesktopResults(BuildContext context) {
return SingleChildScrollView(
padding: const EdgeInsets.symmetric(
horizontal: NooSpace.xl,
vertical: NooSpace.sm,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const NooFileTableHeader(col2Label: 'Modified', col3Label: 'Location'),
for (final item in _results)
NooFileTableRow(
kind: NooFileKind.from(
name: item.name,
mimeType: item.mimeType,
isDirectory: item.isFolder,
),
name: item.name,
col2: DateFormat.yMMMd().format(item.lastModified),
col3: item.isFolder ? item.path : p.dirname(item.path),
favorite: item.isFavorite,
onTap: () => _openResult(item),
),
],
),
);
}
/// Mirrors the pre-rework screen's exact copy: a folder result shows its
/// own full path (there's no separate "location" field to fall back to),
/// a file result shows when it was last modified.
String _mobileMeta(NextcloudItem item) =>
item.isFolder ? item.path : DateFormat.yMMMd().format(item.lastModified);
}
/// Small round "clear" button for [NooTextField.trailing] - clears the
/// query and puts focus back in the field, same as the old `SearchBar`'s
/// trailing close icon.
class _ClearFieldButton extends StatelessWidget {
final VoidCallback onTap;
const _ClearFieldButton({required this.onTap});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
return SizedBox.square(
dimension: 32,
child: Material(
type: MaterialType.transparency,
shape: const CircleBorder(),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
child: Icon(LucideIcons.x, size: 16, color: colors.fg3),
),
),
);
}
} }
+187 -355
View File
@@ -1,6 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:intl/intl.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../models/app_tab.dart'; import '../models/app_tab.dart';
import '../models/nextcloud_item.dart'; import '../models/nextcloud_item.dart';
@@ -9,24 +8,32 @@ import '../providers/session_controller.dart';
import '../providers/settings_controller.dart'; import '../providers/settings_controller.dart';
import '../services/share_intent_service.dart'; import '../services/share_intent_service.dart';
import '../services/upload_service.dart'; import '../services/upload_service.dart';
import '../theme/design_tokens.dart';
import '../widgets/breadcrumbs.dart'; import '../widgets/breadcrumbs.dart';
import '../widgets/item_icon.dart'; import '../widgets/files_controls_row.dart';
import '../widgets/marquee_title.dart'; import '../widgets/noo/core/noo_button.dart';
import '../widgets/profile_avatar_button.dart'; import '../widgets/noo/files/noo_file_kind.dart';
import '../widgets/segmented_icon_toggle.dart'; import '../widgets/noo/files/noo_file_row.dart';
import '../widgets/sort_menu_button.dart'; import '../widgets/noo/files/noo_file_table.dart';
import '../widgets/sticky_header_delegate.dart'; import '../widgets/noo/media/noo_grid_card.dart';
import '../widgets/synced_header_scaffold.dart'; import '../widgets/noo/nav/noo_top_bar.dart';
import '../widgets/noo/nav/noo_toolbar.dart';
import '../widgets/noo/noo_layout.dart';
import '../widgets/shell/shell_common.dart';
import '../widgets/tabs/tab_state_slivers.dart';
/// Shown when another app shares one or more files to Noo (Android's /// Shown when another app shares one or more files to Noo (Android's
/// "Share to..." sheet). Lets the user browse to a destination folder, /// "Share to..." sheet). Lets the user browse to a destination folder,
/// mirroring the Files tab's own controls/filters/listing so this feels /// reusing `FilesController`'s shared folder-browsing state so this feels
/// like the same browser rather than a stripped-down picker, then hands the /// like the same browser Files itself uses, then hands the actual
/// actual prepare+upload off to [UploadService] - a real Android foreground /// prepare+upload off to [UploadService] - a real Android foreground
/// service (see `ShareUploadService.kt`'s doc comment), not something this /// service (see `ShareUploadService.kt`'s doc comment), not something this
/// screen or even the app needs to stay open for. [files] only ever carry /// screen or even the app needs to stay open for. [files] only ever carry
/// cheap Uri metadata (see [ShareIntentService]'s doc comment); that /// cheap Uri metadata (see [ShareIntentService]'s doc comment); that
/// service is the only thing that ever reads their actual bytes. /// service is the only thing that ever reads their actual bytes. Pushed
/// via `Navigator` from outside `MainShellView` (a cold share-intent
/// launch, or Files' own "+" -> "Upload file"), so - unlike a tab - it
/// builds its own complete top chrome rather than relying on the shell's.
class ShareUploadView extends StatefulWidget { class ShareUploadView extends StatefulWidget {
final List<SharedFileRef> files; final List<SharedFileRef> files;
@@ -45,7 +52,9 @@ class _ShareUploadViewState extends State<ShareUploadView> {
// Shared files have no relationship to wherever the user was last // Shared files have no relationship to wherever the user was last
// browsing, so start the destination picker fresh at the root. // browsing, so start the destination picker fresh at the root.
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) context.read<FilesController>().navigateToAbsoluteFolder('/'); if (mounted) {
context.read<FilesController>().navigateToAbsoluteFolder('/');
}
}); });
} }
@@ -86,243 +95,67 @@ class _ShareUploadViewState extends State<ShareUploadView> {
); );
} }
Widget _buildControlsRow(FilesController files) { // Only folders are valid upload destinations, so the listing - unlike
return SizedBox( // Files' own - never shows plain files at all.
height: 44, Widget _buildRow(
child: SingleChildScrollView( BuildContext context,
scrollDirection: Axis.horizontal, NextcloudItem item,
child: Row( int index,
int count,
) {
final colors = context.nooColors;
final files = context.read<FilesController>();
final row = NooFileRow(
kind: NooFileKind.folder,
name: item.name,
meta: 'Folder',
iosStyle: NooLayout.iosStyle(context),
onTap: () => files.navigateToFolder(item.path),
);
final isFirst = index == 0;
final isLast = index == count - 1;
return Column(
children: [ children: [
IconButton( ClipRRect(
icon: Icon( borderRadius: BorderRadius.vertical(
files.filesSortAscending top: isFirst ? const Radius.circular(NooRadii.card) : Radius.zero,
? Icons.arrow_upward_rounded bottom: isLast ? const Radius.circular(NooRadii.card) : Radius.zero,
: Icons.arrow_downward_rounded,
size: 20,
), ),
visualDensity: VisualDensity.compact, child: row,
tooltip: files.filesSortAscending ? 'Ascending' : 'Descending',
onPressed: files.toggleFilesSortOrder,
),
SizedBox(
width: 130,
child: SortMenuButton(
field: files.filesSortField,
onChanged: files.setFilesSortField,
),
),
ToggleIconButton(
icon: files.showHiddenFiles
? Icons.visibility_rounded
: Icons.visibility_off_rounded,
isSelected: files.showHiddenFiles,
onTap: () => files.toggleShowHiddenFiles(),
tooltip: 'Show hidden files',
),
const SizedBox(width: 4),
SegmentedIconGroup(
children: [
ToggleIconButton(
icon: Symbols.circles_rounded,
isSelected: files.storageScope == StorageScope.cloud,
onTap: () => files.setStorageScope(StorageScope.cloud),
tooltip: 'Cloud storage',
),
ToggleIconButton(
icon: Symbols.hard_drive_rounded,
isSelected: files.storageScope == StorageScope.external,
onTap: () => files.setStorageScope(StorageScope.external),
tooltip: 'External storage',
), ),
if (!isLast) Container(height: 1, color: colors.line),
], ],
),
const SizedBox(width: 8),
SegmentedIconGroup(
children: [
ToggleIconButton(
icon: Icons.select_all_rounded,
isSelected: files.filesTypeFilter == FilesTypeFilter.all,
onTap: () => files.setFilesTypeFilter(FilesTypeFilter.all),
tooltip: 'Files & folders',
),
ToggleIconButton(
icon: Icons.insert_drive_file_outlined,
isSelected:
files.filesTypeFilter == FilesTypeFilter.filesOnly,
onTap: () =>
files.setFilesTypeFilter(FilesTypeFilter.filesOnly),
tooltip: 'Files only',
),
ToggleIconButton(
icon: Icons.folder_outlined,
isSelected:
files.filesTypeFilter == FilesTypeFilter.foldersOnly,
onTap: () =>
files.setFilesTypeFilter(FilesTypeFilter.foldersOnly),
tooltip: 'Folders only',
),
],
),
const SizedBox(width: 8),
SegmentedIconGroup(
children: [
ToggleIconButton(
icon: Icons.view_list_rounded,
isSelected: !files.isGridView,
onTap: () => files.setGridView(false),
tooltip: 'List view',
),
ToggleIconButton(
icon: Icons.grid_view_rounded,
isSelected: files.isGridView,
onTap: () => files.setGridView(true),
tooltip: 'Grid view',
),
],
),
],
),
),
); );
} }
// Only folders are valid upload destinations - files still show (so the Widget _buildDesktopRow(BuildContext context, NextcloudItem item) {
// listing matches what the Files tab itself would show for this folder)
// but are visually dimmed and inert rather than hidden outright.
Widget _buildListTile(BuildContext context, NextcloudItem item) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final files = context.read<FilesController>(); final files = context.read<FilesController>();
final session = context.watch<SessionController>(); return NooFileTableRow(
final isFolder = item.isFolder; kind: NooFileKind.folder,
name: item.name,
return Padding( onTap: () => files.navigateToFolder(item.path),
padding: const EdgeInsets.only(bottom: 8),
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Opacity(
opacity: isFolder ? 1 : 0.5,
child: Material(
color: colorScheme.surfaceContainerLow,
child: InkWell(
onTap: isFolder ? () => files.navigateToFolder(item.path) : null,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
child: Row(
children: [
ItemThumbnail(
item: item,
service: session.service,
size: 44,
borderRadius: 12,
iconSize: 22,
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.name,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
fontSize: 14,
),
),
const SizedBox(height: 2),
Text(
isFolder
? 'Folder'
: '${formatBytes(item.size)} • ${DateFormat.yMMMd().format(item.lastModified)}',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
if (isFolder) const Icon(Icons.chevron_right_rounded),
],
),
),
),
),
),
),
); );
} }
Widget _buildGridCard(BuildContext context, NextcloudItem item) { Widget _buildGridCard(BuildContext context, NextcloudItem item) {
final theme = Theme.of(context); final colors = context.nooColors;
final colorScheme = theme.colorScheme;
final files = context.read<FilesController>(); final files = context.read<FilesController>();
final isFolder = item.isFolder; return NooGridCard(
final iconColor = getIconColor(context, item.type); name: item.name,
meta: 'Folder',
return Opacity( placeholderColor: colors.accentSoft,
opacity: isFolder ? 1 : 0.5, icon: LucideIcons.folder,
child: Material( iconColor: colors.accentText,
color: colorScheme.surfaceContainerLow, thumbnailHeight: NooLayout.isDesktop(context) ? 118 : 104,
borderRadius: BorderRadius.circular(20), onTap: () => files.navigateToFolder(item.path),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: isFolder ? () => files.navigateToFolder(item.path) : null,
child: Padding(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: iconColor.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(12),
),
child: Icon(
getItemIcon(item.type),
color: iconColor,
size: 24,
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.name,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
fontSize: 13,
),
),
const SizedBox(height: 2),
Text(
isFolder ? 'Folder' : formatBytes(item.size),
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontSize: 11,
),
),
],
),
],
),
),
),
),
); );
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final colors = context.nooColors;
final colorScheme = theme.colorScheme; final desktop = NooLayout.isDesktop(context);
final files = context.watch<FilesController>(); final files = context.watch<FilesController>();
final session = context.watch<SessionController>(); final session = context.watch<SessionController>();
final hasBreadcrumbs = files.pathStack.length > 1; final hasBreadcrumbs = files.pathStack.length > 1;
@@ -330,17 +163,25 @@ class _ShareUploadViewState extends State<ShareUploadView> {
final currentLabel = currentPath == '/' final currentLabel = currentPath == '/'
? 'Home' ? 'Home'
: currentPath.split('/').where((s) => s.isNotEmpty).last; : currentPath.split('/').where((s) => s.isNotEmpty).last;
final items = files.items; final folders = files.items.where((item) => item.isFolder).toList();
final gutter = NooLayout.gutter(context);
// Mirrors FilesView's own controls-row + breadcrumbs sticky header final slivers = <Widget>[
// exactly (padding, heights) so this reads as the same browser, just SliverToBoxAdapter(
// reached from a share intent instead of the Files tab. child: Padding(
final controlsColumn = Padding( padding: EdgeInsets.fromLTRB(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 12), gutter,
NooSpace.md,
gutter,
NooSpace.sm,
),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_buildControlsRow(files), SizedBox(
height: 44,
child: FilesControlsRow(folderPath: currentPath),
),
if (hasBreadcrumbs) ...[ if (hasBreadcrumbs) ...[
const SizedBox(height: 10), const SizedBox(height: 10),
SizedBox( SizedBox(
@@ -353,148 +194,139 @@ class _ShareUploadViewState extends State<ShareUploadView> {
], ],
], ],
), ),
);
final contentSlivers = <Widget>[
SliverPersistentHeader(
pinned: true,
delegate: StickyHeaderDelegate(
height: hasBreadcrumbs ? 114 : 72,
child: controlsColumn,
), ),
), ),
if (files.isLoading) if (files.isLoading)
const SliverFillRemaining( tabLoadingSliver
hasScrollBody: false, else if (folders.isEmpty)
child: Center(child: CircularProgressIndicator()), tabEmptySliver(
) context,
else if (items.isEmpty) icon: LucideIcons.folder,
SliverFillRemaining( message: 'No folders here',
hasScrollBody: false,
child: Center(
child: Text(
'Folder is empty',
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
),
) )
else if (files.isGridView) else if (files.isGridView)
SliverPadding( SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), padding: EdgeInsets.symmetric(horizontal: gutter),
sliver: SliverGrid( sliver: SliverGrid(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2, crossAxisCount: desktop
childAspectRatio: 1.1, ? 5
crossAxisSpacing: 12, : NooLayout.gridColumns(context, phone: 2, minTile: 180),
mainAxisSpacing: 12, childAspectRatio: desktop ? 1.05 : 0.92,
crossAxisSpacing: desktop ? 16 : 10,
mainAxisSpacing: desktop ? 16 : 10,
),
delegate: SliverChildBuilderDelegate(
(context, index) => _buildGridCard(context, folders[index]),
childCount: folders.length,
),
),
)
else if (desktop)
SliverPadding(
padding: EdgeInsets.symmetric(horizontal: gutter),
sliver: SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) => _buildDesktopRow(context, folders[index]),
childCount: folders.length,
), ),
delegate: SliverChildBuilderDelegate((context, index) {
return _buildGridCard(context, items[index]);
}, childCount: items.length),
), ),
) )
else else
SliverPadding( SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), padding: EdgeInsets.symmetric(horizontal: gutter),
sliver: SliverList( sliver: SliverList(
delegate: SliverChildBuilderDelegate((context, index) { delegate: SliverChildBuilderDelegate(
return _buildListTile(context, items[index]); (context, index) =>
}, childCount: items.length), _buildRow(context, folders[index], index, folders.length),
childCount: folders.length,
), ),
), ),
// So the last row isn't hidden behind the bottom "Upload to..." bar. ),
const SliverToBoxAdapter(child: SizedBox(height: 24)), const SliverToBoxAdapter(child: SizedBox(height: NooSpace.xl)),
]; ];
final title = 'Upload to...';
return Scaffold( return Scaffold(
body: SyncedHeaderScaffold( backgroundColor: colors.bg,
scrollController: _scrollController, appBar: desktop
// Same trailing actions as every other tab except "More tabs" - ? NooToolbar(
// there's nowhere useful for it to go while mid-upload (jumping to title: title,
// Trash/Shares/etc. would abandon this destination picker), so the actions: [
// top chrome is identical to every other tab minus that one entry. NooButton(
// Backing out is still the system back gesture/button, same as any variant: NooButtonVariant.secondary,
// other pushed screen. size: NooButtonSize.toolbar,
actions: const [ProfileAvatarButton()], onTap: () => Navigator.of(context).maybePop(),
contentSlivers: contentSlivers, child: const Text('Cancel'),
), ),
// A rounded-top, elevated bar "peeking" up from the bottom edge - the ],
// uploading-file summary (marqueed if it doesn't fit on one line) )
// sits directly above the destination button, both inside the one : NooTopBar(
// sheet, rather than the summary living up in the scrolling content style: NooLayout.navStyle(context),
// far away from the action it describes. title: title,
bottomNavigationBar: Material( leading: const NooTopBarBack(label: 'Cancel'),
color: colorScheme.surfaceContainerHigh, // Same top-bar access as every tab (avatar -> Settings, swipe
elevation: 8, // to switch accounts) - nothing else belongs here: jumping to
shape: const RoundedRectangleBorder( // another tab mid-upload would abandon this destination
borderRadius: BorderRadius.vertical(top: Radius.circular(24)), // picker, so there's no "more tabs" entry.
actions: const [ShellAvatarButton()],
),
body: SafeArea(
top: false,
child: RefreshIndicator(
color: colors.accent,
backgroundColor: colors.surface,
onRefresh: files.refreshData,
child: CustomScrollView(
controller: _scrollController,
// See files_view.dart's identical fix - without this, pull-to-
// refresh can't be triggered on an empty or single-folder
// listing.
physics: const AlwaysScrollableScrollPhysics(),
slivers: slivers,
),
),
),
// A flat surface bar with a top line, peeking up from the bottom edge -
// a centered item-count meta line sits directly above the destination
// button (DESIGN_SYSTEM.md's "Upload / Move / Copy destination
// picker" recipe: "a meta line ... above a full-width ... primary
// CTA" - matches MoveCopyDestinationPicker's identical bar). A count,
// not a filename: this is the one-file case just as much as the
// many-files case, and a long filename has no good fixed-width
// treatment the way a short count always does.
bottomNavigationBar: DecoratedBox(
decoration: BoxDecoration(
color: colors.surface,
border: Border(top: BorderSide(color: colors.line)),
), ),
clipBehavior: Clip.antiAlias,
child: SafeArea( child: SafeArea(
top: false, top: false,
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 16), padding: const EdgeInsets.fromLTRB(
NooSpace.lg,
NooSpace.md,
NooSpace.lg,
NooSpace.md,
),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
// Same pill/chip look and exact same duller background Text(
// color as the filter toggles' shared background 'Uploading ${widget.files.length} item${widget.files.length == 1 ? '' : 's'}',
// (SegmentedIconGroup's `surfaceContainerHigh`), so it reads textAlign: TextAlign.center,
// as part of the same visual language rather than a new style: NooText.meta.copyWith(color: colors.fg3),
// accent color. Sized to the text itself (like a real chip)
// up to the row's available width - MarqueeTitle needs a
// concrete (not just loose) width to know whether/how far
// to scroll, so this measures the text once up front rather
// than leaving the chip unconstrained.
LayoutBuilder(
builder: (context, constraints) {
final uploadingText = widget.files.length == 1
? 'Uploading ${widget.files.first.name}'
: 'Uploading ${widget.files.length} files';
final chipTextStyle = theme.textTheme.titleSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w700,
);
const horizontalPadding = 28.0;
final painter = TextPainter(
text: TextSpan(text: uploadingText, style: chipTextStyle),
maxLines: 1,
textDirection: Directionality.of(context),
)..layout(maxWidth: double.infinity);
final chipWidth = (painter.width + horizontalPadding).clamp(
0.0,
constraints.maxWidth,
);
return Container(
width: chipWidth,
padding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 8,
),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(20),
),
child: SizedBox(
height: 20,
child: MarqueeTitle(
text: uploadingText,
style: chipTextStyle,
),
),
);
},
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: () => _uploadHere(session),
icon: const Icon(Icons.upload_rounded),
label: Text('Upload to $currentLabel'),
), ),
const SizedBox(height: NooSpace.sm),
NooButton(
variant: NooButtonVariant.primary,
size: NooButtonSize.cta,
fullWidth: true,
icon: LucideIcons.upload,
onTap: () => _uploadHere(session),
child: Text('Upload to $currentLabel'),
), ),
], ],
), ),
+221 -240
View File
@@ -1,53 +1,63 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../models/nextcloud_item.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../models/nextcloud_share.dart'; import '../models/nextcloud_share.dart';
import '../providers/files_controller.dart';
import '../providers/shares_controller.dart'; import '../providers/shares_controller.dart';
import '../widgets/more_tabs_button.dart'; import '../theme/design_tokens.dart';
import '../widgets/profile_avatar_button.dart'; import '../widgets/noo/core/noo_segmented_control.dart';
import '../widgets/synced_header_scaffold.dart'; import '../widgets/noo/files/noo_file_kind.dart';
import '../widgets/noo/files/noo_file_row.dart';
import '../widgets/noo/files/noo_file_table.dart';
import '../widgets/noo/noo_layout.dart';
import '../widgets/share_sheet.dart';
import '../widgets/tabs/tab_state_slivers.dart';
IconData _shareItemIcon(NextcloudItemType type) { /// Which slice of [SharesController.shares] is on screen - `With you` maps
switch (type) { /// straight onto the controller's own `sharedWithMe: true` fetch, but the
case NextcloudItemType.folder: /// controller only ever exposes one boolean scope, so `By you` and `Links`
return Icons.folder_rounded; /// both use its `sharedWithMe: false` fetch and are told apart client-side
case NextcloudItemType.image: /// by [NextcloudShare.shareType] (see `_visibleShares`). `SharesController`
return Icons.image_rounded; /// caches each of its two scopes after its first fetch, so switching
case NextcloudItemType.video: /// between any of the three - including back to `With you` after leaving
return Icons.movie_rounded; /// it - only re-fetches a scope that's never been loaded yet, not on every
case NextcloudItemType.audio: /// single switch.
return Icons.audiotrack_rounded; enum _ShareScope { withYou, byYou, links }
case NextcloudItemType.document:
return Icons.description_rounded;
case NextcloudItemType.archive:
return Icons.folder_zip_rounded;
case NextcloudItemType.file:
return Icons.insert_drive_file_rounded;
}
}
IconData _shareTypeIcon(ShareType type) { IconData _shareTypeIcon(ShareType type) {
switch (type) { switch (type) {
case ShareType.user: case ShareType.user:
return Icons.person_rounded; return LucideIcons.user;
case ShareType.group: case ShareType.group:
return Icons.groups_rounded; return LucideIcons.users;
case ShareType.publicLink: case ShareType.publicLink:
return Icons.link_rounded; return LucideIcons.link;
case ShareType.email: case ShareType.email:
return Icons.email_rounded; return LucideIcons.mail;
case ShareType.federated: case ShareType.federated:
return Icons.public_rounded; return LucideIcons.globe;
case ShareType.other: case ShareType.other:
return Icons.share_rounded; return LucideIcons.share2;
} }
} }
/// "Can edit" when any write bit (update/create/delete) is set on the OCS
/// share permissions bitmask, "Can view" otherwise - kept to the two
/// broad buckets the row's one-line meta has room for.
String _permissionLabel(int permissions) {
const update = 2, create = 4, delete = 8;
final canWrite = permissions & (update | create | delete) != 0;
return canWrite ? 'Can edit' : 'Can view';
}
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();
@@ -55,11 +65,13 @@ class SharesView extends StatefulWidget {
class _SharesViewState extends State<SharesView> { class _SharesViewState extends State<SharesView> {
bool _requested = false; bool _requested = false;
// Mirrors `SharesController.sharedWithMe`'s own default (false, "shared
// by me") so the first fetch this triggers matches what's shown.
_ShareScope _scope = _ShareScope.byYou;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final colors = context.nooColors;
final colorScheme = theme.colorScheme;
final sharesController = context.watch<SharesController>(); final sharesController = context.watch<SharesController>();
if (!_requested) { if (!_requested) {
@@ -69,247 +81,216 @@ class _SharesViewState extends State<SharesView> {
); );
} }
final shares = sharesController.shares; final shares = _visibleShares(sharesController);
final isDesktop = NooLayout.isDesktop(context);
final List<Widget> contentSlivers = [ final List<Widget> contentSlivers = [
SliverToBoxAdapter( if (widget.topBar != null) topBarSliver(widget.topBar!),
child: Padding( SliverPadding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 8), padding: EdgeInsets.fromLTRB(
child: SegmentedButton<bool>( NooLayout.gutter(context),
segments: const [ NooSpace.md,
ButtonSegment(value: false, label: Text('Shared by me')), NooLayout.gutter(context),
ButtonSegment(value: true, label: Text('Shared with me')), 0,
),
sliver: SliverToBoxAdapter(
child: NooSegmentedControl<_ShareScope>(
value: _scope,
options: const [
NooSegmentOption(value: _ShareScope.withYou, label: 'With you'),
NooSegmentOption(value: _ShareScope.byYou, label: 'By you'),
NooSegmentOption(value: _ShareScope.links, label: 'Links'),
], ],
selected: {sharesController.sharedWithMe}, onChanged: (scope) => _selectScope(sharesController, scope),
onSelectionChanged: (set) =>
sharesController.setSharedWithMe(set.first),
), ),
), ),
), ),
const SliverToBoxAdapter(child: SizedBox(height: 8)), const SliverToBoxAdapter(child: SizedBox(height: NooSpace.md)),
if (sharesController.isLoading && shares.isEmpty) if (sharesController.isLoading && shares.isEmpty)
const SliverFillRemaining( tabLoadingSliver
hasScrollBody: false,
child: Center(child: CircularProgressIndicator()),
)
else if (sharesController.errorMessage != null) else if (sharesController.errorMessage != null)
SliverFillRemaining( tabErrorSliver(
hasScrollBody: false, context,
child: Center( title: 'Could not load shares',
child: Padding( message: sharesController.errorMessage!,
padding: const EdgeInsets.all(24), onRetry: sharesController.fetchAll,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline_rounded,
size: 64,
color: colorScheme.error,
),
const SizedBox(height: 16),
Text(
'Could not load shares',
style: theme.textTheme.titleLarge?.copyWith(
color: colorScheme.error,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
sharesController.errorMessage!,
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 20),
FilledButton.icon(
onPressed: sharesController.fetchAll,
icon: const Icon(Icons.refresh_rounded),
label: const Text('Retry'),
),
],
),
),
),
) )
else if (shares.isEmpty) else if (shares.isEmpty)
SliverFillRemaining( tabEmptySliver(
hasScrollBody: false, context,
child: Center( icon: LucideIcons.share2,
child: Column( message: _emptyMessage(),
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.groups_rounded,
size: 64,
color: colorScheme.outlineVariant,
),
const SizedBox(height: 12),
Text(
sharesController.sharedWithMe
? 'Nothing has been shared with you'
: 'You haven\'t shared anything yet',
style: theme.textTheme.titleMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
) )
else if (isDesktop)
_buildDesktopTable(context, shares)
else else
SliverPadding( _buildMobileList(context, shares),
padding: const EdgeInsets.symmetric(horizontal: 20), ...tabBottomInsetSlivers(context),
];
return ColoredBox(
color: colors.bg,
child: RefreshIndicator(
color: colors.accent,
backgroundColor: colors.surface,
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(
controller: widget.scrollController,
// See files_view.dart's identical fix - without this, pull-to-
// refresh can't be triggered on an empty or single-item list.
physics: const AlwaysScrollableScrollPhysics(),
slivers: contentSlivers,
),
),
),
);
}
List<NextcloudShare> _visibleShares(SharesController c) {
switch (_scope) {
case _ShareScope.withYou:
return c.shares;
case _ShareScope.byYou:
return c.shares
.where((s) => s.shareType != ShareType.publicLink)
.toList();
case _ShareScope.links:
return c.shares
.where((s) => s.shareType == ShareType.publicLink)
.toList();
}
}
String _emptyMessage() {
switch (_scope) {
case _ShareScope.withYou:
return 'Nothing has been shared with you';
case _ShareScope.byYou:
return "You haven't shared anything yet";
case _ShareScope.links:
return 'No share links yet';
}
}
void _selectScope(SharesController c, _ShareScope scope) {
setState(() => _scope = scope);
// No-ops (see `SharesController.setSharedWithMe`) when moving between
// `byYou`/`links`, which share the same `sharedWithMe: false` fetch.
c.setSharedWithMe(scope == _ShareScope.withYou);
}
// A lazily-built `SliverList`, not `NooGroupedList` (its own `Column`
// isn't lazy - see files_view.dart's `_buildMobileRow` doc comment, and
// trash_view.dart's `_buildMobileList`, which had the same bug: a heavy
// account's full share list built eagerly up front). Each row still
// reads as one continuous radius-20 card via per-row corner rounding +
// a 1px `line` divider.
Widget _buildMobileList(BuildContext context, List<NextcloudShare> shares) {
final colors = context.nooColors;
return SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: NooSpace.sm),
sliver: SliverList( sliver: SliverList(
delegate: SliverChildBuilderDelegate((context, index) { delegate: SliverChildBuilderDelegate((context, index) {
final share = shares[index]; final share = shares[index];
return _buildShareTile(context, share); final row = NooFileRow(
kind: NooFileKind.from(
name: share.name,
isDirectory: share.isFolder,
),
name: share.name,
meta:
'${share.ownerDisplayName} · ${_permissionLabel(share.permissions)}',
iosStyle: NooLayout.iosStyle(context),
onTap: () => _openShareSheet(context, share),
trailing: Icon(_shareTypeIcon(share.shareType), size: 16),
);
final isFirst = index == 0;
final isLast = index == shares.length - 1;
return Column(
children: [
ClipRRect(
borderRadius: BorderRadius.vertical(
top: isFirst
? const Radius.circular(NooRadii.card)
: Radius.zero,
bottom: isLast
? const Radius.circular(NooRadii.card)
: Radius.zero,
),
child: row,
),
if (!isLast) Container(height: 1, color: colors.line),
],
);
}, childCount: shares.length), }, childCount: shares.length),
), ),
),
const SliverToBoxAdapter(child: SizedBox(height: 100)),
const SliverFillRemaining(hasScrollBody: false, child: SizedBox()),
];
return SyncedHeaderScaffold(
scrollController: widget.scrollController,
actions: const [MoreTabsButton(), ProfileAvatarButton()],
onRefresh: sharesController.fetchAll,
contentSlivers: contentSlivers,
); );
} }
Widget _buildShareTile(BuildContext context, NextcloudShare share) { Widget _buildDesktopTable(BuildContext context, List<NextcloudShare> shares) {
final theme = Theme.of(context); return SliverPadding(
final colorScheme = theme.colorScheme; padding: const EdgeInsets.symmetric(
final iconColor = colorScheme.primary; horizontal: NooSpace.xl,
vertical: NooSpace.xs,
final withWhom = share.sharedWithMe
? 'From ${share.ownerDisplayName}'
: (share.sharedWithDisplayName != null
? 'With ${share.sharedWithDisplayName}'
: 'Shared link');
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Material(
color: colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(16),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: iconColor.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(12),
),
child: Icon(
_shareItemIcon(share.itemType),
color: iconColor,
size: 22,
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
share.name,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
fontSize: 14,
),
),
const SizedBox(height: 2),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(top: 2),
child: Icon(
_shareTypeIcon(share.shareType),
size: 13,
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(width: 4),
Expanded(
child: Text(
'$withWhom • ${DateFormat.yMMMd().format(share.sharedAt)}',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
],
),
],
),
),
IconButton(
icon: Icon(Icons.link_off_rounded, color: colorScheme.error),
tooltip: share.sharedWithMe ? 'Remove' : 'Unshare',
onPressed: () => _confirmUnshare(context, share),
),
],
), ),
sliver: SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
if (index == 0) {
return const NooFileTableHeader(
col2Label: 'Owner',
col3Label: 'Permission',
);
}
final share = shares[index - 1];
return NooFileTableRow(
kind: NooFileKind.from(
name: share.name,
isDirectory: share.isFolder,
), ),
name: share.name,
col2: share.ownerDisplayName,
col3: _permissionLabel(share.permissions),
onTap: () => _openShareSheet(context, share),
);
}, childCount: shares.length + 1),
), ),
); );
} }
Future<void> _confirmUnshare( /// Every row's whole-row tap - a share only carries enough metadata for
/// its own row, not the size/dates the Share sheet's header needs, so
/// this fetches the real item first (see `FilesController.fetchItemAtPath`)
/// rather than opening the sheet straight from [share]. Reused by every
/// scope (With you/By you/Links) - the Share sheet is the one place that
/// already knows how to copy a link or remove access, so there's no
/// separate overflow menu here any more.
Future<void> _openShareSheet(
BuildContext context, BuildContext context,
NextcloudShare share, NextcloudShare share,
) async { ) async {
final confirmed = await showDialog<bool>( final files = context.read<FilesController>();
context: context,
builder: (dialogContext) {
return AlertDialog(
title: Text(share.sharedWithMe ? 'Remove Share' : 'Unshare'),
content: Text(
share.sharedWithMe
? 'Remove "${share.name}" shared with you by ${share.ownerDisplayName}?'
: 'Stop sharing "${share.name}"?',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext, false),
child: const Text('Cancel'),
),
FilledButton(
style: FilledButton.styleFrom(
backgroundColor: Theme.of(dialogContext).colorScheme.error,
),
onPressed: () => Navigator.pop(dialogContext, true),
child: const Text('Remove'),
),
],
);
},
);
if (confirmed != true || !context.mounted) return;
final messenger = ScaffoldMessenger.of(context); final messenger = ScaffoldMessenger.of(context);
final success = await context.read<SharesController>().deleteShare( final item = await files.fetchItemAtPath(share.path);
share, if (!context.mounted) return;
); if (item == null) {
messenger.showSnackBar( messenger.showSnackBar(
SnackBar( const SnackBar(
content: Text( content: Text('Could not find this item - it may have moved'),
success
? 'Removed share for ${share.name}'
: 'Failed to remove share for ${share.name}',
),
behavior: SnackBarBehavior.floating, behavior: SnackBarBehavior.floating,
), ),
); );
return;
}
await ShareSheet.show(context, item);
// The sheet manages shares through `ItemOperations`, not this tab's own
// `SharesController` (see its `deleteShare`'s doc comment) - refetch so
// a share added/removed from inside it doesn't leave this list stale.
if (context.mounted) context.read<SharesController>().fetchAll();
} }
} }
+342 -175
View File
@@ -1,35 +1,35 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../models/nextcloud_item.dart'; import '../models/nextcloud_item.dart';
import '../providers/trash_controller.dart'; import '../providers/trash_controller.dart';
import '../widgets/more_tabs_button.dart'; import '../theme/design_tokens.dart';
import '../widgets/profile_avatar_button.dart'; import '../widgets/noo/core/noo_button.dart';
import '../widgets/synced_header_scaffold.dart'; import '../widgets/noo/files/noo_file_kind.dart';
import '../widgets/noo/files/noo_file_row.dart';
IconData _trashItemIcon(NextcloudItemType type) { import '../widgets/noo/files/noo_file_table.dart';
switch (type) { import '../widgets/noo/files/noo_file_tile.dart';
case NextcloudItemType.folder: import '../widgets/noo/lists/noo_banner.dart';
return Icons.folder_rounded; import '../widgets/noo/noo_layout.dart';
case NextcloudItemType.image: import '../widgets/tabs/tab_location.dart';
return Icons.image_rounded; import '../widgets/tabs/tab_state_slivers.dart';
case NextcloudItemType.video:
return Icons.movie_rounded;
case NextcloudItemType.audio:
return Icons.audiotrack_rounded;
case NextcloudItemType.document:
return Icons.description_rounded;
case NextcloudItemType.archive:
return Icons.folder_zip_rounded;
case NextcloudItemType.file:
return Icons.insert_drive_file_rounded;
}
}
/// The Trash tab: a retention banner (`NooBanner`, with "Empty trash" as
/// its danger action) then the trashed-items list, per `DESIGN_SYSTEM.md`
/// §4. There's no dedicated "empty trash" endpoint anywhere in
/// [TrashController]/`NextcloudService` - [_emptyTrash] composes it from
/// the existing per-item [TrashController.deleteForever], the same way
/// `FavoritesView._confirmDeleteSelected` builds its bulk delete from a
/// per-item call.
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();
@@ -40,8 +40,7 @@ class _TrashViewState extends State<TrashView> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final colors = context.nooColors;
final colorScheme = theme.colorScheme;
final trashController = context.watch<TrashController>(); final trashController = context.watch<TrashController>();
if (!_requested) { if (!_requested) {
@@ -52,176 +51,189 @@ class _TrashViewState extends State<TrashView> {
} }
final trash = trashController.items; final trash = trashController.items;
final isDesktop = NooLayout.isDesktop(context);
final List<Widget> contentSlivers = [ final List<Widget> contentSlivers = [
const SliverToBoxAdapter(child: SizedBox(height: 16)), if (widget.topBar != null) topBarSliver(widget.topBar!),
SliverPadding(
padding: EdgeInsets.fromLTRB(
NooLayout.gutter(context),
NooSpace.md,
NooLayout.gutter(context),
0,
),
sliver: SliverToBoxAdapter(
child: NooBanner(
actionLabel: trash.isEmpty ? null : 'Empty trash',
onAction: trash.isEmpty
? null
: () => _confirmEmptyTrash(context, trash),
child: const Text(
'Deleted items are kept for 30 days, then removed automatically.',
),
),
),
),
const SliverToBoxAdapter(child: SizedBox(height: NooSpace.md)),
if (trashController.isLoading && trash.isEmpty) if (trashController.isLoading && trash.isEmpty)
const SliverFillRemaining( tabLoadingSliver
hasScrollBody: false,
child: Center(child: CircularProgressIndicator()),
)
else if (trashController.errorMessage != null) else if (trashController.errorMessage != null)
SliverFillRemaining( tabErrorSliver(
hasScrollBody: false, context,
child: Center( title: 'Could not load trash',
child: Padding( message: trashController.errorMessage!,
padding: const EdgeInsets.all(24), onRetry: trashController.fetchAll,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline_rounded,
size: 64,
color: colorScheme.error,
),
const SizedBox(height: 16),
Text(
'Could not load trash',
style: theme.textTheme.titleLarge?.copyWith(
color: colorScheme.error,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
trashController.errorMessage!,
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 20),
FilledButton.icon(
onPressed: trashController.fetchAll,
icon: const Icon(Icons.refresh_rounded),
label: const Text('Retry'),
),
],
),
),
),
) )
else if (trash.isEmpty) else if (trash.isEmpty)
SliverFillRemaining( tabEmptySliver(
hasScrollBody: false, context,
child: Center( icon: LucideIcons.trash2,
child: Column( message: 'Trash is empty',
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.delete_outline_rounded,
size: 64,
color: colorScheme.outlineVariant,
),
const SizedBox(height: 12),
Text(
'Trash is empty',
style: theme.textTheme.titleMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
) )
else if (isDesktop)
_buildDesktopTable(context, trash)
else else
SliverPadding( _buildMobileList(context, trash),
padding: const EdgeInsets.symmetric(horizontal: 20), ...tabBottomInsetSlivers(context),
];
return ColoredBox(
color: colors.bg,
child: RefreshIndicator(
color: colors.accent,
backgroundColor: colors.surface,
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(
controller: widget.scrollController,
// See files_view.dart's identical fix - without this, pull-to-
// refresh can't be triggered on an empty or single-item list.
physics: const AlwaysScrollableScrollPhysics(),
slivers: contentSlivers,
),
),
),
);
}
// A lazily-built `SliverList`, not `NooGroupedList` (its own `Column`
// isn't lazy - see files_view.dart's `_buildMobileRow` doc comment for
// the same reasoning): Trash can hold hundreds of items, and building
// every row eagerly up front is what made this tab laggy. Each row still
// reads as one continuous radius-20 card via per-row corner rounding +
// a 1px `line` divider, matching `NooGroupedList`'s look.
Widget _buildMobileList(BuildContext context, List<NextcloudItem> trash) {
final colors = context.nooColors;
return SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: NooSpace.sm),
sliver: SliverList( sliver: SliverList(
delegate: SliverChildBuilderDelegate((context, index) { delegate: SliverChildBuilderDelegate((context, index) {
final item = trash[index]; final item = trash[index];
return _buildTrashTile(context, item); // The overflow menu's only action here is "Delete forever" - a
// bare ellipsis obscures that behind an extra tap to discover it,
// so this row shows the delete icon directly instead of going
// through `NooFileRow.onMore`.
final row = NooFileRow(
kind: NooFileKind.from(
name: item.name,
mimeType: item.mimeType,
isDirectory: item.isFolder,
),
name: item.name,
meta: _meta(item),
iosStyle: NooLayout.iosStyle(context),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
Semantics(
button: true,
label: 'Restore',
child: InkResponse(
onTap: () => _restore(context, item),
radius: 20,
child: const Icon(LucideIcons.rotateCcw),
),
),
const SizedBox(width: NooSpace.xs),
Semantics(
button: true,
label: 'Delete forever',
child: InkResponse(
onTap: () => _confirmDeleteForever(context, item),
radius: 20,
child: Icon(LucideIcons.trash2, color: colors.danger),
),
),
],
),
);
final isFirst = index == 0;
final isLast = index == trash.length - 1;
return Column(
children: [
ClipRRect(
borderRadius: BorderRadius.vertical(
top: isFirst
? const Radius.circular(NooRadii.card)
: Radius.zero,
bottom: isLast
? const Radius.circular(NooRadii.card)
: Radius.zero,
),
child: row,
),
if (!isLast) Container(height: 1, color: colors.line),
],
);
}, childCount: trash.length), }, childCount: trash.length),
), ),
),
const SliverToBoxAdapter(child: SizedBox(height: 100)),
const SliverFillRemaining(hasScrollBody: false, child: SizedBox()),
];
return SyncedHeaderScaffold(
scrollController: widget.scrollController,
actions: const [MoreTabsButton(), ProfileAvatarButton()],
onRefresh: trashController.fetchAll,
contentSlivers: contentSlivers,
); );
} }
Widget _buildTrashTile(BuildContext context, NextcloudItem item) { Widget _buildDesktopTable(BuildContext context, List<NextcloudItem> trash) {
final theme = Theme.of(context); return SliverPadding(
final colorScheme = theme.colorScheme; padding: const EdgeInsets.symmetric(
final iconColor = colorScheme.outline; horizontal: NooSpace.xl,
vertical: NooSpace.xs,
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Material(
color: colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(16),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: iconColor.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(12),
),
child: Icon(
_trashItemIcon(item.type),
color: iconColor,
size: 22,
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
fontSize: 14,
),
),
const SizedBox(height: 2),
Text(
item.deletedAt != null
? 'Deleted ${DateFormat.yMMMd().format(item.deletedAt!)}'
: 'Deleted',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
IconButton(
icon: const Icon(Icons.restore_rounded),
tooltip: 'Restore',
color: colorScheme.primary,
onPressed: () => _restore(context, item),
),
IconButton(
icon: Icon(
Icons.delete_forever_rounded,
color: colorScheme.error,
),
tooltip: 'Delete forever',
onPressed: () => _confirmDeleteForever(context, item),
),
],
), ),
sliver: SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
if (index == 0) {
return const NooFileTableHeader(
col2Label: 'Deleted',
col3Label: 'Original location',
);
}
final item = trash[index - 1];
return _TrashDesktopRow(
kind: NooFileKind.from(
name: item.name,
mimeType: item.mimeType,
isDirectory: item.isFolder,
), ),
name: item.name,
col2: _deletedLabel(item),
col3: item.originalLocation ?? 'Unknown location',
onRestore: () => _restore(context, item),
onDelete: () => _confirmDeleteForever(context, item),
);
}, childCount: trash.length + 1),
), ),
); );
} }
String _meta(NextcloudItem item) =>
'Deleted ${_deletedLabel(item)} · ${tabLocationLabel(item.originalLocation ?? item.path, item.name)}';
String _deletedLabel(NextcloudItem item) => item.deletedAt != null
? DateFormat.yMMMd().format(item.deletedAt!)
: 'recently';
Future<void> _restore(BuildContext context, NextcloudItem item) async { Future<void> _restore(BuildContext context, NextcloudItem item) async {
final messenger = ScaffoldMessenger.of(context); final messenger = ScaffoldMessenger.of(context);
final success = await context.read<TrashController>().restore(item); final success = await context.read<TrashController>().restore(item);
@@ -243,7 +255,7 @@ class _TrashViewState extends State<TrashView> {
context: context, context: context,
builder: (dialogContext) { builder: (dialogContext) {
return AlertDialog( return AlertDialog(
title: const Text('Delete Forever'), title: const Text('Delete forever'),
content: Text( content: Text(
'Permanently delete "${item.name}"? This cannot be undone.', 'Permanently delete "${item.name}"? This cannot be undone.',
), ),
@@ -257,7 +269,7 @@ class _TrashViewState extends State<TrashView> {
backgroundColor: Theme.of(dialogContext).colorScheme.error, backgroundColor: Theme.of(dialogContext).colorScheme.error,
), ),
onPressed: () => Navigator.pop(dialogContext, true), onPressed: () => Navigator.pop(dialogContext, true),
child: const Text('Delete Forever'), child: const Text('Delete forever'),
), ),
], ],
); );
@@ -276,4 +288,159 @@ class _TrashViewState extends State<TrashView> {
), ),
); );
} }
Future<void> _confirmEmptyTrash(
BuildContext context,
List<NextcloudItem> trash,
) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) {
return AlertDialog(
title: const Text('Empty trash'),
content: Text(
'Permanently delete all ${trash.length} item(s) in trash? This cannot be undone.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext, false),
child: const Text('Cancel'),
),
FilledButton(
style: FilledButton.styleFrom(
backgroundColor: Theme.of(dialogContext).colorScheme.error,
),
onPressed: () => Navigator.pop(dialogContext, true),
child: const Text('Empty trash'),
),
],
);
},
);
if (confirmed != true || !context.mounted) return;
final controller = context.read<TrashController>();
final messenger = ScaffoldMessenger.of(context);
var succeeded = 0;
for (final item in trash) {
if (await controller.deleteForever(item)) succeeded++;
}
if (!context.mounted) return;
messenger.showSnackBar(
SnackBar(
content: Text('Deleted $succeeded of ${trash.length} item(s)'),
behavior: SnackBarBehavior.floating,
),
);
}
}
/// Desktop trash row: like `NooFileTableRow`, but its 120px last column
/// holds a tonal "Restore" button plus a danger-tonal "Delete" button
/// (permanent delete) instead of status icons - `NooFileTableRow` has no
/// slot for screen-specific actions there. A labelled pill, not a bare
/// overflow icon: it's the row's only destructive action, not a menu of
/// several, and desktop already prefers labelled buttons to bare icons
/// (see `NooSelectionBar`'s own desktop actions). Column widths
/// (180/160/120) are matched by hand to `NooFileTableHeader`'s (private in
/// `noo_file_table.dart`) so this still lines up under it; promoting an
/// optional `actions` slot onto `NooFileTableRow` would let this fold back
/// into the shared component.
class _TrashDesktopRow extends StatelessWidget {
final NooFileKind kind;
final String name;
final String col2;
final String col3;
final VoidCallback onRestore;
final VoidCallback onDelete;
const _TrashDesktopRow({
required this.kind,
required this.name,
required this.col2,
required this.col3,
required this.onRestore,
required this.onDelete,
});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
final metaStyle = NooText.meta.copyWith(color: colors.fg3);
return SizedBox(
height: NooSizes.rowDesktop,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: NooSpace.sm),
child: Row(
children: [
Expanded(
child: Row(
children: [
NooFileTile(kind: kind, size: NooFileTileSize.desktop),
const SizedBox(width: NooSpace.sm),
Expanded(
child: Text(
name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: NooText.body.copyWith(
fontSize: 14,
fontWeight: FontWeight.w500,
color: colors.fg1,
),
),
),
const SizedBox(width: NooSpace.md),
],
),
),
SizedBox(
width: 180,
child: Text(
col2,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: metaStyle,
),
),
SizedBox(
width: 160,
child: Text(
col3,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: metaStyle,
),
),
SizedBox(
width: 120,
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerRight,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
NooButton(
variant: NooButtonVariant.tonal,
size: NooButtonSize.xs,
onTap: onRestore,
child: const Text('Restore'),
),
const SizedBox(width: 4),
NooButton(
variant: NooButtonVariant.danger,
size: NooButtonSize.xs,
onTap: onDelete,
child: const Text('Delete'),
),
],
),
),
),
],
),
),
);
}
} }
+83
View File
@@ -0,0 +1,83 @@
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/files_controller.dart';
import '../providers/session_controller.dart';
import '../providers/settings_controller.dart';
import '../providers/trash_controller.dart';
import 'noo/core/noo_avatar.dart';
import 'noo/nav/noo_drawer.dart';
import 'shell/shell_common.dart';
/// The mobile drawer (DESIGN_SYSTEM.md 2 "Drawer" / 3; `drawer` in
/// `Mobile Screen.dc.html`): account block, storage bar, the tabs not
/// pinned to the bottom nav bar ("More"), Settings, and an "Edit tabs"
/// link. Opened the same way as before (menu icon in [AppTopBar]), but
/// built on [NooDrawer] instead of the old Material [Drawer] + ad hoc rows.
///
/// Tapping a "More" tab switches the shell straight to it (rather than
/// pushing it as a standalone screen, like the pre-rework `MoreTabsButton`
/// did) via `SettingsController.requestTab` - the same one-shot mechanism
/// `SearchView`/`FavoritesView`/`ShareUploadView` already use to jump the
/// shell to a tab from outside it, so `MainShellView` needs no extra
/// plumbing to support it.
class AppDrawer extends StatelessWidget {
const AppDrawer({super.key});
@override
Widget build(BuildContext context) {
final session = context.watch<SessionController>();
final settings = context.watch<SettingsController>();
final quota = context.watch<FilesController>().quota;
// Cheap and already loaded when the Trash tab has been visited this
// session - not worth a fetch just to populate a drawer badge.
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 NooDrawer(
account: NooDrawerAccount(
avatar: NooAvatar(
initials: accountInitial(session.username),
current: true,
size: 48,
),
name: session.username,
subtitle: serverHost(session.serverUrl),
onTap: closeAndOpenSettings,
onSwitchAccount: () => showAccountSwitcher(context),
),
storage: NooDrawerStorage(
value: quotaFraction(quota),
label: quotaUsedLabel(quota),
),
moreItems: [
for (final tab in hiddenTabs)
NooDrawerItem(
icon: tab.icon,
label: tab.label,
count: tab == AppTab.trash && trashCount > 0
? '$trashCount'
: null,
onTap: () {
Navigator.pop(context);
settings.requestTab(tab);
},
),
],
settings: NooDrawerItem(
icon: LucideIcons.settings,
label: 'Settings',
onTap: closeAndOpenSettings,
),
footer: NooDrawerLink(onTap: closeAndOpenSettings),
);
}
}
+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,
); );
} }
} }
+96
View File
@@ -0,0 +1,96 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../models/app_tab.dart';
import 'avatar_menu.dart';
import 'create_menu.dart';
import 'noo/nav/noo_top_bar.dart';
import 'shell/shell_common.dart';
/// The shell's mobile top bar (DESIGN_SYSTEM.md 3, "Mobile"): every regular
/// tab now gets the same chrome (this used to only wrap Files - the other
/// tabs built their own via `SyncedHeaderScaffold`, which the rework drops).
/// Built on [NooTopBar]:
///
/// - iOS: `menu` leading, `plus` (Files only - no tab besides Files has a
/// create/upload flow) and the account avatar trailing, then the large
/// title, then an inline search field that opens [SearchView].
/// - Android: `menu`, title, avatar actions. Upload has no top-bar icon
/// here - it's the [NooFab] `MainShellView` shows instead. When search
/// isn't in the bottom bar, an inline search-field-styled launcher
/// (`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
/// read from context here, since [preferredSize] is a plain getter with no
/// 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 {
final NooNavStyle style;
final AppTab tab;
final bool searchInBottomBar;
final NooNavMenuStyle navMenuStyle;
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 _showSearch => !searchInBottomBar;
@override
Size get preferredSize => NooTopBar(
style: style,
title: tab.label,
// A throwaway placeholder purely to make preferredSize's "is there a
// search slot" check match what build() actually shows - never
// inserted into the tree, so its content doesn't matter.
search: _ios && _showSearch ? const SizedBox.shrink() : null,
).preferredSize;
@override
Widget build(BuildContext context) {
return NooTopBar(
style: style,
title: tab.label,
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: [
if (_ios && tab == AppTab.files)
NooTopBarButton(
icon: LucideIcons.plus,
tooltip: 'Create',
onTap: () => showCreateMenu(context),
),
ShellAvatarButton(
hitBox: _ios ? 44 : 48,
onTap: navMenuStyle == NooNavMenuStyle.avatarMenu
? () => showAvatarMenu(context)
: null,
label: navMenuStyle == NooNavMenuStyle.avatarMenu
? 'Menu'
: 'Accounts',
),
],
search: _ios && _showSearch ? const ShellSearchLauncher(ios: true) : null,
);
}
}
+385
View File
@@ -0,0 +1,385 @@
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 '../views/login_view.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 StatefulWidget {
const _AvatarMenuContent();
@override
State<_AvatarMenuContent> createState() => _AvatarMenuContentState();
}
class _AvatarMenuContentState extends State<_AvatarMenuContent> {
bool _expanded = false;
@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: () => setState(() => _expanded = !_expanded),
child: Padding(
padding: const EdgeInsets.all(NooSpace.md),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Text(
session.username,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: NooText.bodyL.copyWith(
fontWeight: FontWeight.w600,
color: colors.fg1,
),
),
const SizedBox(height: 2),
Text(
serverHost(session.serverUrl),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: NooText.meta.copyWith(
color: colors.fg3,
),
),
],
),
),
const SizedBox(width: 12),
NooAvatar(
initials: accountInitial(session.username),
current: true,
size: 40,
),
const SizedBox(width: 8),
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: colors.fg1.withValues(alpha: 0.08),
),
child: Icon(
_expanded
? LucideIcons.chevronUp
: LucideIcons.chevronDown,
size: 20,
color: colors.fg2,
),
),
],
),
),
),
AnimatedSize(
duration: NooMotion.fast,
curve: NooMotion.ease,
alignment: Alignment.topCenter,
child: _expanded
? Column(
children: [
for (final account in session.accounts)
if (account.id != session.activeAccountId)
_OtherAccountRow(
name: account.username,
host: serverHost(account.serverUrl),
onTap: () {
Navigator.pop(context);
session.switchAccount(account.id);
},
),
Padding(
padding: const EdgeInsets.fromLTRB(
NooSpace.md,
0,
NooSpace.md,
NooSpace.md,
),
child: Row(
spacing: 8,
children: [
Expanded(
child: _AccountButton(
icon: LucideIcons.userPlus,
label: 'Add Account',
onTap: () {
final nav = Navigator.of(
context,
);
nav.pop();
nav.push(
MaterialPageRoute(
builder: (_) =>
const LoginView(
isAddingAccount: true,
),
),
);
},
),
),
Expanded(
child: _AccountButton(
icon: LucideIcons.users,
label: 'Manage Accounts',
onTap: closeAndOpenSettings,
),
),
],
),
),
],
)
: const SizedBox(width: double.infinity),
),
if (hiddenTabs.isNotEmpty) ...[
Divider(height: 1, color: colors.line),
for (final tab in hiddenTabs)
NooSettingsRow(
icon: tab.icon,
label: Text(tab.label),
trailing: tab == AppTab.trash && trashCount > 0
? NooBadge(
tone: NooBadgeTone.accent,
child: Text('$trashCount'),
)
: null,
onTap: () {
Navigator.pop(context);
settings.requestTab(tab);
},
),
],
Divider(height: 1, color: colors.line),
NooSettingsRow(
icon: LucideIcons.settings,
label: const Text('Settings'),
onTap: closeAndOpenSettings,
),
],
),
),
),
),
),
),
),
),
);
}
}
class _OtherAccountRow extends StatelessWidget {
final String name;
final String host;
final VoidCallback onTap;
const _OtherAccountRow({
required this.name,
required this.host,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
return InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.all(NooSpace.md),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: NooText.bodyL.copyWith(
fontWeight: FontWeight.w600,
color: colors.fg1,
),
),
const SizedBox(height: 2),
Text(
host,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: NooText.meta.copyWith(color: colors.fg3),
),
],
),
),
const SizedBox(width: 12),
NooAvatar(initials: accountInitial(name), current: false, size: 40),
const SizedBox(width: 44),
],
),
),
);
}
}
class _AccountButton extends StatelessWidget {
final IconData icon;
final String label;
final VoidCallback onTap;
const _AccountButton({
required this.icon,
required this.label,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
final radius = BorderRadius.circular(NooRadii.input);
return Material(
color: colors.bg,
borderRadius: radius,
child: InkWell(
borderRadius: radius,
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
spacing: 6,
children: [
Icon(icon, size: 18, color: colors.fg2),
Flexible(
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: NooText.body.copyWith(color: colors.fg1),
),
),
],
),
),
),
);
}
}
+51
View File
@@ -0,0 +1,51 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../models/app_tab.dart';
import 'noo/nav/noo_bottom_bar.dart';
/// Adapts the app's [AppTab] model onto [NooBottomBar]'s generic
/// [NooNavDestination] list, so `MainShellView` only deals in tabs and
/// 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
/// 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 {
final NooNavStyle style;
final NooBottomBarStyle barStyle;
final List<AppTab> tabs;
final int selectedIndex;
final ValueChanged<int> onDestinationSelected;
final VoidCallback? onSearchTap;
const BottomNavBar({
super.key,
required this.style,
this.barStyle = NooBottomBarStyle.attached,
required this.tabs,
required this.selectedIndex,
required this.onDestinationSelected,
this.onSearchTap,
});
@override
Widget build(BuildContext context) {
return NooBottomBar(
style: style,
barStyle: barStyle,
destinations: [
for (final tab in tabs)
NooNavDestination(icon: tab.icon, label: tab.label),
],
selectedIndex: selectedIndex,
onSelected: onDestinationSelected,
searchDestination: onSearchTap == null
? null
: const NooNavDestination(icon: LucideIcons.search, label: 'Search'),
onSearchTap: onSearchTap,
);
}
}
+32 -22
View File
@@ -1,7 +1,13 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../theme/design_tokens.dart';
/// Tappable "Home / Folder / Sub-folder" trail shown whenever the user has /// Tappable "Home / Folder / Sub-folder" trail shown whenever the user has
/// navigated below the root, in both the Files and Photos tabs. /// navigated below the root - now only used by `ShareUploadView` and
/// `MoveCopyDestinationPicker` (Files/Photos moved to their own noo-styled
/// `FileBreadcrumbRow`), so this is free to carry noo tokens directly rather
/// than staying on the old Material look. Same behaviour: every crumb but
/// the last is tappable, jumping back to that depth.
class Breadcrumbs extends StatelessWidget { class Breadcrumbs extends StatelessWidget {
final List<String> pathStack; final List<String> pathStack;
final ValueChanged<int> onTap; final ValueChanged<int> onTap;
@@ -19,8 +25,7 @@ class Breadcrumbs extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final colors = context.nooColors;
final colorScheme = theme.colorScheme;
final lastIndex = pathStack.length - 1; final lastIndex = pathStack.length - 1;
return SingleChildScrollView( return SingleChildScrollView(
@@ -32,37 +37,42 @@ class Breadcrumbs extends StatelessWidget {
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 2), padding: const EdgeInsets.symmetric(horizontal: 2),
child: Icon( child: Icon(
Icons.chevron_right_rounded, LucideIcons.chevronRight,
size: 16, size: 14,
color: colorScheme.outlineVariant, color: colors.fg3,
), ),
), ),
if (i == lastIndex) if (i == lastIndex)
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 8), padding: const EdgeInsets.symmetric(horizontal: 6),
child: Text( child: Text(
_labelFor(i), _labelFor(i),
style: theme.textTheme.bodySmall?.copyWith( style: NooText.body.copyWith(
fontWeight: FontWeight.w700, fontSize: 13,
color: colorScheme.onSurface, fontWeight: FontWeight.w600,
color: colors.fg1,
), ),
), ),
) )
else else
// A chip's whole pill is the tap target (not just the text), // The whole pill is the tap target, not just the text.
// so the previous plain-text crumbs were fiddly to hit. InkWell(
ActionChip( onTap: () => onTap(i),
label: Text(_labelFor(i)), borderRadius: BorderRadius.circular(NooRadii.pill),
labelStyle: theme.textTheme.bodySmall?.copyWith( child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
child: Text(
_labelFor(i),
style: NooText.body.copyWith(
fontSize: 13,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: colorScheme.primary, color: colors.accentText,
),
),
), ),
onPressed: () => onTap(i),
backgroundColor: colorScheme.surfaceContainerHigh,
side: BorderSide.none,
visualDensity: VisualDensity.compact,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
padding: const EdgeInsets.symmetric(horizontal: 4),
), ),
], ],
], ],
+119
View File
@@ -0,0 +1,119 @@
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:provider/provider.dart';
import '../providers/files_controller.dart';
import '../services/share_intent_service.dart';
import '../views/share_upload_view.dart';
import 'noo/core/noo_button.dart';
import 'noo/lists/noo_grouped_list.dart';
import 'noo/lists/noo_settings_row.dart';
import 'noo/overlays/noo_dialog.dart';
import 'noo/overlays/noo_sheet.dart';
import 'noo/overlays/noo_text_field.dart';
/// The Files tab's "+"/FAB action: upload a file or create a folder into
/// the current folder. Shared by `FilesView`'s own create button and
/// `MainShellView`'s FAB (the Noo Design System project's FAB component),
/// since both need to trigger the exact same flow.
void showCreateMenu(BuildContext context) {
showNooSheet(
context,
children: [
NooGroupedList(
children: [
NooSettingsRow(
icon: LucideIcons.upload,
label: const Text('Upload file'),
onTap: () {
Navigator.pop(context);
_pickAndUploadFile(context);
},
),
NooSettingsRow(
icon: LucideIcons.folder,
label: const Text('New folder'),
onTap: () {
Navigator.pop(context);
_showCreateFolderDialog(context);
},
),
],
),
],
);
}
void _showCreateFolderDialog(BuildContext context) {
final files = context.read<FilesController>();
final controller = TextEditingController();
Future<void> submit(BuildContext ctx) async {
final name = controller.text.trim();
if (name.isEmpty) return;
Navigator.pop(ctx);
final success = await files.createFolder(name);
if (ctx.mounted) {
ScaffoldMessenger.of(ctx).showSnackBar(
SnackBar(
content: Text(success ? 'Created folder $name' : 'Failed to create folder'),
behavior: SnackBarBehavior.floating,
),
);
}
}
showNooDialog<void>(
context,
title: 'New folder',
children: [
NooTextField(
controller: controller,
placeholder: 'e.g. Finance',
autofocus: true,
onSurface: false,
onSubmitted: (_) => submit(context),
),
Row(
mainAxisAlignment: MainAxisAlignment.end,
spacing: 10,
children: [
NooButton(
variant: NooButtonVariant.secondary,
onTap: () => Navigator.pop(context),
child: const Text('Cancel'),
),
NooButton(
variant: NooButtonVariant.primary,
onTap: () => submit(context),
child: const Text('Create'),
),
],
),
],
);
}
// Mirrors `main.dart`'s `_handleSharedFiles` exactly, so picking a file via
// "+"/the FAB lands on the same destination-picker screen and background
// foreground-service upload as receiving one via Android's "Share to..."
// sheet does, rather than a separate in-app-only upload path.
Future<void> _pickAndUploadFile(BuildContext context) async {
final picked = await FilePicker.pickFiles();
if (picked.isEmpty) return;
final files = picked
.where((f) => f.path != null)
.map(
(f) => SharedFileRef(
uri: Uri.file(f.path!).toString(),
name: f.name,
size: f.lengthSync(),
),
)
.toList();
if (files.isEmpty || !context.mounted) return;
Navigator.of(
context,
).push(MaterialPageRoute(builder: (_) => ShareUploadView(files: files)));
}
+41 -89
View File
@@ -1,11 +1,18 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../../models/nextcloud_item.dart'; import '../../models/nextcloud_item.dart';
import '../../providers/item_operations.dart'; import '../../providers/item_operations.dart';
import '../../providers/session_controller.dart';
import '../../theme/design_tokens.dart';
import '../noo/lists/noo_grouped_list.dart';
import '../noo/media/noo_activity_item.dart';
import '../noo/media/noo_photo_tile.dart';
/// Per-file activity feed — same list-item look as the account-wide /// Per-file activity feed - the same actor/verb/tail mapping and
/// Activity tab (activity_view.dart), scoped to a single item. /// avatar-palette convention as the account-wide Activity tab
/// (`activity_view.dart`), scoped to a single item.
class DetailsActivityTab extends StatefulWidget { class DetailsActivityTab extends StatefulWidget {
final NextcloudItem item; final NextcloudItem item;
@@ -36,107 +43,52 @@ class _DetailsActivityTabState extends State<DetailsActivityTab> {
_requested = true; _requested = true;
WidgetsBinding.instance.addPostFrameCallback((_) => _load()); WidgetsBinding.instance.addPostFrameCallback((_) => _load());
} }
final theme = Theme.of(context); final colors = context.nooColors;
final colorScheme = theme.colorScheme; final session = context.watch<SessionController>();
if (_isLoading) { if (_isLoading) {
return const Center(child: CircularProgressIndicator()); return const Padding(
padding: EdgeInsets.symmetric(vertical: 32),
child: Center(child: CircularProgressIndicator()),
);
} }
if (_activities.isEmpty) { if (_activities.isEmpty) {
return Center( return Padding(
child: Text( padding: const EdgeInsets.symmetric(vertical: 32),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(LucideIcons.activity, size: 40, color: colors.fg3),
const SizedBox(height: NooSpace.sm),
Text(
'No recent activity', 'No recent activity',
style: theme.textTheme.titleMedium?.copyWith( style: NooText.cardTitle.copyWith(fontSize: 16, color: colors.fg2),
color: colorScheme.onSurfaceVariant, ),
],
), ),
), ),
); );
} }
return ListView.builder( return NooGroupedList(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 24),
itemCount: _activities.length,
itemBuilder: (context, index) {
final act = _activities[index];
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: colorScheme.outlineVariant.withValues(alpha: 0.2),
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Container( for (final act in _activities)
padding: const EdgeInsets.all(10), ColoredBox(
decoration: BoxDecoration( color: colors.surface,
color: colorScheme.primaryContainer, child: NooActivityItem(
shape: BoxShape.circle, actor: act.author,
), verb: act.title,
child: Icon( tail: act.subject.isNotEmpty && act.subject != act.title
act.icon, ? act.subject
color: colorScheme.onPrimaryContainer, : null,
size: 20, time: DateFormat.yMMMd().add_jm().format(act.timestamp),
), avatarColor: NooPhotoTile.paletteColor(act.author.hashCode),
), currentUser:
const SizedBox(width: 14), act.author.toLowerCase() == session.username.toLowerCase(),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Text(
act.title,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
fontSize: 14,
),
),
),
const SizedBox(width: 8),
Text(
DateFormat.jm().format(act.timestamp),
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontSize: 11,
), ),
), ),
], ],
),
const SizedBox(height: 4),
Text(
act.subject,
style: theme.textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
fontSize: 13,
),
),
const SizedBox(height: 6),
Text(
'By ${act.author} • ${DateFormat.MMMd().format(act.timestamp)}',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.primary,
fontWeight: FontWeight.w500,
fontSize: 11,
),
),
],
),
),
],
),
),
);
},
); );
} }
} }
+32 -22
View File
@@ -1,21 +1,19 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import '../../models/nextcloud_item.dart'; import '../../models/nextcloud_item.dart';
import '../../theme/design_tokens.dart';
import '../noo/lists/noo_grouped_list.dart';
import '../synced_header_scaffold.dart' show formatBytes; import '../synced_header_scaffold.dart' show formatBytes;
/// Plain metadata tab of the Details sheet — name, type, size, location, /// Plain metadata list of the Details sheet/dialog - name, type, size,
/// and dates. No network calls, so no loading state needed. /// location, and dates. No network calls, so no loading state needed.
class DetailsInfoTab extends StatelessWidget { class DetailsInfoTab extends StatelessWidget {
final NextcloudItem item; final NextcloudItem item;
final ScrollController? scrollController;
const DetailsInfoTab({super.key, required this.item, this.scrollController}); const DetailsInfoTab({super.key, required this.item});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final rows = <(String, String)>[ final rows = <(String, String)>[
('Name', item.name), ('Name', item.name),
('Type', item.isFolder ? 'Folder' : (item.mimeType ?? 'File')), ('Type', item.isFolder ? 'Folder' : (item.mimeType ?? 'File')),
@@ -26,15 +24,28 @@ class DetailsInfoTab extends StatelessWidget {
('Favorite', item.isFavorite ? 'Yes' : 'No'), ('Favorite', item.isFavorite ? 'Yes' : 'No'),
]; ];
return ListView.separated( return NooGroupedList(
controller: scrollController, children: [for (final row in rows) _InfoRow(label: row.$1, value: row.$2)],
padding: const EdgeInsets.fromLTRB(20, 12, 20, 24), );
itemCount: rows.length, }
separatorBuilder: (context, index) => const Divider(height: 1), }
itemBuilder: (context, index) {
final (label, value) = rows[index]; class _InfoRow extends StatelessWidget {
return Padding( final String label;
padding: const EdgeInsets.symmetric(vertical: 12), final String value;
const _InfoRow({required this.label, required this.value});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
return ColoredBox(
color: colors.surface,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: NooSpace.md,
vertical: NooSpace.sm,
),
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@@ -42,23 +53,22 @@ class DetailsInfoTab extends StatelessWidget {
width: 96, width: 96,
child: Text( child: Text(
label, label,
style: theme.textTheme.bodyMedium?.copyWith( style: NooText.body.copyWith(fontSize: 14, color: colors.fg3),
color: colorScheme.onSurfaceVariant,
),
), ),
), ),
Expanded( Expanded(
child: Text( child: Text(
value, value,
style: theme.textTheme.bodyMedium?.copyWith( style: NooText.body.copyWith(
fontSize: 14,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
color: colors.fg1,
), ),
), ),
), ),
], ],
), ),
); ),
},
); );
} }
} }
+112 -115
View File
@@ -1,160 +1,157 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:intl/intl.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:path/path.dart' as p;
import '../../models/nextcloud_item.dart'; import '../../models/nextcloud_item.dart';
import '../gradual_bottom_sheet.dart'; import '../../theme/design_tokens.dart';
import '../noo/core/noo_segmented_control.dart';
import '../noo/files/noo_file_kind.dart';
import '../noo/files/noo_file_tile.dart';
import '../noo/noo_layout.dart';
import '../noo/overlays/noo_dialog.dart';
import '../noo/overlays/noo_overlay_header.dart';
import '../noo/overlays/noo_sheet.dart';
import '../synced_header_scaffold.dart' show formatBytes; import '../synced_header_scaffold.dart' show formatBytes;
import 'details_activity_tab.dart'; import 'details_activity_tab.dart';
import 'details_info_tab.dart'; import 'details_info_tab.dart';
import 'details_versions_tab.dart'; import 'details_versions_tab.dart';
IconData _detailsItemIcon(NextcloudItemType type) { /// The file-type tile every per-item sheet/dialog opens on
switch (type) { /// (`DESIGN_SYSTEM.md` §1.2/§4) - shared by [DetailsSheet] and [ShareSheet]
case NextcloudItemType.folder: /// so both overlays for the same item use the same header tile.
return Icons.folder_rounded; Widget detailsFileTile(
case NextcloudItemType.image: NextcloudItem item, {
return Icons.image_rounded; NooFileTileSize size = NooFileTileSize.header,
case NextcloudItemType.video: }) {
return Icons.movie_rounded; return NooFileTile(
case NextcloudItemType.audio: kind: NooFileKind.from(
return Icons.audiotrack_rounded; name: item.name,
case NextcloudItemType.document: mimeType: item.mimeType,
return Icons.description_rounded; isDirectory: item.isFolder,
case NextcloudItemType.archive: ),
return Icons.folder_zip_rounded; size: size,
case NextcloudItemType.file: );
return Icons.insert_drive_file_rounded;
}
} }
/// Reusable "Details" bottom sheet for a single file/folder — Info, /// The header's meta subtitle - "12.3 KB · Documents" for a file, "Folder"
/// Versions, and Activity tabs. Sharing lives in its own sheet /// for a folder (`DESIGN_SYSTEM.md` §4: "size · folder").
/// ([ShareSheet]), triggered separately from wherever a "Share" action String detailsMetaLine(NextcloudItem item) {
/// appears. Triggered from long-press selection (exactly one item), the if (item.isFolder) return 'Folder';
/// media viewer's action bar, and the "open externally" flow for final parent = p.basename(p.dirname(item.path));
/// unsupported file types. final location = parent.isEmpty || parent == '.' || parent == '/'
? 'Home'
: parent;
return '${formatBytes(item.size)} · $location';
}
enum _DetailsTab { info, versions, activity }
/// Reusable "Details" bottom sheet/dialog for a single file/folder - Info,
/// Versions and Activity switched by a segmented control (there's no
/// tab-strip component in the noo kit, so this is the closest fit - see the
/// rebuild report). Sharing lives in its own sheet ([ShareSheet]), triggered
/// separately from wherever a "Share" action appears. Triggered from
/// long-press selection (exactly one item), the media viewer's action bar,
/// and the "open externally" flow for unsupported file types.
///
/// Only used directly on mobile (bundles its own [NooOverlayHeader]); the
/// desktop path in [show] passes [DetailsBody] straight to [showNooDialog],
/// which renders the header itself via `leading`/`title`/`subtitle`.
class DetailsSheet extends StatelessWidget { class DetailsSheet extends StatelessWidget {
final NextcloudItem item; final NextcloudItem item;
const DetailsSheet({super.key, required this.item}); const DetailsSheet({super.key, required this.item});
static Future<void> show(BuildContext context, NextcloudItem item) { static Future<void> show(BuildContext context, NextcloudItem item) {
return showGradualBottomSheet( if (NooLayout.isDesktop(context)) {
return showNooDialog(
context, context,
builder: (context, scrollController) => DetailsSheet(item: item), leading: detailsFileTile(item),
title: item.name,
subtitle: detailsMetaLine(item),
children: [DetailsBody(item: item)],
); );
} }
return showNooSheet(context, children: [DetailsSheet(item: item)]);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return DefaultTabController( return Column(
length: 3, crossAxisAlignment: CrossAxisAlignment.stretch,
child: Column(
children: [ children: [
DetailsHeader(item: item), NooOverlayHeader(
// Icon size matches the bottom nav bar's own tab icons leading: detailsFileTile(item),
// (FloatingBottomNavBar) rather than TabBar's oversized default, title: item.name,
// so a "tabs" affordance reads the same size everywhere in the subtitle: detailsMetaLine(item),
// app. onClose: () => Navigator.pop(context),
const TabBar(
tabs: [
Tab(
icon: Icon(Icons.info_outline_rounded, size: 22),
text: 'Info',
),
Tab(
icon: Icon(Icons.history_rounded, size: 22),
text: 'Versions',
),
Tab(
icon: Icon(Icons.electric_bolt_rounded, size: 22),
text: 'Activity',
), ),
const SizedBox(height: NooSpace.lg),
DetailsBody(item: item),
], ],
),
Expanded(
child: TabBarView(
children: [
DetailsInfoTab(item: item),
DetailsVersionsTab(item: item),
DetailsActivityTab(item: item),
],
),
),
],
),
); );
} }
} }
/// Icon/name/size/date row shared by [DetailsSheet], the media viewer's /// The segmented control plus whichever tab's content it selects. A private
/// collapsed "peek" state (which shows just this, before the user drags the /// [StatefulWidget] rather than three fields on [DetailsSheet] so the
/// sheet open far enough to reveal the tabs), and [ShareSheet]'s own title /// selection survives independently of how the header/container around it
/// (so every per-item bottom sheet opens on the same header instead of /// is built (mobile embeds this once, desktop hands it to [showNooDialog]
/// [ShareSheet] alone using a bare title `Text`). /// directly). All three tabs are built once and kept mounted in an
class DetailsHeader extends StatelessWidget { /// [IndexedStack] rather than swapped in/out of the tree - Versions and
/// Activity each fetch on their own first build (see their `_requested`
/// guard), so switching tabs via a bare `switch` on the selected type would
/// tear down and rebuild whichever tab isn't showing, discarding its
/// fetched data and re-requesting it every time the user switched back.
class DetailsBody extends StatefulWidget {
final NextcloudItem item; final NextcloudItem item;
/// Defaults to this widget's own standalone inset ([DetailsSheet]); a const DetailsBody({super.key, required this.item});
/// caller whose surrounding scroll view already applies horizontal
/// padding (e.g. [ShareSheet]'s `ListView`) should pass a padding with
/// zero left/right to avoid doubling it up.
final EdgeInsetsGeometry padding;
const DetailsHeader({ @override
super.key, State<DetailsBody> createState() => _DetailsBodyState();
required this.item, }
this.padding = const EdgeInsets.fromLTRB(20, 12, 20, 8),
}); class _DetailsBodyState extends State<DetailsBody> {
_DetailsTab _tab = _DetailsTab.info;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); return Column(
final colorScheme = theme.colorScheme; crossAxisAlignment: CrossAxisAlignment.stretch,
return Padding(
padding: padding,
child: Row(
children: [ children: [
Container( NooSegmentedControl<_DetailsTab>(
width: 44, onSurface: true,
height: 44, fill: true,
decoration: BoxDecoration( value: _tab,
color: colorScheme.primaryContainer, onChanged: (tab) => setState(() => _tab = tab),
borderRadius: BorderRadius.circular(12), options: const [
NooSegmentOption(
value: _DetailsTab.info,
icon: LucideIcons.info,
label: 'Info',
), ),
child: Icon( NooSegmentOption(
_detailsItemIcon(item.type), value: _DetailsTab.versions,
color: colorScheme.onPrimaryContainer, icon: LucideIcons.history,
size: 22, label: 'Versions',
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 2),
Text(
item.isFolder
? DateFormat.yMMMd().format(item.lastModified)
: '${formatBytes(item.size)} • ${DateFormat.yMMMd().format(item.lastModified)}',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
), ),
NooSegmentOption(
value: _DetailsTab.activity,
icon: LucideIcons.activity,
label: 'Activity',
), ),
], ],
), ),
), const SizedBox(height: NooSpace.lg),
IndexedStack(
index: _tab.index,
children: [
DetailsInfoTab(item: widget.item),
DetailsVersionsTab(item: widget.item),
DetailsActivityTab(item: widget.item),
], ],
), ),
],
); );
} }
} }
+120 -32
View File
@@ -1,15 +1,20 @@
import 'package:file_saver/file_saver.dart'; import 'package:file_saver/file_saver.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../../models/nextcloud_file_version.dart'; import '../../models/nextcloud_file_version.dart';
import '../../models/nextcloud_item.dart'; import '../../models/nextcloud_item.dart';
import '../../providers/item_operations.dart'; import '../../providers/item_operations.dart';
import '../../theme/design_tokens.dart';
import '../noo/files/noo_file_kind.dart';
import '../noo/files/noo_file_tile.dart';
import '../noo/lists/noo_grouped_list.dart';
import '../synced_header_scaffold.dart' show formatBytes; import '../synced_header_scaffold.dart' show formatBytes;
/// File version history — a synthetic "Current version" row (from the /// File version history - a synthetic "Current version" row (from the
/// item's own metadata) followed by whatever the DAV versions endpoint /// item's own metadata) followed by whatever the DAV versions endpoint
/// returns, each with Restore/Download actions. /// returns, each with Restore/Download actions.
class DetailsVersionsTab extends StatefulWidget { class DetailsVersionsTab extends StatefulWidget {
@@ -75,7 +80,7 @@ class _DetailsVersionsTabState extends State<DetailsVersionsTab> {
await FileSaver.instance.saveFile( await FileSaver.instance.saveFile(
name: baseName, name: baseName,
filePath: tempPath, filePath: tempPath,
ext: ext, fileExtension: ext,
); );
if (!mounted) return; if (!mounted) return;
messenger.showSnackBar( messenger.showSnackBar(
@@ -101,11 +106,12 @@ class _DetailsVersionsTabState extends State<DetailsVersionsTab> {
_requested = true; _requested = true;
WidgetsBinding.instance.addPostFrameCallback((_) => _load()); WidgetsBinding.instance.addPostFrameCallback((_) => _load());
} }
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
if (_isLoading) { if (_isLoading) {
return const Center(child: CircularProgressIndicator()); return const Padding(
padding: EdgeInsets.symmetric(vertical: 32),
child: Center(child: CircularProgressIndicator()),
);
} }
final items = [ final items = [
@@ -117,44 +123,126 @@ class _DetailsVersionsTabState extends State<DetailsVersionsTab> {
), ),
..._versions, ..._versions,
]; ];
final kind = NooFileKind.from(
name: widget.item.name,
mimeType: widget.item.mimeType,
isDirectory: false,
);
return ListView.separated( return NooGroupedList(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 24), children: [
itemCount: items.length, for (final version in items)
separatorBuilder: (context, index) => const SizedBox(height: 4), _VersionRow(
itemBuilder: (context, index) { kind: kind,
final version = items[index]; version: version,
return ListTile( onRestore: () => _restore(version),
contentPadding: EdgeInsets.zero, onDownload: () => _download(version),
leading: CircleAvatar(
backgroundColor: colorScheme.primaryContainer,
child: Icon(
Icons.history_rounded,
color: colorScheme.onPrimaryContainer,
size: 20,
), ),
],
);
}
}
class _VersionRow extends StatelessWidget {
final NooFileKind kind;
final NextcloudFileVersion version;
final VoidCallback onRestore;
final VoidCallback onDownload;
const _VersionRow({
required this.kind,
required this.version,
required this.onRestore,
required this.onDownload,
});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
return ColoredBox(
color: colors.surface,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: NooSpace.md,
vertical: NooSpace.sm,
), ),
title: Text( child: Row(
children: [
NooFileTile(kind: kind, size: NooFileTileSize.activity),
const SizedBox(width: NooSpace.sm),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
version.isCurrent version.isCurrent
? 'Current version' ? 'Current version'
: DateFormat.yMMMd().add_jm().format(version.timestamp), : DateFormat.yMMMd().add_jm().format(version.timestamp),
style: NooText.bodyL.copyWith(
fontWeight: FontWeight.w500,
color: colors.fg1,
), ),
subtitle: Text(
'${DateFormat.yMMMd().format(version.timestamp)} • ${formatBytes(version.size)}',
), ),
trailing: version.isCurrent const SizedBox(height: 2),
? null Text(
: PopupMenuButton<String>( '${formatBytes(version.size)} · ${DateFormat.yMMMd().format(version.timestamp)}',
onSelected: (value) => value == 'restore' style: NooText.meta.copyWith(color: colors.fg3),
? _restore(version) ),
: _download(version),
itemBuilder: (context) => const [
PopupMenuItem(value: 'restore', child: Text('Restore')),
PopupMenuItem(value: 'download', child: Text('Download')),
], ],
), ),
),
if (!version.isCurrent) ...[
const SizedBox(width: NooSpace.xs),
_RowIconButton(
icon: LucideIcons.rotateCcw,
tooltip: 'Restore',
onTap: onRestore,
),
const SizedBox(width: NooSpace.xxs),
_RowIconButton(
icon: LucideIcons.download,
tooltip: 'Download',
onTap: onDownload,
),
],
],
),
),
); );
}, }
}
/// A small round icon button matching [NooCloseButton]'s visual language
/// (surface-2 circle) but for an arbitrary action - promote this to `noo/`
/// if another row-level icon action needs the same treatment.
class _RowIconButton extends StatelessWidget {
final IconData icon;
final String tooltip;
final VoidCallback onTap;
const _RowIconButton({
required this.icon,
required this.tooltip,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
return Tooltip(
message: tooltip,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Container(
width: 32,
height: 32,
alignment: Alignment.center,
decoration: BoxDecoration(color: colors.surface2, shape: BoxShape.circle),
child: Icon(icon, size: 16, color: colors.fg2),
),
),
); );
} }
} }
@@ -0,0 +1,85 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../theme/design_tokens.dart';
/// A noo-styled "Home / Folder / Sub-folder" trail for the Files/Offline
/// tabs - a private restyle of the shared `Breadcrumbs` widget (which other
/// screens still use in its old Material look), so this screen's crumbs use
/// noo tokens without touching that shared file. Same behaviour: every
/// crumb but the last is tappable, jumping back to that depth.
class FileBreadcrumbRow extends StatelessWidget {
final List<String> pathStack;
final ValueChanged<int> onTap;
const FileBreadcrumbRow({
super.key,
required this.pathStack,
required this.onTap,
});
String _labelFor(int index) {
if (index == 0) return 'Home';
final segments = pathStack[index]
.split('/')
.where((s) => s.isNotEmpty)
.toList();
return segments.isEmpty ? 'Home' : segments.last;
}
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
final lastIndex = pathStack.length - 1;
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
for (var i = 0; i < pathStack.length; i++) ...[
if (i > 0)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 2),
child: Icon(
LucideIcons.chevronRight,
size: 14,
color: colors.fg3,
),
),
if (i == lastIndex)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 6),
child: Text(
_labelFor(i),
style: NooText.body.copyWith(
fontSize: 13,
fontWeight: FontWeight.w600,
color: colors.fg1,
),
),
)
else
// The whole pill is the tap target, not just the text.
InkWell(
onTap: () => onTap(i),
borderRadius: BorderRadius.circular(NooRadii.pill),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
child: Text(
_labelFor(i),
style: NooText.body.copyWith(
fontSize: 13,
fontWeight: FontWeight.w600,
color: colors.accentText,
),
),
),
),
],
],
),
);
}
}
+195 -103
View File
@@ -1,136 +1,228 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../providers/files_controller.dart'; import '../providers/files_controller.dart';
import 'segmented_icon_toggle.dart'; import '../theme/design_tokens.dart';
import 'sort_menu_button.dart'; import 'noo/core/noo_chip.dart';
import 'noo/core/noo_segmented_control.dart';
import 'noo/core/noo_toggle.dart';
import 'noo/lists/noo_grouped_list.dart';
import 'noo/lists/noo_settings_row.dart';
import 'noo/overlays/noo_sheet.dart';
import 'sort_menu_button.dart' show sortFieldLabel;
/// The Files tab's sort/hidden/scope/type-filter/view-mode controls row, String _typeFilterLabel(FilesTypeFilter filter) {
/// shared with the Offline tab so both read and write the exact same switch (filter) {
/// [FilesController] prefs. [folderPath] picks which folder's sort applies case FilesTypeFilter.all:
/// (the Files tab's current folder, or the folder being browsed offline). return 'Filters';
/// [showStorageScope] is off for Offline - a local mirror has no case FilesTypeFilter.filesOnly:
/// cloud/external distinction. return 'Files only';
case FilesTypeFilter.foldersOnly:
return 'Folders only';
}
}
/// The Files tab's sort/filter/view-mode row (Noo Design System project's
/// Files screen: a sort chip, a filter chip, a spacer, then the list/grid
/// segmented toggle) - shared with the Offline tab, which reads and writes
/// the exact same [FilesController] prefs. [folderPath] picks which
/// folder's sort applies. [showStorageScope] is off for Offline - a local
/// mirror has no cloud/external distinction.
///
/// Hidden-files and storage-scope, which have no home in the mockup's
/// simple two-chip row, live inside the filter chip's sheet instead of as
/// their own row controls.
///
/// A plain `Row`, not a horizontally-scrolling one - it used to wrap in a
/// `SingleChildScrollView`, which gives its child unbounded width, silently
/// breaking the trailing `Spacer` (a flex child needs a bounded width to
/// size against) and leaving the List/Grid toggle sitting right after the
/// filter chip instead of pinned to the row's right edge. The two chips
/// plus an icon-only toggle never come close to needing to scroll anyway.
class FilesControlsRow extends StatelessWidget { class FilesControlsRow extends StatelessWidget {
final String folderPath; final String folderPath;
final bool showStorageScope; final bool showStorageScope;
final ScrollController? scrollController;
const FilesControlsRow({ const FilesControlsRow({
super.key, super.key,
required this.folderPath, required this.folderPath,
this.showStorageScope = true, this.showStorageScope = true,
this.scrollController,
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final files = context.watch<FilesController>(); final files = context.watch<FilesController>();
final filtersActive =
files.filesTypeFilter != FilesTypeFilter.all ||
files.showHiddenFiles ||
(showStorageScope && files.storageScope != StorageScope.cloud);
return SizedBox( return SizedBox(
height: 44, height: 44,
child: SingleChildScrollView(
controller: scrollController,
scrollDirection: Axis.horizontal,
child: Row( child: Row(
children: [ children: [
IconButton( NooChip(
icon: Icon( icon: files.sortAscendingFor(folderPath)
files.sortAscendingFor(folderPath) ? LucideIcons.arrowUp
? Icons.arrow_upward_rounded : LucideIcons.arrowDown,
: Icons.arrow_downward_rounded, onTap: () => _showSortSheet(context, files),
size: 20, child: Text(sortFieldLabel(files.sortFieldFor(folderPath))),
),
visualDensity: VisualDensity.compact,
tooltip: files.sortAscendingFor(folderPath)
? 'Ascending'
: 'Descending',
onPressed: () => files.toggleSortOrderFor(folderPath),
),
// A plain width, not Expanded - this row scrolls horizontally,
// which gives every control unbounded width to lay out in, so a
// flex child would throw.
SizedBox(
width: 130,
child: SortMenuButton(
field: files.sortFieldFor(folderPath),
onChanged: (field) => files.setSortFieldFor(folderPath, field),
),
),
ToggleIconButton(
icon: files.showHiddenFiles
? Icons.visibility_rounded
: Icons.visibility_off_rounded,
isSelected: files.showHiddenFiles,
onTap: () => files.toggleShowHiddenFiles(),
tooltip: 'Show hidden files',
),
const SizedBox(width: 4),
if (showStorageScope) ...[
SegmentedIconGroup(
children: [
ToggleIconButton(
icon: Symbols.circles_rounded,
isSelected: files.storageScope == StorageScope.cloud,
onTap: () => files.setStorageScope(StorageScope.cloud),
tooltip: 'Cloud storage',
),
ToggleIconButton(
icon: Symbols.hard_drive_rounded,
isSelected: files.storageScope == StorageScope.external,
onTap: () => files.setStorageScope(StorageScope.external),
tooltip: 'External storage',
),
],
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
], NooChip(
SegmentedIconGroup( icon: LucideIcons.filter,
children: [ trailing: NooChipTrailing.menu,
ToggleIconButton( selected: filtersActive,
icon: Icons.select_all_rounded, onTap: () => _showFilterSheet(context, files),
isSelected: files.filesTypeFilter == FilesTypeFilter.all, child: Text(_typeFilterLabel(files.filesTypeFilter)),
onTap: () => files.setFilesTypeFilter(FilesTypeFilter.all),
tooltip: 'Files & folders',
),
ToggleIconButton(
icon: Icons.insert_drive_file_outlined,
isSelected:
files.filesTypeFilter == FilesTypeFilter.filesOnly,
onTap: () =>
files.setFilesTypeFilter(FilesTypeFilter.filesOnly),
tooltip: 'Files only',
),
ToggleIconButton(
icon: Icons.folder_outlined,
isSelected:
files.filesTypeFilter == FilesTypeFilter.foldersOnly,
onTap: () =>
files.setFilesTypeFilter(FilesTypeFilter.foldersOnly),
tooltip: 'Folders only',
),
],
),
const SizedBox(width: 8),
SegmentedIconGroup(
children: [
ToggleIconButton(
icon: Icons.view_list_rounded,
isSelected: !files.isGridView,
onTap: () => files.setGridView(false),
tooltip: 'List view',
),
ToggleIconButton(
icon: Icons.grid_view_rounded,
isSelected: files.isGridView,
onTap: () => files.setGridView(true),
tooltip: 'Grid view',
), ),
const Spacer(),
NooSegmentedControl<bool>(
iconOnly: true,
value: files.isGridView,
onChanged: files.setGridView,
options: const [
NooSegmentOption(value: false, icon: LucideIcons.list),
NooSegmentOption(value: true, icon: LucideIcons.grid),
], ],
), ),
], ],
), ),
);
}
void _showSortSheet(BuildContext context, FilesController files) {
showNooSheet(
context,
children: [
// `showNooSheet`'s `children` are built once, up front - a bare
// checkmark/value here would freeze at whatever it was when the
// sheet opened, since tapping a row calls `files.set...` and
// notifies the controller, not this already-built widget tree.
// `ListenableBuilder` re-runs its `builder` on every
// `FilesController.notifyListeners()` instead, so the selection
// updates live - see the identical fix in `_showFilterSheet`.
ListenableBuilder(
listenable: files,
builder: (context, _) => Column(
mainAxisSize: MainAxisSize.min,
children: [
NooSegmentedControl<bool>(
fill: true,
onSurface: true,
value: files.sortAscendingFor(folderPath),
onChanged: (asc) {
if (asc != files.sortAscendingFor(folderPath)) {
files.toggleSortOrderFor(folderPath);
}
},
options: const [
NooSegmentOption(
value: true,
icon: LucideIcons.arrowUp,
label: 'Ascending',
), ),
NooSegmentOption(
value: false,
icon: LucideIcons.arrowDown,
label: 'Descending',
),
],
),
const SizedBox(height: 22),
NooGroupedList(
children: [
for (final field in FileSortField.values)
NooSettingsRow(
label: Text(sortFieldLabel(field)),
trailing: field == files.sortFieldFor(folderPath)
? Icon(
LucideIcons.check,
size: 18,
color: context.nooColors.accentText,
)
: null,
onTap: () {
files.setSortFieldFor(folderPath, field);
Navigator.pop(context);
},
),
],
),
],
),
),
],
);
}
void _showFilterSheet(BuildContext context, FilesController files) {
showNooSheet(
context,
children: [
// See `_showSortSheet`'s comment: without this, neither the type
// filter's selected segment nor the toggles below would move until
// the sheet is closed and reopened.
ListenableBuilder(
listenable: files,
builder: (context, _) => Column(
mainAxisSize: MainAxisSize.min,
children: [
// Same "icon always, label only when selected" pill as
// Photos' own type filter below - not a checkmark list, so
// the two screens' filter sheets look and behave the same.
NooSegmentedControl<FilesTypeFilter>(
fill: true,
onSurface: true,
labelOnlySelected: true,
value: files.filesTypeFilter,
onChanged: files.setFilesTypeFilter,
options: const [
NooSegmentOption(
value: FilesTypeFilter.all,
icon: LucideIcons.layoutGrid,
label: 'All',
),
NooSegmentOption(
value: FilesTypeFilter.filesOnly,
icon: LucideIcons.file,
label: 'Files',
),
NooSegmentOption(
value: FilesTypeFilter.foldersOnly,
icon: LucideIcons.folder,
label: 'Folders',
),
],
),
const SizedBox(height: 22),
NooGroupedList(
children: [
NooSettingsRow(
icon: LucideIcons.eye,
label: const Text('Show hidden files'),
trailing: NooToggle(
checked: files.showHiddenFiles,
onChanged: (_) => files.toggleShowHiddenFiles(),
),
),
if (showStorageScope)
NooSettingsRow(
icon: LucideIcons.hardDrive,
label: const Text('External storage'),
trailing: NooToggle(
checked: files.storageScope == StorageScope.external,
onChanged: (external) => files.setStorageScope(
external ? StorageScope.external : StorageScope.cloud,
),
),
),
],
),
],
),
),
],
); );
} }
} }
-151
View File
@@ -1,151 +0,0 @@
import 'package:flutter/material.dart';
import 'frosted_glass_container.dart';
class FloatingNavItem {
final String label;
final IconData icon;
const FloatingNavItem({required this.label, required this.icon});
}
/// A floating pill-shaped bottom navigation bar in the style of Google
/// Photos' 2026 redesign: unselected destinations collapse to just their
/// icon, the selected destination expands into an icon+label capsule, and
/// a detached circular action (search) sits just outside the pill.
class FloatingBottomNavBar extends StatelessWidget {
final int selectedIndex;
final ValueChanged<int> onDestinationSelected;
final List<FloatingNavItem> items;
final VoidCallback? onSearchTap;
final double opacity;
final double blurSigma;
const FloatingBottomNavBar({
super.key,
required this.selectedIndex,
required this.onDestinationSelected,
required this.items,
this.onSearchTap,
this.opacity = 0.55,
this.blurSigma = 28,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return SafeArea(
child: Align(
alignment: Alignment.bottomCenter,
child: Padding(
padding: const EdgeInsets.only(bottom: 20, left: 20, right: 20),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 480),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_buildPill(context, colorScheme),
const SizedBox(width: 10),
_buildSearchButton(context, colorScheme),
],
),
),
),
),
);
}
Widget _buildPill(BuildContext context, ColorScheme colorScheme) {
return FrostedGlassContainer(
opacity: opacity,
blurSigma: blurSigma,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 6),
// The selected item's label expands the pill, and with enough
// tabs visible that can outgrow the available width (the
// ConstrainedBox above caps it at 480, but the screen itself may
// be narrower) - wrapped in a scroll view rather than a plain Row
// so it degrades to a swipe instead of overflowing/clipping.
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
physics: const BouncingScrollPhysics(),
child: Row(
mainAxisSize: MainAxisSize.min,
children: List.generate(items.length, (index) {
final item = items[index];
final isSelected = selectedIndex == index;
return InkWell(
onTap: () => onDestinationSelected(index),
borderRadius: BorderRadius.circular(26),
splashColor: colorScheme.primary.withValues(alpha: 0.12),
highlightColor: Colors.transparent,
child: AnimatedContainer(
duration: const Duration(milliseconds: 250),
curve: Curves.easeInOutCubic,
padding: EdgeInsets.symmetric(
horizontal: isSelected ? 16 : 12,
vertical: 12,
),
decoration: BoxDecoration(
color: isSelected
? colorScheme.primary
: Colors.transparent,
borderRadius: BorderRadius.circular(26),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
item.icon,
size: 22,
color: isSelected
? colorScheme.onPrimary
: colorScheme.onSurfaceVariant,
),
if (isSelected) ...[
const SizedBox(width: 8),
Text(
item.label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: colorScheme.onPrimary,
letterSpacing: 0.1,
),
),
],
],
),
),
);
}),
),
),
),
);
}
Widget _buildSearchButton(BuildContext context, ColorScheme colorScheme) {
return FrostedGlassContainer(
opacity: opacity,
blurSigma: blurSigma,
child: SizedBox(
width: 52,
height: 52,
child: InkWell(
onTap: onSearchTap,
borderRadius: BorderRadius.circular(32),
splashColor: colorScheme.primary.withValues(alpha: 0.12),
child: Icon(
Icons.search_rounded,
color: colorScheme.onSurfaceVariant,
size: 24,
),
),
),
);
}
}
+30 -24
View File
@@ -1,13 +1,25 @@
import 'dart:ui'; import 'dart:ui';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../theme/design_tokens.dart';
/// The frosted-glass pill background shared by all floating chrome in the /// The blurred/translucent chrome for the media viewer's overlay bars
/// app (bottom nav bar, media viewer action bar): blurred backdrop, a /// (`file_viewer_screen.dart`'s top bar, `MediaActionBar`, the video
/// translucent tonal fill, a faint outline, and a soft drop shadow. /// transport row) - the one deliberate exception to the design system's
/// flat, no-shadow product UI (DESIGN_SYSTEM.md 1.4), since this chrome
/// floats over photo/video content rather than over the app's own
/// surfaces. No shadow, no outline: [color] defaults to the app's own
/// `surface` token (so this panel reads as light or dark to match the
/// active theme, like the rest of the media viewer, instead of a fixed
/// dark tone regardless of theme) at a higher-than-usual [opacity], since
/// a light tint needs denser coverage than a near-black one did to stay
/// legible over arbitrary photo/video brightness underneath.
class FrostedGlassContainer extends StatelessWidget { class FrostedGlassContainer extends StatelessWidget {
final Widget child; final Widget child;
final double borderRadius; final double borderRadius;
/// Overrides [borderRadius] for non-uniform corners (e.g. top-only).
final BorderRadius? radius;
/// Blur sigma for the backdrop filter. Defaults to a fixed value; pass an /// Blur sigma for the backdrop filter. Defaults to a fixed value; pass an
/// explicit value (e.g. from user settings) to make it adjustable. /// explicit value (e.g. from user settings) to make it adjustable.
final double blurSigma; final double blurSigma;
@@ -17,39 +29,33 @@ class FrostedGlassContainer extends StatelessWidget {
/// adjustable. /// adjustable.
final double opacity; final double opacity;
/// The tint under the blur. Defaults to [NooColors.surface] (resolved at
/// build time, so it always matches the active theme) - pass an explicit
/// color only to override that, e.g. for a fixed-color panel elsewhere.
final Color? color;
const FrostedGlassContainer({ const FrostedGlassContainer({
super.key, super.key,
required this.child, required this.child,
this.borderRadius = 32, this.borderRadius = 0,
this.blurSigma = 28, this.radius,
this.opacity = 0.55, this.blurSigma = 20,
this.opacity = 0.8,
this.color,
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme; final shape = radius ?? BorderRadius.circular(borderRadius);
final tint = color ?? context.nooColors.surface;
return ClipRRect( return ClipRRect(
borderRadius: BorderRadius.circular(borderRadius), borderRadius: shape,
child: BackdropFilter( child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: blurSigma, sigmaY: blurSigma), filter: ImageFilter.blur(sigmaX: blurSigma, sigmaY: blurSigma),
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest.withValues( color: tint.withValues(alpha: opacity),
alpha: opacity, borderRadius: shape,
),
borderRadius: BorderRadius.circular(borderRadius),
border: Border.all(
color: colorScheme.outlineVariant.withValues(alpha: 0.35),
width: 1,
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.14),
blurRadius: 24,
offset: const Offset(0, 8),
),
],
), ),
child: child, child: child,
), ),
+53 -28
View File
@@ -1,48 +1,73 @@
import 'dart:io'; import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../models/nextcloud_item.dart'; import '../models/nextcloud_item.dart';
import '../services/nextcloud_service.dart'; import '../services/nextcloud_service.dart';
import '../theme/design_tokens.dart';
/// The icon/color/thumbnail treatment for a file or folder, shared by any /// The icon/color/thumbnail treatment for a file or folder, shared by any
/// screen that lists [NextcloudItem]s the same way the Files tab does (see /// screen that lists [NextcloudItem]s the same way the Files tab does (see
/// `files_view.dart`, and `share_upload_view.dart`'s destination picker). /// `files_view.dart`, and `share_upload_view.dart`'s destination picker).
/// Matches DESIGN_SYSTEM.md 1.2's file-type tile table (`KIND`) - a pdf is
/// its own kind there (distinct from a generic document), which is why
/// these take the whole [NextcloudItem] rather than just its
/// [NextcloudItemType]: the type alone can't tell a pdf from any other
/// document.
bool _isPdf(NextcloudItem item) => item.name.toLowerCase().endsWith('.pdf');
IconData getItemIcon(NextcloudItemType type) { IconData getItemIcon(NextcloudItem item) {
switch (type) { switch (item.type) {
case NextcloudItemType.folder: case NextcloudItemType.folder:
return Icons.folder_rounded; return LucideIcons.folder;
case NextcloudItemType.image: case NextcloudItemType.image:
return Icons.image_rounded; return LucideIcons.image;
case NextcloudItemType.video: case NextcloudItemType.video:
return Icons.movie_rounded; return LucideIcons.film;
case NextcloudItemType.audio:
return Icons.audiotrack_rounded;
case NextcloudItemType.document: case NextcloudItemType.document:
return Icons.description_rounded; return LucideIcons.fileText;
case NextcloudItemType.archive: case NextcloudItemType.archive:
return Icons.folder_zip_rounded; return LucideIcons.fileArchive;
case NextcloudItemType.audio:
case NextcloudItemType.file: case NextcloudItemType.file:
return Icons.insert_drive_file_rounded; return LucideIcons.file;
} }
} }
Color getIconColor(BuildContext context, NextcloudItemType type) { Color getIconColor(BuildContext context, NextcloudItem item) {
final colorScheme = Theme.of(context).colorScheme; final colors = context.nooColors;
switch (type) { switch (item.type) {
case NextcloudItemType.folder: case NextcloudItemType.folder:
return colorScheme.primary; return colors.accentText;
case NextcloudItemType.image:
return Colors.amber.shade700;
case NextcloudItemType.video:
return Colors.deepOrange.shade600;
case NextcloudItemType.audio:
return Colors.purple.shade600;
case NextcloudItemType.document: case NextcloudItemType.document:
return Colors.blue.shade700; return _isPdf(item) ? colors.danger : colors.info;
case NextcloudItemType.image:
return colors.info;
case NextcloudItemType.video:
return colors.warning;
case NextcloudItemType.archive: case NextcloudItemType.archive:
return Colors.teal.shade700; case NextcloudItemType.audio:
case NextcloudItemType.file: case NextcloudItemType.file:
return colorScheme.outline; return colors.fg2;
}
}
/// The tile's background (the soft-tinted square/circle behind the icon) -
/// the other half of the [getIconColor] pair every call site pairs it with.
Color getIconBackground(BuildContext context, NextcloudItem item) {
final colors = context.nooColors;
switch (item.type) {
case NextcloudItemType.folder:
return colors.accentSoft;
case NextcloudItemType.document:
return _isPdf(item) ? colors.dangerSoft : colors.infoSoft;
case NextcloudItemType.image:
return colors.infoSoft;
case NextcloudItemType.video:
return colors.warningSoft;
case NextcloudItemType.archive:
case NextcloudItemType.audio:
case NextcloudItemType.file:
return colors.surface3;
} }
} }
@@ -74,7 +99,7 @@ class ItemThumbnail extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final iconColor = getIconColor(context, item.type); final iconColor = getIconColor(context, item);
final isMedia = final isMedia =
item.type == NextcloudItemType.image || item.type == NextcloudItemType.image ||
item.type == NextcloudItemType.video; item.type == NextcloudItemType.video;
@@ -91,7 +116,7 @@ class ItemThumbnail extends StatelessWidget {
height: size, height: size,
clipBehavior: Clip.antiAlias, clipBehavior: Clip.antiAlias,
decoration: BoxDecoration( decoration: BoxDecoration(
color: iconColor.withValues(alpha: 0.12), color: getIconBackground(context, item),
borderRadius: BorderRadius.circular(borderRadius), borderRadius: BorderRadius.circular(borderRadius),
), ),
child: useLocal || (isMedia && item.previewUrl != null) child: useLocal || (isMedia && item.previewUrl != null)
@@ -116,7 +141,7 @@ class ItemThumbnail extends StatelessWidget {
if (progress == null) return child; if (progress == null) return child;
return Center( return Center(
child: Icon( child: Icon(
getItemIcon(item.type), getItemIcon(item),
color: iconColor, color: iconColor,
size: iconSize, size: iconSize,
), ),
@@ -124,7 +149,7 @@ class ItemThumbnail extends StatelessWidget {
}, },
errorBuilder: (context, error, stack) => Center( errorBuilder: (context, error, stack) => Center(
child: Icon( child: Icon(
getItemIcon(item.type), getItemIcon(item),
color: iconColor, color: iconColor,
size: iconSize, size: iconSize,
), ),
@@ -142,7 +167,7 @@ class ItemThumbnail extends StatelessWidget {
) )
: Center( : Center(
child: Icon( child: Icon(
getItemIcon(item.type), getItemIcon(item),
color: iconColor, color: iconColor,
size: iconSize, size: iconSize,
), ),
+27 -48
View File
@@ -1,6 +1,11 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../providers/sync_status_controller.dart'; import '../providers/sync_status_controller.dart';
import '../theme/design_tokens.dart';
import 'noo/lists/noo_grouped_list.dart';
import 'noo/lists/noo_settings_row.dart';
import 'noo/overlays/noo_sheet.dart';
/// The configured sync targets (folders/files) with a "stop syncing" /// The configured sync targets (folders/files) with a "stop syncing"
/// action per target - what used to be Settings' Device Sync card's own /// action per target - what used to be Settings' Device Sync card's own
@@ -14,16 +19,7 @@ class ManageSyncedFoldersSheet extends StatelessWidget {
static void show(BuildContext context) { static void show(BuildContext context) {
final sync = context.read<SyncStatusController>(); final sync = context.read<SyncStatusController>();
showModalBottomSheet( showNooSheet(context, children: [ManageSyncedFoldersSheet(sync: sync)]);
context: context,
isScrollControlled: true,
backgroundColor: Theme.of(context).colorScheme.surfaceContainerHigh,
showDragHandle: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
),
builder: (sheetContext) => ManageSyncedFoldersSheet(sync: sync),
);
} }
void _remove(BuildContext context, String path) { void _remove(BuildContext context, String path) {
@@ -38,69 +34,52 @@ class ManageSyncedFoldersSheet extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colors = context.nooColors;
// AnimatedBuilder, not a one-shot read: removing a target should update
// this list in place without closing the sheet, and showNooSheet's
// `children` is built once by the caller - this widget owns its own
// rebuild instead.
return AnimatedBuilder( return AnimatedBuilder(
animation: sync, animation: sync,
builder: (context, _) { builder: (context, _) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final everything = sync.syncEverything; final everything = sync.syncEverything;
final targets = sync.syncedPaths; final targets = sync.syncedPaths;
return SafeArea( return Column(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 4, 20, 20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
'Synced to this device', 'Synced to this device',
style: theme.textTheme.titleMedium?.copyWith( style: NooText.cardTitle.copyWith(color: colors.fg1),
fontWeight: FontWeight.w700,
), ),
), const SizedBox(height: NooSpace.sm),
const SizedBox(height: 12),
if (everything) if (everything)
Text( Text(
'Every folder in this account is being synced to this ' 'Every folder in this account is being synced to this device.',
'device.', style: NooText.body.copyWith(color: colors.fg3),
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
) )
else if (targets.isEmpty) else if (targets.isEmpty)
Text( Text(
'Nothing synced yet - select a folder or file in Files ' 'Nothing synced yet. Select a folder or file in Files and '
'and use "Sync to device" to mirror it here for offline ' 'use "Sync to device" to mirror it here for offline access.',
'access.', style: NooText.body.copyWith(color: colors.fg3),
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
) )
else else
Flexible( NooGroupedList(
child: ListView.separated( children: [
shrinkWrap: true, for (final target in targets)
itemCount: targets.length, NooSettingsRow(
separatorBuilder: (_, _) => const Divider(height: 1), icon: LucideIcons.folderSync,
itemBuilder: (context, index) { label: Text(target),
final target = targets[index];
return ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.sync_rounded),
title: Text(target),
trailing: IconButton( trailing: IconButton(
icon: const Icon(Icons.close_rounded), icon: Icon(LucideIcons.x, size: 18, color: colors.fg3),
tooltip: 'Stop syncing', tooltip: 'Stop syncing',
onPressed: () => _remove(context, target), onPressed: () => _remove(context, target),
), ),
);
},
),
), ),
], ],
), ),
), ],
); );
}, },
); );
-98
View File
@@ -1,98 +0,0 @@
import 'package:flutter/material.dart';
import '../models/nextcloud_item.dart';
import 'synced_header_scaffold.dart' show formatBytes;
/// Grid content for an image/video: the actual preview fills the whole card
/// as a background, with the name/size legible over a bottom scrim -
/// matching a Google Photos-style grid instead of a small icon badge.
/// Shared by the Files and Offline tabs; they differ only in where the
/// pixels come from ([imageBuilder] - a server preview vs. the local
/// mirror file) and what to show if that fails ([fallbackBuilder]).
class MediaGridTile extends StatelessWidget {
final NextcloudItem item;
/// Given the pixel width to decode at, returns the image to paint.
/// Decoding straight to the painted size (rather than the full 500x500
/// server preview / full-resolution local file) avoids a real source of
/// scrolling jank.
final ImageProvider Function(int cachePixels) imageBuilder;
final WidgetBuilder fallbackBuilder;
const MediaGridTile({
super.key,
required this.item,
required this.imageBuilder,
required this.fallbackBuilder,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return LayoutBuilder(
builder: (context, constraints) {
final cachePixels =
(constraints.maxWidth * MediaQuery.of(context).devicePixelRatio)
.round();
return Stack(
fit: StackFit.expand,
children: [
Image(
image: imageBuilder(cachePixels),
fit: BoxFit.cover,
filterQuality: FilterQuality.low,
gaplessPlayback: true,
errorBuilder: (ctx, err, stack) => fallbackBuilder(ctx),
),
if (item.type == NextcloudItemType.video)
const Center(
child: Icon(
Icons.play_circle_fill_rounded,
color: Colors.white,
size: 36,
),
),
Positioned(
left: 0,
right: 0,
bottom: 0,
child: Container(
padding: const EdgeInsets.fromLTRB(10, 20, 10, 8),
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.transparent, Colors.black87],
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleSmall?.copyWith(
color: Colors.white,
fontWeight: FontWeight.w600,
fontSize: 12,
),
),
Text(
formatBytes(item.size),
style: theme.textTheme.bodySmall?.copyWith(
color: Colors.white70,
fontSize: 10,
),
),
],
),
),
),
],
);
},
);
}
}
+154 -103
View File
@@ -1,6 +1,15 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../models/move_copy_result.dart'; import '../models/move_copy_result.dart';
import 'gradual_bottom_sheet.dart'; import '../theme/design_tokens.dart';
import 'noo/core/noo_button.dart';
import 'noo/core/noo_segmented_control.dart';
import 'noo/files/noo_file_kind.dart';
import 'noo/files/noo_file_tile.dart';
import 'noo/lists/noo_grouped_list.dart';
import 'noo/noo_layout.dart';
import 'noo/overlays/noo_dialog.dart';
import 'noo/overlays/noo_overlay_header.dart';
import 'noo/overlays/noo_sheet.dart';
/// Shown after a Move/Copy batch comes back with `MoveCopyResult.conflicts` /// Shown after a Move/Copy batch comes back with `MoveCopyResult.conflicts`
/// non-empty (an item with the same name already exists at the /// non-empty (an item with the same name already exists at the
@@ -8,27 +17,36 @@ import 'gradual_bottom_sheet.dart';
/// per item as they're hit, and lets the user resolve them all the same /// per item as they're hit, and lets the user resolve them all the same
/// way (overwrite/keep both) or open a per-item breakdown. Returns the /// way (overwrite/keep both) or open a per-item breakdown. Returns the
/// `ConflictChoice` map `ItemOperations.resolveConflicts` expects, or null /// `ConflictChoice` map `ItemOperations.resolveConflicts` expects, or null
/// if dismissed without choosing. /// if dismissed without choosing. A `showNooDialog` (desktop) / `showNooSheet`
/// (mobile) instead of the old `showGradualBottomSheet` - the content never
/// grows past a couple of screens, so the drag-to-resize sheet that needed
/// isn't worth it here.
class MoveCopyConflictSheet extends StatefulWidget { class MoveCopyConflictSheet extends StatefulWidget {
final List<MoveCopyConflict> conflicts; final List<MoveCopyConflict> conflicts;
final ScrollController? scrollController;
const MoveCopyConflictSheet({ const MoveCopyConflictSheet({super.key, required this.conflicts});
super.key,
required this.conflicts,
this.scrollController,
});
static Future<Map<String, ConflictChoice>?> show( static Future<Map<String, ConflictChoice>?> show(
BuildContext context, BuildContext context,
List<MoveCopyConflict> conflicts, List<MoveCopyConflict> conflicts,
) { ) {
return showGradualBottomSheet<Map<String, ConflictChoice>>( final content = MoveCopyConflictSheet(conflicts: conflicts);
final count = conflicts.length;
final title = count == 1
? '1 item already exists'
: '$count items already exist';
if (NooLayout.isDesktop(context)) {
return showNooDialog<Map<String, ConflictChoice>>(
context, context,
builder: (context, scrollController) => MoveCopyConflictSheet( title: title,
conflicts: conflicts, subtitle: 'Choose how to handle the conflicts',
scrollController: scrollController, children: [content],
), );
}
return showNooSheet<Map<String, ConflictChoice>>(
context,
children: [content],
); );
} }
@@ -55,118 +73,151 @@ class _MoveCopyConflictSheetState extends State<MoveCopyConflictSheet> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final isDesktop = NooLayout.isDesktop(context);
final colorScheme = theme.colorScheme;
final count = widget.conflicts.length; final count = widget.conflicts.length;
return ListView( return Column(
controller: widget.scrollController, crossAxisAlignment: CrossAxisAlignment.stretch,
padding: const EdgeInsets.fromLTRB(20, 12, 20, 24),
children: [ children: [
Text( // The dialog already renders this title/subtitle in its own header
count == 1 // (see `show` above); the sheet has no header of its own, so it
? '1 item already exists at the destination' // needs one here.
: '$count items already exist at the destination', if (!isDesktop) ...[
style: theme.textTheme.titleMedium?.copyWith( NooOverlayHeader(
fontWeight: FontWeight.w700, title: count == 1
? '1 item already exists'
: '$count items already exist',
subtitle: 'Choose how to handle the conflicts',
onClose: () => Navigator.pop(context),
), ),
), const SizedBox(height: NooSpace.lg),
const SizedBox(height: 4), ],
Text(
'Choose how to handle the conflicts.',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 16),
if (!_decidingPerItem) ...[ if (!_decidingPerItem) ...[
FilledButton( NooButton(
onPressed: () => _resolveAll(ConflictChoice.overwrite), variant: NooButtonVariant.primary,
size: NooButtonSize.card,
fullWidth: true,
onTap: () => _resolveAll(ConflictChoice.overwrite),
child: const Text('Overwrite all'), child: const Text('Overwrite all'),
), ),
const SizedBox(height: 8), const SizedBox(height: NooSpace.xs),
FilledButton.tonal( NooButton(
onPressed: () => _resolveAll(ConflictChoice.keepBoth), variant: NooButtonVariant.tonal,
size: NooButtonSize.card,
fullWidth: true,
onTap: () => _resolveAll(ConflictChoice.keepBoth),
child: const Text('Keep both (rename)'), child: const Text('Keep both (rename)'),
), ),
const SizedBox(height: 8), const SizedBox(height: NooSpace.xs),
OutlinedButton( NooButton(
onPressed: () => setState(() => _decidingPerItem = true), variant: NooButtonVariant.outline,
size: NooButtonSize.card,
fullWidth: true,
onTap: () => setState(() => _decidingPerItem = true),
child: const Text('Decide per item'), child: const Text('Decide per item'),
), ),
const SizedBox(height: 16), const SizedBox(height: NooSpace.lg),
const Divider(),
const SizedBox(height: 8),
], ],
NooGroupedList(
label: _decidingPerItem ? 'Items' : null,
children: [
for (final conflict in widget.conflicts) for (final conflict in widget.conflicts)
Padding( _ConflictRow(
padding: const EdgeInsets.symmetric(vertical: 8), conflict: conflict,
child: Column( decidingPerItem: _decidingPerItem,
crossAxisAlignment: CrossAxisAlignment.start, choice: _perItemChoices[conflict.item.id]!,
children: [ onChoiceChanged: (choice) => setState(() {
Row( _perItemChoices[conflict.item.id] = choice;
children: [
Icon(
conflict.item.isFolder
? Icons.folder_rounded
: Icons.insert_drive_file_rounded,
color: colorScheme.onSurfaceVariant,
size: 20,
),
const SizedBox(width: 10),
Expanded(
child: Text(
conflict.item.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
),
],
),
if (_decidingPerItem) ...[
const SizedBox(height: 6),
SizedBox(
width: double.infinity,
child: SegmentedButton<ConflictChoice>(
segments: const [
ButtonSegment(
value: ConflictChoice.overwrite,
label: Text('Overwrite'),
),
ButtonSegment(
value: ConflictChoice.keepBoth,
label: Text('Keep both'),
),
ButtonSegment(
value: ConflictChoice.skip,
label: Text('Skip'),
),
],
selected: {_perItemChoices[conflict.item.id]!},
showSelectedIcon: false,
onSelectionChanged: (selection) => setState(() {
_perItemChoices[conflict.item.id] = selection.first;
}), }),
), ),
),
], ],
],
),
), ),
if (_decidingPerItem) ...[ if (_decidingPerItem) ...[
const SizedBox(height: 8), const SizedBox(height: NooSpace.lg),
SizedBox( NooButton(
width: double.infinity, variant: NooButtonVariant.primary,
child: FilledButton( size: NooButtonSize.card,
onPressed: _confirmPerItem, fullWidth: true,
onTap: _confirmPerItem,
child: const Text('Confirm'), child: const Text('Confirm'),
), ),
),
], ],
], ],
); );
} }
} }
/// One conflicting item: its file-type tile and name, plus (once "Decide
/// per item" is active) a segmented overwrite/keep both/skip choice.
class _ConflictRow extends StatelessWidget {
final MoveCopyConflict conflict;
final bool decidingPerItem;
final ConflictChoice choice;
final ValueChanged<ConflictChoice> onChoiceChanged;
const _ConflictRow({
required this.conflict,
required this.decidingPerItem,
required this.choice,
required this.onChoiceChanged,
});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
final item = conflict.item;
final kind = NooFileKind.from(name: item.name, isDirectory: item.isFolder);
return Material(
color: colors.surface,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: NooSpace.md,
vertical: NooSpace.sm,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
NooFileTile(kind: kind),
const SizedBox(width: NooSpace.sm),
Expanded(
child: Text(
item.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: NooText.bodyL.copyWith(
fontWeight: FontWeight.w500,
color: colors.fg1,
),
),
),
],
),
if (decidingPerItem) ...[
const SizedBox(height: NooSpace.xs),
NooSegmentedControl<ConflictChoice>(
fill: true,
onSurface: true,
value: choice,
onChanged: onChoiceChanged,
options: const [
NooSegmentOption(
value: ConflictChoice.overwrite,
label: 'Overwrite',
),
NooSegmentOption(
value: ConflictChoice.keepBoth,
label: 'Keep both',
),
NooSegmentOption(value: ConflictChoice.skip, label: 'Skip'),
],
),
],
],
),
),
);
}
}
+61
View File
@@ -0,0 +1,61 @@
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import '../../../theme/design_tokens.dart';
/// Initials circle on a secondary-palette colour; the current user uses
/// accent-soft (Noo Design System project, `components/core/Avatar.jsx`).
/// Sizes: 30-32 in bars, 36 in lists, 48 in the drawer, 52-56 on the
/// account card.
class NooAvatar extends StatelessWidget {
final String? initials;
final double size;
/// One of [NooColors.avatarPalette]; ignored when [current] or [icon] is set.
final Color? color;
final bool current;
/// Show an icon instead of initials (e.g. a group share).
final IconData? icon;
const NooAvatar({
super.key,
this.initials,
this.size = 36,
this.color,
this.current = false,
this.icon,
});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
final Color bg;
final Color fg;
if (current) {
bg = colors.accentSoft;
fg = colors.accentText;
} else if (icon != null) {
bg = colors.surface2;
fg = colors.fg2;
} else {
bg = color ?? NooColors.avatarPalette.first;
fg = NooColors.avatarTextColor;
}
return Container(
width: size,
height: size,
alignment: Alignment.center,
decoration: BoxDecoration(color: bg, shape: BoxShape.circle),
child: icon != null
? Icon(icon, size: (size * 0.5).round().toDouble(), color: fg)
: Text(
initials ?? '',
style: GoogleFonts.instrumentSans(
fontWeight: FontWeight.w600,
fontSize: (size * 0.36).round().toDouble(),
height: 1,
color: fg,
),
),
);
}
}
+47
View File
@@ -0,0 +1,47 @@
import 'package:flutter/material.dart';
import '../../../theme/design_tokens.dart';
enum NooBadgeTone { success, danger, accent, warning, info }
/// Small status pill, e.g. "Connected" in settings (Noo Design System
/// project, `components/core/Badge.jsx`).
class NooBadge extends StatelessWidget {
final NooBadgeTone tone;
final Widget child;
const NooBadge({super.key, this.tone = NooBadgeTone.success, required this.child});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
final (bg, fg) = switch (tone) {
NooBadgeTone.success => (colors.successSoft, colors.success),
NooBadgeTone.danger => (colors.dangerSoft, colors.danger),
NooBadgeTone.accent => (colors.accentSoft, colors.accentText),
NooBadgeTone.warning => (colors.warningSoft, colors.warning),
NooBadgeTone.info => (colors.infoSoft, colors.info),
};
return Container(
height: 24,
// Centering the text vertically within the fixed 24px height needs
// *some* alignment mechanism, but Container's own `alignment:` (with
// a height but no width) would expand to fill all available
// *bounded* width instead of shrink-wrapping (see NooFab's doc
// comment) - a Row with mainAxisSize.min sidesteps that entirely:
// it shrink-wraps horizontally regardless of incoming constraints,
// while its default crossAxisAlignment.center still centers the
// text within the Container's tight 24px height.
padding: const EdgeInsets.symmetric(horizontal: 10),
decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(NooRadii.pill)),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
DefaultTextStyle(
style: NooText.label.copyWith(fontSize: 12, height: 1, color: fg),
child: child,
),
],
),
);
}
}
+143
View File
@@ -0,0 +1,143 @@
import 'package:flutter/material.dart';
import '../../../theme/design_tokens.dart';
enum NooButtonVariant { primary, tonal, secondary, danger, outline, text, textDanger }
enum NooButtonSize { cta, card, field, toolbar, compact, xs }
class _SizeSpec {
final double height;
final double px;
final double font;
final double icon;
final double gap;
const _SizeSpec(this.height, this.px, this.font, this.icon, this.gap);
}
const _sizes = {
NooButtonSize.cta: _SizeSpec(52, 20, 16, 20, 10),
NooButtonSize.card: _SizeSpec(44, 18, 15, 16, 8),
NooButtonSize.field: _SizeSpec(40, 16, 14, 16, 6),
NooButtonSize.toolbar: _SizeSpec(36, 16, 14, 16, 8),
NooButtonSize.compact: _SizeSpec(32, 14, 13, 14, 6),
NooButtonSize.xs: _SizeSpec(28, 12, 12, 14, 6),
};
/// The pill button for every action in the design system
/// (Noo Design System project, `components/core/Button.jsx`). Pick
/// [NooButtonVariant] by emphasis and [NooButtonSize] by context - never
/// build a bespoke `ElevatedButton`/`FilledButton` for app chrome; extend
/// this instead.
class NooButton extends StatefulWidget {
final NooButtonVariant variant;
final NooButtonSize size;
final IconData? icon;
final Widget? child;
final bool fullWidth;
final bool disabled;
final VoidCallback? onTap;
const NooButton({
super.key,
this.variant = NooButtonVariant.primary,
this.size = NooButtonSize.toolbar,
this.icon,
this.child,
this.fullWidth = false,
this.disabled = false,
this.onTap,
});
@override
State<NooButton> createState() => _NooButtonState();
}
class _NooButtonState extends State<NooButton> {
bool _down = false;
(Color, Color, BoxBorder?) _colors(NooColors colors) {
switch (widget.variant) {
case NooButtonVariant.primary:
return (colors.accent, Colors.white, null);
case NooButtonVariant.tonal:
return (colors.accentSoft, colors.accentText, null);
case NooButtonVariant.secondary:
return (colors.surface2, colors.fg1, null);
case NooButtonVariant.danger:
return (colors.dangerSoft, colors.danger, null);
case NooButtonVariant.outline:
return (
Colors.transparent,
colors.fg1,
Border.all(color: colors.surface3, width: 1.5),
);
case NooButtonVariant.text:
return (Colors.transparent, colors.accentText, null);
case NooButtonVariant.textDanger:
return (Colors.transparent, colors.danger, null);
}
}
bool get _isTextVariant =>
widget.variant == NooButtonVariant.text ||
widget.variant == NooButtonVariant.textDanger;
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
final s = _sizes[widget.size]!;
final (bg, fg, border) = _colors(colors);
final hasLeadingIcon = widget.icon != null && widget.child != null && !_isTextVariant;
final content = Row(
mainAxisSize: MainAxisSize.min,
children: [
if (widget.icon != null) Icon(widget.icon, size: s.icon, color: fg),
if (widget.icon != null && widget.child != null) SizedBox(width: s.gap),
if (widget.child != null)
DefaultTextStyle(
style: NooText.buttonSm.copyWith(fontSize: s.font, color: fg),
child: widget.child!,
),
],
);
return Opacity(
opacity: widget.disabled ? 0.45 : 1,
child: GestureDetector(
onTapDown: widget.disabled ? null : (_) => setState(() => _down = true),
onTapUp: widget.disabled ? null : (_) => setState(() => _down = false),
onTapCancel: widget.disabled ? null : () => setState(() => _down = false),
onTap: widget.disabled ? null : widget.onTap,
child: AnimatedScale(
scale: _down && !widget.disabled ? NooMotion.pressScale : 1,
duration: NooMotion.fast,
curve: NooMotion.ease,
child: Container(
height: s.height,
width: widget.fullWidth ? double.infinity : null,
padding: _isTextVariant
? EdgeInsets.zero
: EdgeInsets.only(
left: hasLeadingIcon ? s.px - 4 : s.px,
right: s.px,
),
// Only align when fullWidth actually expands this Container -
// otherwise (no width, alignment set) Container wraps the child
// in an Align that fills all available *bounded* space instead
// of shrink-wrapping to it, which is what stretched this button
// edge-to-edge instead of sizing to its content (see NooFab's
// doc comment for the same bug).
alignment: widget.fullWidth ? Alignment.center : null,
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(NooRadii.pill),
border: border,
),
child: content,
),
),
),
);
}
}
+92
View File
@@ -0,0 +1,92 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../../theme/design_tokens.dart';
enum NooChipTrailing { none, menu, remove }
/// A pill filter/sort chip; selected = accent-soft (Noo Design System
/// project, `components/core/Chip.jsx`).
class NooChip extends StatelessWidget {
final Widget child;
final IconData? icon;
final bool selected;
final NooChipTrailing trailing;
/// filled = mobile (surface fill, 34px); outline = desktop (1px line, 32px)
final bool outline;
final VoidCallback? onTap;
const NooChip({
super.key,
required this.child,
this.icon,
this.selected = false,
this.trailing = NooChipTrailing.none,
this.outline = false,
this.onTap,
});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
final height = outline ? 32.0 : 34.0;
final hasSideContent = icon != null || trailing != NooChipTrailing.none;
Color bg;
Color fg;
FontWeight weight;
Border? border;
if (selected) {
bg = colors.accentSoft;
fg = colors.accentText;
weight = FontWeight.w600;
} else if (outline) {
bg = Colors.transparent;
fg = colors.fg1;
weight = FontWeight.w500;
border = Border.all(color: colors.line, width: 1);
} else {
bg = colors.surface;
fg = colors.fg1;
weight = FontWeight.w500;
}
return GestureDetector(
onTap: onTap,
child: Container(
height: height,
padding: EdgeInsets.symmetric(horizontal: hasSideContent ? 12 : 14),
decoration: BoxDecoration(
color: bg,
border: border,
borderRadius: BorderRadius.circular(NooRadii.pill),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null) ...[
Icon(icon, size: outline ? 14 : 16, color: fg),
const SizedBox(width: 6),
],
DefaultTextStyle(
style: NooText.body.copyWith(
fontSize: outline ? 13 : 14,
height: 1,
fontWeight: weight,
color: fg,
),
child: child,
),
if (trailing == NooChipTrailing.menu) ...[
const SizedBox(width: 6),
Icon(LucideIcons.chevronDown, size: 14, color: fg),
],
if (trailing == NooChipTrailing.remove) ...[
const SizedBox(width: 6),
Icon(LucideIcons.x, size: 14, color: fg),
],
],
),
),
);
}
}
+100
View File
@@ -0,0 +1,100 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../../theme/design_tokens.dart';
import '../nav/noo_nav_style.dart';
/// Android-only extended Upload pill, bottom-right of Files and Photos (Noo
/// Design System project, `components/core/FAB.jsx`). 56px tall, accent
/// fill - iOS uses a `plus` in the nav bar instead (not yet built).
///
/// [collapsed] shrinks it to an icon-only circle (no label) on tabs that
/// 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 -
/// 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 {
final String label;
final IconData icon;
final VoidCallback? onTap;
final bool collapsed;
final NooBottomBarStyle barStyle;
const NooFab({
super.key,
this.label = 'Upload',
this.icon = LucideIcons.plus,
this.onTap,
this.collapsed = false,
required this.barStyle,
});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
final radius = BorderRadius.circular(
barStyle == NooBottomBarStyle.attached ? NooRadii.card : NooRadii.pill,
);
return Material(
color: colors.accent,
borderRadius: radius,
child: InkWell(
onTap: onTap,
borderRadius: radius,
child: AnimatedContainer(
duration: NooMotion.base,
curve: NooMotion.ease,
height: 56,
padding: EdgeInsetsDirectional.fromSTEB(
16,
0,
collapsed ? 16 : 20,
0,
),
// No `alignment:` here - Container without an explicit width
// wraps its child in an Align when alignment is set, and Align
// EXPANDS to fill all available bounded space (not just the
// child's size) unless the incoming constraints are unbounded.
// The Scaffold floating-action-button slot gives bounded
// constraints, so this shipped as a full-width bar instead of a
// compact pill. Row+mainAxisSize.min already sizes correctly on
// its own; the fix is just not adding alignment back here.
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 24, color: Colors.white),
ClipRect(
child: AnimatedSize(
duration: NooMotion.base,
curve: NooMotion.ease,
alignment: AlignmentDirectional.centerStart,
child: collapsed
? const SizedBox(height: 24)
: Row(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(width: 10),
Text(
label,
style: NooText.button.copyWith(
color: Colors.white,
),
),
],
),
),
),
],
),
),
),
);
}
}
@@ -0,0 +1,27 @@
import 'package:flutter/material.dart';
import '../../../theme/design_tokens.dart';
/// 6px pill meter for storage, cache and offline limits (Noo Design System
/// project, `components/core/ProgressBar.jsx`).
class NooProgressBar extends StatelessWidget {
/// 0-1
final double value;
const NooProgressBar({super.key, required this.value});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
return ClipRRect(
borderRadius: BorderRadius.circular(NooRadii.pill),
child: SizedBox(
height: 6,
child: LinearProgressIndicator(
value: value.clamp(0.0, 1.0),
backgroundColor: colors.surface3,
valueColor: AlwaysStoppedAnimation(colors.accent),
),
),
);
}
}
@@ -0,0 +1,84 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../../theme/design_tokens.dart';
/// Pill search input with a leading icon (Noo Design System project,
/// `components/core/SearchField.jsx`).
class NooSearchField extends StatelessWidget {
final String placeholder;
final TextEditingController? controller;
final FocusNode? focusNode;
final ValueChanged<String>? onChanged;
final ValueChanged<String>? onSubmitted;
final bool autofocus;
/// true = iOS inline field (radius 12, 38px, 16px text, below the large
/// title); false = the generic 36px pill used everywhere else (also the
/// Android "expands from a top-bar icon" search field once expanded).
final bool ios;
/// True when placed directly on a `surface` background (uses surface-2
/// fill instead of surface, so it doesn't disappear against it).
final bool onSurface;
final double? width;
/// A trailing widget (e.g. a clear button), shown after the input.
final Widget? trailing;
const NooSearchField({
super.key,
this.placeholder = 'Search',
this.controller,
this.focusNode,
this.onChanged,
this.onSubmitted,
this.autofocus = false,
this.ios = false,
this.onSurface = false,
this.width,
this.trailing,
});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
return Container(
width: width,
height: ios ? 38 : 36,
padding: EdgeInsets.symmetric(horizontal: ios ? 10 : 14),
decoration: BoxDecoration(
color: onSurface ? colors.surface2 : colors.surface,
borderRadius: BorderRadius.circular(ios ? 12 : NooRadii.pill),
),
child: Row(
children: [
Icon(LucideIcons.search, size: ios ? 18 : 16, color: colors.fg3),
const SizedBox(width: 8),
Expanded(
child: TextField(
controller: controller,
focusNode: focusNode,
onChanged: onChanged,
onSubmitted: onSubmitted,
autofocus: autofocus,
style: NooText.body.copyWith(
fontSize: ios ? 16 : 14,
height: 1,
color: colors.fg1,
),
decoration: InputDecoration(
isDense: true,
border: InputBorder.none,
contentPadding: EdgeInsets.zero,
hintText: placeholder,
hintStyle: NooText.body.copyWith(
fontSize: ios ? 16 : 14,
height: 1,
color: colors.fg3,
),
),
),
),
?trailing,
],
),
);
}
}
@@ -0,0 +1,117 @@
import 'package:flutter/material.dart';
import '../../../theme/design_tokens.dart';
class NooSegmentOption<T> {
final T value;
final String? label;
final IconData? icon;
const NooSegmentOption({required this.value, this.label, this.icon});
}
enum NooSegmentedSize { md, sm, xs }
const _heights = {
NooSegmentedSize.md: 34.0,
NooSegmentedSize.sm: 30.0,
NooSegmentedSize.xs: 28.0,
};
/// Pill track with an accent-soft active segment - List/Grid, Shares scope,
/// Theme (Noo Design System project, `components/core/SegmentedControl.jsx`).
/// Replaces the older `SegmentedIconGroup`/`ToggleIconButton` pair; migrate
/// call sites to this as they're touched rather than adding new ones there.
class NooSegmentedControl<T> extends StatelessWidget {
final List<NooSegmentOption<T>> options;
final T value;
final ValueChanged<T>? onChanged;
/// Use surface on bg, surface-2 on surface.
final bool onSurface;
final NooSegmentedSize size;
final bool fill;
final bool iconOnly;
/// Every segment always shows its icon, but only the *selected* one also
/// shows its label - a middle ground between `iconOnly` (no segment is
/// ever distinguishable without a label) and the default (every segment's
/// label always visible, crowding a 3+-option control). Used for Files'/
/// Photos' type filter (All/Files/Folders, All/Photos/Videos) so both use
/// the same List/Grid-toggle-style pill instead of Files' old checkmark
/// list and Photos' own always-labelled control.
final bool labelOnlySelected;
const NooSegmentedControl({
super.key,
required this.options,
required this.value,
this.onChanged,
this.onSurface = false,
this.size = NooSegmentedSize.md,
this.fill = false,
this.iconOnly = false,
this.labelOnlySelected = false,
});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
final h = iconOnly ? 28.0 : (_heights[size] ?? 34.0);
final segments = options.map((o) {
final on = o.value == value;
final showLabel = labelOnlySelected ? on : !iconOnly;
final child = Container(
height: h,
width: iconOnly ? 36 : null,
padding: iconOnly || fill
? EdgeInsets.zero
: const EdgeInsets.symmetric(horizontal: 12),
alignment: Alignment.center,
decoration: BoxDecoration(
color: on ? colors.accentSoft : Colors.transparent,
borderRadius: BorderRadius.circular(NooRadii.pill),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (o.icon != null)
Icon(
o.icon,
size: iconOnly ? (size == NooSegmentedSize.md ? 18 : 16) : 14,
color: on ? colors.accentText : colors.fg2,
),
if (o.icon != null && showLabel && o.label != null)
const SizedBox(width: 6),
if (showLabel && o.label != null)
Text(
o.label!,
style: NooText.body.copyWith(
fontSize: size == NooSegmentedSize.md ? 14 : 13,
height: 1,
fontWeight: on ? FontWeight.w600 : FontWeight.w500,
color: on ? colors.accentText : colors.fg2,
),
),
],
),
);
final tappable = GestureDetector(
onTap: onChanged == null ? null : () => onChanged!(o.value),
child: child,
);
return fill ? Expanded(child: tappable) : tappable;
}).toList();
return Container(
padding: const EdgeInsets.all(3),
decoration: BoxDecoration(
color: onSurface ? colors.surface2 : colors.surface,
borderRadius: BorderRadius.circular(NooRadii.pill),
),
child: Row(
mainAxisSize: fill ? MainAxisSize.max : MainAxisSize.min,
children: segments,
),
);
}
}
+54
View File
@@ -0,0 +1,54 @@
import 'package:flutter/material.dart';
import '../../../theme/design_tokens.dart';
/// On/off switch; accent track on, surface-3 track off, white knob -
/// identical on every platform, never a native iOS/Android switch (Noo
/// Design System project, `components/core/Toggle.jsx`).
class NooToggle extends StatelessWidget {
final bool checked;
final ValueChanged<bool>? onChanged;
final bool desktop;
const NooToggle({
super.key,
required this.checked,
this.onChanged,
this.desktop = false,
});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
final w = desktop ? 44.0 : 48.0;
final h = desktop ? 26.0 : 28.0;
final knob = h - 6;
return GestureDetector(
onTap: onChanged == null ? null : () => onChanged!(!checked),
child: AnimatedContainer(
duration: NooMotion.fast,
curve: NooMotion.ease,
width: w,
height: h,
padding: const EdgeInsets.all(3),
decoration: BoxDecoration(
color: checked ? colors.accent : colors.surface3,
borderRadius: BorderRadius.circular(NooRadii.pill),
),
child: AnimatedAlign(
duration: NooMotion.fast,
curve: NooMotion.ease,
alignment: checked ? Alignment.centerRight : Alignment.centerLeft,
child: Container(
width: knob,
height: knob,
decoration: const BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
),
),
),
),
);
}
}
+188
View File
@@ -0,0 +1,188 @@
import 'package:flutter/widgets.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../../theme/design_tokens.dart';
/// File-type buckets from `DESIGN_SYSTEM.md` 1.2 (`KIND` in `noo-kit.js`).
/// Drives the tile tint + icon in `NooFileTile`, `NooFileRow` and
/// `NooFileTableRow`. [other] isn't in the spec table - it's the neutral
/// fallback (archive colors, plain `file` icon) for anything the table
/// doesn't cover (audio, binaries, unknown extensions) so those don't
/// masquerade as an archive or a document.
enum NooFileKind {
folder,
pdf,
doc,
sheet,
video,
image,
archive,
other;
/// Lucide icon for this kind.
IconData get icon {
switch (this) {
case NooFileKind.folder:
return LucideIcons.folder;
case NooFileKind.pdf:
case NooFileKind.doc:
return LucideIcons.fileText;
case NooFileKind.sheet:
return LucideIcons.sheet;
case NooFileKind.video:
return LucideIcons.film;
case NooFileKind.image:
return LucideIcons.image;
case NooFileKind.archive:
return LucideIcons.fileArchive;
case NooFileKind.other:
return LucideIcons.file;
}
}
/// Tile background - the soft half of the pair.
Color background(NooColors colors) {
switch (this) {
case NooFileKind.folder:
return colors.accentSoft;
case NooFileKind.pdf:
return colors.dangerSoft;
case NooFileKind.doc:
case NooFileKind.image:
return colors.infoSoft;
case NooFileKind.sheet:
return colors.successSoft;
case NooFileKind.video:
return colors.warningSoft;
case NooFileKind.archive:
case NooFileKind.other:
return colors.surface3;
}
}
/// Icon color - always the strong half of [background]'s pair.
Color foreground(NooColors colors) {
switch (this) {
case NooFileKind.folder:
return colors.accentText;
case NooFileKind.pdf:
return colors.danger;
case NooFileKind.doc:
case NooFileKind.image:
return colors.info;
case NooFileKind.sheet:
return colors.success;
case NooFileKind.video:
return colors.warning;
case NooFileKind.archive:
case NooFileKind.other:
return colors.fg2;
}
}
/// Derives a kind from whatever the caller has: [isDirectory] wins, then
/// [mimeType] (e.g. WebDAV `getcontenttype`), then [name]'s extension.
static NooFileKind from({
String? name,
String? mimeType,
bool isDirectory = false,
}) {
final mime = mimeType?.toLowerCase().trim();
if (isDirectory || mime == 'httpd/unix-directory') {
return NooFileKind.folder;
}
if (mime != null && mime.isNotEmpty) {
final byMime = _fromMime(mime);
if (byMime != null) return byMime;
}
if (name != null) {
final dot = name.lastIndexOf('.');
if (dot >= 0 && dot < name.length - 1) {
final byExt = _extensions[name.substring(dot + 1).toLowerCase()];
if (byExt != null) return byExt;
}
}
return NooFileKind.other;
}
static NooFileKind? _fromMime(String mime) {
if (mime == 'application/pdf') return NooFileKind.pdf;
if (mime.startsWith('image/')) return NooFileKind.image;
if (mime.startsWith('video/')) return NooFileKind.video;
if (mime.contains('spreadsheet') ||
mime.contains('ms-excel') ||
mime == 'text/csv') {
return NooFileKind.sheet;
}
if (mime.contains('zip') ||
mime.contains('compressed') ||
mime.contains('x-tar') ||
mime.contains('x-7z') ||
mime.contains('x-rar') ||
mime.contains('gzip')) {
return NooFileKind.archive;
}
if (mime.startsWith('text/') ||
mime.contains('wordprocessing') ||
mime.contains('msword') ||
mime.contains('opendocument.text') ||
mime.contains('presentation') ||
mime.contains('powerpoint') ||
mime == 'application/rtf') {
return NooFileKind.doc;
}
// Generic types (application/octet-stream etc.) fall through to the
// extension check.
return null;
}
static const _extensions = <String, NooFileKind>{
'pdf': NooFileKind.pdf,
// doc / text
'doc': NooFileKind.doc,
'docx': NooFileKind.doc,
'odt': NooFileKind.doc,
'rtf': NooFileKind.doc,
'txt': NooFileKind.doc,
'md': NooFileKind.doc,
'ppt': NooFileKind.doc,
'pptx': NooFileKind.doc,
'odp': NooFileKind.doc,
'pages': NooFileKind.doc,
// sheet
'xls': NooFileKind.sheet,
'xlsx': NooFileKind.sheet,
'ods': NooFileKind.sheet,
'csv': NooFileKind.sheet,
'numbers': NooFileKind.sheet,
// video
'mp4': NooFileKind.video,
'mov': NooFileKind.video,
'mkv': NooFileKind.video,
'webm': NooFileKind.video,
'avi': NooFileKind.video,
'm4v': NooFileKind.video,
'3gp': NooFileKind.video,
// image
'jpg': NooFileKind.image,
'jpeg': NooFileKind.image,
'png': NooFileKind.image,
'gif': NooFileKind.image,
'webp': NooFileKind.image,
'heic': NooFileKind.image,
'heif': NooFileKind.image,
'bmp': NooFileKind.image,
'svg': NooFileKind.image,
'tif': NooFileKind.image,
'tiff': NooFileKind.image,
'dng': NooFileKind.image,
// archive
'zip': NooFileKind.archive,
'tar': NooFileKind.archive,
'gz': NooFileKind.archive,
'tgz': NooFileKind.archive,
'bz2': NooFileKind.archive,
'xz': NooFileKind.archive,
'7z': NooFileKind.archive,
'rar': NooFileKind.archive,
};
}
+193
View File
@@ -0,0 +1,193 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../../theme/design_tokens.dart';
import 'noo_file_kind.dart';
import 'noo_file_tile.dart';
import 'noo_status_icon.dart';
/// Mobile 64px file row (`DESIGN_SYSTEM.md` 2, "File row"): 40px tile, name
/// (16/500, one line) over a meta line (`[status][shared] size · modified`,
/// 13 fg-3), an optional favorite star / [trailing] widget, then the
/// overflow button. Paints its own `surface` background (accent-soft when
/// [selected]), so it can sit directly in a list or inside a
/// `NooSwipeAction`.
class NooFileRow extends StatelessWidget {
final NooFileKind kind;
final String name;
/// Formatted size, e.g. "2.4 MB". Joined to [modified] with " · ".
final String? size;
/// Formatted modified time, e.g. "Yesterday".
final String? modified;
/// Replaces the "size · modified" text entirely - use it for the error
/// hint ("Couldn't sync · Tap to retry") or screen-specific meta (Trash's
/// "Deleted 3 days ago").
final String? meta;
/// Leading meta-line icons, drawn in order (spec order is sync status,
/// then shared). When this contains [NooSyncStatus.error] the meta text
/// turns `danger`.
final List<NooSyncStatus> statuses;
/// Shows the accent-text star before the overflow button.
final bool favorite;
/// Screen-specific trailing icon (e.g. `rotate-ccw` in Trash), placed
/// after the favorite star and before the overflow button.
final Widget? trailing;
/// `ellipsis` on iOS, `ellipsis-vertical` everywhere else.
final bool iosStyle;
final bool selected;
/// Real image/video thumbnail for the tile; see [NooFileTile.thumbnail].
final Widget? thumbnail;
final VoidCallback? onTap;
final VoidCallback? onLongPress;
/// Overflow menu tap. The button is hidden when null.
final VoidCallback? onMore;
const NooFileRow({
super.key,
required this.kind,
required this.name,
this.size,
this.modified,
this.meta,
this.statuses = const [],
this.favorite = false,
this.trailing,
this.iosStyle = false,
this.selected = false,
this.thumbnail,
this.onTap,
this.onLongPress,
this.onMore,
});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
final metaText =
meta ??
[
size,
modified,
].whereType<String>().where((s) => s.isNotEmpty).join(' · ');
final hasError = statuses.contains(NooSyncStatus.error);
return Material(
color: selected ? colors.accentSoft : colors.surface,
child: InkWell(
onTap: onTap,
onLongPress: onLongPress,
child: SizedBox(
height: NooSizes.rowMobile,
child: Padding(
padding: EdgeInsets.only(
left: NooSpace.md,
right: onMore != null ? NooSpace.xxs : NooSpace.md,
),
child: Row(
children: [
NooFileTile(kind: kind, thumbnail: thumbnail),
const SizedBox(width: NooSpace.sm),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: NooText.bodyL.copyWith(
fontWeight: FontWeight.w500,
color: colors.fg1,
),
),
if (statuses.isNotEmpty || metaText.isNotEmpty) ...[
const SizedBox(height: NooSpace.xxs),
Row(
children: [
for (final s in statuses) ...[
NooStatusIcon(s),
const SizedBox(width: NooSpace.xxs),
],
Expanded(
child: Text(
metaText,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: NooText.meta.copyWith(
color: hasError ? colors.danger : colors.fg3,
),
),
),
],
),
],
],
),
),
if (favorite) ...[
const SizedBox(width: NooSpace.xs),
Icon(LucideIcons.star, size: 18, color: colors.accentText),
],
if (trailing != null) ...[
const SizedBox(width: NooSpace.xs),
IconTheme.merge(
data: IconThemeData(size: 18, color: colors.fg3),
child: trailing!,
),
],
if (onMore != null)
NooOverflowButton(iosStyle: iosStyle, onTap: onMore),
],
),
),
),
),
);
}
}
/// The 40px round overflow ("more") hit target used at the end of file
/// rows - `ellipsis` when [iosStyle], `ellipsis-vertical` otherwise, 20px
/// in fg-3.
class NooOverflowButton extends StatelessWidget {
final bool iosStyle;
final double size;
final VoidCallback? onTap;
const NooOverflowButton({
super.key,
this.iosStyle = false,
this.size = 40,
this.onTap,
});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
return SizedBox.square(
dimension: size,
child: Material(
type: MaterialType.transparency,
shape: const CircleBorder(),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
child: Icon(
iosStyle ? LucideIcons.ellipsis : LucideIcons.ellipsisVertical,
size: size >= 40 ? 20 : 18,
color: colors.fg3,
),
),
),
);
}
}
+246
View File
@@ -0,0 +1,246 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../../theme/design_tokens.dart';
import 'noo_file_kind.dart';
import 'noo_file_row.dart';
import 'noo_file_tile.dart';
import 'noo_status_icon.dart';
/// Fixed widths of the desktop file table's grid
/// (`minmax(0,1fr) 180px 160px 120px`, `DESIGN_SYSTEM.md` 2 "File row").
/// The first column takes the remaining width.
const double _col2Width = 180;
const double _col3Width = 160;
const double _col4Width = 120;
/// Inset shared by header and rows so their columns line up; also the
/// padding inside a selected row's r12 fill.
const double _tableInset = NooSpace.sm;
/// Lays out the four table columns. Shared by [NooFileTableHeader] and
/// [NooFileTableRow] so the grid can't drift between them.
class _TableColumns extends StatelessWidget {
final Widget name;
final Widget col2;
final Widget col3;
final Widget col4;
const _TableColumns({
required this.name,
required this.col2,
required this.col3,
required this.col4,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: _tableInset),
child: Row(
children: [
Expanded(child: name),
SizedBox(width: _col2Width, child: col2),
SizedBox(width: _col3Width, child: col3),
SizedBox(width: _col4Width, child: col4),
],
),
);
}
}
/// Desktop file table header: 36px, 12/600 fg-3 labels, and a sort arrow
/// on the active column. The fourth (status/overflow) column is unlabelled.
class NooFileTableHeader extends StatelessWidget {
/// Label for the flexible Name column.
final String nameLabel;
/// Labels for the 180px and 160px data columns, e.g. "Modified", "Size".
final String col2Label;
final String col3Label;
/// Active sort column - 0 = name, 1 = col2, 2 = col3 - or null for none.
final int? sortColumn;
final bool sortAscending;
/// Called with the tapped column's index; header cells aren't tappable
/// when null.
final ValueChanged<int>? onSort;
const NooFileTableHeader({
super.key,
this.nameLabel = 'Name',
required this.col2Label,
required this.col3Label,
this.sortColumn,
this.sortAscending = true,
this.onSort,
});
Widget _cell(BuildContext context, int index) {
final colors = context.nooColors;
final style = NooText.label.copyWith(
fontSize: 12,
height: 1,
color: colors.fg3,
);
final active = sortColumn == index;
final content = Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Text(
[nameLabel, col2Label, col3Label][index],
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: style,
),
),
if (active) ...[
const SizedBox(width: NooSpace.xxs),
Icon(
sortAscending ? LucideIcons.arrowUp : LucideIcons.arrowDown,
size: 12,
color: colors.fg3,
),
],
],
);
return Align(
alignment: Alignment.centerLeft,
child: onSort == null
? content
: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => onSort!(index),
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: content,
),
),
);
}
@override
Widget build(BuildContext context) {
return SizedBox(
height: 36,
child: _TableColumns(
name: _cell(context, 0),
col2: _cell(context, 1),
col3: _cell(context, 2),
col4: const SizedBox.shrink(),
),
);
}
}
/// Desktop file table row: 52px, 30px tile + name (14/500) in the flexible
/// column, two meta-style data columns, then status icons and the overflow
/// button right-aligned in the 120px column. Selected rows get an
/// accent-soft r12 fill; otherwise the row is transparent over the pane.
class NooFileTableRow extends StatelessWidget {
final NooFileKind kind;
final String name;
/// Second and third column contents, e.g. modified time and size.
final String? col2;
final String? col3;
/// Status icons in the last column, drawn before the favorite star.
final List<NooSyncStatus> statuses;
final bool favorite;
final bool selected;
final Widget? thumbnail;
final VoidCallback? onTap;
final VoidCallback? onDoubleTap;
final VoidCallback? onSecondaryTap;
final VoidCallback? onLongPress;
/// Overflow menu tap. The button is hidden when null.
final VoidCallback? onMore;
const NooFileTableRow({
super.key,
required this.kind,
required this.name,
this.col2,
this.col3,
this.statuses = const [],
this.favorite = false,
this.selected = false,
this.thumbnail,
this.onTap,
this.onDoubleTap,
this.onSecondaryTap,
this.onLongPress,
this.onMore,
});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
final metaStyle = NooText.meta.copyWith(color: colors.fg3);
Widget dataCell(String? text) => Text(
text ?? '',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: metaStyle,
);
return Material(
color: selected ? colors.accentSoft : Colors.transparent,
borderRadius: BorderRadius.circular(NooRadii.sidebarItem),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
onDoubleTap: onDoubleTap,
onSecondaryTap: onSecondaryTap,
onLongPress: onLongPress,
child: SizedBox(
height: NooSizes.rowDesktop,
child: _TableColumns(
name: Row(
children: [
NooFileTile(
kind: kind,
size: NooFileTileSize.desktop,
thumbnail: thumbnail,
),
const SizedBox(width: NooSpace.sm),
Expanded(
child: Text(
name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: NooText.body.copyWith(
fontSize: 14,
fontWeight: FontWeight.w500,
color: colors.fg1,
),
),
),
const SizedBox(width: NooSpace.md),
],
),
col2: dataCell(col2),
col3: dataCell(col3),
col4: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
for (final s in statuses) ...[
NooStatusIcon(s),
const SizedBox(width: 6),
],
if (favorite) ...[
Icon(LucideIcons.star, size: 14, color: colors.accentText),
const SizedBox(width: 6),
],
if (onMore != null) NooOverflowButton(size: 32, onTap: onMore),
],
),
),
),
),
);
}
}

Some files were not shown because too many files have changed in this diff Show More