74 Commits
Author SHA1 Message Date
Ayushya Amitabh f6407c447e Fix bottom avatar popup account ordering on Android
Build APK / build (push) Successful in 5m25s
2026-10-07 03:14:14 -04:00
Ayushya Amitabh ce2273c9a6 Refine iOS navigation, media controls, and background sync
Build APK / build (push) Successful in 5m50s
2026-10-06 23:18:50 -04:00
Ayushya AmitabhandClaude Sonnet 5.5 c5b46fd493 Avatar menu: same side inset as the Files list rows
The card was inset 16px from each screen edge; the list rows are 12px, so it
read as narrower. Now NooSpace.sm, with a test on the account row's width.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 21:02:21 -04:00
Ayushya AmitabhandClaude Sonnet 5.5 8805bafb97 iOS: device sync (Swift port of the Android engine) behind the sync_service channel
PROPFIND walk / diff / GET / PUT mirror with conflicts, per-account state,
BGTaskScheduler background runs, and conflict notifications with Keep local /
Use server. Reviewed by Codex against the Kotlin original; its data-loss
findings are fixed and each has a regression test (fake-server end-to-end
tests, 68 Swift tests total): truncated/unusable PROPFIND answers are never a
manifest, unreadable state aborts the run, server paths can't leave the mirror,
case/Unicode collisions are skipped, atomic downloads, a locally edited file
deleted on the server is kept, '/' scope matching, serialised removal,
tracked/cancellable background runs, per-account conflicts, and sign-out vs
background-off credentials (cancel gains a 'forget' flag).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 21:01:08 -04:00
Ayushya AmitabhandClaude Sonnet 5.5 1634d34341 Viewer: round icon buttons with backgrounds; plain text follows Edit -> Save too
Edit/Preview and Save are now compact (36px) icon-only NooButtons - primary when
alone, Edit/Preview secondary beside a primary Save, a spinner circle while
saving. Plain text opens read-only like Markdown and gets the same toggle.
NooButton gains an opt-in iconOnly mode.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 20:50:26 -04:00
Ayushya AmitabhandClaude Sonnet 5.5 5fa20ff3e9 Media viewer: Edit/Preview and Save move to the top bar
A TextPreviewController (owned by the viewer, bound by MediaTextPreview)
mirrors the editing/dirty/saving state out so the top bar draws the buttons;
Save shows a spinner while saving. Located and tested via a Codex consult
(its end-to-end widget test, with the provider fixed and the saving state
adapted); the controller design replaces its builder-wrapping patch.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 20:38:19 -04:00
Ayushya AmitabhandClaude Sonnet 5.5 4ab6c5d85c Share sheet: ask to unlock on every gated action, like the app
The sheet remembered one unlock for its whole lifetime, so later account picks
and hidden toggles skipped Face ID. Each switch / each turning-on of hidden
folders now prompts again; one action needing both is a single prompt.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 20:24:20 -04:00
Ayushya AmitabhandClaude Sonnet 5.5 02d9d799a6 iOS: authenticate each request by its own header only (no cookies/credential cache)
Two accounts can share one server; the Share Extension's folder listing and
the upload sessions now ignore cookies and stored credentials so one account's
session can never answer for another. Logs each PROPFIND (user, path, status).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 20:18:20 -04:00
Ayushya AmitabhandClaude Sonnet 5.5 02d612bfec Share sheet: Hidden and Storage toggles, plus diagnostics for the missing accounts
Two menu chips start at the account's app settings (hidden filter, storage
scope - now published per account), hidden-on asks for the app's unlock, and
external storage is detected via nc:mount-type incl. everything under a
mount. Adds logging for which accounts get published and why one is skipped.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 20:15:19 -04:00
Ayushya AmitabhandClaude Sonnet 5.5 22a6602f08 Security locks work independently; other-account avatars flush right in the avatar menu
Login lock, lock account switching and lock hidden files no longer depend on
each other: passGate prompts on its own flag, the two sub-locks can be set
with login lock off, and disabling login lock leaves them alone. Turning any
lock on or off asks for device auth (on also checks the device can). The iOS
Share Extension's unlock rules follow. The avatar dropdown's additional
accounts lose the 44px spacer so their avatars sit at the right edge.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 20:07:28 -04:00
Ayushya AmitabhandClaude Sonnet 5.5 27c8ac12f2 Share Extension: remove the legacy single-account keychain item on clear
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 19:59:38 -04:00
Ayushya AmitabhandClaude Sonnet 5.5 a37524e959 Share Extension: account picker first, hidden folders follow the app's filter + unlock
The app now publishes every uploadable account, the active one, the three
lock settings and each account's own hidden-files filter (ShareAccountSync).
The sheet lists accounts first when there are several, asks for unlock to use
a non-active account (login lock + account-switching lock), and shows hidden
folders per the account's app setting behind the hidden-files unlock.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 19:59:29 -04:00
Ayushya AmitabhandClaude Sonnet 5.5 0cda4a1241 Frosted bottom bar: adjustable blur and opacity with Less/Default/More presets
Settings > Appearance shows a preset control plus Blur and Opacity sliders
(new NooSlider) while frosted glass is on; values persist and feed
NooBottomBar.frostedBlur/frostedOpacity. Default matches the old fixed look.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 19:46:54 -04:00
Ayushya AmitabhandClaude Sonnet 5.5 0c063c1fb4 iOS transfers: 'Uploading...' then 'Uploaded' notification, and count share uploads
The Share Extension's session had no delegate, so a fast upload finished
unseen and the invalidated session was discarded before the app could hear of
it - no summary notification. Results are now handled by a shared delegate in
whichever process is alive, started/finished notifications replace each
other in place, and the app refreshes the folder the extension uploaded to.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 19:44:19 -04:00
Ayushya AmitabhandClaude Sonnet 5.5 afc9b90963 iOS Share Extension: pick the destination inside the share sheet
SwiftUI folder picker in the extension (WebDAV PROPFIND), account shared via
a Keychain group, upload on a background session that outlives the extension
and is finished by the app. The inbox + notification hand-off remains as the
fallback (no account / 'choose later'). Shared code moves to ios/Shared.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 19:36:04 -04:00
Ayushya AmitabhandClaude Sonnet 5.5 525504bc47 iOS: Share Extension (share to Noo) via an App Group inbox
ShareExtension target copies shared files into group.dev.ayushya.noo and
leaves a manifest; the app consumes it on launch/activation through the
existing share_intent channel into the destination picker. Extension is
embedded before Flutter's script phases; versions follow FLUTTER_BUILD_*.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 19:23:37 -04:00
Ayushya AmitabhandClaude Sonnet 5.5 8b86aa8f34 iOS: show transfer notifications as banners while the app is open
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 19:15:59 -04:00
Ayushya AmitabhandClaude Sonnet 5.5 469e57cca7 iOS: native upload and download services (background URLSession)
Swift implementation of the upload_service/download_service channels:
background PUT/GET against the account's WebDAV root, one summary local
notification per batch, Downloads visible in the Files app. Pure helpers
covered by RunnerTests.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 19:12:15 -04:00
Ayushya AmitabhandClaude Sonnet 5.5 d008185f98 iOS: degrade gracefully where the Android-only native services are missing
Share/pick/sync calls and streams no longer throw MissingPluginException at
startup; upload/download/sync-now show 'X isn't available on this platform
yet'. Offline/sync mirror dir no longer uses the Android-only external dir.
Info.plist: Face ID and photo library usage strings.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 19:06:19 -04:00
Ayushya AmitabhandClaude Sonnet 5.5 813c613249 Dynamic color: keep card surfaces lighter than the page
Build APK / build (push) Successful in 5m39s
In light mode surfaceContainerLow is slightly darker than the OS surface bg
maps to, so the selection bar and other cards blended into the page. Use
surfaceContainerLowest (light) / surfaceContainer (dark) for NooColors.surface.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 22:53:37 -04:00
Ayushya AmitabhandClaude Sonnet 5.5 c0a67f3efe iOS: replace default Flutter app icons with the Noo icon
Generated from assets/icon/app_icon.png, opaque RGB at every size in the set.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 22:42:30 -04:00
Ayushya AmitabhandClaude Sonnet 5.5 5fbac333d5 Filter sheets: 'Type' label above the type control; shorten 'All + ...' to 'All'
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 22:38:37 -04:00
Ayushya AmitabhandClaude Sonnet 5.5 3c60f8c709 Filters: three-way hidden files and external storage
Hidden: hide / only hidden / all + hidden. External: cloud only / only
external / all + external; with 'all' Files shows the external items in a
collapsible section. Old show-hidden bool prefs are still read as fallback.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 22:34:11 -04:00
Ayushya AmitabhandClaude Sonnet 5.5 944b87b054 Bottom bar: add frosted glass option (Settings > Appearance)
Translucent fill over a backdrop blur, both platforms and both bar styles.
Frosted bars draw over an extended body, so the shell's extendBody and the
tab clearance now key off NooBottomBar.drawsBehindBody.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 22:16:27 -04:00
Ayushya AmitabhandClaude Sonnet 5.5 d6448984d9 Files: don't context.watch from the item tap handler
watch outside build asserts in debug builds, so taps on rows silently did
nothing in debug (release strips the assert, which hid it on Android).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 22:13:45 -04:00
Ayushya AmitabhandClaude Sonnet 5.5 52abd16983 Floating bottom bar: use Android row (icon-only idle tabs) on iOS too
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 21:36:35 -04:00
Ayushya AmitabhandClaude Sonnet 5.5 cb66b4a0ad iOS: raise deployment target to 15.0 (required by current Flutter)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 20:38:17 -04:00
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
237 changed files with 33885 additions and 7489 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.
+196 -49
View File
@@ -13,20 +13,34 @@ lib/
# ItemOperations, a plain cross-domain coordinator
services/ # network/IO: NextcloudService, LoginFlowService,
# 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,
# SharesView, RecentView, ActivityView, SearchView,
# AccountView, LoginView, FileViewerScreen,
# ShareUploadView, LockScreenView)
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
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
`Navigator`). `widgets/` files are building blocks used by more than one
view (or complex enough to warrant their own file) — nothing in `widgets/`
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
@@ -43,15 +57,16 @@ new provider instance):
saved-accounts list, which one is active, `sessionGeneration` — see
below), auth/login-flow state (`isLoggedIn`, `isRestoringSession`,
`loginFlowStatus`), the active `NextcloudService` instance, and login lock
(`loginLockEnabled`/`lockAccountSwitching`/`lockHiddenFiles`/
`needsUnlock`/`passGate`). Exposes `addAccountClearedListener`/
(`loginLockEnabled`/`lockAccountSwitching`/`lockHiddenFiles` - three
independent locks - plus `needsUnlock`/`passGate`). Exposes `addAccountClearedListener`/
`addAccountActivatedListener` (plain `List<VoidCallback>`) so sibling
controllers — constructed after `SessionController` and unable to hold a
forward reference to it — can react to login/logout/account-switch
without a circular dependency.
- [`SettingsController`](../../lib/providers/settings_controller.dart) —
global UI prefs independent of login state: theme mode/seed color/dynamic
color, bottom-bar opacity/blur, tap-tab-to-scroll-top, seek bar style, tab
color, bottom-bar opacity/blur, avatar and selection action-bar positions,
tap-tab-to-scroll-top, seek bar style, tab
order/visibility/default (`requestedTab`/`requestTab`/
`consumeRequestedTab`), swipe actions.
- [`FilesController`](../../lib/providers/files_controller.dart) — the
@@ -100,8 +115,9 @@ new provider instance):
mirror, not a second view. `offline` only swaps the data source, reads
images from the local file (`localFileFor`) instead of a server preview,
and turns off what needs the server: selection and bulk actions, swipe
actions, sync badges, "+" (replaced by "Manage synced folders"), pick
mode. The two `FilesView`s are keyed in `buildAppTabView` so the
actions, per-item sync status icons, "+" (replaced by "Manage synced
folders"), pick mode. The two `FilesView`s are keyed in `buildAppTabView`
so the
`IndexedStack` never hands one's State to the other.
- [`OfflineController`](../../lib/providers/offline_controller.dart) — the
Files tab's folder listing read from local storage instead of the
@@ -138,6 +154,42 @@ new provider instance):
(shares/activity/versions/restore) — stateless, so they don't need a
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
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
@@ -175,15 +227,18 @@ directly, which call `NextcloudService`/`LoginFlowService`/`AccountStore`/
- else `session.needsUnlock` → `LockScreenView` (login lock — see above)
- else `MainShellView`
`MainShellView` is a bottom-nav `IndexedStack` over up to 8 tabs — Files,
Photos, Favorites, Activity, Trash, Shares, Recent, Offline — user-configurable
`MainShellView` is an `IndexedStack` over up to 8 tabs — Files, Photos,
Favorites, Activity, Trash, Shares, Recent, Offline — user-configurable
(order, visibility up to `maxVisibleTabs`, default tab) via
`AppTab`/`SettingsController` and rendered through `buildAppTabView`
(`widgets/app_tab_view_builder.dart`). `maxVisibleTabs` (5) is less than the
total tab count, and `SettingsController._enforceMaxVisibleTabs` already
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
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
`IndexedStack`'s built-but-hidden trees) and tapping the already-active tab
scrolls it back to top (`tapTabToScrollTop` setting). `MainShellView` also
@@ -206,40 +261,116 @@ only two that make sense as external "choose a file" sources. The same
override mechanism restricts the visible tabs to just Offline while
`ConnectivityController.isOffline` - see `server.md`'s "Working offline"
section for the full story, including why session restore itself has to
avoid making a network request in that case. `MoreTabsButton` hides
entirely in both cases (picking, offline) for the same reason - there's
nothing it could usefully open.
avoid making a network request in that case. The drawer/sidebar's "More"
section is empty in both cases (picking, offline) for the same reason -
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
its first mount (`_maybeRequestNotificationPermission`, gated by a plain
`shared_preferences` flag so it only ever asks once, not on every
launch): a plain-language `AlertDialog` explaining why Noo wants it
(upload/download progress notifications - see `ShareUploadService.kt`/
`DownloadService.kt`) before the OS's own `permission_handler`-driven
`Permission.notification.request()`, since the bare system prompt gives
no context on its own.
launch): a plain-language dialog (`showNooDialog`, two `NooButton`s)
explaining why Noo wants it (upload/download progress notifications - see
`ShareUploadService.kt`/`DownloadService.kt`) before the OS's own
`permission_handler`-driven `Permission.notification.request()`, since the
bare system prompt gives no context on its own.
`FloatingBottomNavBar` (`widgets/floating_bottom_bar.dart`) wraps its
pill's item row in a horizontal `SingleChildScrollView` rather than a
plain `Row`, so a wide selected-item label plus several icon-only tabs
scrolls instead of overflowing on narrower screens.
`MainShellView` builds its chrome from the Noo nav kit
(`widgets/noo/nav/`) and switches between two layouts on
`NooLayout.isDesktop` (width >= 900dp *and* shortest side >= 600dp, so a
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
picker, pushed rather than a tab - see below), share
[`SyncedHeaderScaffold`](../../lib/widgets/synced_header_scaffold.dart) — a
- **Mobile:** `AppTopBar` (`widgets/app_top_bar.dart`) wraps `NooTopBar`
for *every* tab (previously only Files had shell-level top chrome, with
the rest building their own via `SyncedHeaderScaffold`) - iOS gets a
large title with Upload and the top account avatar on the same row,
and an inline search field below; Android
gets a compact title row with `search`/avatar actions, relying on an
extended `NooFab` instead of a top-bar upload button. Both use the same
size preference: Auto expands on Files/Photos and collapses elsewhere,
Mini always collapses, and Expanded always shows Upload. Rather than one
shared instance in `Scaffold.appBar`,
`MainShellView` builds a separate `AppTopBar` per tab (labelled for that
tab) and each tab plants its own as the first sliver in its own
`CustomScrollView` (`topBarSliver` in `widgets/tabs/tab_state_slivers.dart`,
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. `AvatarNavigationHost` wraps the mobile scaffold to animate a
card from the top or bottom avatar; `AvatarNavigationBody` moves only
content for the top popup. The bottom popup overlays content. Floating
mode moves the avatar into the popup and hands its satellite slot to
Search; attached mode keeps the avatar and Search in the full-width bar
and expands the popup upward from it. Position is a global setting.
See `styling.md` for motion and dismissal behavior.
- **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
Photos-style) and a classic Material refresh spinner shown during a
pull-triggered sync. `ShareUploadView` mirrors the Files tab's own
controls-row/breadcrumbs sticky header almost exactly, so arriving via
another app's "Share to..." sheet still lands on the same top chrome
instead of a plain `AppBar` - its `actions` are `[ProfileAvatarButton()]`
only (no `MoreTabsButton`: there's nowhere useful for it to go mid-upload,
since jumping to another tab would abandon the destination picker);
backing out is the system back gesture/button, not a bespoke close icon in
the app bar. Its bottom action - "Upload to
{folder}" - and the uploading-file-name summary above it (single line,
auto-scrolling via `MarqueeTitle` if it doesn't fit) live together in one
rounded-top, elevated `Material` bar as `bottomNavigationBar`, reading as a
sheet peeking up from the bottom edge rather than a plain flat bar.
pull-triggered sync. Now that every tab is rebuilt on Noo, each renders its
own content directly in a plain `RefreshIndicator` + `CustomScrollView`
instead (`ColoredBox(colors.bg)` background, no shared header widget);
device-sync status shows per-row (`NooFileRow`/`NooFileTableRow`'s
`NooStatusIcon`s) or in the Offline tab's `NooSummaryCard`, not a shared
pinned chip. `SyncedHeaderScaffold` is now unused - the last two screens on
it, `ShareUploadView` (the share-to-upload destination picker, pushed
rather than a tab - see below) and `MoveCopyDestinationPicker`, are
rebuilt too: both are a `Scaffold` with a `NooTopBar`+`NooTopBarBack`
(mobile) / `NooToolbar` (desktop) top bar, a noo-styled
[`Breadcrumbs`](../../lib/widgets/breadcrumbs.dart) row + folder list
(`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
shared with `FileViewerScreen`'s title - falls back to a plain ellipsized
`Text` when the content already fits, so short text never marquees.
@@ -259,16 +390,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
reusing the shared state would strand that folder's `pathStack` under it;
its local state means popping back always lands the user exactly where
they were, untouched.
`SearchView`, `AccountView` (Settings), the file-details sheet, and the
share sheet are pushed on top via
`Navigator`/`showModalBottomSheet`/`showGradualBottomSheet` rather than
being tabs. `ProfileAvatarButton` (top-right on every tab) opens Settings on
tap and cycles between saved accounts on a vertical swipe.
they were, untouched. Unlike `ShareUploadView`, it keeps `MoreTabsButton`
in its top bar: that button pushes a hidden tab as its own stacked screen
rather than jumping the shell there, so it doesn't abandon this picker the
way jumping tabs would. Its bottom bar shows a "Moving/Copying N item(s)"
summary line (replaced by a danger-colored warning when the current folder
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
toolbar into `SyncedHeaderScaffold`'s `selectionBar` param rather than
rendering it as a second sliver app bar inside their own content: while
non-null, it fully takes over the pinned top bar in place of the
sync-status chip/`actions`/pull-to-reveal quota panel, so selecting reads
as replacing the whole top chrome rather than adding a strip beneath it.
Files and Photos (the two tabs with multi-select) render their selection
bar as a `pinned: true` sliver at the top of their own `CustomScrollView`,
swapped in for the controls row/type-chips row while `_isSelecting` -
selecting reads as replacing that row in place, not adding a strip
beneath it. (An earlier version of this routed the selection bar through
`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
+292 -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
file exist because WebDAV responses use RFC 1123 dates and either bare
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,
file versions) goes through Nextcloud's OCS APIs (`/ocs/v2.php/...`), JSON
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
everything the Kotlin side needs (the pre-built `Authorization` header
from `NextcloudService.authHeaders`, not the raw password) as Intent
extras, a one-way handoff with no channel back to Dart afterward. Keep
the two upload implementations in sync manually if upload semantics
change. Progress/cancellation is entirely notification-driven (one
ongoing, updatable notification for the whole batch; its Cancel action
re-delivers an Intent to the same running service instance, which an
`AtomicBoolean` the copy/upload loops poll) - there's no plumbing back to
the Dart UI, by design, since the app may not even be running.
extras, a one-way handoff for the start of the upload - the Kotlin side
never asks Dart anything mid-upload, since the app may not even be
running by then. Keep the two upload implementations in sync manually if
upload semantics change. Progress/cancellation is entirely notification-
driven (one ongoing, updatable notification for the whole batch; its
Cancel action re-delivers an Intent to the same running service instance,
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)
@@ -776,6 +798,269 @@ plugin's own manifest via merge, but kept explicit here too).
own requirement) via `maxOf(24, flutter.minSdkVersion)` rather than trusting
Flutter's own default to already be high enough.
**The three locks are independent** (Settings -> Security;
`SessionController`): `loginLockEnabled` (unlock to open the app - the only one
`needsUnlock`/the lock screen look at), `lockAccountSwitching` (unlock to
switch accounts) and `lockHiddenFiles` (unlock to turn on showing hidden
files). Each gate works with the others off - `passGate(gate, reason)`
prompts whenever its own `gate` is on, whether or not login lock is. Turning
any of them on **or off** needs a successful `AppLockService.authenticate`
(on also needs `isDeviceSupported`, so a gate nobody can pass can't be set;
off needs it so someone with a momentarily unlocked phone can't remove it),
and `disableLoginLock` leaves the other two untouched. The old master/sub-toggle
dependency is gone. Tests replace the prompt through
`AppLockService.debugAuthenticate`/`debugIsDeviceSupported`.
`isRestoringSession` still gates the splash screen until the above resolves
— see `standards.md` for why widget tests must mock both storage channels
rather than relying on this async path throwing naturally.
- `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.
## Native services on iOS (in progress)
The five `dev.ayushya.noo/*` channels (`share_intent`, `pick_intent`,
`upload_service`, `download_service`, `sync_service`, plus their status
`EventChannel`s) were written for Android in Kotlin. iOS implements
`upload_service` (+ its status stream), `download_service` and
`share_intent` and `sync_service` so far (`ios/Runner/Native/` +
`ios/ShareExtension/`, see below); for `pick_intent` there's no handler, so
calls throw `MissingPluginException`. `lib/services/native_channel.dart`
makes that safe until each one is built in Swift:
- `invokeIfAvailable` - a missing implementation returns null. For cold-start
checks (`getInitialShare`, `getPickRequest`, `getSyncStatus`) and
scheduling/cleanup (`reschedule`, `cancel`, `removeLocalSync`,
`finishPick`/`cancelPick`).
- `invokeOrExplain` - throws `NativeServiceUnavailable("<Feature>")`, whose
message callers already show in a snackbar ("Uploading isn't available on
this platform yet."). For things the user just asked for: `startUpload`,
`startDownload`, `syncNow`, `resolveConflict`.
- `quietEvents` - wraps an `EventChannel` so the missing-implementation error
is dropped instead of surfacing as an unhandled stream error.
When a channel gets a real iOS implementation nothing changes on the Dart
side - the helpers only act on `MissingPluginException`.
`SyncService.baseDirectory()` is where the `sync/<accountId>/...` mirror
lives: `getExternalStorageDirectory()` on Android (what `SyncEngine.kt`
writes), the app support directory elsewhere, because that call throws on
iOS. `OfflineController` and `localSyncedFilePath` both go through it.
### iOS upload/download (`ios/Runner/Native/`)
- `NativeServices.swift` registers the channels from
`AppDelegate.didInitializeImplicitFlutterEngine` (via
`engineBridge.applicationRegistrar.messenger()`) and forwards
`startUpload`/`startDownload` to `TransferManager`; a batch's completion
goes back over `upload_service/status` so `FilesController` refreshes the
folder like on Android.
- `TransferManager.swift` runs everything on one *background* `URLSession`
(`dev.ayushya.noo.transfers`, `sessionSendsLaunchEvents`), so transfers
finish after the app is suspended/closed; `AppDelegate` hands the system's
`handleEventsForBackgroundURLSession` completion handler to it and calls
`reconnect()` at launch. Each task carries its info in `taskDescription`
and its batch's running totals live in `UserDefaults`, so the one summary
local notification ("Uploaded 3 files") still fires after a relaunch.
- Uploads: one `PUT` per file to `<server>/remote.php/dav/files/<user>/...`
from a staged copy in Caches (no chunking, so very large files are bound by
the server's single-request limit; an existing file with the same name is
overwritten). Sends `X-OC-Mtime`. Downloads: `GET` into
`Documents/Downloads`, with `UIFileSharingEnabled` +
`LSSupportsOpeningDocumentsInPlace` set so it shows in the Files app under
"On My iPhone > Noo"; a name clash becomes `a (1).txt`.
- Notifications (`ios/Shared/TransferNotifications.swift`): "Uploading 3
files..." when a batch starts, replaced in place (same request id =
`transfer.<batch>`) by "Uploaded 3 files" when it ends. iOS can't show live
byte progress from a background session the way Android's foreground
service does (a Live Activity would, but needs a widget extension), and
there's no cancel yet. The Dart snackbar text still says "see the
notification for progress".
- `WebDAV.swift` (URL building) and `LocalFiles.uniqueURL` are pure and
covered by `ios/RunnerTests` (`xcodebuild test -workspace
ios/Runner.xcworkspace -scheme Runner -destination 'platform=iOS
Simulator,id=<udid>'`).
### iOS Share Extension (`ios/ShareExtension/`, `ios/Shared/`)
"Share to Noo" from any app's share sheet. A Share Extension can't open its
host app (`extensionContext.open` is Today-widget-only), so - like
Reminders/Notes - the destination is picked *inside* the sheet:
1. `ShareViewController` copies each shared file (`NSItemProvider.
loadFileRepresentation`, public.item) into the App Group container
(`group.dev.ayushya.noo`, `SharedInbox/<batch>/`) and hosts the SwiftUI
`SharePickerView` (`ShareModel` holds its state).
2. It reads the accounts from the shared Keychain (`SharedAccountStore` ->
`SharedAccounts`) and lists folders over WebDAV (`DavClient` `PROPFIND`
Depth 1, parsed by `DavFolderParser`). With **several accounts an account
list comes first** (the app's active one is marked "Active"; "Change
account" returns to it); with one it goes straight to its folders. The
Dart side keeps the keychain data current: `ShareAccountSync`
(`lib/services/share_account_service.dart`, started from the shell's
`initState`) republishes - through the native `share_account` channel's
`setAccounts` - when an account becomes ready or when its change
signature moves: the account list/active account, the three lock
settings, or the active account's hidden-files filter / storage scope;
sign-out calls
`clearAccount`. It publishes every saved account that has a stored
password and isn't signed out, each with its **own Files hidden-files
filter** (`FilesController.savedHiddenFilter`).
- **Toggles**: the folder list has two menu chips, **Hidden** (hide / only
hidden / all) and **Storage** (cloud only / only external / all +
external), each starting at the chosen account's own app setting
(`hiddenFilter`, `storageScope` published per account) and changeable for
this share only - nothing is written back. The list is fetched once per
folder (`DavClient.listFolders` returns everything, hidden and external
included, with `nc:mount-type` requested) and filtered in memory
(`ShareModel.visibleFolders`), so flipping a toggle just returns to the
top. Hidden uses the app's rule (`HiddenFilter`): a folder is hidden when
it *or any ancestor* starts with a dot. External storage: only a mount's
*root* has `mount-type=external`, so `ShareModel` remembers the roots it
has seen and treats anything under one as external too (the app's own
filter doesn't, which empties its list inside a mount). "All" lists
external folders in their own "External storage" group.
- **Hidden unlock**: turning hidden folders *on* (from `hide`), or opening
the sheet with the account's filter already not `hide`, asks for
Face ID/passcode when "lock hidden files" is on (`DeviceAuth`, the
`.deviceOwnerAuthentication` policy - biometrics with passcode fallback,
like `local_auth` with `biometricOnly: false`); cancelling keeps them
hidden, with a note. Switching `only` <-> `all`, or back to `hide`, never
asks.
- **Account switching**: choosing any account other than the app's
active one asks for the same unlock when "lock account switching" is on.
**Every gated action prompts every time**, exactly like the app - no
unlock is remembered for the sheet (an earlier version cached one, so
later account picks / hidden toggles skipped Face ID). The only
merging is within one action: picking another account whose own filter
already shows hidden folders is a single prompt covering both
(`SharedAccounts.unlockNeeds` / `SelectionUnlock`, unit-tested).
Declining the account unlock cancels the pick; declining only the
hidden unlock shows the account with hidden folders hidden.
- Opening the sheet itself is not gated - only these two actions are
(as in the app, where login lock guards launch and these are separate
locks).
3. **Upload** calls `ShareUpload.enqueue`: one `PUT` per file on a *background*
`URLSession` (`dev.ayushya.noo.transfers.share`, with
`sharedContainerIdentifier`) that outlives the extension, and posts
"Uploading N files...". Results go to whichever process is alive:
files that finish while the sheet is still open are counted by the
extension's `ShareUploadDelegate`; the rest by the app, which recreates
that session at launch (`TransferManager.reconnect`) and is relaunched by
the system. Either way `UploadResults.handle` deletes the staged copy,
counts the file in `TransferBatchStore` (batch totals live in the App
Group's `UserDefaults` because the two are different processes) and
posts the "Uploaded N files" summary on the last one; an upload the
extension finished leaves a note (`noteFinishedUpload`) so the app
refreshes that folder on its next activation. **Don't give the
extension's session no delegate** - a fast upload then finishes unseen and
the invalidated session is discarded before the app can ever hear of it
(found the hard way: no notification, no count).
4. Fallbacks: with no account (or the user taps "Choose a folder later in
Noo") the files go into `SharedInbox/pending.json` instead and a local
notification asks the user to open Noo; the app consumes that through
`share_intent` (`getInitialShare` at cold start, `share_intent/new` on
`UIApplication.didBecomeActiveNotification`) into the same Dart
`ShareUploadView` flow as an Android share, and `TransferManager.
startUpload` deletes the inbox copy once staged.
The keychain group is `$(AppIdentifierPrefix)dev.ayushya.noo.shared`
(`keychain-access-groups` in both `.entitlements`); both Info.plists also
carry it as `NooKeychainAccessGroup`, which `SharedAccountStore` reads, so the
two processes always agree on the team-prefixed id. Everything is stored as
JSON in one generic-password item (`kSecAttrAccessibleAfterFirstUnlock`).
`ios/Shared/` (compiled into both targets): `SharedInbox`, `SharedAccount`,
`ShareUpload`, `DavFolders`, `TransferTypes`, `WebDAV`. Everything in it that
isn't UI is unit-tested in `RunnerTests`. The extension's bundle id is
`dev.ayushya.noo.ShareExtension` and its version/build come from the same
`FLUTTER_BUILD_NAME`/`FLUTTER_BUILD_NUMBER` xcconfig as the app (iOS rejects a
mismatch); it's embedded by an "Embed Foundation Extensions" phase placed
*before* Flutter's script phases (after them Xcode reports a dependency
cycle). The simulator honours the App Group and keychain group without a
signing team; a real device needs a team that owns both ids.
### iOS device sync (`ios/Runner/Native/SyncEngine/`)
A Swift port of Android's `SyncEngine.kt`/`SyncWorker.kt`/
`ConflictResolveWorker.kt`/`SyncStatusBus.kt`, behind the same `sync_service`
channel (+ `/status` event stream) so the Dart side is unchanged apart from
`cancelAccount`'s `forget` flag (below). Same rules: only paths the user
synced are mirrored; brand-new local files are never uploaded (only edits to
already-synced files are); a file edited on both sides is a conflict (neither
copy is touched, "Keep local"/"Use server" resolves it); a local deletion is
repaired by re-downloading; the mirror lives at
`<Application Support>/sync/<accountId>/...` (what Dart's
`SyncService.baseDirectory()` reads for the Offline tab, excluded from
backup) with state in `<...>/sync-state/<accountId>/*.json`.
- `SyncDiff` (pure decisions, mirrors `diffFolder`), `DavSyncClient`
(PROPFIND/GET/PUT on a cookie-less ephemeral session), `SyncRunner` (one
account's pass), `SyncStore`, `SyncConfigStore` (Keychain), `SyncCoordinator`
(runs, `BGTaskScheduler`, notifications), and the `registerSync` channel
handlers in `NativeServices.swift`.
- **Safeguards (each has a regression test in `SyncEngineTests`, which drives
the real runner against an in-memory fake Nextcloud):** an unreachable
server, a non-207, or a PROPFIND answer that is truncated, not a
`multistatus`, empty, or has an href outside the account root / with dot
components throws `SyncRemoteUnavailable` and the path is skipped - never
diffed as empty or partial (which would delete local files); unreadable
recorded state makes the run do nothing (`SyncStateUnreadable`) instead of
treating every file as untracked and overwriting local edits; every
server-supplied path is resolved through `LocalFS.resolve` (no escaping the
mirror) and entries that would land on the same local file (case or Unicode
spellings) are skipped, not downloaded over each other; downloads go to a
temp file and replace atomically, never replacing a folder; empty-folder
pruning uses `rmdir` and only on a successful empty read; a root's etag
shortcut marker is only written when the path synced cleanly *and* the state
was saved.
- **Server deletions:** remove the local copy even if edited locally, matching
Android.
- **Deliberate differences from Android:** after our own upload the file's
etag is re-read so it isn't downloaded back; state is flushed every 20 transfers; conflicts are kept
per account on the status bus; downloads write to a temp file first. No
ongoing progress notification (a run posts a summary, and conflict
notifications with Keep local / Use server actions).
- **Running:** every run - "Sync now", the in-app refresh, and background -
goes through `SyncCoordinator.startRun`: one tracked run per account
(`force` supersedes it, a quiet check leaves it alone), serialised by one
cancellable `AsyncMutex`, tagged with a per-account *generation* so a run
that was only queued never starts for an account that was cancelled or had a
path removed meanwhile. `removeLocalSync` cancels the run and takes the same
mutex, so a finishing run can't write stale state back over it.
- **Background:** `BGAppRefreshTask` + `BGProcessingTask`
(`dev.ayushya.noo.sync.refresh`/`.processing`, `UIBackgroundModes` fetch +
processing, `BGTaskSchedulerPermittedIdentifiers` in Info.plist; handlers
registered before launch finishes). `intervalMinutes` (floored at 15, like
Android) is only the *earliest* start - iOS picks when. The Files cache
settings explain this on iOS; the 15-minute Android warning appears only
on Android for intervals below 15 minutes. Wi-Fi-only is
checked with `NWPathMonitor` (`isExpensive`) since a BGTask can't require
it. An expiring task cancels its runs (they save state as they go) and is
completed exactly once (`OnceGate`). **Not testable in the simulator**
(needs a real device and the Xcode `_simulateLaunchForTaskWithIdentifier`
debugger command).
- **Simulator verification:** a temporary debug control ran the native
background slot and confirmed files update for a non-active saved account.
The control and its channel handler were removed after verification.
`startBackgroundSlot` retains a regression test for periodic eligibility.
OS wake-up delivery and background execution time limits remain unverified.
- **Credentials:** `SyncConfigStore` keeps each account's server, auth header,
paths, interval and notify flag as one Keychain item
(`AfterFirstUnlock`, so a background run works with the phone locked).
Conflict notification actions look the account up there - nothing secret is
in a notification. `sync_service.cancel` takes `forget` (default true:
signed out/removed -> credentials dropped; false: only background sync was
turned off -> kept for manual runs and conflict actions). Dart sends
`forget: false` only for "paths configured but no background interval".
- **Gotcha:** `ios/.gitignore` has `**/*sync/`, which (case-insensitively on
macOS) swallows any directory named `Sync` - hence `SyncEngine/`.
Not built on iOS yet: the picker (File Provider) - see the iOS handoff notes.
+48 -9
View File
@@ -75,6 +75,19 @@ class/method already makes obvious.
never completes) and the app stays on `_SplashView`'s indeterminate
spinner, which makes `pumpAndSettle()` hang until its own timeout instead
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`.
## 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
gitignored keystore file, e.g. `android/app/release-keystore.jks`), then
CI env vars (`RELEASE_KEYSTORE_PATH`/`_PASSWORD`, `RELEASE_KEY_ALIAS`/
`_PASSWORD`, set by `.gitea/workflows/build.yml` from repo secrets), then
falls back to the debug key if neither is configured. As long as the same
dedicated release keystore backs both `key.properties` locally and the
Gitea secrets, local release builds and CI-built release APKs share one
signature, so `adb install -r` works cleanly either way. A local checkout
with no `key.properties` set up falls 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.
`_PASSWORD`, set from the same repo secrets by both `.gitea/workflows/
build.yml`, triggered by `RC*` tags and producing a sideloadable APK, and
`.gitea/workflows/release.yml`, triggered by `Release-*` tags and producing
the `.aab` Play Console wants), then falls back to the debug key if neither
is configured. As long as the same dedicated release keystore backs both
`key.properties` locally and the Gitea secrets, local release builds and
CI-built release APKs share one signature, so `adb install -r` works
cleanly either way. A local checkout with no `key.properties` set up falls
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
@@ -115,3 +131,26 @@ Networking is deliberately split: `package:http` for simple JSON/XML
request-response calls, `package:dio` only where streaming/progress is
needed (downloads). Don't introduce a third HTTP client — extend the
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/`.
+363 -56
View File
@@ -1,5 +1,21 @@
# 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
All theming goes through [`AppTheme`](../../lib/theme/app_theme.dart)
@@ -13,11 +29,20 @@ widgets. Key points:
whole app in `main.dart`. When available and `useDynamicColor` is on, the
OS-provided `ColorScheme` wins over the seed color — always thread both
`dynamicScheme` and `useDynamicColor` through when adding a theme knob.
`NooColors.fromDynamicScheme` keeps `bg` on the OS `surface` but picks
`surface` (cards, selection bar, rows) from the container roles so it
stays lighter than `bg`: `surfaceContainerLowest` (white) in light,
`surfaceContainer` in dark. The dynamic_color package seeds the container
roles from the primary color rather than the OS palette, and in light mode
`surfaceContainerLow` is slightly *darker* than the OS `surface`, which
made cards blend into the page ("the selection bar has no background").
- **Font**: Inter via `google_fonts`, applied through
`GoogleFonts.interTextTheme(...)`. In tests, set
`GoogleFonts.config.allowRuntimeFetching = false` in `setUpAll` — without
it, the font-fetch call to Google's CDN can stall `pumpAndSettle`
indefinitely (see `standards.md`).
`GoogleFonts.interTextTheme(...)`. Font files ship in
`assets/google_fonts/` (Instrument Sans, Schibsted Grotesk) and
`main()` sets `GoogleFonts.config.allowRuntimeFetching = false`, so the
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,
`surfaceContainerLow`.
- **App bars**: flat, not centered, `surface` background.
@@ -34,69 +59,323 @@ widgets. Key points:
and no `scrollbarTheme` override (tried once, explicitly reverted). Don't
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`, `NooSlider`, `NooSearchField`, `NooAvatar`, `NooBadge`, `NooProgressBar` |
| `lists/` | `NooGroupedList`, `NooSettingsRow`, `NooTabOrderRow`, `NooBanner`, `NooInfoNote` (tinted info callout; `SettingsSection(notice:)`), `NooSummaryCard`, `NooSelectionBar` |
| `files/` | `NooFileKind` (spec §1.2 tiles; `NooFileKind.from(name:, mimeType:, isDirectory:)`), `NooFileTile`, `NooStatusIcon`/`NooSyncStatus`, `NooFileRow` (mobile 64px), `NooFileTableHeader`/`NooFileTableRow` (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' filter sheets and Settings controls on surface
rows use the same `surface2` track).
- Any `RefreshIndicator` needs `physics: const AlwaysScrollableScrollPhysics()`
on its scrollable child, or pull-to-refresh silently can't be triggered
once the list is short enough to fit the viewport (empty, or one item) -
every tab's `CustomScrollView` sets this explicitly for exactly that
reason.
## Reusable chrome
- `MediaGridTile` (`lib/widgets/media_grid_tile.dart`): the full-bleed image/video
grid card with name/size scrim, shared by Files (server preview) and Offline
(local `FileImage`, images only - no video frame-extraction plugin, so offline
videos use the plain icon card). `ItemThumbnail` takes an optional `localFile`
for the same offline-image case in list tiles.
- `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).
**Rebuilt on the Noo design system** (Files/Offline, Photos, Favorites,
Recent, Activity, Trash, Shares, Settings, the app shell, lock screen,
login, `ShareUploadView`, `MoveCopyDestinationPicker`, `DetailsSheet` and
`ShareSheet`): these no longer use the pieces below. Their own building
blocks are noted where they matter:
- `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.
- Selection action bars in Files, Photos, and Favorites have a global
Settings → Action bar → Position choice: Top (default) or Bottom,
persisted as `ui_selection_bar_position`. Top stays pinned in the content
header; Bottom uses `SelectionBarOverlay` above navigation (or the content
pane's bottom on desktop), with extra list clearance. The overlay uses
Scaffold's MediaQuery bottom padding so attached/floating/frosted navigation
and safe areas are accounted for without counting their height twice.
The shell hides Android's Upload FAB during selection via view callbacks.
The mobile Action bar settings use one grouped card for Position and
the reorder list, with no nested rounded corners or repeated page title.
Their header backdrop is transparent, so content scrolls behind the inset
`surface` card. A `line` border and `nooDialogShadow` match the floating
bottom bar's solid treatment.
- Hidden files and external storage are each a three-way segmented row
(`HiddenFilesFilterRow`/`StorageScopeRow`, `lib/widgets/filter_mode_rows.dart`,
in both the Files and Photos filter sheets), not toggles:
`HiddenFilesFilter` hide (default) / only / include, and `StorageScope`
cloud (default) / external / all. Files and Photos each persist their own
hidden filter (`ui_hidden_filter`, `ui_hidden_filter_photos`; the old
`ui_show_hidden*` bools are still read as a fallback, `true` → include);
`StorageScope` stays shared. Photos uses the same segmented pill for
Favorites (All / Favorites only). Leaving `hide` is still behind the
hidden-files lock gate. With `StorageScope.all`, Files splits the listing:
regular items first, then a collapsible "External storage" section
(`_ExternalStorageHeader`, local `_externalExpanded` state, default open)
holding the external ones; Photos/Favorites just merge them into one list.
- `lib/widgets/files/file_breadcrumb_row.dart` — the noo-styled breadcrumb
trail Files uses in place of the shared
[`Breadcrumbs`](../../lib/widgets/breadcrumbs.dart) widget. `Breadcrumbs`
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)
— the icon/color/thumbnail treatment for a file or folder, shared by any
screen that lists `NextcloudItem`s the way the Files tab does (currently
`files_view.dart` and `share_upload_view.dart`'s destination picker).
Extend this rather than re-deriving per-type icons/colors at a new call
site.
- [`DetailsHeader`](../../lib/widgets/details/details_sheet.dart) — the
icon-box/name/meta row every per-item bottom sheet opens on
(`DetailsSheet`, the media viewer's collapsed peek state, and
`ShareSheet`). Takes a `padding` override for callers whose own scroll
view already applies horizontal insets (`ShareSheet`'s `ListView`), so it
doesn't get doubled up. Reuse this instead of a bare title `Text` for any
new per-item sheet - a plain title reads as under-designed next to the
other sheets (a real instance: `ShareSheet` used to be just that).
— `getItemIcon`/`getIconColor` are superseded by `NooFileKind` in
rebuilt screens; `ItemThumbnail` is still reused as-is, fed into
`NooFileTile`/`NooFileRow`/`NooFileTableRow`/`NooGridCard`'s `thumbnail`
slot. `ShareUploadView`/`MoveCopyDestinationPicker` don't use any of the
three any more - both destination pickers now list folders only (see
below), and a folder never gets a real thumbnail (only image/video do),
so a plain `NooFileTile(kind: NooFileKind.folder)` covers every row.
- `DetailsSheet`/`ShareSheet` (`lib/widgets/details/details_sheet.dart`,
`lib/widgets/share_sheet.dart`) are rebuilt: `showNooSheet`/`showNooDialog`
per `NooLayout.isDesktop`, with the header built from `detailsFileTile`
(a `NooFileTile` keyed by `NooFileKind.from`) and `detailsMetaLine` -
shared top-level helpers in `details_sheet.dart` so both sheets open on
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) —
the blurred/translucent pill background shared by all floating chrome
(bottom nav bar, media-viewer top/bottom bars and video transport
controls). Reuse this for any new floating overlay instead of building a
new blur/shadow combo.
- [`FloatingBottomNavBar`](../../lib/widgets/floating_bottom_bar.dart) — the
main tab bar; opacity/blur are user-adjustable settings
(`SettingsController.bottomBarOpacity`/`bottomBarBlur`), not constants —
pull new adjustable visual knobs from `SettingsController` the same way
rather than hardcoding them.
the blurred/translucent pill background for the media viewer's top/bottom
bars and video transport controls (its other former user, the floating
bottom nav bar, is gone - see below). Reuse this for any new floating
overlay instead of building a new blur/shadow combo.
- Upload FAB size: `SettingsController.fabStyle` (`FabStyle.auto/mini/expanded`,
Settings > Appearance > "Upload button") drives `NooFab.collapsed` in
`main.dart` - auto collapses off Files/Photos, mini always icon-only,
expanded always labelled. Phone/Android layout only.
- 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` (also used by floating selection bars - see
that constant's doc comment) instead of edge to edge (no opacity/blur knob
either). `floating` ignores `NooNavStyle` and always uses the Android row
(icon-only idle tabs, sliding pill, 64px), so iOS matches Android there;
only the attached bar still has a distinct iOS row. A separate, orthogonal
`SettingsController.bottomBarFrosted` toggle (Settings → Appearance →
"Frosted glass bottom bar", off by default, both platforms and both bar
styles) swaps the bar's solid `surface` for a translucent one over a
`BackdropFilter` blur and drops
`nooDialogShadow` (it would show through the glass). Strength is user-set:
`FrostedGlassPreset` Less (blur 10 / opacity 0.88), Default (20 / 0.72, the
old fixed values) and More (32 / 0.55), plus Blur (0-40) and Opacity
(0.3-1.0) sliders (`NooSlider`, `core/`) in Settings -> Appearance, shown
only while frosted glass is on; both persist
(`ui_bottom_bar_frosted_blur`/`_opacity`) and reach the bar as
`NooBottomBar.frostedBlur`/`frostedOpacity`. No preset is highlighted once
the sliders are moved off all three. A frosted bar only
reads as glass with content behind it, so the shell uses
`NooBottomBar.drawsBehindBody(barStyle, frosted)` for `extendBody`, and
`bottomBarClearance` (and Files' own trailing sliver) reserve the bar's
footprint for attached+frosted too, not just floating. The host `Scaffold` needs `extendBody: true` while it's active
(`main.dart` already wires this off `SettingsController.bottomBarStyle`),
which also means every tab's own scrollable list has to reserve enough
bottom padding to clear the bar - nothing does that automatically once the
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` chooses hamburger/drawer or avatar popup
navigation (Settings → Appearance → Navigation menu). Avatar mode exposes
a persisted Avatar position choice (`ui_avatar_position`, Top by default).
`AvatarNavigationHost` owns one 420ms open / 320ms close animation using
`easeInOutCubicEmphasized`. The card expands from the real avatar anchor;
its controls fade in after the expansion starts. Top moves the content pane
down through `AvatarNavigationBody`, keeping bottom navigation stationary.
Bottom moves the avatar upward into the card's bottom-right corner and
overlays the list immediately above navigation. Both use the Files gutter.
The floating rail beside a bottom avatar shows text only on the selected
tab. Attached iOS also shows text only for the selected tab and keeps the full-width bar,
with the avatar inside the trailing end. Its popup expands upward with
full screen width, rounded top corners and no gap above the bar; the
avatar stays in the bar, and Search keeps its inline position.
The attached popup has no outer border or shadow, blending into the bar.
All bottom popups order navigation (including Settings), current account,
then expanded secondary accounts and Add/Manage Account controls.
The iOS shell places Upload and the top avatar on the large-title row.
Upload uses the shared `ui_fab_style` preference: Auto expands on Files
and Photos and collapses elsewhere; Mini is icon-only; Expanded always
shows the plus-and-Upload button. Upload uses the folder icon's tonal
`accentSoft` background and `accentText` foreground. Search stays below.
Upload uses the compact 32px size to match the visible top avatar.
On iOS the accent picker hides the unsupported wallpaper color option;
the accent subtitle, swatch and selection always reflect the seed color.
Upload's width, padding and label opacity interpolate over `NooMotion.base`.
A shared shell animation keeps separately mounted tab headers in sync
during Auto tab changes; reduced motion skips the transition.
Avatar navigation and its bottom satellite share the bottom bar's frost
toggle, blur and opacity, including throughout the popup animation.
The setting is labelled "Frosted glass" and also controls both media
viewer panels through `FrostedGlassContainer`. Off uses an opaque surface
without a backdrop filter; On uses the shared blur and tint opacity.
Search starts inside the rail when a bottom avatar occupies the satellite;
its row slot smoothly collapses as Search fades into that satellite while
the avatar moves into the popup. Closing reverses the same path. The card
retains hidden tabs, Settings, account switching, Add Account, and Manage
Accounts. Account controls scroll on short screens. Tap outside, tap the
popup avatar, or system Back to close. Reduced motion opens/closes without
the transition. Drawer gestures are disabled in avatar mode. Standalone
top bars outside the shell retain `showGeneralDialog` as a fallback.
`ShellAvatarButton` keeps its vertical-swipe account shortcuts in either
position. Its onTap override must have a matching tooltip/semantics label.
Also added
`NooTopBar.androidTitleTrailing`: Android has no large title to put a
second search row under the way iOS's `search:` slot does, so an inline
search bar (`AppTopBar` passes a plain `ShellSearchLauncher()` when
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) —
the pull-to-sync `CustomScrollView` header shared by all 8 tabs (see
`architecture.md`); also where the pull-to-refresh gesture thresholds and
the classic Material refresh spinner live. Its persistent compact chip
(icon + "Sync off"/"Syncing…"/"Synced"/"Sync issue") reflects device-sync
status (`SyncStatusController.syncHeaderStatus`), not the WebDAV-refresh
loading state the pull gesture itself triggers - that has its own,
separate floating spinner bubble, so nothing was lost by handing the
persistent text/icon over. The expanded panel's headline is a separate,
more detailed string (`_syncSummary` in `synced_header_scaffold.dart`) -
counts of what's actually configured to sync ("2 folders & 1 file
synced") rather than just repeating the chip's generic label.
Deliberately doesn't add up the individual files inside a synced folder
("1 folder synced", not "1 folder & 4 items synced") - once a folder's
synced, its file count is an implementation detail, not something the
user picked.
- [`SyncStatusBadge`](../../lib/widgets/sync_status_badge.dart) — the small
corner badge over a thumbnail showing per-item device-sync status
(`cloud_done`/`sync`, nothing for not-synced/conflict); used in Files'
list and grid tiles today. Reuse this rather than a new ad hoc badge if
another view starts showing sync status per item.
the pull-to-sync `CustomScrollView` header with the persistent sync-status
chip and pull-to-refresh gesture/spinner. Every screen (including
`ShareUploadView`/`MoveCopyDestinationPicker`, its last two users) has
dropped it for a plain `RefreshIndicator` + `CustomScrollView`
(device-sync status now shows per-row via `NooStatusIcon`/the Offline
`NooSummaryCard`, not a shared header chip), so the `SyncedHeaderScaffold`
class itself is now dead code - kept only because the same file's
top-level `formatBytes` helper is still widely used
(`files_view.dart`/`favorites_view.dart`/`shell_common.dart`/
`widgets/details/*`).
- [`SegmentedIconGroup`/`ToggleIconButton`](../../lib/widgets/segmented_icon_toggle.dart)
and [`SortMenuButton`](../../lib/widgets/sort_menu_button.dart) — the
Material sort/filter-chip pieces `ShareUploadView`/
`MoveCopyDestinationPicker` used to mirror Files' old controls row with.
Both destination pickers dropped that whole row (they only ever browse
folders, so sort/hidden/scope/type-filter/grid controls don't apply), so
these two files are now dead code too - `Breadcrumbs` is the only shared
widget promoted to noo styling instead of removed, since Files' own
`FileBreadcrumbRow` proved the same trail is still wanted elsewhere.
- [`SeekBarPainter`/`SeekBarPreview`](../../lib/widgets/seek_bar_painter.dart)
— the four `MediaProgressBarStyle` presets (Default/Wavy/Slim/Squiggly)
for the video player's seek bar, plus a perpetually-animated
for the non-iOS video player's seek bar, plus a perpetually-animated
`SeekBarPreview` wrapper used by the Settings style picker so every
preview always matches the real widget exactly (same painter, just fed
demo `progress`/`phase` values). Add new seek-bar presets here, not by
forking the painter.
iOS uses `IosSeekBar` with `CupertinoSlider` instead and hides the style
picker. Scrubbing pauses playback temporarily and restores its prior state.
- Chrome inside the media viewer (`file_viewer_screen.dart` — the top bar's
back button + filename, the bottom action bar, the video transport
controls) all share one small hand-rolled icon-button pattern
@@ -127,4 +406,32 @@ widgets. Key points:
ColorFilter.mode(colorScheme.onSurface, BlendMode.srcIn), ...)` so it
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
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; every text file opens read-only (plain text as-is, Markdown rendered) and follows Edit -> Save: an Edit/Preview toggle and, once dirty, a Save button live in the viewer's top bar as round icon-only `NooButton`s (`iconOnly`, 36px; a lone button is primary, with two Edit/Preview is secondary and Save primary; Save becomes a primary circle with a spinner while it saves). Offline copies are read-only with no buttons. The preview owns the text, the editing mode and the save; a `TextPreviewController` (owned by `FileViewerScreen`, passed to `MediaTextPreview`) mirrors that state out so the screen's top bar can draw - and trigger - the buttons, which keeps the preview's widget tree unchanged. `NooPersonAccessRow.trailing` replaces the owner label/permission pill (used by share-search results). Sheets whose close button lives in `showNooSheet` children must pop via a `Builder` context, not the caller's.
`showNooSheet` insets its body by the keyboard (`viewInsets.bottom`) so focused fields stay visible; the body's widget structure must not change when the keyboard opens, or the sheet content is rebuilt and loses focus.
Markdown files (`.md`/`.markdown`) in `MediaTextPreview` open rendered via `flutter_markdown_plus` (`Markdown`, styled from Noo tokens in `_markdownStyle`), with the same Edit/Preview toggle in the viewer's top bar (hidden for read-only offline copies). Other text files go straight to the editor.
`ShareSheet`: focusing the people search field does not scroll; once the user types, `_revealPeopleSection` animates the "Share with people" section (keyed by `_peopleKey` on the `NooShareSection`, not the inner column) to just below the sheet's top edge with a small gap.
## 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/profile
/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
- [`standards.md`](.claude/context/standards.md) — code style, comment
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
+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
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
Nextcloud server — files, photos, shares, activity, and trash, all in one
app, themed to match your device.
Noo is a clean, modern way to browse and manage the files on your own
Nextcloud server: files, photos, shares, activity, and trash in one app,
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
- **Files** — browse, upload, move, rename, delete, and favorite files and
folders; sort and filter per folder
- **Photos** — a dedicated media gallery with a full-screen viewer for images
and video
- **Activity** — a feed of what's changed on your server
- **Shares** — see and manage what you've shared and what's shared with you
- **Trash** — restore or permanently delete recently removed items
- **Material You theming** — dynamic color on supported devices, five
built-in accent colors, light/dark/AMOLED
- **Secure sign-in** — authenticates via Nextcloud's Login Flow v2: you sign
in through your browser, and Noo never sees or stores your password,
only a scoped app token
## Screenshots
_Coming soon._
- **Files**: browse, upload, move, copy, rename, delete, and favorite files
and folders; sort and filter per folder; list or grid view
- **Photos**: a media gallery with a full-screen viewer for images and video
- **Viewer**: images, video, PDF (pinch to zoom), Markdown and text, with a
pull-up details panel (info, activity, sharing)
- **Shares, Recent, Favorites, Activity, Trash**: see and manage what you
have shared, what changed, and what you deleted
- **Search** across your server
- **Offline and sync**: keep files available offline and sync chosen folders
in the background
- **Multiple accounts**: switch between saved accounts from the avatar menu
- **App lock**: optionally require device authentication to open the app,
switch accounts, or reveal hidden files
- **Share to Noo**: upload from any app's share sheet
- **Make it yours**: Material You dynamic color, five accent colors,
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
Prerequisites:
- [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
Nextcloud 15)
@@ -39,38 +49,83 @@ flutter pub get
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.
## Supported platforms
Android, iOS, and Windows.
## Building
```bash
flutter build apk # Android
flutter build apk # Android (debug-signed unless you configure a key)
flutter build ios # iOS
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
Flutter with Material 3, [`provider`](https://pub.dev/packages/provider) for
state management, and `http`/`dio` talking directly to Nextcloud's WebDAV
and OCS APIs — no bundled Nextcloud SDK.
state management, and `http`/`dio` talking directly to Nextcloud's WebDAV and
OCS APIs, with no bundled Nextcloud SDK. Android has native workers for
transfers and background sync.
## Contributing
Project context for contributors (human or AI) lives in
[`CLAUDE.md`](CLAUDE.md) and `.claude/context/` — architecture, the server
integration, styling conventions, and code standards.
Contributions are welcome. Please read [`CONTRIBUTING.md`](CONTRIBUTING.md)
first. In short:
```bash
flutter test # run tests
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
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 syncServiceChannelName = "dev.ayushya.noo/sync_service"
private val syncStatusChannelName = "dev.ayushya.noo/sync_service/status"
private val uploadStatusChannelName = "dev.ayushya.noo/upload_service/status"
private val notificationPermissionRequestCode = 4202
// Lazy, not a field initializer - `packageName` reads through the
// 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 newPickSink: EventChannel.EventSink? = null
private var syncStatusListener: ((SyncStatusBus.Status) -> Unit)? = null
private var uploadCompletedListener: ((UploadEventBus.Completed) -> Unit)? = null
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
@@ -157,6 +159,29 @@ class MainActivity : FlutterFragmentActivity() {
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
@@ -185,6 +185,13 @@ class ShareUploadService : Service() {
else -> "Uploaded $succeeded of ${batch.files.size} files - $failed failed"
}
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) {
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.
+323 -3
View File
@@ -7,15 +7,44 @@
objects = {
/* Begin PBXBuildFile section */
0EA689E7D32E498936A37CDC /* DavSyncClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 308A73EC19D196226AB395DC /* DavSyncClient.swift */; };
134157C4C8F53C9997FF265C /* TransferTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20B0C3085FA6467C1A4A2947 /* TransferTypes.swift */; };
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
167672C5B1FBD70656C2FEED /* TransferTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20B0C3085FA6467C1A4A2947 /* TransferTypes.swift */; };
187CEAB4C9995B379F33409F /* SyncCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57FF20A8DFA5A1A9F75A5F5A /* SyncCoordinator.swift */; };
223784E5DAE5A653F0F61771 /* ShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 405A5408F4071B4D12F08B21 /* ShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
33B3C3BED4C1F26439DD6825 /* SharedInbox.swift in Sources */ = {isa = PBXBuildFile; fileRef = 836D37F2376AFCE0EFDD30A7 /* SharedInbox.swift */; };
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
3C1A0DAA5F9B37FEA4C604BE /* DavFolders.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51604DEE43E937A5FDCA7A58 /* DavFolders.swift */; };
3DEDE94F2A4C921407EF4382 /* ShareUpload.swift in Sources */ = {isa = PBXBuildFile; fileRef = B93DB1F595F44E2F2057BB2A /* ShareUpload.swift */; };
43DB9AB631DD359AEC613A1D /* WebDAV.swift in Sources */ = {isa = PBXBuildFile; fileRef = 871EDB5791D5B3DA0ECF3D4E /* WebDAV.swift */; };
44078FC9474337A0E102B524 /* TransferNotifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3247AAA4B65FE0F1A7279FA1 /* TransferNotifications.swift */; };
4618DB104BDFF73311A5DA6E /* SyncStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A39E02713460B6C7875F3278 /* SyncStore.swift */; };
47EF34D26CA64490D2118B0D /* SyncRunner.swift in Sources */ = {isa = PBXBuildFile; fileRef = C028B2E4E2874BB74498EABD /* SyncRunner.swift */; };
5694337ECF8D3ADD3695CBCD /* NativeServices.swift in Sources */ = {isa = PBXBuildFile; fileRef = 901C0CDB4FB819302E2A7563 /* NativeServices.swift */; };
62028E64976F15B4FDEDA9F1 /* ShareUpload.swift in Sources */ = {isa = PBXBuildFile; fileRef = B93DB1F595F44E2F2057BB2A /* ShareUpload.swift */; };
63229CACE4281880037D33E6 /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27BDF61F5197F9B8F648E8AE /* ShareViewController.swift */; };
66AFC246E251554A80B2476F /* SharePickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BE7B8C31FF78E212BD71DFC /* SharePickerView.swift */; };
721B9C528F23F144966483E0 /* SyncModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF9CFAFB2387EB4E34A992BC /* SyncModels.swift */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; };
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
804B3C9878B9A8BB30E09340 /* SyncEngineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2947D6E441BA30E10B1F8C17 /* SyncEngineTests.swift */; };
852C0C8EBA96CB6389DF6917 /* SharedAccount.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F2C956C98F58A97B5595307 /* SharedAccount.swift */; };
87DD0AED8048F7F7FF908FDB /* SyncDiff.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0933A96CE0B65A9A6AB2D098 /* SyncDiff.swift */; };
8C73B4CB445AEF383B7640E1 /* DeviceAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97ACCF43E1EC13AA2538E42C /* DeviceAuth.swift */; };
91C1658DC7467CACB2AAC7FF /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0AE6F681A3100A461B4A5318 /* Foundation.framework */; };
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
A16680889B3A9D8672FA29C1 /* TransferNotifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3247AAA4B65FE0F1A7279FA1 /* TransferNotifications.swift */; };
A3B3AB501C29516E20455F23 /* DavFolders.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51604DEE43E937A5FDCA7A58 /* DavFolders.swift */; };
B749ED5B4464A1950E1A4B18 /* SyncConfigStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18691F7A45AD55A2246075DA /* SyncConfigStore.swift */; };
E295E45196E3973A5F7E1D8E /* SharedAccount.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F2C956C98F58A97B5595307 /* SharedAccount.swift */; };
F598B112F4D33953984FEFF4 /* WebDAV.swift in Sources */ = {isa = PBXBuildFile; fileRef = 871EDB5791D5B3DA0ECF3D4E /* WebDAV.swift */; };
F8D51B4045340275FBD12D16 /* SharedInbox.swift in Sources */ = {isa = PBXBuildFile; fileRef = 836D37F2376AFCE0EFDD30A7 /* SharedInbox.swift */; };
FDFA55C4921747A9CBCBBEEC /* TransferManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8064CC1652B8F0A44E371638 /* TransferManager.swift */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -26,9 +55,27 @@
remoteGlobalIDString = 97C146ED1CF9000F007C117D;
remoteInfo = Runner;
};
850C66E713F7C56BDA822FB7 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 97C146E61CF9000F007C117D /* Project object */;
proxyType = 1;
remoteGlobalIDString = F9BE9B2F18B10324D7B84776;
remoteInfo = ShareExtension;
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
201269C4EFA9E5FDBC74F86F /* Embed Foundation Extensions */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 13;
files = (
223784E5DAE5A653F0F61771 /* ShareExtension.appex in Embed Foundation Extensions */,
);
name = "Embed Foundation Extensions";
runOnlyForDeploymentPostprocessing = 0;
};
9705A1C41CF9048500538489 /* Embed Frameworks */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
@@ -42,26 +89,59 @@
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
0933A96CE0B65A9A6AB2D098 /* SyncDiff.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SyncDiff.swift; sourceTree = "<group>"; };
0AE6F681A3100A461B4A5318 /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; };
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
18691F7A45AD55A2246075DA /* SyncConfigStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SyncConfigStore.swift; sourceTree = "<group>"; };
1F2C956C98F58A97B5595307 /* SharedAccount.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SharedAccount.swift; sourceTree = "<group>"; };
20B0C3085FA6467C1A4A2947 /* TransferTypes.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TransferTypes.swift; sourceTree = "<group>"; };
27BDF61F5197F9B8F648E8AE /* ShareViewController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = "<group>"; };
2947D6E441BA30E10B1F8C17 /* SyncEngineTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SyncEngineTests.swift; sourceTree = "<group>"; };
308A73EC19D196226AB395DC /* DavSyncClient.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DavSyncClient.swift; sourceTree = "<group>"; };
3247AAA4B65FE0F1A7279FA1 /* TransferNotifications.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TransferNotifications.swift; sourceTree = "<group>"; };
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
405A5408F4071B4D12F08B21 /* ShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = ShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
49213989F0FA1B83B0B8A523 /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
51604DEE43E937A5FDCA7A58 /* DavFolders.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DavFolders.swift; sourceTree = "<group>"; };
57FF20A8DFA5A1A9F75A5F5A /* SyncCoordinator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SyncCoordinator.swift; sourceTree = "<group>"; };
5BE7B8C31FF78E212BD71DFC /* SharePickerView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SharePickerView.swift; sourceTree = "<group>"; };
65F57A1EB0C62774E561860D /* ShareExtension.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = ShareExtension.entitlements; sourceTree = "<group>"; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
8064CC1652B8F0A44E371638 /* TransferManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TransferManager.swift; sourceTree = "<group>"; };
836D37F2376AFCE0EFDD30A7 /* SharedInbox.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SharedInbox.swift; sourceTree = "<group>"; };
857A0E7637776A71AFBB0614 /* Runner.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = "<group>"; };
871EDB5791D5B3DA0ECF3D4E /* WebDAV.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WebDAV.swift; sourceTree = "<group>"; };
901C0CDB4FB819302E2A7563 /* NativeServices.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NativeServices.swift; sourceTree = "<group>"; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
97ACCF43E1EC13AA2538E42C /* DeviceAuth.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DeviceAuth.swift; sourceTree = "<group>"; };
97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };
97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
A39E02713460B6C7875F3278 /* SyncStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SyncStore.swift; sourceTree = "<group>"; };
AF9CFAFB2387EB4E34A992BC /* SyncModels.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SyncModels.swift; sourceTree = "<group>"; };
B93DB1F595F44E2F2057BB2A /* ShareUpload.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ShareUpload.swift; sourceTree = "<group>"; };
C028B2E4E2874BB74498EABD /* SyncRunner.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SyncRunner.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
807C81744907462A25A6C1F8 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
91C1658DC7467CACB2AAC7FF /* Foundation.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
97C146EB1CF9000F007C117D /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
@@ -73,14 +153,62 @@
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
2B7F16B0F0EA2C5A842ED860 /* iOS */ = {
isa = PBXGroup;
children = (
0AE6F681A3100A461B4A5318 /* Foundation.framework */,
);
name = iOS;
sourceTree = "<group>";
};
331C8082294A63A400263BE5 /* RunnerTests */ = {
isa = PBXGroup;
children = (
331C807B294A618700263BE5 /* RunnerTests.swift */,
2947D6E441BA30E10B1F8C17 /* SyncEngineTests.swift */,
);
path = RunnerTests;
sourceTree = "<group>";
};
3664F08C433ECEB00D1C4BDB /* ShareExtension */ = {
isa = PBXGroup;
children = (
27BDF61F5197F9B8F648E8AE /* ShareViewController.swift */,
49213989F0FA1B83B0B8A523 /* Info.plist */,
65F57A1EB0C62774E561860D /* ShareExtension.entitlements */,
5BE7B8C31FF78E212BD71DFC /* SharePickerView.swift */,
97ACCF43E1EC13AA2538E42C /* DeviceAuth.swift */,
);
name = ShareExtension;
path = ShareExtension;
sourceTree = "<group>";
};
5055E53DC55C0BCABD5F04F4 /* Native */ = {
isa = PBXGroup;
children = (
8064CC1652B8F0A44E371638 /* TransferManager.swift */,
901C0CDB4FB819302E2A7563 /* NativeServices.swift */,
DDC89BA8A42212F55851CBA1 /* SyncEngine */,
);
name = Native;
path = Native;
sourceTree = "<group>";
};
5DF0A9032A8500B67D6C89AE /* Shared */ = {
isa = PBXGroup;
children = (
836D37F2376AFCE0EFDD30A7 /* SharedInbox.swift */,
871EDB5791D5B3DA0ECF3D4E /* WebDAV.swift */,
20B0C3085FA6467C1A4A2947 /* TransferTypes.swift */,
1F2C956C98F58A97B5595307 /* SharedAccount.swift */,
B93DB1F595F44E2F2057BB2A /* ShareUpload.swift */,
51604DEE43E937A5FDCA7A58 /* DavFolders.swift */,
3247AAA4B65FE0F1A7279FA1 /* TransferNotifications.swift */,
);
name = Shared;
path = Shared;
sourceTree = "<group>";
};
9740EEB11CF90186004384FC /* Flutter */ = {
isa = PBXGroup;
children = (
@@ -100,6 +228,9 @@
97C146F01CF9000F007C117D /* Runner */,
97C146EF1CF9000F007C117D /* Products */,
331C8082294A63A400263BE5 /* RunnerTests */,
5DF0A9032A8500B67D6C89AE /* Shared */,
3664F08C433ECEB00D1C4BDB /* ShareExtension */,
A5B80AB7F4AB17F90B745D0D /* Frameworks */,
);
sourceTree = "<group>";
};
@@ -108,6 +239,7 @@
children = (
97C146EE1CF9000F007C117D /* Runner.app */,
331C8081294A63A400263BE5 /* RunnerTests.xctest */,
405A5408F4071B4D12F08B21 /* ShareExtension.appex */,
);
name = Products;
sourceTree = "<group>";
@@ -124,10 +256,35 @@
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */,
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
5055E53DC55C0BCABD5F04F4 /* Native */,
857A0E7637776A71AFBB0614 /* Runner.entitlements */,
);
path = Runner;
sourceTree = "<group>";
};
A5B80AB7F4AB17F90B745D0D /* Frameworks */ = {
isa = PBXGroup;
children = (
2B7F16B0F0EA2C5A842ED860 /* iOS */,
);
name = Frameworks;
sourceTree = "<group>";
};
DDC89BA8A42212F55851CBA1 /* SyncEngine */ = {
isa = PBXGroup;
children = (
AF9CFAFB2387EB4E34A992BC /* SyncModels.swift */,
A39E02713460B6C7875F3278 /* SyncStore.swift */,
308A73EC19D196226AB395DC /* DavSyncClient.swift */,
0933A96CE0B65A9A6AB2D098 /* SyncDiff.swift */,
C028B2E4E2874BB74498EABD /* SyncRunner.swift */,
18691F7A45AD55A2246075DA /* SyncConfigStore.swift */,
57FF20A8DFA5A1A9F75A5F5A /* SyncCoordinator.swift */,
);
name = SyncEngine;
path = SyncEngine;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
@@ -152,6 +309,7 @@
isa = PBXNativeTarget;
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
buildPhases = (
201269C4EFA9E5FDBC74F86F /* Embed Foundation Extensions */,
9740EEB61CF901F6004384FC /* Run Script */,
97C146EA1CF9000F007C117D /* Sources */,
97C146EB1CF9000F007C117D /* Frameworks */,
@@ -162,6 +320,7 @@
buildRules = (
);
dependencies = (
6E0BAC83BFDF8623DBFB9D75 /* PBXTargetDependency */,
);
name = Runner;
packageProductDependencies = (
@@ -171,6 +330,23 @@
productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
productType = "com.apple.product-type.application";
};
F9BE9B2F18B10324D7B84776 /* ShareExtension */ = {
isa = PBXNativeTarget;
buildConfigurationList = E1076120E395D274D37607ED /* Build configuration list for PBXNativeTarget "ShareExtension" */;
buildPhases = (
3CD40C8747E650E6F817CDDB /* Sources */,
807C81744907462A25A6C1F8 /* Frameworks */,
4EDBC7D61EA0C0974DA27BD6 /* Resources */,
);
buildRules = (
);
dependencies = (
);
name = ShareExtension;
productName = ShareExtension;
productReference = 405A5408F4071B4D12F08B21 /* ShareExtension.appex */;
productType = "com.apple.product-type.app-extension";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
@@ -209,6 +385,7 @@
targets = (
97C146ED1CF9000F007C117D /* Runner */,
331C8080294A63A400263BE5 /* RunnerTests */,
F9BE9B2F18B10324D7B84776 /* ShareExtension */,
);
};
/* End PBXProject section */
@@ -221,6 +398,13 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
4EDBC7D61EA0C0974DA27BD6 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
97C146EC1CF9000F007C117D /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
@@ -274,6 +458,24 @@
buildActionMask = 2147483647;
files = (
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */,
804B3C9878B9A8BB30E09340 /* SyncEngineTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
3CD40C8747E650E6F817CDDB /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
63229CACE4281880037D33E6 /* ShareViewController.swift in Sources */,
33B3C3BED4C1F26439DD6825 /* SharedInbox.swift in Sources */,
F598B112F4D33953984FEFF4 /* WebDAV.swift in Sources */,
167672C5B1FBD70656C2FEED /* TransferTypes.swift in Sources */,
852C0C8EBA96CB6389DF6917 /* SharedAccount.swift in Sources */,
3DEDE94F2A4C921407EF4382 /* ShareUpload.swift in Sources */,
3C1A0DAA5F9B37FEA4C604BE /* DavFolders.swift in Sources */,
66AFC246E251554A80B2476F /* SharePickerView.swift in Sources */,
A16680889B3A9D8672FA29C1 /* TransferNotifications.swift in Sources */,
8C73B4CB445AEF383B7640E1 /* DeviceAuth.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -284,6 +486,22 @@
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */,
FDFA55C4921747A9CBCBBEEC /* TransferManager.swift in Sources */,
5694337ECF8D3ADD3695CBCD /* NativeServices.swift in Sources */,
F8D51B4045340275FBD12D16 /* SharedInbox.swift in Sources */,
43DB9AB631DD359AEC613A1D /* WebDAV.swift in Sources */,
134157C4C8F53C9997FF265C /* TransferTypes.swift in Sources */,
E295E45196E3973A5F7E1D8E /* SharedAccount.swift in Sources */,
62028E64976F15B4FDEDA9F1 /* ShareUpload.swift in Sources */,
A3B3AB501C29516E20455F23 /* DavFolders.swift in Sources */,
44078FC9474337A0E102B524 /* TransferNotifications.swift in Sources */,
721B9C528F23F144966483E0 /* SyncModels.swift in Sources */,
4618DB104BDFF73311A5DA6E /* SyncStore.swift in Sources */,
0EA689E7D32E498936A37CDC /* DavSyncClient.swift in Sources */,
87DD0AED8048F7F7FF908FDB /* SyncDiff.swift in Sources */,
47EF34D26CA64490D2118B0D /* SyncRunner.swift in Sources */,
B749ED5B4464A1950E1A4B18 /* SyncConfigStore.swift in Sources */,
187CEAB4C9995B379F33409F /* SyncCoordinator.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -295,6 +513,12 @@
target = 97C146ED1CF9000F007C117D /* Runner */;
targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */;
};
6E0BAC83BFDF8623DBFB9D75 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
name = ShareExtension;
target = F9BE9B2F18B10324D7B84776 /* ShareExtension */;
targetProxy = 850C66E713F7C56BDA822FB7 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin PBXVariantGroup section */
@@ -360,7 +584,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
@@ -375,6 +599,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@@ -437,6 +662,89 @@
};
name = Profile;
};
3B057BF66761E30DEABF8B30 /* Profile */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
buildSettings = {
APPLICATION_EXTENSION_API_ONLY = YES;
CLANG_ENABLE_OBJC_WEAK = NO;
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = ShareExtension/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)";
PRODUCT_BUNDLE_IDENTIFIER = dev.ayushya.noo.ShareExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SKIP_INSTALL = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
VALIDATE_PRODUCT = YES;
};
name = Profile;
};
6BF252CEA7A55024A2F58A36 /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
buildSettings = {
APPLICATION_EXTENSION_API_ONLY = YES;
CLANG_ENABLE_OBJC_WEAK = NO;
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = ShareExtension/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)";
PRODUCT_BUNDLE_IDENTIFIER = dev.ayushya.noo.ShareExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SKIP_INSTALL = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
VALIDATE_PRODUCT = YES;
};
name = Release;
};
6FB3F72FB5A94DE254FF4FBF /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */;
buildSettings = {
APPLICATION_EXTENSION_API_ONLY = YES;
CLANG_ENABLE_OBJC_WEAK = NO;
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = ShareExtension/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)";
PRODUCT_BUNDLE_IDENTIFIER = dev.ayushya.noo.ShareExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SKIP_INSTALL = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
97C147031CF9000F007C117D /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
@@ -486,7 +794,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
@@ -537,7 +845,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
@@ -554,6 +862,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@@ -576,6 +885,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@@ -624,6 +934,16 @@
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
E1076120E395D274D37607ED /* Build configuration list for PBXNativeTarget "ShareExtension" */ = {
isa = XCConfigurationList;
buildConfigurations = (
6BF252CEA7A55024A2F58A36 /* Release */,
6FB3F72FB5A94DE254FF4FBF /* Debug */,
3B057BF66761E30DEABF8B30 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
/* Begin XCLocalSwiftPackageReference section */
+44 -1
View File
@@ -1,5 +1,6 @@
import Flutter
import UIKit
import UserNotifications
@main
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
@@ -7,10 +8,52 @@ import UIKit
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
TransferManager.shared.reconnect()
// BGTask handlers have to be registered before launch finishes.
SyncCoordinator.shared.registerBackgroundTasks()
SyncCoordinator.shared.registerNotificationCategories()
let launched = super.application(application, didFinishLaunchingWithOptions: launchOptions)
UNUserNotificationCenter.current().delegate = self
SyncCoordinator.shared.scheduleBackgroundWork()
return launched
}
/// Taps on a sync-conflict notification's "Keep local" / "Use server".
override func userNotificationCenter(
_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse,
withCompletionHandler completionHandler: @escaping () -> Void
) {
if SyncCoordinator.shared.handleNotificationResponse(response, completion: completionHandler) { return }
super.userNotificationCenter(center, didReceive: response, withCompletionHandler: completionHandler)
}
/// A transfer summary ("Downloaded 1 file") usually finishes while the app
/// is open; without this iOS delivers it silently to Notification Center
/// instead of showing the banner.
override func userNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification,
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
) {
completionHandler([.banner, .list, .sound])
}
override func application(
_ application: UIApplication,
handleEventsForBackgroundURLSession identifier: String,
completionHandler: @escaping () -> Void
) {
if identifier == TransferManager.sessionIdentifier || identifier == ShareUpload.sessionIdentifier {
TransferManager.shared.backgroundCompletionHandler = completionHandler
} else {
super.application(
application, handleEventsForBackgroundURLSession: identifier, completionHandler: completionHandler)
}
}
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
NativeServices.register(messenger: engineBridge.applicationRegistrar.messenger())
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 258 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 295 B

After

Width:  |  Height:  |  Size: 930 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 450 B

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 B

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 462 B

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 704 B

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 586 B

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 762 B

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 19 KiB

+20
View File
@@ -53,6 +53,26 @@
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UIFileSharingEnabled</key>
<true/>
<key>LSSupportsOpeningDocumentsInPlace</key>
<true/>
<key>NooKeychainAccessGroup</key>
<string>$(AppIdentifierPrefix)dev.ayushya.noo.shared</string>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>dev.ayushya.noo.sync.refresh</string>
<string>dev.ayushya.noo.sync.processing</string>
</array>
<key>UIBackgroundModes</key>
<array>
<string>fetch</string>
<string>processing</string>
</array>
<key>NSFaceIDUsageDescription</key>
<string>Noo uses Face ID to unlock the app and to protect hidden files.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>Noo needs access to your photos and videos so you can choose which ones to upload to your server.</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
+295
View File
@@ -0,0 +1,295 @@
import Flutter
import Foundation
import UIKit
/// Holds the one native-side listener of an `EventChannel` so Swift code can
/// push events to Dart whenever they happen.
final class SinkStreamHandler: NSObject, FlutterStreamHandler {
var sink: FlutterEventSink?
func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink)
-> FlutterError?
{
sink = events
return nil
}
func onCancel(withArguments arguments: Any?) -> FlutterError? {
sink = nil
return nil
}
}
/// The iOS side of the `dev.ayushya.noo/*` channels `lib/services/` talks to
/// (Android's lives in `MainActivity.kt`). Upload, download and share (the
/// receiving half of the Share Extension) are implemented so far; the rest
/// still throw `MissingPluginException`, which the Dart side treats as
/// "unavailable" (`lib/services/native_channel.dart`).
enum NativeServices {
private static let uploadStatus = SinkStreamHandler()
static func register(messenger: FlutterBinaryMessenger) {
FlutterMethodChannel(name: "dev.ayushya.noo/upload_service", binaryMessenger: messenger)
.setMethodCallHandler { call, result in
guard call.method == "startUpload" else { return result(FlutterMethodNotImplemented) }
handle(call, result) { args in
let files = try parseJSONList(args["files"])
try TransferManager.shared.startUpload(
serverUrl: try string(args, "serverUrl"),
username: try string(args, "username"),
authHeader: try string(args, "authHeader"),
files: files.compactMap { entry in
guard let uri = entry["uri"] as? String, let name = entry["name"] as? String else {
return nil
}
return UploadFile(uri: uri, name: name)
},
remoteFolder: (args["remoteFolder"] as? String) ?? "/"
)
}
}
FlutterMethodChannel(name: "dev.ayushya.noo/download_service", binaryMessenger: messenger)
.setMethodCallHandler { call, result in
guard call.method == "startDownload" else { return result(FlutterMethodNotImplemented) }
handle(call, result) { args in
let items = try parseJSONList(args["files"])
try TransferManager.shared.startDownload(
serverUrl: try string(args, "serverUrl"),
username: try string(args, "username"),
authHeader: try string(args, "authHeader"),
items: items.compactMap { entry in
guard let path = entry["path"] as? String, let name = entry["name"] as? String else {
return nil
}
return (path: path, name: name)
}
)
}
}
FlutterEventChannel(name: "dev.ayushya.noo/upload_service/status", binaryMessenger: messenger)
.setStreamHandler(uploadStatus)
TransferManager.shared.onUploadBatchFinished = { folder, succeeded, failed in
uploadStatus.sink?(["remoteFolder": folder, "succeeded": succeeded, "failed": failed])
}
TransferManager.shared.reconnect()
registerShare(messenger: messenger)
registerShareAccount(messenger: messenger)
registerSync(messenger: messenger)
}
// MARK: - Device sync
/// Sends the current sync status the moment Dart starts listening, then
/// every change - same as Android's `SyncStatusBus.subscribe`.
private final class SyncStatusStream: NSObject, FlutterStreamHandler {
var sink: FlutterEventSink?
func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink)
-> FlutterError?
{
sink = events
let coordinator = SyncCoordinator.shared
events(coordinator.statusMap(coordinator.bus.snapshot()))
return nil
}
func onCancel(withArguments arguments: Any?) -> FlutterError? {
sink = nil
return nil
}
}
private static let syncStatusStream = SyncStatusStream()
/// Serial, so status events reach Dart in the order they happened even
/// though building each one reads the state from disk.
private static let syncStatusQueue = DispatchQueue(label: "dev.ayushya.noo.sync-status", qos: .utility)
/// `sync_service`: the same methods Android's `MainActivity.kt` serves,
/// backed by `SyncCoordinator`/`SyncRunner`.
private static func registerSync(messenger: FlutterBinaryMessenger) {
let coordinator = SyncCoordinator.shared
FlutterMethodChannel(name: "dev.ayushya.noo/sync_service", binaryMessenger: messenger)
.setMethodCallHandler { call, result in
let args = call.arguments as? [String: Any] ?? [:]
switch call.method {
case "reschedule":
handle(call, result) { _ in coordinator.reschedule(try syncConfig(args)) }
case "cancel":
// `forget` (default true) = signed out / removed; false = only the
// background job is off - credentials stay for manual runs and
// conflict actions.
handle(call, result) { _ in
coordinator.cancel(
accountId: try string(args, "accountId"), forget: args["forget"] as? Bool ?? true)
}
case "syncNow":
handle(call, result) { _ in
let force = args["force"] as? Bool ?? true
coordinator.syncNow(try syncConfig(args), force: force, wifiOnly: args["wifiOnly"] as? Bool)
}
case "getSyncStatus":
let accountId = args["accountId"] as? String
syncStatusQueue.async {
let map = coordinator.statusMap(coordinator.bus.snapshot(), accountIdOverride: accountId)
DispatchQueue.main.async { result(map) }
}
case "resolveConflict":
handle(call, result) { _ in
let conflict = SyncConflict(
accountId: try string(args, "accountId"), fileId: try string(args, "fileId"),
remotePath: try string(args, "remotePath"), relPath: try string(args, "relPath"),
name: ((args["relPath"] as? String ?? "") as NSString).lastPathComponent)
let creds = SyncCredentials(
serverUrl: try string(args, "serverUrl"), username: try string(args, "username"),
authHeader: try string(args, "authHeader"))
let resolution = try string(args, "resolution")
// Fire and forget, like Android: the status stream reports the result.
Task { _ = await coordinator.resolveConflict(conflict, resolution: resolution, creds: creds) }
}
case "removeLocalSync":
guard let accountId = args["accountId"] as? String, let path = args["path"] as? String else {
return result(FlutterError(code: "bad_args", message: "Missing required arguments", details: nil))
}
Task {
await coordinator.removeLocalSync(accountId: accountId, path: path)
await MainActor.run { result(nil) }
}
default:
result(FlutterMethodNotImplemented)
}
}
FlutterEventChannel(name: "dev.ayushya.noo/sync_service/status", binaryMessenger: messenger)
.setStreamHandler(syncStatusStream)
coordinator.bus.onChange = { status in
syncStatusQueue.async {
let map = coordinator.statusMap(status)
DispatchQueue.main.async { syncStatusStream.sink?(map) }
}
}
}
/// The account credentials + settings every sync call carries, as a config.
private static func syncConfig(_ args: [String: Any]) throws -> SyncAccountConfig {
var folders: [String] = []
if let json = args["folders"] as? String, let data = json.data(using: .utf8),
let list = try? JSONSerialization.jsonObject(with: data) as? [String]
{
folders = list
}
return SyncAccountConfig(
accountId: try string(args, "accountId"), serverUrl: try string(args, "serverUrl"),
username: try string(args, "username"), authHeader: try string(args, "authHeader"),
folders: folders, wifiOnly: args["wifiOnly"] as? Bool ?? true,
intervalMinutes: args["intervalMinutes"] as? Int, notify: args["notify"] as? Bool ?? true)
}
// MARK: - Account for the Share Extension
/// `share_account`: Dart hands native every account that can upload, which
/// one is active, and the app-lock settings, so the Share Extension - a
/// separate process with no Flutter engine - can list folders and upload as
/// them. Stored in the shared Keychain group.
private static func registerShareAccount(messenger: FlutterBinaryMessenger) {
FlutterMethodChannel(name: "dev.ayushya.noo/share_account", binaryMessenger: messenger)
.setMethodCallHandler { call, result in
switch call.method {
case "setAccounts":
handle(call, result) { args in
guard let json = args["accounts"] as? String,
let data = json.data(using: .utf8),
let accounts = try? JSONDecoder().decode(SharedAccounts.self, from: data)
else { throw TransferError(message: "Bad accounts payload.") }
try SharedAccountStore.save(accounts)
NSLog("[ShareAccounts] saved %d account(s), active=%@", accounts.accounts.count, accounts.activeId ?? "none")
}
case "clearAccount":
SharedAccountStore.clear()
result(nil)
default:
result(FlutterMethodNotImplemented)
}
}
}
// MARK: - Share Extension inbox
private static let shareEvents = SinkStreamHandler()
/// `share_intent`: files the Share Extension left in the App Group inbox.
/// Cold start asks once (`getInitialShare`); a share that arrives while the
/// app is running is pushed when it next becomes active (the extension
/// can't signal the app any other way).
private static func registerShare(messenger: FlutterBinaryMessenger) {
FlutterMethodChannel(name: "dev.ayushya.noo/share_intent", binaryMessenger: messenger)
.setMethodCallHandler { call, result in
guard call.method == "getInitialShare" else { return result(FlutterMethodNotImplemented) }
result(consumeShared().map(encode))
}
FlutterEventChannel(name: "dev.ayushya.noo/share_intent/new", binaryMessenger: messenger)
.setStreamHandler(shareEvents)
NotificationCenter.default.addObserver(
forName: UIApplication.didBecomeActiveNotification, object: nil, queue: .main
) { _ in
TransferManager.shared.deliverExtensionUploads()
guard let sink = shareEvents.sink else { return }
let items = consumeShared()
if !items.isEmpty { sink(items.map(encode)) }
}
}
private static func consumeShared() -> [SharedItem] {
guard let root = AppGroup.containerURL else { return [] }
return SharedInbox.consume(in: root)
}
private static func encode(_ item: SharedItem) -> [String: Any] {
[
"uri": URL(fileURLWithPath: item.path).absoluteString,
"name": item.name,
"mimeType": item.mimeType ?? NSNull(),
"size": item.size,
]
}
// MARK: - Argument helpers
/// Runs [work] with the call's argument map, turning a thrown error into
/// the `FlutterError` Dart's `catch` shows the user.
private static func handle(
_ call: FlutterMethodCall,
_ result: @escaping FlutterResult,
work: ([String: Any]) throws -> Void
) {
guard let args = call.arguments as? [String: Any] else {
return result(FlutterError(code: "bad_args", message: "Missing arguments.", details: nil))
}
do {
try work(args)
result(nil)
} catch {
result(FlutterError(code: "transfer_failed", message: error.localizedDescription, details: nil))
}
}
private static func string(_ args: [String: Any], _ key: String) throws -> String {
guard let value = args[key] as? String, !value.isEmpty else {
throw TransferError(message: "Missing \(key).")
}
return value
}
/// The Dart services pass their file list as one JSON string.
private static func parseJSONList(_ value: Any?) throws -> [[String: Any]] {
guard let json = value as? String,
let data = json.data(using: .utf8),
let list = try JSONSerialization.jsonObject(with: data) as? [[String: Any]]
else {
throw TransferError(message: "Missing file list.")
}
return list
}
}
@@ -0,0 +1,294 @@
import Foundation
/// Credentials for one account's WebDAV root.
struct SyncCredentials {
let serverUrl: String
let username: String
let authHeader: String
}
/// The WebDAV calls sync needs: PROPFIND (a folder's children, or one item),
/// a recursive walk, and GET/PUT of a single file. Foreground
/// `URLSession` work - sync runs inside the app or a `BGTask`, not on a
/// background session, so a run can walk, diff and decide in one go.
///
/// Authenticates by each request's own `Authorization` header only (no cookies,
/// no credential cache): two accounts can share a server, and one account's
/// session must never answer for another.
final class DavSyncClient {
private let session: URLSession
init(session: URLSession = DavSyncClient.makeSession()) {
self.session = session
}
static func makeSession() -> URLSession {
let config = URLSessionConfiguration.ephemeral
config.httpShouldSetCookies = false
config.httpCookieAcceptPolicy = .never
config.urlCredentialStorage = nil
config.requestCachePolicy = .reloadIgnoringLocalCacheData
config.timeoutIntervalForRequest = 30
config.timeoutIntervalForResource = 15 * 60
return URLSession(configuration: config)
}
// MARK: - PROPFIND
private static let propfindBody = """
<?xml version="1.0" encoding="utf-8" ?>
<d:propfind xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">
<d:prop>
<d:getlastmodified/>
<d:getcontentlength/>
<d:resourcetype/>
<d:getetag/>
<oc:fileid/>
</d:prop>
</d:propfind>
"""
/// Depth-1 PROPFIND of [path]: its direct children only. A clean 404 (the
/// folder is genuinely gone) is an empty list; anything else that goes wrong
/// throws `SyncRemoteUnavailable`.
func propfindChildren(_ creds: SyncCredentials, path: String) async throws -> [SyncRemoteEntry] {
try await propfind(creds, path: path, depth: "1", skipSelf: true)
}
/// Depth-0 PROPFIND of [path] itself - nil on a clean 404.
func propfindSelf(_ creds: SyncCredentials, path: String) async throws -> SyncRemoteEntry? {
try await propfind(creds, path: path, depth: "0", skipSelf: false).first
}
private func propfind(
_ creds: SyncCredentials, path: String, depth: String, skipSelf: Bool
) async throws -> [SyncRemoteEntry] {
var clean = path.trimmingCharacters(in: .whitespaces)
if !clean.hasPrefix("/") { clean = "/" + clean }
guard var url = WebDAV.fileURL(serverUrl: creds.serverUrl, username: creds.username, remotePath: clean)
else { throw SyncRemoteUnavailable(message: "Invalid server address.") }
// Collections are addressed with a trailing slash.
if depth == "1", let slashed = URL(string: url.absoluteString + "/") { url = slashed }
var request = URLRequest(url: url)
request.httpMethod = "PROPFIND"
request.setValue(depth, forHTTPHeaderField: "Depth")
request.setValue("application/xml", forHTTPHeaderField: "Content-Type")
request.setValue(creds.authHeader, forHTTPHeaderField: "Authorization")
request.httpBody = Self.propfindBody.data(using: .utf8)
let data: Data
let status: Int
do {
let (body, response) = try await session.data(for: request)
data = body
status = (response as? HTTPURLResponse)?.statusCode ?? 0
} catch {
try Self.rethrowIfCancelled(error)
throw SyncRemoteUnavailable(message: "PROPFIND \(url.path) failed: \(error.localizedDescription)")
}
if status == 404 { return [] } // genuinely gone
guard status == 207 else {
throw SyncRemoteUnavailable(message: "PROPFIND \(url.path) -> \(status)")
}
return try DavSyncParser.entries(from: data, username: creds.username, requestedPath: clean, skipSelf: skipSelf)
}
/// Recursively walks [root] (Depth-1 PROPFINDs, breadth-first) into a flat
/// manifest. Throws if *any* level fails - a partial manifest would look
/// like the missing part had been deleted on the server.
func walk(_ creds: SyncCredentials, root: String) async throws -> [SyncRemoteEntry] {
var result: [SyncRemoteEntry] = []
var queue = [root]
var index = 0
while index < queue.count {
try Task.checkCancellation()
let children = try await propfindChildren(creds, path: queue[index])
index += 1
for child in children {
result.append(child)
if child.isFolder { queue.append(child.path) }
}
}
return result
}
// MARK: - GET / PUT
/// Downloads [remotePath] to [destination]. Written to a temp file first and
/// moved into place, so an interrupted download never leaves a truncated
/// file where a good one used to be. False on any failure.
func download(_ creds: SyncCredentials, remotePath: String, to destination: URL) async throws -> Bool {
guard let url = WebDAV.fileURL(serverUrl: creds.serverUrl, username: creds.username, remotePath: remotePath)
else { return false }
var request = URLRequest(url: url)
request.setValue(creds.authHeader, forHTTPHeaderField: "Authorization")
do {
let (temp, response) = try await session.download(for: request)
guard let status = (response as? HTTPURLResponse)?.statusCode, (200..<300).contains(status) else {
try? FileManager.default.removeItem(at: temp)
return false
}
try Task.checkCancellation()
let fm = FileManager.default
try fm.createDirectory(at: destination.deletingLastPathComponent(), withIntermediateDirectories: true)
var isDirectory: ObjCBool = false
if fm.fileExists(atPath: destination.path, isDirectory: &isDirectory) {
// Never replace a folder with a file, and replace a file atomically:
// the old copy stays until the new one is fully in place.
guard !isDirectory.boolValue else {
try? fm.removeItem(at: temp)
return false
}
_ = try fm.replaceItemAt(destination, withItemAt: temp)
} else {
try fm.moveItem(at: temp, to: destination)
}
return true
} catch {
try Self.rethrowIfCancelled(error)
return false
}
}
/// PUTs [source] to [remotePath]. False on any failure.
func upload(_ creds: SyncCredentials, remotePath: String, from source: URL) async throws -> Bool {
guard let url = WebDAV.fileURL(serverUrl: creds.serverUrl, username: creds.username, remotePath: remotePath)
else { return false }
var request = URLRequest(url: url)
request.httpMethod = "PUT"
request.setValue(creds.authHeader, forHTTPHeaderField: "Authorization")
do {
let (_, response) = try await session.upload(for: request, fromFile: source)
guard let status = (response as? HTTPURLResponse)?.statusCode else { return false }
return (200..<300).contains(status)
} catch {
try Self.rethrowIfCancelled(error)
return false
}
}
/// A cancelled run must stop, not be mistaken for a flaky network.
private static func rethrowIfCancelled(_ error: Error) throws {
if error is CancellationError || (error as? URLError)?.code == .cancelled || Task.isCancelled {
throw CancellationError()
}
}
}
/// Parses a PROPFIND multistatus into [SyncRemoteEntry]s. Pure, so it's
/// unit-tested.
enum DavSyncParser {
private struct Raw {
var href = ""
var etag = ""
var modified = ""
var length = ""
var fileId = ""
var isCollection = false
}
private final class Delegate: NSObject, XMLParserDelegate {
var responses: [Raw] = []
var sawMultistatus = false
private var current: Raw?
private var text = ""
func parser(
_ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?,
qualifiedName qName: String?, attributes attributeDict: [String: String] = [:]
) {
text = ""
if elementName == "multistatus" { sawMultistatus = true }
if elementName == "response" { current = Raw() }
if elementName == "collection" { current?.isCollection = true }
}
func parser(_ parser: XMLParser, foundCharacters string: String) {
text += string
}
func parser(
_ parser: XMLParser, didEndElement elementName: String, namespaceURI: String?,
qualifiedName qName: String?
) {
let value = text.trimmingCharacters(in: .whitespacesAndNewlines)
switch elementName {
case "href": current?.href = value
case "getetag": current?.etag = value
case "getlastmodified": current?.modified = value
case "getcontentlength": current?.length = value
case "fileid": current?.fileId = value
case "response":
if let current { responses.append(current) }
current = nil
default: break
}
text = ""
}
}
private static let httpDate: DateFormatter = {
let f = DateFormatter()
f.locale = Locale(identifier: "en_US_POSIX")
f.timeZone = TimeZone(secondsFromGMT: 0)
f.dateFormat = "EEE, dd MMM yyyy HH:mm:ss zzz"
return f
}()
/// Parses a PROPFIND multistatus. Throws `SyncRemoteUnavailable` for
/// anything that isn't a complete, plausible answer - malformed or
/// truncated XML, no `multistatus` envelope, no responses at all (a 207
/// always has at least the requested resource), or an href that isn't
/// under this account's files root or tries to climb out of it. A partial
/// manifest would look like the missing part was deleted on the server
/// (and an empty one like the whole path was), so the caller must treat
/// those as "couldn't reach the server", never as data.
static func entries(
from xml: Data, username: String, requestedPath: String, skipSelf: Bool
) throws -> [SyncRemoteEntry] {
let delegate = Delegate()
let parser = XMLParser(data: xml)
parser.shouldProcessNamespaces = true
parser.delegate = delegate
guard parser.parse(), delegate.sawMultistatus, !delegate.responses.isEmpty else {
throw SyncRemoteUnavailable(message: "Unusable PROPFIND answer")
}
let marker = "/remote.php/dav/files/\(username)"
let selfPath = trimTrailingSlashes(requestedPath)
var entries: [SyncRemoteEntry] = []
for raw in delegate.responses {
let decoded = raw.href.removingPercentEncoding ?? raw.href
guard let range = decoded.range(of: marker) else {
throw SyncRemoteUnavailable(message: "PROPFIND href outside the account's files root")
}
let hrefPath = String(decoded[range.upperBound...])
// A well-formed path continues the marker at a component boundary and
// never contains a dot component.
guard hrefPath.isEmpty || hrefPath.hasPrefix("/"),
!hrefPath.split(separator: "/").contains(where: { $0 == ".." || $0 == "." })
else {
throw SyncRemoteUnavailable(message: "PROPFIND href has an unexpected path")
}
let normalized = trimTrailingSlashes(hrefPath.isEmpty ? "/" : hrefPath)
if skipSelf && normalized == selfPath { continue } // the folder itself, not a child
entries.append(
SyncRemoteEntry(
path: normalized.isEmpty ? "/" : normalized,
fileId: raw.fileId.isEmpty ? (normalized.isEmpty ? "/" : normalized) : raw.fileId,
etag: raw.etag.trimmingCharacters(in: CharacterSet(charactersIn: "\"")),
lastModified: httpDate.date(from: raw.modified).map { Int64($0.timeIntervalSince1970 * 1000) } ?? 0,
size: Int64(raw.length) ?? 0,
isFolder: raw.isCollection))
}
return entries
}
private static func trimTrailingSlashes(_ path: String) -> String {
var p = path
while p.hasSuffix("/") { p.removeLast() }
return p
}
}
@@ -0,0 +1,120 @@
import Foundation
import Security
/// Every account's sync settings and credentials, kept in the Keychain (the
/// auth header is a secret) as one JSON item. A background run - which may
/// start while the app isn't running - reads these to know what to sync and
/// as whom, and a conflict notification's action looks the account up here
/// instead of carrying credentials in the notification itself.
///
/// `kSecAttrAccessibleAfterFirstUnlock`, not "when unlocked": a background
/// task can fire with the phone locked.
///
/// All accounts live in one item, so a write must never be built on a
/// failed read: an unreadable item makes `upsert`/`remove` do nothing (and
/// say so) rather than save a map holding only one account, and an update
/// replaces the item in place instead of deleting it first.
final class SyncConfigStore {
private struct KeychainError: Error { let status: OSStatus }
private let service: String
private let account = "configs"
private let lock = NSLock()
init(service: String = "dev.ayushya.noo.sync-configs") {
self.service = service
}
/// Empty if there are none - or if the item can't be read, in which case
/// nothing runs in the background until it can.
func all() -> [SyncAccountConfig] {
lock.lock()
defer { lock.unlock() }
return Array(((try? load()) ?? [:]).values).sorted { $0.accountId < $1.accountId }
}
func config(for accountId: String) -> SyncAccountConfig? {
lock.lock()
defer { lock.unlock() }
return (try? load())?[accountId]
}
/// False if it couldn't be stored (the previous contents are untouched).
@discardableResult
func upsert(_ config: SyncAccountConfig) -> Bool {
lock.lock()
defer { lock.unlock() }
do {
var configs = try load()
configs[config.accountId] = config
try save(configs)
return true
} catch {
NSLog("[Sync] couldn't store config for %@: %@", config.accountId, String(describing: error))
return false
}
}
@discardableResult
func remove(accountId: String) -> Bool {
lock.lock()
defer { lock.unlock() }
do {
var configs = try load()
guard configs.removeValue(forKey: accountId) != nil else { return true }
try save(configs)
return true
} catch {
NSLog("[Sync] couldn't remove config for %@: %@", accountId, String(describing: error))
return false
}
}
func removeAll() {
lock.lock()
defer { lock.unlock() }
SecItemDelete(baseQuery() as CFDictionary)
}
// MARK: - Keychain
private func baseQuery() -> [String: Any] {
[
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
]
}
/// `[:]` only when nothing is stored yet; any other failure throws.
private func load() throws -> [String: SyncAccountConfig] {
var query = baseQuery()
query[kSecReturnData as String] = true
query[kSecMatchLimit as String] = kSecMatchLimitOne
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
if status == errSecItemNotFound { return [:] }
guard status == errSecSuccess, let data = result as? Data else { throw KeychainError(status: status) }
return try JSONDecoder().decode([String: SyncAccountConfig].self, from: data)
}
private func save(_ configs: [String: SyncAccountConfig]) throws {
guard !configs.isEmpty else {
let status = SecItemDelete(baseQuery() as CFDictionary)
if status != errSecSuccess && status != errSecItemNotFound { throw KeychainError(status: status) }
return
}
let data = try JSONEncoder().encode(configs) // before touching the item
let status = SecItemUpdate(
baseQuery() as CFDictionary, [kSecValueData as String: data] as CFDictionary)
if status == errSecItemNotFound {
var query = baseQuery()
query[kSecValueData as String] = data
query[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
let added = SecItemAdd(query as CFDictionary, nil)
if added != errSecSuccess { throw KeychainError(status: added) }
} else if status != errSecSuccess {
throw KeychainError(status: status)
}
}
}
@@ -0,0 +1,454 @@
import BackgroundTasks
import Network
import UIKit
import UserNotifications
/// Whether the network currently allows a sync run: any connection normally,
/// but with "Wi-Fi only" nothing metered (cellular, a phone's hotspot) -
/// iOS's equivalent of Android's `NetworkType.UNMETERED` work constraint,
/// which a `BGTask` doesn't offer.
enum NetworkGate {
static func allows(wifiOnly: Bool) async -> Bool {
let monitor = NWPathMonitor()
let queue = DispatchQueue(label: "dev.ayushya.noo.network-gate")
return await withCheckedContinuation { continuation in
var answered = false // only touched on `queue`
monitor.pathUpdateHandler = { path in
guard !answered else { return }
answered = true
monitor.cancel()
let connected = path.status == .satisfied
continuation.resume(returning: connected && (!wifiOnly || !path.isExpensive))
}
monitor.start(queue: queue)
}
}
}
/// Runs an action at most once - for completing a `BGTask`, which must be
/// told it's done exactly one time whether the work finished or the system's
/// time ran out first.
final class OnceGate {
private let lock = NSLock()
private var done = false
private let action: (Bool) -> Void
init(_ action: @escaping (Bool) -> Void) {
self.action = action
}
func finish(_ success: Bool) {
lock.lock()
if done {
lock.unlock()
return
}
done = true
lock.unlock()
action(success)
}
}
/// Runs sync for the app: the "Sync now" / in-app refresh runs the Dart side
/// asks for, the periodic background runs iOS grants through
/// `BGTaskScheduler`, conflict resolution, and the notifications around them.
/// The iOS counterpart of Android's `SyncWorker` scheduling + `MainActivity`'s
/// `sync_service` handlers; the actual walk/diff/transfer is `SyncRunner`.
///
/// Every run - foreground or background - goes through `startRun`, so one
/// per-account record knows what's running, "Sync now" can supersede it and a
/// quiet check can leave it alone, and signing out or removing a path can
/// cancel it. Runs are also serialised against each other (and against
/// removal) by one mutex, and carry the account's *generation*: cancelling or
/// removing bumps it, so a run that was only queued behind another never
/// starts for an account that's gone in the meantime.
///
/// iOS decides *when* background work runs (typically while charging, on
/// Wi-Fi, learned from how you use the phone) - `intervalMinutes` is only the
/// earliest it may start, not a schedule, and a run is cut off when the
/// system's time budget ends (the runner saves its state as it goes).
final class SyncCoordinator {
static let shared = SyncCoordinator()
static let refreshTaskId = "dev.ayushya.noo.sync.refresh"
static let processingTaskId = "dev.ayushya.noo.sync.processing"
static let conflictCategory = "dev.ayushya.noo.sync.conflict"
static let keepLocalAction = "dev.ayushya.noo.sync.keepLocal"
static let useServerAction = "dev.ayushya.noo.sync.useServer"
let store: SyncStore
let bus: SyncStatusBus
let configs: SyncConfigStore
private let runner: SyncRunner
/// One run at a time, app-wide (see `AsyncMutex`).
private let mutex = AsyncMutex()
private let lock = NSLock()
private var running: [String: (token: UUID, task: Task<Void, Never>)] = [:]
private var generations: [String: Int] = [:]
init(
store: SyncStore = .shared, bus: SyncStatusBus = SyncStatusBus(),
configs: SyncConfigStore = SyncConfigStore(), client: DavSyncClient = DavSyncClient()
) {
self.store = store
self.bus = bus
self.configs = configs
self.runner = SyncRunner(client: client, store: store, bus: bus)
}
// MARK: - Settings from Dart
/// Brings the background job in line with [config] (called whenever the
/// synced paths, account or network setting changes). A config with no
/// folders means this account has nothing to sync.
func reschedule(_ config: SyncAccountConfig) {
if config.folders.isEmpty {
cancel(accountId: config.accountId, forget: true)
return
}
configs.upsert(config)
scheduleBackgroundWork()
}
/// [forget] true: the account was removed or signed out - stop its run and
/// drop its settings and credentials. [forget] false: only background sync
/// was turned off (manual refresh is still possible) - the credentials stay,
/// since a conflict notification's actions and the next "Sync now" need
/// them, and a run in progress is left to finish.
func cancel(accountId: String, forget: Bool = true) {
if forget {
cancelRun(accountId: accountId, invalidateQueued: true)
configs.remove(accountId: accountId)
} else if var config = configs.config(for: accountId) {
config.intervalMinutes = nil
configs.upsert(config)
}
scheduleBackgroundWork()
}
/// A one-off run. [force] is an explicit "Sync now" / newly added path:
/// a full walk, a summary notification, and it supersedes any run already
/// going for this account; without it (pull-to-refresh, foreground timer,
/// app resume) it's a quiet check that never interrupts a run in progress.
/// [wifiOnly] non-nil means "respect the Wi-Fi-only setting".
func syncNow(_ config: SyncAccountConfig, force: Bool, wifiOnly: Bool?) {
if config.folders.isEmpty { return }
// Keep the background settings this call doesn't carry.
var stored = config
if let existing = configs.config(for: config.accountId) {
stored.intervalMinutes = existing.intervalMinutes
stored.wifiOnly = existing.wifiOnly
}
configs.upsert(stored)
startRun(stored.withNotify(config.notify), force: force, wifiOnly: wifiOnly)
}
/// Stops [accountId]'s run, if any. [invalidateQueued] also stops runs that
/// are only waiting their turn from ever starting.
private func cancelRun(accountId: String, invalidateQueued: Bool) {
lock.lock()
if invalidateQueued { generations[accountId, default: 0] += 1 }
running[accountId]?.task.cancel()
lock.unlock()
}
private func generation(of accountId: String) -> Int {
lock.lock()
defer { lock.unlock() }
return generations[accountId] ?? 0
}
/// Starts a tracked run, or - for a non-forced one when this account is
/// already running - returns nil and leaves the current run alone.
@discardableResult
private func startRun(_ config: SyncAccountConfig, force: Bool, wifiOnly: Bool?) -> Task<Void, Never>? {
lock.lock()
if let existing = running[config.accountId] {
if force {
existing.task.cancel()
} else {
lock.unlock()
return nil
}
}
let token = UUID()
let generation = generations[config.accountId] ?? 0
let task = Task { [weak self] in
await self?.execute(config, force: force, wifiOnly: wifiOnly, generation: generation)
self?.finished(accountId: config.accountId, token: token)
}
running[config.accountId] = (token, task)
lock.unlock()
return task
}
private func finished(accountId: String, token: UUID) {
lock.lock()
if running[accountId]?.token == token { running[accountId] = nil }
lock.unlock()
}
/// Turning sync off for [path]: stop this account's run first and do the
/// removal under the same mutex as runs - a run still holding the old
/// state would otherwise write it back over the removal (and a transfer
/// finishing after it would leave a file the state doesn't know about).
func removeLocalSync(accountId: String, path: String) async {
cancelRun(accountId: accountId, invalidateQueued: true)
_ = try? await mutex.withLock {
store.removeLocalSync(accountId: accountId, path: path)
}
}
/// The map Dart's `SyncStatusSnapshot.fromMap` reads. [accountIdOverride]
/// is the account Dart is asking about: the bus only learns an account once
/// a run has happened in this process, so on a fresh start the durable
/// per-file synced state would otherwise be reported as empty.
func statusMap(_ status: SyncStatusBus.Status, accountIdOverride: String? = nil) -> [String: Any] {
let accountId = accountIdOverride ?? status.accountId
// Live syncing state belongs to whichever account last synced.
let busMatches = status.accountId == nil || status.accountId == accountId
let synced = accountId.map { Array(((try? store.loadState(accountId: $0)) ?? [:]).keys) } ?? []
let missing = accountId.map { Array(store.loadMissingRoots(accountId: $0)) } ?? []
return [
"accountId": accountId as Any,
"syncing": busMatches && status.syncing,
"syncingFileIds": busMatches ? Array(status.syncingFileIds) : [],
"syncedFileIds": synced,
"missingRoots": missing,
// Conflicts are per account (a file id is only unique within one).
"conflicts": status.conflicts.filter { $0.accountId == accountId }.map {
[
"accountId": $0.accountId, "fileId": $0.fileId, "remotePath": $0.remotePath,
"relPath": $0.relPath, "name": $0.name,
]
},
]
}
// MARK: - Running
/// One account's pass, serialised against every other run.
private func execute(
_ config: SyncAccountConfig, force: Bool, wifiOnly: Bool?, generation: Int
) async {
// A few extra seconds if the app is backgrounded mid-run; if even those
// run out, stop this account's run so it saves and ends cleanly.
let box = BackgroundTaskBox()
box.id = await MainActor.run {
UIApplication.shared.beginBackgroundTask(withName: "noo.sync") { [weak self] in
self?.cancelRun(accountId: config.accountId, invalidateQueued: false)
UIApplication.shared.endBackgroundTask(box.id)
}
}
defer { Task { @MainActor in UIApplication.shared.endBackgroundTask(box.id) } }
do {
try await mutex.withLock {
try Task.checkCancellation()
// Gone (signed out / removed) while this was queued?
guard self.generation(of: config.accountId) == generation,
self.configs.config(for: config.accountId) != nil
else { return }
if let wifiOnly, await !NetworkGate.allows(wifiOnly: wifiOnly) { return }
let summary = await runner.run(config, force: force)
if summary.changedAnything && (force || config.notify) {
SyncNotifications.summary(summary, username: config.username, accountId: config.accountId)
}
SyncNotifications.conflicts(summary.conflicts, username: config.username)
}
} catch {
// Cancelled while waiting for its turn - nothing ran.
}
}
/// Resolves a conflict from a notification action, using the stored
/// credentials for its account.
func resolveConflict(_ conflict: SyncConflict, resolution: String, creds: SyncCredentials) async -> Bool {
(try? await mutex.withLock {
await runner.resolveConflict(conflict, resolution: resolution, creds: creds)
}) ?? false
}
// MARK: - Background tasks
/// Registers the two `BGTask` handlers. Must run before the app finishes
/// launching (see `AppDelegate`).
func registerBackgroundTasks() {
for id in [Self.refreshTaskId, Self.processingTaskId] {
BGTaskScheduler.shared.register(forTaskWithIdentifier: id, using: nil) { [weak self] task in
self?.handleBackground(task)
}
}
}
/// Asks iOS for the next background run: a short app-refresh slot and a
/// longer processing slot (the system picks when, e.g. overnight on power).
/// Nothing is requested when no account has background sync turned on.
func scheduleBackgroundWork() {
let scheduler = BGTaskScheduler.shared
let due = backgroundConfigs()
guard let minutes = due.compactMap(\.intervalMinutes).min() else {
scheduler.cancel(taskRequestWithIdentifier: Self.refreshTaskId)
scheduler.cancel(taskRequestWithIdentifier: Self.processingTaskId)
return
}
// Same floor as Android's periodic work.
let earliest = Date(timeIntervalSinceNow: TimeInterval(max(minutes, 15) * 60))
let refresh = BGAppRefreshTaskRequest(identifier: Self.refreshTaskId)
refresh.earliestBeginDate = earliest
let processing = BGProcessingTaskRequest(identifier: Self.processingTaskId)
processing.earliestBeginDate = earliest
processing.requiresNetworkConnectivity = true
processing.requiresExternalPower = false
for request in [refresh, processing] as [BGTaskRequest] {
do { try scheduler.submit(request) } catch {
NSLog("[Sync] couldn't schedule %@: %@", request.identifier, String(describing: error))
}
}
}
private func backgroundConfigs() -> [SyncAccountConfig] {
configs.all().filter { $0.intervalMinutes != nil && !$0.folders.isEmpty }
}
/// A background slot: runs every account that has background sync on, each
/// through the normal tracked path (so it can be cancelled by sign-out, and
/// a run already going is left alone). The task is reported complete
/// exactly once - when the work ends or when the system's time does.
private func handleBackground(_ task: BGTask) {
scheduleBackgroundWork() // line up the next one before this one runs
task.expirationHandler = startBackgroundSlot { task.setTaskCompleted(success: $0) }
}
/// Runs a background slot and returns its expiration handler.
@discardableResult
func startBackgroundSlot(completion: @escaping (Bool) -> Void) -> () -> Void {
let gate = OnceGate(completion)
let accountIds = backgroundConfigs().map(\.accountId)
let work = Task { [weak self] in
guard let self else { return gate.finish(false) }
for config in self.backgroundConfigs() {
if Task.isCancelled { break }
await self.startRun(config, force: false, wifiOnly: config.wifiOnly)?.value
}
gate.finish(!Task.isCancelled)
}
// The system's time is up: stop the work and the runs it started (each
// saves what it has) and report.
return { [weak self] in
work.cancel()
for id in accountIds { self?.cancelRun(accountId: id, invalidateQueued: false) }
gate.finish(false)
}
}
// MARK: - Notifications
/// Registers the conflict notification's actions. Call once at launch.
func registerNotificationCategories() {
let keep = UNNotificationAction(identifier: Self.keepLocalAction, title: "Keep local", options: [])
let server = UNNotificationAction(identifier: Self.useServerAction, title: "Use server", options: [])
let category = UNNotificationCategory(
identifier: Self.conflictCategory, actions: [keep, server], intentIdentifiers: [], options: [])
UNUserNotificationCenter.current().setNotificationCategories([category])
}
/// Handles a tap on a conflict notification's action. Returns whether the
/// response was ours (so `AppDelegate` knows not to pass it on).
@MainActor
func handleNotificationResponse(
_ response: UNNotificationResponse, completion: @escaping () -> Void
) -> Bool {
let content = response.notification.request.content
guard content.categoryIdentifier == Self.conflictCategory else { return false }
let resolution: String
switch response.actionIdentifier {
case Self.keepLocalAction: resolution = "local"
case Self.useServerAction: resolution = "server"
default:
completion() // plain tap: just opens the app
return true
}
let info = content.userInfo
guard let accountId = info["accountId"] as? String,
let fileId = info["fileId"] as? String,
let remotePath = info["remotePath"] as? String,
let relPath = info["relPath"] as? String,
let config = configs.config(for: accountId)
else {
completion()
return true
}
let conflict = SyncConflict(
accountId: accountId, fileId: fileId, remotePath: remotePath, relPath: relPath,
name: (relPath as NSString).lastPathComponent)
let creds = SyncCredentials(serverUrl: config.serverUrl, username: config.username, authHeader: config.authHeader)
let box = BackgroundTaskBox()
box.id = UIApplication.shared.beginBackgroundTask(withName: "noo.sync.resolve") {
completion()
UIApplication.shared.endBackgroundTask(box.id)
}
Task {
let ok = await self.resolveConflict(conflict, resolution: resolution, creds: creds)
if ok {
UNUserNotificationCenter.current().removeDeliveredNotifications(
withIdentifiers: [response.notification.request.identifier])
}
completion()
await MainActor.run { UIApplication.shared.endBackgroundTask(box.id) }
}
return true
}
}
/// Holds a `UIBackgroundTaskIdentifier` so an expiration handler (created
/// before the identifier exists) can end the task it belongs to.
final class BackgroundTaskBox {
var id: UIBackgroundTaskIdentifier = .invalid
}
extension SyncAccountConfig {
func withNotify(_ notify: Bool) -> SyncAccountConfig {
var copy = self
copy.notify = notify
return copy
}
}
/// The local notifications sync posts: a summary of what changed, and one per
/// conflict (which always posts - it needs a decision) with Keep local / Use
/// server actions.
enum SyncNotifications {
static func summary(_ summary: SyncRunSummary, username: String, accountId: String) {
var parts: [String] = []
if summary.downloaded > 0 { parts.append("\(summary.downloaded) updated") }
if summary.uploaded > 0 { parts.append("\(summary.uploaded) uploaded") }
if summary.deleted > 0 { parts.append("\(summary.deleted) removed") }
let content = UNMutableNotificationContent()
content.title = "Noo sync · \(username)"
content.body = parts.joined(separator: ", ")
// One per account, so a newer summary replaces an older one in place.
post(content, id: "sync.summary.\(accountId)")
}
static func conflicts(_ conflicts: [SyncConflict], username: String) {
for conflict in conflicts {
let content = UNMutableNotificationContent()
content.title = "Sync conflict: \(conflict.name)"
content.body = "\(username) - changed both on this device and on the server."
content.categoryIdentifier = SyncCoordinator.conflictCategory
content.userInfo = [
"accountId": conflict.accountId, "fileId": conflict.fileId,
"remotePath": conflict.remotePath, "relPath": conflict.relPath,
]
post(content, id: "sync.conflict.\(conflict.accountId).\(conflict.relPath)")
}
}
private static func post(_ content: UNMutableNotificationContent, id: String) {
UNUserNotificationCenter.current().add(UNNotificationRequest(identifier: id, content: content, trigger: nil))
}
}
+186
View File
@@ -0,0 +1,186 @@
import Foundation
/// A local file's size and modification time (ms), the pair sync compares
/// against what it recorded.
struct LocalStat: Equatable {
let size: Int64
let mtimeMs: Int64
}
enum LocalFS {
/// nil if [url] doesn't exist.
static func stat(_ url: URL) -> LocalStat? {
guard let attrs = try? FileManager.default.attributesOfItem(atPath: url.path) else { return nil }
let size = (attrs[.size] as? NSNumber)?.int64Value ?? 0
let mtime = (attrs[.modificationDate] as? Date).map { Int64($0.timeIntervalSince1970 * 1000) } ?? 0
return LocalStat(size: size, mtimeMs: mtime)
}
static func relative(_ path: String) -> String {
path.hasPrefix("/") ? String(path.dropFirst()) : path
}
static func trimTrailingSlashes(_ path: String) -> String {
var p = path
while p.hasSuffix("/") { p.removeLast() }
return p
}
/// [rel] under [root], or nil if it isn't safely inside it: empty, with a
/// `.`/`..` component, or resolving outside [root]. Every path that came
/// from the server goes through this before anything is read, written or
/// deleted, so a hostile or buggy server can't point sync at other files.
static func resolve(_ rel: String, in root: URL) -> URL? {
let parts = rel.split(separator: "/", omittingEmptySubsequences: true)
guard !parts.isEmpty, !parts.contains(where: { $0 == ".." || $0 == "." }) else { return nil }
let url = root.appendingPathComponent(rel)
let rootPath = root.standardizedFileURL.path
guard url.standardizedFileURL.path.hasPrefix(rootPath + "/") else { return nil }
return url
}
}
/// The decisions of a sync pass, kept free of networking so they can be
/// unit-tested. A direct port of Android's `SyncEngine.diffFolder` and its
/// helpers - the same rules, so a device behaves the same on either platform.
enum SyncDiff {
/// Diffs one synced path's remote manifest against the recorded state.
/// Callers own actually doing the GET/PUT/delete and updating state.
///
/// - A file with no recorded state is simply pulled.
/// - Server changed and device changed -> conflict (never silently overwrite).
/// - Only the server changed -> download. Only the device changed -> upload.
/// - Recorded but missing on the device -> pull it back (it's inside a path
/// the user asked to sync, so a vanished copy is repaired, not propagated).
/// - Recorded for this path but absent from a *successful* listing ->
/// deleted on the server, so the local copy goes too. (An unreachable
/// server never gets here - the caller skips the path instead.)
static func diffFolder(
entries: [SyncRemoteEntry],
state: [String: SyncFileState],
priorFolderFileIds: Set<String>,
syncRoot: URL
) -> [SyncAction] {
var actions: [SyncAction] = []
var seen = Set<String>()
for entry in entries where !entry.isFolder {
seen.insert(entry.fileId)
let rel = LocalFS.relative(entry.path)
let local = LocalFS.stat(syncRoot.appendingPathComponent(rel))
guard let prior = state[entry.fileId] else {
actions.append(.download(entry))
continue
}
let serverChanged = entry.etag != prior.etag
let localChanged = local.map { $0.mtimeMs != prior.localMTime || $0.size != prior.size } ?? false
if local == nil {
actions.append(.download(entry))
} else if serverChanged && localChanged {
actions.append(.conflict(entry, relPath: rel))
} else if serverChanged {
actions.append(.download(entry))
} else if localChanged {
actions.append(.upload(relPath: rel, fileId: entry.fileId))
}
}
for fileId in priorFolderFileIds where !seen.contains(fileId) {
guard let prior = state[fileId] else { continue }
actions.append(.delete(relPath: prior.relPath, fileId: fileId))
}
return actions
}
/// The recorded files that belong to the synced [path]: everything for the
/// root ("/"), else the file itself or anything under it - matched on a
/// path-component boundary, so "/Docs" never claims "/Docs2" or
/// "/Documents".
static func priorFileIds(state: [String: SyncFileState], path: String) -> Set<String> {
let rootRel = LocalFS.relative(LocalFS.trimTrailingSlashes(path))
return Set(
state.filter {
rootRel.isEmpty || $0.value.relPath == rootRel || $0.value.relPath.hasPrefix(rootRel + "/")
}.keys)
}
/// Local paths (as spelled by the server) that more than one remote entry
/// would land on: "a.txt" and "A.txt" on a case-insensitive volume, or two
/// canonically-equivalent Unicode spellings. Downloading either would
/// overwrite the other, so callers skip them.
static func collidingRelPaths(_ entries: [SyncRemoteEntry]) -> Set<String> {
var byKey: [String: [String]] = [:]
for entry in entries {
let rel = LocalFS.relative(entry.path)
byKey[rel.precomposedStringWithCanonicalMapping.lowercased(), default: []].append(rel)
}
// Compared as raw bytes: Swift's own `String` equality already treats
// canonically equivalent spellings as the same string, which is exactly
// the distinction the server preserves and the disk may not.
return Set(byKey.values.filter { Set($0.map { Array($0.utf8) }).count > 1 }.flatMap { $0 })
}
/// True if every file in [fileIds] is still on disk exactly as recorded
/// (same size and mtime): nothing local to upload and nothing missing to
/// re-download. Pure local stat calls, no network.
static func localMatchesState(
state: [String: SyncFileState], fileIds: Set<String>, syncRoot: URL
) -> Bool {
fileIds.allSatisfy { id in
guard let s = state[id], let local = LocalFS.stat(syncRoot.appendingPathComponent(s.relPath)) else {
return false
}
return local.size == s.size && local.mtimeMs == s.localMTime
}
}
/// Creates a local directory for every remote folder, so empty folders
/// exist on the device too.
static func mirrorFolders(
entries: [SyncRemoteEntry], syncRoot: URL, rootRel: String, collisions: Set<String> = []
) {
let fm = FileManager.default
if !rootRel.isEmpty, let dir = LocalFS.resolve(rootRel, in: syncRoot) {
try? fm.createDirectory(at: dir, withIntermediateDirectories: true)
}
for entry in entries where entry.isFolder {
let rel = entry.path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
// Skip anything unsafe or that would share a local folder with another
// remote folder (a/A on a case-insensitive volume).
guard !collisions.contains(rel), let dir = LocalFS.resolve(rel, in: syncRoot) else { continue }
try? fm.createDirectory(at: dir, withIntermediateDirectories: true)
}
}
/// Removes local directories under [rootRel] that no longer exist on the
/// server ([remoteFolders], relative paths). Only *empty* directories go:
/// by the time this runs the diff has already deleted the files that were
/// inside a deleted server folder, while a directory that still holds
/// something (a file this device created that hasn't synced) is left alone.
/// Never removes the sync root itself.
static func pruneRemovedFolders(syncRoot: URL, rootRel: String, remoteFolders: Set<String>) {
let fm = FileManager.default
let top = rootRel.isEmpty ? syncRoot : syncRoot.appendingPathComponent(rootRel)
var isDir: ObjCBool = false
guard fm.fileExists(atPath: top.path, isDirectory: &isDir), isDir.boolValue else { return }
func prune(_ dir: URL, rel: String) {
let children = (try? fm.contentsOfDirectory(at: dir, includingPropertiesForKeys: [.isDirectoryKey])) ?? []
for child in children {
let childIsDir = (try? child.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false
if childIsDir {
prune(child, rel: rel.isEmpty ? child.lastPathComponent : rel + "/" + child.lastPathComponent)
}
}
guard !rel.isEmpty, !remoteFolders.contains(rel),
let remaining = try? fm.contentsOfDirectory(atPath: dir.path), remaining.isEmpty
else { return }
// rmdir, not removeItem: it refuses a directory that isn't empty, so a
// file that appeared since the check survives.
rmdir(dir.path)
}
prune(top, rel: rootRel)
}
}
@@ -0,0 +1,95 @@
import Foundation
// The iOS port of Android's `SyncEngine.kt`/`SyncWorker.kt` data model. Times
// are milliseconds since the epoch, like the Kotlin side, so the persisted
// state means the same thing on both platforms.
/// One file or folder the server reported in a PROPFIND.
struct SyncRemoteEntry: Equatable {
/// Path relative to the user's files root, no trailing slash ("/Docs/a.txt").
let path: String
let fileId: String
let etag: String
let lastModified: Int64
let size: Int64
let isFolder: Bool
}
/// What the last successful sync recorded for a file - the baseline a later
/// pass diffs the server and the device against.
struct SyncFileState: Codable, Equatable {
let relPath: String
let etag: String
let lastModified: Int64
let size: Int64
let localMTime: Int64
}
/// A configured sync root's etag at the end of its last clean, complete walk.
/// Nextcloud propagates a change to any descendant up through every ancestor's
/// etag, so an unchanged root etag means nothing beneath it changed.
struct SyncRootMarker: Codable, Equatable {
let etag: String
let fullWalkAt: Int64
}
enum SyncAction: Equatable {
case download(SyncRemoteEntry)
case upload(relPath: String, fileId: String)
case delete(relPath: String, fileId: String)
case conflict(SyncRemoteEntry, relPath: String)
}
/// A file changed both on the device and on the server since the last sync.
struct SyncConflict: Codable, Equatable {
let accountId: String
let fileId: String
let remotePath: String
let relPath: String
let name: String
}
/// Everything a sync run (foreground or background) needs for one account.
/// Persisted in the Keychain - it carries the auth header.
struct SyncAccountConfig: Codable, Equatable {
let accountId: String
let serverUrl: String
let username: String
let authHeader: String
/// Remote paths (files or folders) to mirror.
var folders: [String]
var wifiOnly: Bool
/// Minutes between background syncs; nil = no background sync (manual and
/// in-app refresh only).
var intervalMinutes: Int?
/// Whether automatic runs may post a summary notification.
var notify: Bool
}
struct SyncRunSummary: Equatable {
var downloaded = 0
var uploaded = 0
var deleted = 0
var conflicts: [SyncConflict] = []
var changedAnything: Bool { downloaded > 0 || uploaded > 0 || deleted > 0 }
}
/// The server couldn't be reached or answered with an error (anything but a
/// clean 404). Distinct from "the path is genuinely gone": treating a network
/// blip as an empty listing made every previously synced file look deleted
/// server-side, and the diff then deleted the local copies. Callers skip the
/// affected path for this run instead.
struct SyncRemoteUnavailable: Error, CustomStringConvertible {
let message: String
var description: String { message }
}
/// The recorded sync state exists but couldn't be read (corrupt, or an I/O
/// error). Treating that as "nothing recorded yet" would make every local
/// file look untracked and overwrite local edits with the server's copy, so
/// a run that hits it does nothing at all.
struct SyncStateUnreadable: Error, CustomStringConvertible {
let accountId: String
var description: String { "Sync state for \(accountId) is unreadable" }
}
@@ -0,0 +1,387 @@
import Foundation
/// A mutex for `async` code: one holder at a time, waiters resume in order.
/// (An `actor` isn't enough - it lets other calls in at every `await`.) Held
/// for the whole of any run that reads-then-rewrites a sync state map, which
/// is also what keeps `SyncStatusBus` - it tracks one account at a time -
/// coherent when several accounts are due.
///
/// Waiting is cancellable: a task that's cancelled while queued (a background
/// run whose time budget expired, a superseded "Sync now") leaves the queue
/// at once with `CancellationError` instead of sitting behind a long run.
final class AsyncMutex {
private let lock = NSLock()
private var locked = false
private var waiters: [(id: UUID, continuation: CheckedContinuation<Void, Error>)] = []
func withLock<T>(_ body: () async throws -> T) async throws -> T {
try await acquire()
defer { release() }
return try await body()
}
private func acquire() async throws {
let id = UUID()
try await withTaskCancellationHandler {
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
lock.lock()
if Task.isCancelled {
lock.unlock()
continuation.resume(throwing: CancellationError())
} else if !locked {
locked = true
lock.unlock()
continuation.resume()
} else {
waiters.append((id, continuation))
lock.unlock()
}
}
} onCancel: {
lock.lock()
if let index = waiters.firstIndex(where: { $0.id == id }) {
let waiter = waiters.remove(at: index)
lock.unlock()
waiter.continuation.resume(throwing: CancellationError())
} else {
lock.unlock()
}
}
}
private func release() {
lock.lock()
if waiters.isEmpty {
locked = false
lock.unlock()
} else {
let next = waiters.removeFirst()
lock.unlock()
next.continuation.resume()
}
}
}
/// Live sync status for the app: whether a run is going, which files are
/// transferring, and unresolved conflicts. In-memory only - what actually
/// answers "is this file synced" is the on-disk state; this carries just the
/// transient parts. The iOS twin of Android's `SyncStatusBus`, except that
/// conflicts are kept per account (a file id is only unique within one).
final class SyncStatusBus {
struct Status: Equatable {
var accountId: String?
var syncing = false
var syncingFileIds: Set<String> = []
/// Every account's unresolved conflicts - readers filter by account.
var conflicts: [SyncConflict] = []
}
private let lock = NSLock()
private var current = Status()
/// Called (on whatever thread changed it) after every update.
var onChange: ((Status) -> Void)?
func snapshot() -> Status {
lock.lock()
defer { lock.unlock() }
return current
}
func setSyncing(accountId: String, _ syncing: Bool) {
update {
$0.accountId = accountId
$0.syncing = syncing
if !syncing { $0.syncingFileIds = [] }
}
}
func markFileSyncing(accountId: String, fileId: String, _ syncing: Bool) {
update {
$0.accountId = accountId
if syncing { $0.syncingFileIds.insert(fileId) } else { $0.syncingFileIds.remove(fileId) }
}
}
func addConflicts(accountId: String, _ new: [SyncConflict]) {
guard !new.isEmpty else { return }
update {
$0.accountId = accountId
for conflict in new
where !$0.conflicts.contains(where: { $0.accountId == conflict.accountId && $0.fileId == conflict.fileId }) {
$0.conflicts.append(conflict)
}
}
}
func removeConflict(accountId: String, fileId: String) {
update {
$0.accountId = accountId
$0.conflicts.removeAll { $0.accountId == accountId && $0.fileId == fileId }
}
}
private func update(_ mutate: (inout Status) -> Void) {
lock.lock()
mutate(&current)
let snapshot = current
lock.unlock()
onChange?(snapshot)
}
}
/// One account's sync pass: walk each configured path, diff it against the
/// recorded state, transfer, and record the result. The iOS port of Android's
/// `SyncWorker.performSync` (and `ConflictResolveWorker`), with the same
/// safeguards - and a few more, found in review:
///
/// - If the server can't be reached for a path - or answers with something
/// that isn't a complete, plausible listing - that path is skipped for this
/// run, never diffed against an empty or partial listing, which would look
/// like files were deleted and wipe the local copies.
/// - If the recorded state can't be read the run does nothing at all (it
/// would otherwise treat every local file as untracked and overwrite local
/// edits with the server's copies).
/// - Paths the server sends are checked to stay inside the mirror, and two
/// remote files that would land on the same local file are both skipped.
/// - Server deletions remove the local copy, including local edits, like Android.
/// - A root's etag is only remembered once its whole path synced cleanly, and
/// only if the state it relies on was actually saved.
struct SyncRunner {
let client: DavSyncClient
let store: SyncStore
let bus: SyncStatusBus
var now: () -> Int64 = { Int64(Date().timeIntervalSince1970 * 1000) }
/// Even when a root's etag hasn't changed, walk it in full at least this
/// often: etag propagation isn't reliable everywhere (external storage
/// mounts in particular), so this bounds how stale a mirror can get.
static let fullWalkMaxAgeMs: Int64 = 6 * 60 * 60 * 1000
/// State is flushed to disk after this many transfers.
static let saveEvery = 20
/// [force] is an explicit "Sync now" / newly added path: always a full
/// walk, never trusting the root-etag shortcut.
func run(_ config: SyncAccountConfig, force: Bool) async -> SyncRunSummary {
var summary = SyncRunSummary()
guard !config.folders.isEmpty else { return summary }
let accountId = config.accountId
let creds = SyncCredentials(serverUrl: config.serverUrl, username: config.username, authHeader: config.authHeader)
let syncRoot = store.syncRoot(accountId: accountId)
var state: [String: SyncFileState]
do {
state = try store.loadState(accountId: accountId)
} catch {
NSLog("[Sync] %@ - skipping this run", String(describing: error))
return summary
}
var markers = store.loadRootMarkers(accountId: accountId)
var sinceSave = 0
bus.setSyncing(accountId: accountId, true)
defer {
// State first, then the markers that depend on it, then announce.
if store.saveState(accountId: accountId, state) {
markers = markers.filter { config.folders.contains($0.key) }
store.saveRootMarkers(accountId: accountId, markers)
} else {
NSLog("[Sync] couldn't save state for %@; not recording this run's markers", accountId)
}
bus.setSyncing(accountId: accountId, false)
}
func transferred() {
sinceSave += 1
if sinceSave >= Self.saveEvery {
if !store.saveState(accountId: accountId, state) { NSLog("[Sync] periodic state save failed") }
sinceSave = 0
}
}
for path in config.folders {
if Task.isCancelled { break }
do {
// A configured path can be a file or a folder - check which before
// deciding whether to walk it or just diff the single item.
let selfEntry = try await client.propfindSelf(creds, path: path)
let priorIds = SyncDiff.priorFileIds(state: state, path: path)
// Cheap "did anything change?" check: an unchanged root etag means
// nothing beneath it changed on the server, and if every local file
// is also still as recorded there's nothing to upload or re-download
// either - skip the walk (one Depth-0 PROPFIND instead of one per
// subfolder).
if !force, let root = selfEntry, root.isFolder, let marker = markers[path],
marker.etag == root.etag,
now() - marker.fullWalkAt < Self.fullWalkMaxAgeMs,
!priorIds.isEmpty,
SyncDiff.localMatchesState(state: state, fileIds: priorIds, syncRoot: syncRoot)
{
continue
}
// The server says this synced path no longer exists (a clean 404, not
// a network error): flag it so the app drops it from its synced list.
store.setRootMissing(accountId: accountId, path: path, missing: selfEntry == nil)
var entries: [SyncRemoteEntry] = []
if let root = selfEntry {
if root.isFolder {
entries = try await client.walk(creds, root: path)
} else {
entries = [root]
}
}
var pathClean = true
let collisions = SyncDiff.collidingRelPaths(entries)
let actions = SyncDiff.diffFolder(
entries: entries, state: state, priorFolderFileIds: priorIds, syncRoot: syncRoot)
for action in actions {
try Task.checkCancellation()
switch action {
case .download(let entry):
let rel = LocalFS.relative(entry.path)
guard !collisions.contains(rel), let dest = LocalFS.resolve(rel, in: syncRoot) else {
NSLog("[Sync] not downloading %@: unsafe or colliding local path", rel)
pathClean = false
continue
}
bus.markFileSyncing(accountId: accountId, fileId: entry.fileId, true)
let ok = try await client.download(creds, remotePath: entry.path, to: dest)
bus.markFileSyncing(accountId: accountId, fileId: entry.fileId, false)
if ok {
let local = LocalFS.stat(dest)
state[entry.fileId] = SyncFileState(
relPath: rel, etag: entry.etag, lastModified: entry.lastModified,
size: local?.size ?? entry.size, localMTime: local?.mtimeMs ?? 0)
summary.downloaded += 1
transferred()
} else {
pathClean = false
}
case .upload(let rel, let fileId):
guard let local = LocalFS.resolve(rel, in: syncRoot) else {
pathClean = false
continue
}
bus.markFileSyncing(accountId: accountId, fileId: fileId, true)
var ok = false
if FileManager.default.fileExists(atPath: local.path) {
ok = try await client.upload(creds, remotePath: "/" + rel, from: local)
}
bus.markFileSyncing(accountId: accountId, fileId: fileId, false)
if ok, let stat = LocalFS.stat(local) {
// Our own PUT changed the file's etag; record the new one, or
// the next pass would see "server changed" and re-download it.
// If it can't be read back, keep the old one and let the root
// be walked for real next time.
let fresh = (try? await client.propfindSelf(creds, path: "/" + rel)) ?? nil
if fresh == nil { pathClean = false }
state[fileId] = SyncFileState(
relPath: rel, etag: fresh?.etag ?? state[fileId]?.etag ?? "",
lastModified: stat.mtimeMs, size: stat.size, localMTime: stat.mtimeMs)
summary.uploaded += 1
transferred()
} else {
pathClean = false
}
case .delete(let rel, let fileId):
// Only counts as a removal if a local copy existed and went;
// dropping stale state for a file never on disk isn't news.
if let local = LocalFS.resolve(rel, in: syncRoot),
FileManager.default.fileExists(atPath: local.path),
(try? FileManager.default.removeItem(at: local)) != nil
{
summary.deleted += 1
}
state[fileId] = nil
case .conflict(let entry, let rel):
summary.conflicts.append(
SyncConflict(
accountId: accountId, fileId: entry.fileId, remotePath: entry.path, relPath: rel,
name: (rel as NSString).lastPathComponent))
pathClean = false
}
}
// Mirror the folder structure itself: empty folders created on the
// server appear on the device, and folders deleted on the server
// (whose files the diff has already removed) don't linger.
if selfEntry == nil || selfEntry?.isFolder == true {
let rootRel = path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
if selfEntry != nil {
SyncDiff.mirrorFolders(entries: entries, syncRoot: syncRoot, rootRel: rootRel, collisions: collisions)
}
var remoteFolders = Set(
entries.filter(\.isFolder).map { $0.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) })
if selfEntry != nil { remoteFolders.insert(rootRel) }
SyncDiff.pruneRemovedFolders(syncRoot: syncRoot, rootRel: rootRel, remoteFolders: remoteFolders)
}
if let root = selfEntry, root.isFolder, pathClean {
markers[path] = SyncRootMarker(etag: root.etag, fullWalkAt: now())
} else {
markers[path] = nil
}
} catch is CancellationError {
markers[path] = nil // cut short: make the next run walk it for real
break
} catch {
// Couldn't reach the server for this path (or it answered with
// nothing usable): leave its state and marker alone and try again
// next run.
NSLog("[Sync] skipping %@ this run: %@", path, String(describing: error))
}
}
if !summary.conflicts.isEmpty {
bus.addConflicts(accountId: accountId, summary.conflicts)
}
return summary
}
/// Applies the user's choice for one conflicted file - `local` uploads the
/// device's copy over the server's, `server` downloads the server's over
/// the device's - then refreshes the recorded etag so the next pass doesn't
/// immediately re-flag it. Returns whether it worked.
func resolveConflict(
_ conflict: SyncConflict, resolution: String, creds: SyncCredentials
) async -> Bool {
// An unreadable baseline must not be overwritten with a one-file one.
guard var state = try? store.loadState(accountId: conflict.accountId),
let local = LocalFS.resolve(conflict.relPath, in: store.syncRoot(accountId: conflict.accountId))
else { return false }
var ok = false
do {
switch resolution {
case "local":
if FileManager.default.fileExists(atPath: local.path) {
ok = try await client.upload(creds, remotePath: conflict.remotePath, from: local)
}
case "server":
ok = try await client.download(creds, remotePath: conflict.remotePath, to: local)
default:
break
}
} catch {
return false
}
guard ok else { return false }
let fresh = (try? await client.propfindSelf(creds, path: conflict.remotePath)) ?? nil
let stat = LocalFS.stat(local)
state[conflict.fileId] = SyncFileState(
relPath: conflict.relPath, etag: fresh?.etag ?? "", lastModified: fresh?.lastModified ?? 0,
size: stat?.size ?? 0, localMTime: stat?.mtimeMs ?? 0)
store.saveState(accountId: conflict.accountId, state)
bus.removeConflict(accountId: conflict.accountId, fileId: conflict.fileId)
return true
}
}
@@ -0,0 +1,175 @@
import Foundation
/// The on-device side of sync: the mirrored files and the per-account sync
/// state. Laid out under one base directory (Application Support, which is
/// also where Dart's `SyncService.baseDirectory()` looks for the mirror):
///
/// <base>/sync/<accountId>/... the mirrored files
/// <base>/sync-state/<accountId>/*.json state, root markers, missing roots
///
/// Plain JSON files rather than `UserDefaults`: the state map grows with the
/// number of synced files. Not thread-safe by itself - the sync runner holds
/// one lock around anything that reads-then-rewrites it (see `AsyncMutex`).
final class SyncStore {
/// Bumped whenever a walk starts doing something new (e.g. mirroring empty
/// folders) so markers recorded by an older version - which would make the
/// next run skip the walk - are ignored once. Same value as Android.
static let rootsVersion = 2
static let shared = SyncStore(base: defaultBase)
static var defaultBase: URL {
FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
}
let base: URL
private let fileManager = FileManager.default
init(base: URL) {
self.base = base
}
// MARK: - Locations
/// The account's mirror folder, created on demand. Marked excluded from
/// iCloud/device backups: it's a re-downloadable copy of server data.
func syncRoot(accountId: String) -> URL {
let top = base.appendingPathComponent("sync", isDirectory: true)
let root = top.appendingPathComponent(accountId, isDirectory: true)
try? fileManager.createDirectory(at: root, withIntermediateDirectories: true)
var values = URLResourceValues()
values.isExcludedFromBackup = true
var topMutable = top
try? topMutable.setResourceValues(values)
return root
}
private func stateDirectory(accountId: String) -> URL {
let dir = base.appendingPathComponent("sync-state", isDirectory: true)
.appendingPathComponent(accountId, isDirectory: true)
try? fileManager.createDirectory(at: dir, withIntermediateDirectories: true)
return dir
}
private func file(_ accountId: String, _ name: String) -> URL {
stateDirectory(accountId: accountId).appendingPathComponent(name)
}
// MARK: - Per-file state
/// The recorded baseline - empty if nothing has been recorded yet, and
/// *throws* if there is a file that can't be read or decoded (see
/// `SyncStateUnreadable`).
func loadState(accountId: String) throws -> [String: SyncFileState] {
let url = file(accountId, "state.json")
guard fileManager.fileExists(atPath: url.path) else { return [:] }
do {
return try JSONDecoder().decode([String: SyncFileState].self, from: Data(contentsOf: url))
} catch {
throw SyncStateUnreadable(accountId: accountId)
}
}
/// False if the state couldn't be written - callers must not then record
/// anything that assumes it was.
@discardableResult
func saveState(accountId: String, _ state: [String: SyncFileState]) -> Bool {
write(state, to: file(accountId, "state.json"))
}
// MARK: - Root markers
private struct MarkersFile: Codable {
let version: Int
let markers: [String: SyncRootMarker]
}
func loadRootMarkers(accountId: String) -> [String: SyncRootMarker] {
guard let stored = read(MarkersFile.self, from: file(accountId, "roots.json")),
stored.version == Self.rootsVersion
else { return [:] }
return stored.markers
}
@discardableResult
func saveRootMarkers(accountId: String, _ markers: [String: SyncRootMarker]) -> Bool {
write(MarkersFile(version: Self.rootsVersion, markers: markers), to: file(accountId, "roots.json"))
}
// MARK: - Roots the server says are gone
func loadMissingRoots(accountId: String) -> Set<String> {
Set(read([String].self, from: file(accountId, "missing.json")) ?? [])
}
func setRootMissing(accountId: String, path: String, missing: Bool) {
var current = loadMissingRoots(accountId: accountId)
let changed = missing ? current.insert(path).inserted : (current.remove(path) != nil)
if changed { _ = write(Array(current).sorted(), to: file(accountId, "missing.json")) }
}
// MARK: - Removing a path's mirror
/// Deletes [path]'s local mirror (a file, or a whole folder's worth) and
/// forgets its entries - called when the user turns sync off for that path.
/// Without clearing the state too, a later re-add would see the (now
/// missing) local file as "deleted, server unchanged" and not pull it back.
func removeLocalSync(accountId: String, path: String) {
var cleanPath = path.trimmingCharacters(in: .whitespaces)
if cleanPath.hasPrefix("/") { cleanPath.removeFirst() }
while cleanPath.hasSuffix("/") { cleanPath.removeLast() }
let prefix = cleanPath.isEmpty ? "" : cleanPath + "/"
// An unreadable baseline is left alone rather than overwritten with a
// partial one; the next run reports it and does nothing.
if var state = try? loadState(accountId: accountId) {
// The sync root itself ("/") covers every recorded file.
for (fileId, entry) in state
where cleanPath.isEmpty || entry.relPath == cleanPath || entry.relPath.hasPrefix(prefix) {
state[fileId] = nil
}
saveState(accountId: accountId, state)
}
setRootMissing(accountId: accountId, path: "/" + cleanPath, missing: false)
setRootMissing(accountId: accountId, path: cleanPath, missing: false)
// A root marker for this path (or an ancestor/descendant that also covers
// some of what was just deleted) would make the next background pass
// think nothing changed and skip re-downloading.
let removedPath = "/" + cleanPath
var markers = loadRootMarkers(accountId: accountId)
let stale = markers.keys.filter { key in
var k = key
while k.hasSuffix("/") { k.removeLast() }
return k.isEmpty || removedPath == "/" || k == removedPath || k.hasPrefix(removedPath + "/")
|| removedPath.hasPrefix(k + "/")
}
if !stale.isEmpty {
stale.forEach { markers[$0] = nil }
saveRootMarkers(accountId: accountId, markers)
}
let root = syncRoot(accountId: accountId)
let target = cleanPath.isEmpty ? root : root.appendingPathComponent(cleanPath)
if fileManager.fileExists(atPath: target.path) { try? fileManager.removeItem(at: target) }
if cleanPath.isEmpty { _ = syncRoot(accountId: accountId) }
}
// MARK: - Helpers
private func read<T: Decodable>(_ type: T.Type, from url: URL) -> T? {
guard let data = try? Data(contentsOf: url) else { return nil }
return try? JSONDecoder().decode(T.self, from: data)
}
private func write<T: Encodable>(_ value: T, to url: URL) -> Bool {
guard let data = try? JSONEncoder().encode(value) else { return false }
do {
try data.write(to: url, options: .atomic)
return true
} catch {
return false
}
}
}
+245
View File
@@ -0,0 +1,245 @@
import Foundation
import UserNotifications
struct UploadFile {
let uri: String
let name: String
}
/// Uploads and downloads on a background `URLSession`, so a transfer keeps
/// going - and finishes - after the app is suspended or closed. The iOS
/// counterpart of Android's `ShareUploadService`/`DownloadService`
/// foreground services, behind the same Dart channels (`NativeServices`).
///
/// Uploads are one `PUT` per file straight to the account's WebDAV root
/// (no chunking), from a staged copy in Caches - a background upload task
/// needs a file that stays put, and the picker's temp file may not. Downloads
/// land in `Documents/Downloads`, which is visible in the Files app
/// (`UIFileSharingEnabled` + `LSSupportsOpeningDocumentsInPlace`).
final class TransferManager: NSObject {
static let shared = TransferManager()
static let sessionIdentifier = "dev.ayushya.noo.transfers"
/// Called on the main thread when an upload batch ends with at least one
/// file uploaded: (remote folder, succeeded, failed).
var onUploadBatchFinished: ((String, Int, Int) -> Void)?
/// The system's completion handler from
/// `application(_:handleEventsForBackgroundURLSession:)`, called once the
/// session has delivered every pending event.
var backgroundCompletionHandler: (() -> Void)?
private lazy var session: URLSession = {
let config = URLSessionConfiguration.background(withIdentifier: Self.sessionIdentifier)
config.sessionSendsLaunchEvents = true
config.isDiscretionary = false
config.waitsForConnectivity = true
// Two accounts can share a server: authenticate by the request's own
// header only, never a cookie another account's request left behind.
config.httpShouldSetCookies = false
config.httpCookieAcceptPolicy = .never
config.urlCredentialStorage = nil
return URLSession(configuration: config, delegate: self, delegateQueue: nil)
}()
/// The session the Share Extension's uploads run on (see `ShareUpload`).
/// The extension is gone by the time they finish, so the app recreates it -
/// same identifier and shared container - to receive the results.
private lazy var shareSession = URLSession(
configuration: ShareUpload.configuration(), delegate: self, delegateQueue: nil)
/// Touch both sessions at launch so a system relaunch for finished
/// background transfers reconnects to them.
func reconnect() {
_ = session
_ = shareSession
}
// MARK: - Starting transfers
func startUpload(
serverUrl: String,
username: String,
authHeader: String,
files: [UploadFile],
remoteFolder: String
) throws {
guard !files.isEmpty else { throw TransferError(message: "No files to upload.") }
let batch = UUID().uuidString
let stageDir = try Self.stagingDirectory().appendingPathComponent(batch, isDirectory: true)
try FileManager.default.createDirectory(at: stageDir, withIntermediateDirectories: true)
var tasks: [URLSessionUploadTask] = []
for (index, file) in files.enumerated() {
guard let source = URL(string: file.uri), source.isFileURL else {
throw TransferError(message: "Can't read \(file.name).")
}
let staged = stageDir.appendingPathComponent("\(index)-\(file.name)")
do {
try FileManager.default.copyItem(at: source, to: staged)
// A file the Share Extension left in the App Group is ours to clean
// up once it's staged; a picker's temp file isn't.
if source.path.contains("/\(SharedInbox.folderName)/") {
try? FileManager.default.removeItem(at: source)
}
} catch {
throw TransferError(message: "Can't read \(file.name): \(error.localizedDescription)")
}
guard let url = WebDAV.fileURL(
serverUrl: serverUrl,
username: username,
remotePath: WebDAV.join(remoteFolder, file.name)
) else {
throw TransferError(message: "Invalid server address.")
}
var request = URLRequest(url: url)
request.httpMethod = "PUT"
request.setValue(authHeader, forHTTPHeaderField: "Authorization")
if let modified = (try? FileManager.default.attributesOfItem(atPath: staged.path))?[.modificationDate] as? Date {
request.setValue(String(Int(modified.timeIntervalSince1970)), forHTTPHeaderField: "X-OC-Mtime")
}
let task = session.uploadTask(with: request, fromFile: staged)
task.taskDescription = Self.encode(
TransferTaskInfo(
kind: .upload, batch: batch, name: file.name,
remoteFolder: remoteFolder, stagedPath: staged.path
)
)
tasks.append(task)
}
TransferBatchStore.save(
TransferBatch(kind: .upload, remoteFolder: remoteFolder, total: tasks.count, succeeded: 0, failed: 0),
id: batch
)
TransferNotifications.started(kind: .upload, batch: batch, count: tasks.count)
tasks.forEach { $0.resume() }
}
func startDownload(
serverUrl: String,
username: String,
authHeader: String,
items: [(path: String, name: String)]
) throws {
guard !items.isEmpty else { throw TransferError(message: "No files to download.") }
let batch = UUID().uuidString
var tasks: [URLSessionDownloadTask] = []
for item in items {
guard let url = WebDAV.fileURL(serverUrl: serverUrl, username: username, remotePath: item.path) else {
throw TransferError(message: "Invalid server address.")
}
var request = URLRequest(url: url)
request.setValue(authHeader, forHTTPHeaderField: "Authorization")
let task = session.downloadTask(with: request)
task.taskDescription = Self.encode(
TransferTaskInfo(kind: .download, batch: batch, name: item.name, remoteFolder: nil, stagedPath: nil)
)
tasks.append(task)
}
TransferBatchStore.save(
TransferBatch(kind: .download, remoteFolder: nil, total: tasks.count, succeeded: 0, failed: 0),
id: batch
)
TransferNotifications.started(kind: .download, batch: batch, count: tasks.count)
tasks.forEach { $0.resume() }
}
// MARK: - Locations
private static func stagingDirectory() throws -> URL {
let caches = try FileManager.default.url(
for: .cachesDirectory, in: .userDomainMask, appropriateFor: nil, create: true
)
return caches.appendingPathComponent("uploads", isDirectory: true)
}
static func downloadsDirectory() throws -> URL {
let documents = try FileManager.default.url(
for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: true
)
let dir = documents.appendingPathComponent("Downloads", isDirectory: true)
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
return dir
}
// MARK: - Batch bookkeeping
private static func encode(_ info: TransferTaskInfo) -> String? {
(try? JSONEncoder().encode(info)).flatMap { String(data: $0, encoding: .utf8) }
}
private static func info(for task: URLSessionTask) -> TransferTaskInfo? {
task.taskDescription
.flatMap { $0.data(using: .utf8) }
.flatMap { try? JSONDecoder().decode(TransferTaskInfo.self, from: $0) }
}
/// Counts one file's outcome; fires the batch summary when it was the last.
private func record(_ info: TransferTaskInfo, success: Bool) {
if let batch = TransferBatchStore.record(info, success: success) { finish(batch, id: info.batch) }
}
private func finish(_ batch: TransferBatch, id: String) {
TransferNotifications.finished(batch, id: id)
reportToDart(batch)
}
/// Tells Dart (so `FilesController` can refresh the folder) once an upload
/// batch has landed at least one file.
private func reportToDart(_ batch: TransferBatch) {
if batch.kind == .upload, batch.succeeded > 0, let folder = batch.remoteFolder {
DispatchQueue.main.async { self.onUploadBatchFinished?(folder, batch.succeeded, batch.failed) }
}
}
/// Folders the Share Extension finished uploading into while the app wasn't
/// running - call when the app becomes active.
func deliverExtensionUploads() {
for folder in TransferBatchStore.consumeFinishedUploadFolders() {
DispatchQueue.main.async { self.onUploadBatchFinished?(folder, 1, 0) }
}
}
}
// MARK: - URLSession delegates
extension TransferManager: URLSessionDownloadDelegate {
func urlSession(
_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL
) {
guard let info = Self.info(for: downloadTask) else { return }
let status = (downloadTask.response as? HTTPURLResponse)?.statusCode ?? 0
guard (200..<300).contains(status) else {
record(info, success: false)
return
}
do {
let destination = LocalFiles.uniqueURL(in: try Self.downloadsDirectory(), name: info.name)
try FileManager.default.moveItem(at: location, to: destination)
record(info, success: true)
} catch {
record(info, success: false)
}
}
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
guard let info = Self.info(for: task) else { return }
switch info.kind {
case .upload:
if let result = UploadResults.handle(task, error: error), let batch = result.batch {
reportToDart(batch)
}
case .download:
// A finished download was already counted in didFinishDownloadingTo;
// only a transport failure arrives here.
if error != nil { record(info, success: false) }
}
}
func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) {
DispatchQueue.main.async {
self.backgroundCompletionHandler?()
self.backgroundCompletionHandler = nil
}
}
}
+14
View File
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.dev.ayushya.noo</string>
</array>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)dev.ayushya.noo.shared</string>
</array>
</dict>
</plist>
+330 -3
View File
@@ -2,11 +2,338 @@ import Flutter
import UIKit
import XCTest
@testable import Runner
class RunnerTests: XCTestCase {
func testExample() {
// If you add code to the Runner application, consider adding tests here.
// See https://developer.apple.com/documentation/xctest for more information about using XCTest.
func testFileURLEncodesEachSegment() {
let url = WebDAV.fileURL(
serverUrl: "https://cloud.example.com", username: "alice",
remotePath: "/Tax Forms/2026 #1/a&b.pdf")
XCTAssertEqual(
url?.absoluteString,
"https://cloud.example.com/remote.php/dav/files/alice/Tax%20Forms/2026%20%231/a&b.pdf")
}
func testFileURLKeepsServerSubPathAndDropsTrailingSlash() {
let url = WebDAV.fileURL(
serverUrl: "https://example.com/nextcloud/", username: "bob@home", remotePath: "docs/x.txt")
XCTAssertEqual(
url?.absoluteString,
"https://example.com/nextcloud/remote.php/dav/files/bob@home/docs/x.txt")
}
func testFileURLForRoot() {
XCTAssertEqual(
WebDAV.fileURL(serverUrl: "https://c.io", username: "u", remotePath: "/")?.absoluteString,
"https://c.io/remote.php/dav/files/u")
}
func testJoinUsesOneSlash() {
XCTAssertEqual(WebDAV.join("/", "a.txt"), "/a.txt")
XCTAssertEqual(WebDAV.join("/docs", "a.txt"), "/docs/a.txt")
XCTAssertEqual(WebDAV.join("/docs/", "a.txt"), "/docs/a.txt")
}
func testUniqueURLNumbersCollisions() throws {
let dir = FileManager.default.temporaryDirectory
.appendingPathComponent("noo-tests-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: dir) }
XCTAssertEqual(LocalFiles.uniqueURL(in: dir, name: "a.txt").lastPathComponent, "a.txt")
FileManager.default.createFile(atPath: dir.appendingPathComponent("a.txt").path, contents: nil)
XCTAssertEqual(LocalFiles.uniqueURL(in: dir, name: "a.txt").lastPathComponent, "a (1).txt")
FileManager.default.createFile(atPath: dir.appendingPathComponent("a (1).txt").path, contents: nil)
XCTAssertEqual(LocalFiles.uniqueURL(in: dir, name: "a.txt").lastPathComponent, "a (2).txt")
FileManager.default.createFile(atPath: dir.appendingPathComponent("README").path, contents: nil)
XCTAssertEqual(LocalFiles.uniqueURL(in: dir, name: "README").lastPathComponent, "README (1)")
}
// MARK: - SharedInbox (Share Extension hand-off)
private func makeRoot() throws -> URL {
let root = FileManager.default.temporaryDirectory
.appendingPathComponent("noo-inbox-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
addTeardownBlock { try? FileManager.default.removeItem(at: root) }
return root
}
private func makeSharedFile(in root: URL, name: String, contents: String = "hi") throws -> SharedItem {
let batch = try SharedInbox.makeBatchDirectory(in: root)
let file = batch.appendingPathComponent(name)
try contents.write(to: file, atomically: true, encoding: .utf8)
return SharedItem(path: file.path, name: name, mimeType: "text/plain", size: Int64(contents.utf8.count))
}
func testInboxConsumeReturnsAppendedItemsOnceThenNothing() throws {
let root = try makeRoot()
let a = try makeSharedFile(in: root, name: "a.txt")
let b = try makeSharedFile(in: root, name: "b.txt")
try SharedInbox.append([a], in: root)
try SharedInbox.append([b], in: root)
XCTAssertEqual(SharedInbox.consume(in: root), [a, b])
XCTAssertEqual(SharedInbox.consume(in: root), [], "each share is delivered once")
}
func testInboxConsumeDropsFilesThatNoLongerExist() throws {
let root = try makeRoot()
let kept = try makeSharedFile(in: root, name: "kept.txt")
let gone = try makeSharedFile(in: root, name: "gone.txt")
try SharedInbox.append([kept, gone], in: root)
try FileManager.default.removeItem(atPath: gone.path)
XCTAssertEqual(SharedInbox.consume(in: root), [kept])
}
func testInboxConsumeWithNothingPendingIsEmpty() throws {
XCTAssertEqual(SharedInbox.consume(in: try makeRoot()), [])
}
func testInboxAppendOfNothingCreatesNoManifest() throws {
let root = try makeRoot()
try SharedInbox.append([], in: root)
XCTAssertEqual(SharedInbox.consume(in: root), [])
}
// MARK: - DavFolderParser (Share Extension folder picker)
private func multistatus(_ responses: [(href: String, collection: Bool)]) -> Data {
let body = responses.map { r in
"""
<d:response><d:href>\(r.href)</d:href><d:propstat><d:prop>\
<d:resourcetype>\(r.collection ? "<d:collection/>" : "")</d:resourcetype>\
</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>
"""
}.joined()
return Data(
"<?xml version=\"1.0\"?><d:multistatus xmlns:d=\"DAV:\">\(body)</d:multistatus>".utf8)
}
func testFoldersListsOnlyChildFoldersSortedWithoutHidden() {
let xml = multistatus([
("/remote.php/dav/files/alice/Docs/", true),
("/remote.php/dav/files/alice/Docs/zeta/", true),
("/remote.php/dav/files/alice/Docs/Tax%20Forms/", true),
("/remote.php/dav/files/alice/Docs/note.txt", false),
("/remote.php/dav/files/alice/Docs/.git/", true),
])
XCTAssertEqual(
DavFolderParser.folders(from: xml, excluding: "/Docs"),
[DavFolder(name: "Tax Forms", path: "/Docs/Tax Forms"), DavFolder(name: "zeta", path: "/Docs/zeta")])
XCTAssertEqual(
DavFolderParser.folders(from: xml, excluding: "/Docs", hidden: .include).map(\.name),
[".git", "Tax Forms", "zeta"])
XCTAssertEqual(
DavFolderParser.folders(from: xml, excluding: "/Docs", hidden: .only).map(\.name),
[".git"])
}
func testHiddenFilterTreatsAnyDotSegmentAsHidden() {
XCTAssertTrue(HiddenFilter.isHidden(path: "/.cache"))
XCTAssertTrue(HiddenFilter.isHidden(path: "/.cache/inside/deeper"), "children of a hidden folder are hidden")
XCTAssertFalse(HiddenFilter.isHidden(path: "/Docs/Tax Forms"))
XCTAssertTrue(HiddenFilter.hide.shows(path: "/Docs"))
XCTAssertFalse(HiddenFilter.hide.shows(path: "/.git"))
XCTAssertTrue(HiddenFilter.only.shows(path: "/.git/hooks"))
XCTAssertFalse(HiddenFilter.only.shows(path: "/Docs"))
XCTAssertTrue(HiddenFilter.include.shows(path: "/Docs"))
XCTAssertEqual(HiddenFilter(raw: "include"), .include)
XCTAssertEqual(HiddenFilter(raw: "nonsense"), .hide, "unknown values fall back to hiding")
}
func testFoldersAtRootAndUnderServerSubPath() {
let xml = multistatus([
("/nextcloud/remote.php/dav/files/bob/", true),
("/nextcloud/remote.php/dav/files/bob/Photos/", true),
])
XCTAssertEqual(
DavFolderParser.folders(from: xml, excluding: "/"),
[DavFolder(name: "Photos", path: "/Photos")])
}
func testRelativePath() {
XCTAssertEqual(DavFolderParser.relativePath(fromHref: "/remote.php/dav/files/a/B%20C/D/"), "/B C/D")
XCTAssertEqual(DavFolderParser.relativePath(fromHref: "/remote.php/dav/files/a/"), "/")
XCTAssertNil(DavFolderParser.relativePath(fromHref: "/somewhere/else"))
}
func testFoldersIgnoresGarbage() {
XCTAssertEqual(DavFolderParser.folders(from: Data("not xml".utf8), excluding: "/"), [])
}
// MARK: - Shared account (Keychain group shared with the extension)
private func account(_ id: String, hidden: String = "hide") -> SharedAccount {
SharedAccount(
id: id, serverUrl: "https://\(id).example.com", username: id,
authHeader: "Basic \(id)", displayName: "\(id)@\(id).example.com", hiddenFilter: hidden)
}
func testSharedAccountsRoundTripThroughTheKeychain() throws {
let accounts = SharedAccounts(
accounts: [account("alice", hidden: "include"), account("bob")], activeId: "bob",
loginLockEnabled: true, lockAccountSwitching: true, lockHiddenFiles: false)
addTeardownBlock { SharedAccountStore.clear() }
SharedAccountStore.clear()
XCTAssertNil(SharedAccountStore.load())
try SharedAccountStore.save(accounts)
XCTAssertEqual(SharedAccountStore.load(), accounts)
let replacement = SharedAccounts(
accounts: [account("carol")], activeId: "carol",
loginLockEnabled: false, lockAccountSwitching: false, lockHiddenFiles: false)
try SharedAccountStore.save(replacement)
XCTAssertEqual(SharedAccountStore.load(), replacement, "saving replaces, never duplicates")
SharedAccountStore.clear()
XCTAssertNil(SharedAccountStore.load())
}
func testSharedAccountsActiveFallsBackToTheFirstAccount() {
var shared = SharedAccounts(
accounts: [account("alice"), account("bob")], activeId: "bob",
loginLockEnabled: false, lockAccountSwitching: false, lockHiddenFiles: false)
XCTAssertEqual(shared.active?.id, "bob")
shared.activeId = "gone"
XCTAssertEqual(shared.active?.id, "alice")
shared.activeId = nil
XCTAssertEqual(shared.active?.id, "alice")
}
func testUnlockRulesAreIndependentOfLoginLock() {
func rules(master: Bool, switching: Bool, hidden: Bool) -> (Bool, Bool) {
let shared = SharedAccounts(
accounts: [account("a")], activeId: "a",
loginLockEnabled: master, lockAccountSwitching: switching, lockHiddenFiles: hidden)
return (shared.needsUnlockToSwitchAccount, shared.needsUnlockForHidden)
}
XCTAssertTrue(rules(master: false, switching: true, hidden: false) == (true, false))
XCTAssertTrue(rules(master: false, switching: false, hidden: true) == (false, true))
XCTAssertTrue(rules(master: true, switching: false, hidden: false) == (false, false))
XCTAssertTrue(rules(master: true, switching: true, hidden: true) == (true, true))
}
// Which actions in the share sheet ask to unlock - and how many prompts.
private func lockedAccounts(switching: Bool, hidden: Bool) -> SharedAccounts {
SharedAccounts(
accounts: [account("alice"), account("bob")], activeId: "alice",
loginLockEnabled: false, lockAccountSwitching: switching, lockHiddenFiles: hidden)
}
func testChoosingTheActiveAccountWithoutHiddenFoldersNeverPrompts() {
let shared = lockedAccounts(switching: true, hidden: true)
let needs = shared.unlockNeeds(choosing: account("alice"), showing: .hide)
XCTAssertEqual(needs, SelectionUnlock(switchesAccount: false, showsHidden: false))
XCTAssertFalse(needs.needsPrompt)
}
func testChoosingAnotherAccountPromptsEveryTime() {
let shared = lockedAccounts(switching: true, hidden: false)
// No unlock is remembered: the same choice asks again on every call.
for _ in 0..<3 {
XCTAssertTrue(shared.unlockNeeds(choosing: account("bob"), showing: .hide).needsPrompt)
}
XCTAssertFalse(
lockedAccounts(switching: false, hidden: false).unlockNeeds(choosing: account("bob"), showing: .hide).needsPrompt)
}
func testSwitchPlusHiddenFoldersIsOnePromptThatCoversBoth() {
let needs = lockedAccounts(switching: true, hidden: true)
.unlockNeeds(choosing: account("bob"), showing: .include)
XCTAssertEqual(needs, SelectionUnlock(switchesAccount: true, showsHidden: true))
XCTAssertTrue(needs.needsPrompt)
}
func testHiddenFoldersAloneOnTheActiveAccountPromptsForHidden() {
let needs = lockedAccounts(switching: true, hidden: true)
.unlockNeeds(choosing: account("alice"), showing: .only)
XCTAssertEqual(needs, SelectionUnlock(switchesAccount: false, showsHidden: true))
}
func testOnlyTurningHiddenFoldersOnAsksToUnlock() {
let locked = lockedAccounts(switching: false, hidden: true)
XCTAssertTrue(locked.needsUnlockToChangeHidden(from: .hide, to: .only))
XCTAssertTrue(locked.needsUnlockToChangeHidden(from: .hide, to: .include))
XCTAssertFalse(locked.needsUnlockToChangeHidden(from: .include, to: .hide), "hiding again never asks")
XCTAssertFalse(locked.needsUnlockToChangeHidden(from: .only, to: .include), "only <-> all never asks")
XCTAssertFalse(
lockedAccounts(switching: false, hidden: false).needsUnlockToChangeHidden(from: .hide, to: .include),
"no lock set, no prompt")
}
// MARK: - TransferBatchStore
func testBatchSummaryFiresOnlyOnTheLastFile() {
let batchId = UUID().uuidString
TransferBatchStore.save(
TransferBatch(kind: .upload, remoteFolder: "/Docs", total: 3, succeeded: 0, failed: 0), id: batchId)
let info = TransferTaskInfo(kind: .upload, batch: batchId, name: "a", remoteFolder: "/Docs", stagedPath: nil)
XCTAssertNil(TransferBatchStore.record(info, success: true))
XCTAssertNil(TransferBatchStore.record(info, success: false))
let finished = TransferBatchStore.record(info, success: true)
XCTAssertEqual(finished?.succeeded, 2)
XCTAssertEqual(finished?.failed, 1)
XCTAssertEqual(finished?.remoteFolder, "/Docs")
}
func testFinishedUploadFoldersAreNotedOnceAndConsumed() {
_ = TransferBatchStore.consumeFinishedUploadFolders() // start clean
TransferBatchStore.noteFinishedUpload(folder: "/Docs")
TransferBatchStore.noteFinishedUpload(folder: "/Docs")
TransferBatchStore.noteFinishedUpload(folder: "/Photos")
XCTAssertEqual(TransferBatchStore.consumeFinishedUploadFolders(), ["/Docs", "/Photos"])
XCTAssertEqual(TransferBatchStore.consumeFinishedUploadFolders(), [])
}
func testNotificationFileCountWording() {
XCTAssertEqual(TransferNotifications.files(1), "1 file")
XCTAssertEqual(TransferNotifications.files(3), "3 files")
}
// MARK: - External storage + tolerant decoding
func testFoldersFlagExternalMountsAndStorageFilterAppliesToThem() {
let xml = Data(
"""
<?xml version="1.0"?><d:multistatus xmlns:d="DAV:" xmlns:nc="http://nextcloud.org/ns">
<d:response><d:href>/remote.php/dav/files/a/</d:href><d:propstat><d:prop>
<d:resourcetype><d:collection/></d:resourcetype></d:prop></d:propstat></d:response>
<d:response><d:href>/remote.php/dav/files/a/NAS/</d:href><d:propstat><d:prop>
<d:resourcetype><d:collection/></d:resourcetype><nc:mount-type>external</nc:mount-type>
</d:prop></d:propstat></d:response>
<d:response><d:href>/remote.php/dav/files/a/Docs/</d:href><d:propstat><d:prop>
<d:resourcetype><d:collection/></d:resourcetype></d:prop></d:propstat></d:response>
<d:response><d:href>/remote.php/dav/files/a/Shared/</d:href><d:propstat><d:prop>
<d:resourcetype><d:collection/></d:resourcetype><nc:mount-type>shared</nc:mount-type>
</d:prop></d:propstat></d:response>
</d:multistatus>
""".utf8)
let folders = DavFolderParser.folders(from: xml, excluding: "/")
XCTAssertEqual(folders.map(\.name), ["Docs", "NAS", "Shared"])
XCTAssertEqual(folders.map(\.isExternal), [false, true, false], "only mount-type=external counts")
XCTAssertTrue(StorageFilter.cloud.shows(isExternal: false))
XCTAssertFalse(StorageFilter.cloud.shows(isExternal: true))
XCTAssertTrue(StorageFilter.external.shows(isExternal: true))
XCTAssertFalse(StorageFilter.external.shows(isExternal: false))
XCTAssertTrue(StorageFilter.all.shows(isExternal: true) && StorageFilter.all.shows(isExternal: false))
XCTAssertEqual(StorageFilter(raw: "bogus"), .cloud)
}
func testSharedAccountDecodesDataWrittenBeforeNewFieldsExisted() throws {
let old = Data(
"""
{"id":"a","serverUrl":"https://x","username":"u","authHeader":"Basic x","displayName":"u@x"}
""".utf8)
let decoded = try JSONDecoder().decode(SharedAccount.self, from: old)
XCTAssertEqual(decoded.hiddenFilter, "hide")
XCTAssertEqual(decoded.storageScope, "cloud")
}
}
+875
View File
@@ -0,0 +1,875 @@
import XCTest
@testable import Runner
/// An in-memory Nextcloud that answers just enough WebDAV (PROPFIND depth 0/1,
/// GET, PUT) for the sync engine to run end to end against - with ancestor
/// etags bumping on every change, like the real thing.
final class FakeDav: URLProtocol {
struct Node {
var isFolder: Bool
var data = Data()
var etag: String
var fileId: String
var mtime = Date(timeIntervalSince1970: 1_700_000_000)
}
static var nodes: [String: Node] = [:]
static var failAll = false
/// A folder whose PROPFIND answers 500 while everything else works - for
/// proving a half-built manifest is never trusted.
static var failPropfindPath: String?
/// Answer PROPFIND with a 207 whose XML is cut off mid-way.
static var truncatePropfind = false
static var log: [String] = []
private static var counter = 0
static let user = "alice"
private static let base = "/remote.php/dav/files/alice"
static func reset() {
nodes = ["/": Node(isFolder: true, etag: nextTag(), fileId: "root")]
failAll = false
failPropfindPath = nil
truncatePropfind = false
log = []
counter = 0
}
private static func nextTag() -> String {
counter += 1
return "tag\(counter)"
}
private static func parent(of path: String) -> String {
let p = (path as NSString).deletingLastPathComponent
return p.isEmpty ? "/" : p
}
/// Bumps [path]'s etag and every ancestor's.
private static func touch(_ path: String) {
var p = path
while true {
nodes[p]?.etag = nextTag()
if p == "/" { break }
p = parent(of: p)
}
}
// MARK: Test-side helpers
static func mkdir(_ path: String) {
var parts: [String] = []
for comp in path.split(separator: "/") {
parts.append(String(comp))
let p = "/" + parts.joined(separator: "/")
if nodes[p] == nil {
nodes[p] = Node(isFolder: true, etag: nextTag(), fileId: "id\(counter)")
touch(parent(of: p))
}
}
}
static func put(_ path: String, _ text: String) {
mkdir(parent(of: path))
var node = nodes[path] ?? Node(isFolder: false, etag: "", fileId: "id\(nextTag())")
node.data = Data(text.utf8)
nodes[path] = node
touch(path)
}
static func remove(_ path: String) {
for key in nodes.keys where key == path || key.hasPrefix(path + "/") { nodes[key] = nil }
touch(parent(of: path))
}
static func text(_ path: String) -> String? {
nodes[path].flatMap { String(data: $0.data, encoding: .utf8) }
}
// MARK: URLProtocol
override class func canInit(with request: URLRequest) -> Bool { true }
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
override func stopLoading() {}
override func startLoading() {
let request = self.request
let method = request.httpMethod ?? "GET"
let depth = request.value(forHTTPHeaderField: "Depth") ?? "-"
var path = (request.url?.path ?? "").removingPercentEncoding ?? ""
if path.hasPrefix(Self.base) { path = String(path.dropFirst(Self.base.count)) }
while path.count > 1 && path.hasSuffix("/") { path.removeLast() }
if path.isEmpty { path = "/" }
Self.log.append("\(method) \(depth) \(path)")
func send(_ status: Int, _ body: Data = Data()) {
let response = HTTPURLResponse(
url: request.url!, statusCode: status, httpVersion: "HTTP/1.1", headerFields: nil)!
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
client?.urlProtocol(self, didLoad: body)
client?.urlProtocolDidFinishLoading(self)
}
if Self.failAll { return send(503) }
switch method {
case "PROPFIND":
if path == Self.failPropfindPath { return send(500) }
guard Self.nodes[path] != nil else { return send(404) }
var paths = [path]
if depth == "1" {
paths += Self.nodes.keys.filter { $0 != path && Self.parent(of: $0) == path }.sorted()
}
var body = Self.multistatus(paths)
// Only folder listings: a cut-off *listing* still holds a few complete
// responses, which is the dangerous case (a partial manifest).
if Self.truncatePropfind && depth == "1" { body = String(body.prefix(body.count * 2 / 3)) }
send(207, Data(body.utf8))
case "GET":
guard let node = Self.nodes[path], !node.isFolder else { return send(404) }
send(200, node.data)
case "PUT":
var data = Data()
if let stream = request.httpBodyStream {
stream.open()
var buffer = [UInt8](repeating: 0, count: 4096)
while stream.hasBytesAvailable {
let n = stream.read(&buffer, maxLength: buffer.count)
if n <= 0 { break }
data.append(buffer, count: n)
}
stream.close()
} else if let body = request.httpBody {
data = body
}
Self.mkdir(Self.parent(of: path))
var node = Self.nodes[path] ?? Node(isFolder: false, etag: "", fileId: "id\(Self.nextTag())")
node.data = data
Self.nodes[path] = node
Self.touch(path)
send(201)
default:
send(405)
}
}
private static let httpDate: DateFormatter = {
let f = DateFormatter()
f.locale = Locale(identifier: "en_US_POSIX")
f.timeZone = TimeZone(secondsFromGMT: 0)
f.dateFormat = "EEE, dd MMM yyyy HH:mm:ss 'GMT'"
return f
}()
private static func multistatus(_ paths: [String]) -> String {
let responses = paths.map { p -> String in
let node = nodes[p]!
let encoded = p.split(separator: "/").map { WebDAV.encodeSegment(String($0)) }.joined(separator: "/")
let href = base + (encoded.isEmpty ? "" : "/" + encoded) + (node.isFolder ? "/" : "")
return """
<d:response><d:href>\(href)</d:href><d:propstat><d:prop>\
<d:getlastmodified>\(httpDate.string(from: node.mtime))</d:getlastmodified>\
<d:getcontentlength>\(node.data.count)</d:getcontentlength>\
<d:resourcetype>\(node.isFolder ? "<d:collection/>" : "")</d:resourcetype>\
<d:getetag>"\(node.etag)"</d:getetag><oc:fileid>\(node.fileId)</oc:fileid>\
</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>
"""
}.joined()
return """
<?xml version="1.0"?><d:multistatus xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">\(responses)</d:multistatus>
"""
}
}
final class SyncEngineTests: XCTestCase {
private var store: SyncStore!
private var bus: SyncStatusBus!
private var runner: SyncRunner!
private var fakeSession: URLSession!
private var clock: Int64 = 1_700_000_000_000
private let config = SyncAccountConfig(
accountId: "acct", serverUrl: "https://dav.test", username: "alice", authHeader: "Basic x",
folders: ["/Docs"], wifiOnly: false, intervalMinutes: nil, notify: false)
private var creds: SyncCredentials {
SyncCredentials(serverUrl: config.serverUrl, username: config.username, authHeader: config.authHeader)
}
override func setUp() {
super.setUp()
FakeDav.reset()
let base = FileManager.default.temporaryDirectory
.appendingPathComponent("noo-sync-\(UUID().uuidString)", isDirectory: true)
try? FileManager.default.createDirectory(at: base, withIntermediateDirectories: true)
addTeardownBlock { try? FileManager.default.removeItem(at: base) }
store = SyncStore(base: base)
bus = SyncStatusBus()
let sessionConfig = URLSessionConfiguration.ephemeral
sessionConfig.protocolClasses = [FakeDav.self]
fakeSession = URLSession(configuration: sessionConfig)
runner = SyncRunner(
client: DavSyncClient(session: fakeSession),
store: store, bus: bus, now: { [unowned self] in self.clock })
}
private func state() -> [String: SyncFileState] {
(try? store.loadState(accountId: "acct")) ?? [:]
}
private var mirror: URL { store.syncRoot(accountId: "acct") }
private func local(_ rel: String) -> String? {
try? String(contentsOf: mirror.appendingPathComponent(rel), encoding: .utf8)
}
private func exists(_ rel: String) -> Bool {
FileManager.default.fileExists(atPath: mirror.appendingPathComponent(rel).path)
}
private func editLocally(_ rel: String, _ text: String) throws {
let url = mirror.appendingPathComponent(rel)
try text.write(to: url, atomically: true, encoding: .utf8)
try FileManager.default.setAttributes([.modificationDate: Date().addingTimeInterval(60)], ofItemAtPath: url.path)
}
private func depth1Requests() -> Int { FakeDav.log.filter { $0.hasPrefix("PROPFIND 1") }.count }
private func seedServer() {
FakeDav.put("/Docs/a.txt", "alpha")
FakeDav.put("/Docs/sub/b.txt", "bravo")
}
// MARK: - The happy path
func testFirstSyncDownloadsEverythingAndMirrorsFolders() async {
seedServer()
FakeDav.mkdir("/Docs/empty")
let summary = await runner.run(config, force: false)
XCTAssertEqual(summary.downloaded, 2)
XCTAssertEqual(local("Docs/a.txt"), "alpha")
XCTAssertEqual(local("Docs/sub/b.txt"), "bravo")
XCTAssertTrue(exists("Docs/empty"), "empty server folders are mirrored")
XCTAssertEqual(state().count, 2)
XCTAssertNotNil(store.loadRootMarkers(accountId: "acct")["/Docs"])
XCTAssertFalse(bus.snapshot().syncing, "announced as finished")
}
func testUnchangedServerIsOneCheapRequestNotAWalk() async {
seedServer()
_ = await runner.run(config, force: false)
let walks = depth1Requests()
FakeDav.log = []
let summary = await runner.run(config, force: false)
XCTAssertFalse(summary.changedAnything)
XCTAssertEqual(depth1Requests(), 0, "the root etag shortcut skipped the walk (was \(walks) before)")
XCTAssertEqual(FakeDav.log, ["PROPFIND 0 /Docs"])
}
func testForceAlwaysWalks() async {
seedServer()
_ = await runner.run(config, force: false)
FakeDav.log = []
_ = await runner.run(config, force: true)
XCTAssertGreaterThan(depth1Requests(), 0)
}
func testShortcutExpiresAfterTheMaxAge() async {
seedServer()
_ = await runner.run(config, force: false)
clock += SyncRunner.fullWalkMaxAgeMs + 1
FakeDav.log = []
_ = await runner.run(config, force: false)
XCTAssertGreaterThan(depth1Requests(), 0, "etags aren't trusted forever")
}
func testServerChangeIsDownloaded() async {
seedServer()
_ = await runner.run(config, force: false)
FakeDav.put("/Docs/a.txt", "alpha v2")
FakeDav.put("/Docs/new.txt", "fresh")
let summary = await runner.run(config, force: false)
XCTAssertEqual(summary.downloaded, 2)
XCTAssertEqual(local("Docs/a.txt"), "alpha v2")
XCTAssertEqual(local("Docs/new.txt"), "fresh")
}
func testLocalEditIsUploadedOnceAndNotReDownloaded() async throws {
seedServer()
_ = await runner.run(config, force: false)
try editLocally("Docs/a.txt", "alpha edited on phone")
let summary = await runner.run(config, force: false)
XCTAssertEqual(summary.uploaded, 1)
XCTAssertEqual(FakeDav.text("/Docs/a.txt"), "alpha edited on phone")
let again = await runner.run(config, force: false)
XCTAssertFalse(again.changedAnything, "our own upload must not come back as a server change")
XCTAssertEqual(local("Docs/a.txt"), "alpha edited on phone")
}
func testFileDeletedLocallyIsPulledBack() async throws {
seedServer()
_ = await runner.run(config, force: false)
try FileManager.default.removeItem(at: mirror.appendingPathComponent("Docs/a.txt"))
let summary = await runner.run(config, force: false)
XCTAssertEqual(summary.downloaded, 1)
XCTAssertEqual(local("Docs/a.txt"), "alpha")
XCTAssertNotNil(FakeDav.text("/Docs/a.txt"), "a local delete never deletes on the server")
}
// MARK: - Conflicts
func testChangedOnBothSidesIsAConflictAndNothingIsOverwritten() async throws {
seedServer()
_ = await runner.run(config, force: false)
FakeDav.put("/Docs/a.txt", "server edit")
try editLocally("Docs/a.txt", "phone edit")
let summary = await runner.run(config, force: false)
XCTAssertEqual(summary.conflicts.map(\.relPath), ["Docs/a.txt"])
XCTAssertEqual(summary.conflicts.first?.name, "a.txt")
XCTAssertEqual(local("Docs/a.txt"), "phone edit", "the device copy is untouched")
XCTAssertEqual(FakeDav.text("/Docs/a.txt"), "server edit", "the server copy is untouched")
XCTAssertEqual(bus.snapshot().conflicts.count, 1)
XCTAssertNil(store.loadRootMarkers(accountId: "acct")["/Docs"], "an unresolved conflict forces a real walk next time")
}
func testResolveWithServerCopyThenNoConflictOnTheNextPass() async throws {
seedServer()
_ = await runner.run(config, force: false)
FakeDav.put("/Docs/a.txt", "server edit")
try editLocally("Docs/a.txt", "phone edit")
let conflict = await runner.run(config, force: false).conflicts[0]
let ok = await runner.resolveConflict(conflict, resolution: "server", creds: creds)
XCTAssertTrue(ok)
XCTAssertEqual(local("Docs/a.txt"), "server edit")
XCTAssertTrue(bus.snapshot().conflicts.isEmpty)
let next = await runner.run(config, force: false)
XCTAssertTrue(next.conflicts.isEmpty)
XCTAssertFalse(next.changedAnything)
}
func testResolveWithLocalCopyUploadsIt() async throws {
seedServer()
_ = await runner.run(config, force: false)
FakeDav.put("/Docs/a.txt", "server edit")
try editLocally("Docs/a.txt", "phone edit")
let conflict = await runner.run(config, force: false).conflicts[0]
let ok = await runner.resolveConflict(conflict, resolution: "local", creds: creds)
XCTAssertTrue(ok)
XCTAssertEqual(FakeDav.text("/Docs/a.txt"), "phone edit")
let next = await runner.run(config, force: false)
XCTAssertTrue(next.conflicts.isEmpty && !next.changedAnything)
}
func testAnUnknownResolutionDoesNothing() async throws {
seedServer()
_ = await runner.run(config, force: false)
FakeDav.put("/Docs/a.txt", "server edit")
try editLocally("Docs/a.txt", "phone edit")
let conflict = await runner.run(config, force: false).conflicts[0]
let ok = await runner.resolveConflict(conflict, resolution: "bogus", creds: creds)
XCTAssertFalse(ok)
XCTAssertEqual(local("Docs/a.txt"), "phone edit")
XCTAssertEqual(FakeDav.text("/Docs/a.txt"), "server edit")
}
// MARK: - Deletes (the dangerous part)
func testFileDeletedOnTheServerIsRemovedLocally() async {
seedServer()
_ = await runner.run(config, force: false)
FakeDav.remove("/Docs/a.txt")
let summary = await runner.run(config, force: false)
XCTAssertEqual(summary.deleted, 1)
XCTAssertFalse(exists("Docs/a.txt"))
XCTAssertTrue(exists("Docs/sub/b.txt"), "only the deleted file goes")
}
func testFolderDeletedOnTheServerRemovesItsFilesAndTheEmptyDirectory() async {
seedServer()
_ = await runner.run(config, force: false)
FakeDav.remove("/Docs/sub")
let summary = await runner.run(config, force: false)
XCTAssertEqual(summary.deleted, 1)
XCTAssertFalse(exists("Docs/sub/b.txt"))
XCTAssertFalse(exists("Docs/sub"), "the now-empty directory is pruned")
XCTAssertTrue(exists("Docs/a.txt"))
}
func testAnUnreachableServerNeverDeletesAnything() async {
seedServer()
_ = await runner.run(config, force: false)
let before = state()
FakeDav.failAll = true
let summary = await runner.run(config, force: true)
XCTAssertFalse(summary.changedAnything, "a 503 is not an empty folder")
XCTAssertEqual(local("Docs/a.txt"), "alpha")
XCTAssertEqual(local("Docs/sub/b.txt"), "bravo")
XCTAssertEqual(state(), before)
XCTAssertTrue(store.loadMissingRoots(accountId: "acct").isEmpty, "unreachable is not 'gone'")
FakeDav.failAll = false
let recovered = await runner.run(config, force: true)
XCTAssertFalse(recovered.changedAnything)
XCTAssertEqual(local("Docs/a.txt"), "alpha")
}
func testAPartiallyFailingWalkDoesNotLookLikeAMassDeletion() async {
seedServer()
_ = await runner.run(config, force: false)
// Only the subfolder listing breaks: a half-built manifest must not make
// sub/b.txt look deleted.
FakeDav.nodes["/Docs/sub"]?.etag = "changed"
FakeDav.nodes["/Docs"]?.etag = "changed"
FakeDav.failPropfindPath = "/Docs/sub"
let summary = await runner.run(config, force: true)
XCTAssertEqual(summary.deleted, 0)
XCTAssertEqual(local("Docs/sub/b.txt"), "bravo")
XCTAssertEqual(local("Docs/a.txt"), "alpha")
}
func testAPathDeletedOnTheServerIsFlaggedAndItsMirrorRemoved() async {
seedServer()
_ = await runner.run(config, force: false)
FakeDav.remove("/Docs")
let summary = await runner.run(config, force: false)
XCTAssertEqual(summary.deleted, 2)
XCTAssertEqual(store.loadMissingRoots(accountId: "acct"), ["/Docs"])
XCTAssertFalse(exists("Docs/a.txt"))
}
// MARK: - Single files, scoping, housekeeping
func testASyncedSingleFilePath() async {
FakeDav.put("/Notes/todo.txt", "buy milk")
var single = config
single.folders = ["/Notes/todo.txt"]
let summary = await runner.run(single, force: false)
XCTAssertEqual(summary.downloaded, 1)
XCTAssertEqual(local("Notes/todo.txt"), "buy milk")
FakeDav.put("/Notes/todo.txt", "buy milk and eggs")
_ = await runner.run(single, force: false)
XCTAssertEqual(local("Notes/todo.txt"), "buy milk and eggs")
}
func testSyncOnlyTouchesTheConfiguredPaths() async {
seedServer()
FakeDav.put("/Other/x.txt", "not synced")
_ = await runner.run(config, force: false)
XCTAssertFalse(exists("Other/x.txt"))
}
func testNothingConfiguredDoesNothing() async {
seedServer()
var empty = config
empty.folders = []
let summary = await runner.run(empty, force: true)
XCTAssertEqual(summary, SyncRunSummary())
XCTAssertTrue(FakeDav.log.isEmpty)
}
func testRemoveLocalSyncForgetsThePathSoItComesBackOnReAdd() async {
seedServer()
_ = await runner.run(config, force: false)
store.removeLocalSync(accountId: "acct", path: "/Docs/sub")
XCTAssertFalse(exists("Docs/sub/b.txt"))
XCTAssertTrue(exists("Docs/a.txt"))
XCTAssertEqual(state().count, 1)
XCTAssertNil(store.loadRootMarkers(accountId: "acct")["/Docs"], "an ancestor's marker would skip the re-download")
let summary = await runner.run(config, force: false)
XCTAssertEqual(summary.downloaded, 1)
XCTAssertEqual(local("Docs/sub/b.txt"), "bravo")
}
func testStatusMarksFilesAsSyncingDuringTheRun() async {
seedServer()
var seen = Set<String>()
bus.onChange = { seen.formUnion($0.syncingFileIds) }
_ = await runner.run(config, force: false)
XCTAssertEqual(seen.count, 2, "each transferring file was announced")
XCTAssertTrue(bus.snapshot().syncingFileIds.isEmpty)
}
func testStaleMarkersForRemovedPathsAreDropped() async {
seedServer()
_ = await runner.run(config, force: false)
var other = config
other.folders = ["/Other"]
FakeDav.mkdir("/Other")
_ = await runner.run(other, force: false)
XCTAssertNil(store.loadRootMarkers(accountId: "acct")["/Docs"])
}
// MARK: - Parsing
func testParserReadsEtagFileIdSizeAndDates() {
let xml = Data(
"""
<?xml version="1.0"?><d:multistatus xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">
<d:response><d:href>/nextcloud/remote.php/dav/files/alice/Docs/</d:href><d:propstat><d:prop>
<d:resourcetype><d:collection/></d:resourcetype><d:getetag>"abc123"</d:getetag>
<oc:fileid>7</oc:fileid></d:prop></d:propstat></d:response>
<d:response><d:href>/nextcloud/remote.php/dav/files/alice/Docs/Tax%20Forms.pdf</d:href><d:propstat><d:prop>
<d:getlastmodified>Tue, 14 Nov 2023 22:13:20 GMT</d:getlastmodified>
<d:getcontentlength>2048</d:getcontentlength><d:resourcetype/>
<d:getetag>"e1"</d:getetag><oc:fileid>9</oc:fileid></d:prop></d:propstat></d:response>
</d:multistatus>
""".utf8)
let entries = try! DavSyncParser.entries(from: xml, username: "alice", requestedPath: "/Docs", skipSelf: true)
XCTAssertEqual(entries.count, 1, "the folder itself is skipped")
XCTAssertEqual(entries[0].path, "/Docs/Tax Forms.pdf")
XCTAssertEqual(entries[0].etag, "e1", "quotes are stripped")
XCTAssertEqual(entries[0].fileId, "9")
XCTAssertEqual(entries[0].size, 2048)
XCTAssertEqual(entries[0].lastModified, 1_700_000_000_000)
XCTAssertFalse(entries[0].isFolder)
let all = try! DavSyncParser.entries(from: xml, username: "alice", requestedPath: "/Docs", skipSelf: false)
XCTAssertEqual(all.map(\.path), ["/Docs", "/Docs/Tax Forms.pdf"])
XCTAssertTrue(all[0].isFolder)
}
func testParserRefusesAnythingThatIsNotACompleteAnswer() {
func entries(_ xml: String) throws -> [SyncRemoteEntry] {
try DavSyncParser.entries(from: Data(xml.utf8), username: "alice", requestedPath: "/Docs", skipSelf: false)
}
XCTAssertThrowsError(try entries("nope"), "not XML")
XCTAssertThrowsError(try entries("<d:multistatus xmlns:d=\"DAV:\"><d:response><d:href>/remote.php"), "truncated")
XCTAssertThrowsError(
try entries(
"<d:multistatus xmlns:d=\"DAV:\"><d:response><d:href>/remote.php/dav/files/alice/Docs/a.txt</d:href></d:response>"
+ "<d:response><d:href>/remote.php/dav/files/alice/Docs/b"),
"one complete response, then cut off: a partial manifest")
XCTAssertThrowsError(try entries("<html><body>Maintenance</body></html>"), "well-formed but not a multistatus")
XCTAssertThrowsError(try entries("<d:multistatus xmlns:d=\"DAV:\"></d:multistatus>"), "a 207 always has the resource itself")
}
func testParserRefusesHrefsOutsideTheAccountRootOrClimbingOut() {
func parse(_ href: String) throws -> [SyncRemoteEntry] {
let xml = "<d:multistatus xmlns:d=\"DAV:\"><d:response><d:href>\(href)</d:href><d:propstat><d:prop><d:resourcetype/></d:prop></d:propstat></d:response></d:multistatus>"
return try DavSyncParser.entries(from: Data(xml.utf8), username: "alice", requestedPath: "/", skipSelf: false)
}
XCTAssertNoThrow(try parse("/remote.php/dav/files/alice/Docs/a.txt"))
XCTAssertThrowsError(try parse("/remote.php/dav/files/alice/../bob/secret.txt"), "dot-dot component")
XCTAssertThrowsError(try parse("/remote.php/dav/files/alice/Docs/%2E%2E/%2E%2E/x"), "encoded dot-dot")
XCTAssertThrowsError(try parse("/somewhere/else/a.txt"), "not under the files root")
XCTAssertThrowsError(try parse("/remote.php/dav/files/alicia/a.txt"), "another user's root that merely starts the same")
}
// MARK: - Review findings: each of these used to lose or misplace data
func testATruncatedAnswerIsNotAManifest() async {
seedServer()
_ = await runner.run(config, force: false)
FakeDav.truncatePropfind = true
let summary = await runner.run(config, force: true)
XCTAssertFalse(summary.changedAnything, "half a listing must not look like deletions")
XCTAssertEqual(local("Docs/a.txt"), "alpha")
XCTAssertEqual(local("Docs/sub/b.txt"), "bravo")
XCTAssertTrue(store.loadMissingRoots(accountId: "acct").isEmpty, "nor like the folder being gone")
}
func testDeletedOnTheServerButEditedHereIsDeletedLikeAndroid() async throws {
seedServer()
_ = await runner.run(config, force: false)
try editLocally("Docs/a.txt", "my only copy of this edit")
FakeDav.remove("/Docs/a.txt")
let summary = await runner.run(config, force: false)
XCTAssertEqual(summary.deleted, 1)
XCTAssertFalse(exists("Docs/a.txt"))
XCTAssertNil(state().first { $0.value.relPath == "Docs/a.txt" }, "no longer tracked")
let again = await runner.run(config, force: false)
XCTAssertFalse(exists("Docs/a.txt"), "not restored on a later sync")
XCTAssertFalse(again.changedAnything)
}
func testSyncingTheWholeAccountNoticesServerDeletions() async {
FakeDav.put("/a.txt", "top")
FakeDav.put("/sub/b.txt", "nested")
var everything = config
everything.folders = ["/"]
_ = await runner.run(everything, force: false)
XCTAssertEqual(local("a.txt"), "top")
FakeDav.log = []
_ = await runner.run(everything, force: false)
XCTAssertEqual(depth1Requests(), 0, "the root shortcut works for '/' too")
FakeDav.remove("/a.txt")
let summary = await runner.run(everything, force: false)
XCTAssertEqual(summary.deleted, 1)
XCTAssertFalse(exists("a.txt"))
XCTAssertTrue(exists("sub/b.txt"))
}
func testAPathNeverClaimsASiblingWithTheSamePrefix() {
let entry = { (rel: String) in SyncFileState(relPath: rel, etag: "e", lastModified: 0, size: 1, localMTime: 1) }
let state = ["1": entry("Docs/a.txt"), "2": entry("Docs2/b.txt"), "3": entry("Documents/c.txt"), "4": entry("Docs")]
XCTAssertEqual(SyncDiff.priorFileIds(state: state, path: "/Docs"), ["1", "4"])
XCTAssertEqual(SyncDiff.priorFileIds(state: state, path: "/Docs/"), ["1", "4"])
XCTAssertEqual(SyncDiff.priorFileIds(state: state, path: "/"), ["1", "2", "3", "4"], "the root covers everything")
}
func testTwoRemoteNamesForOneLocalFileAreNeitherDownloaded() async {
FakeDav.put("/Docs/a.txt", "lower")
FakeDav.put("/Docs/A.txt", "upper")
FakeDav.put("/Docs/fine.txt", "ok")
let summary = await runner.run(config, force: false)
XCTAssertEqual(summary.downloaded, 1, "only the file with no collision")
XCTAssertEqual(local("Docs/fine.txt"), "ok")
XCTAssertNil(local("Docs/a.txt"), "neither of the colliding pair overwrote the other")
XCTAssertNil(store.loadRootMarkers(accountId: "acct")["/Docs"], "and the path isn't called clean")
let unicode = SyncDiff.collidingRelPaths([
SyncRemoteEntry(path: "/x/\u{C4}.txt", fileId: "1", etag: "", lastModified: 0, size: 0, isFolder: false),
SyncRemoteEntry(path: "/x/A\u{308}.txt", fileId: "2", etag: "", lastModified: 0, size: 0, isFolder: false),
])
XCTAssertTrue(unicode.contains("x/\u{C4}.txt") && unicode.contains("x/A\u{308}.txt"),
"canonically equivalent Unicode spellings collide too")
XCTAssertTrue(
SyncDiff.collidingRelPaths([
SyncRemoteEntry(path: "/x/same.txt", fileId: "1", etag: "", lastModified: 0, size: 0, isFolder: false)
]).isEmpty, "one spelling is never a collision")
}
func testServerPathsCanNeverLeaveTheMirror() {
let root = FileManager.default.temporaryDirectory.appendingPathComponent("noo-root-\(UUID().uuidString)")
XCTAssertNotNil(LocalFS.resolve("Docs/a.txt", in: root))
XCTAssertNil(LocalFS.resolve("../outside.txt", in: root))
XCTAssertNil(LocalFS.resolve("Docs/../../outside.txt", in: root))
XCTAssertNil(LocalFS.resolve("./x", in: root))
XCTAssertNil(LocalFS.resolve("", in: root))
XCTAssertNil(LocalFS.resolve("/", in: root))
}
func testAnUnreadableStateMakesTheRunDoNothing() async throws {
seedServer()
_ = await runner.run(config, force: false)
try editLocally("Docs/a.txt", "unsynced local edit")
let stateFile = store.base.appendingPathComponent("sync-state/acct/state.json")
try Data("{ this is not json".utf8).write(to: stateFile)
FakeDav.log = []
let summary = await runner.run(config, force: true)
XCTAssertEqual(summary, SyncRunSummary())
XCTAssertEqual(local("Docs/a.txt"), "unsynced local edit", "an untracked-looking file must not be overwritten")
XCTAssertTrue(FakeDav.log.isEmpty, "it didn't even talk to the server")
XCTAssertThrowsError(try store.loadState(accountId: "acct"))
XCTAssertEqual(try Data(contentsOf: stateFile), Data("{ this is not json".utf8), "the corrupt file isn't overwritten")
}
func testNoStateYetIsJustAFirstSyncNotAnError() throws {
XCTAssertEqual(try store.loadState(accountId: "brand-new"), [:])
}
func testADownloadNeverReplacesAFolderOrDestroysTheOldCopyOnFailure() async throws {
FakeDav.put("/Docs/a.txt", "server")
let client = DavSyncClient(session: fakeSession)
let folder = mirror.appendingPathComponent("Docs/a.txt")
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
try "inside".write(to: folder.appendingPathComponent("keep.txt"), atomically: true, encoding: .utf8)
let ok = try await client.download(creds, remotePath: "/Docs/a.txt", to: folder)
XCTAssertFalse(ok, "a folder is not replaced by a file")
XCTAssertEqual(local("Docs/a.txt/keep.txt"), "inside")
let file = mirror.appendingPathComponent("Docs/b.txt")
try "old".write(to: file, atomically: true, encoding: .utf8)
FakeDav.put("/Docs/b.txt", "new")
let replaced = try await client.download(creds, remotePath: "/Docs/b.txt", to: file)
XCTAssertTrue(replaced)
XCTAssertEqual(local("Docs/b.txt"), "new")
FakeDav.failAll = true
let failed = try await client.download(creds, remotePath: "/Docs/b.txt", to: file)
XCTAssertFalse(failed)
XCTAssertEqual(local("Docs/b.txt"), "new", "a failed download leaves what was there")
}
func testRemovingTheWholeRootForgetsEveryFile() async {
seedServer()
_ = await runner.run(config, force: false)
store.removeLocalSync(accountId: "acct", path: "/")
XCTAssertTrue(state().isEmpty, "'/' covers everything recorded")
XCTAssertFalse(exists("Docs/a.txt"))
}
func testPruningNeverRemovesAFolderThatHoldsSomething() throws {
let root = mirror
try FileManager.default.createDirectory(
at: root.appendingPathComponent("Docs/stale"), withIntermediateDirectories: true)
try "mine".write(to: root.appendingPathComponent("Docs/stale/note.txt"), atomically: true, encoding: .utf8)
try FileManager.default.createDirectory(
at: root.appendingPathComponent("Docs/empty-stale"), withIntermediateDirectories: true)
SyncDiff.pruneRemovedFolders(syncRoot: root, rootRel: "Docs", remoteFolders: ["Docs"])
XCTAssertEqual(local("Docs/stale/note.txt"), "mine")
XCTAssertFalse(exists("Docs/empty-stale"), "an empty folder the server no longer has goes")
}
// MARK: - Status, coordinator, config store, mutex
func testConflictsAreKeptPerAccount() {
let a = SyncConflict(accountId: "A", fileId: "7", remotePath: "/x", relPath: "x", name: "x")
let b = SyncConflict(accountId: "B", fileId: "7", remotePath: "/y", relPath: "y", name: "y")
bus.addConflicts(accountId: "A", [a])
bus.addConflicts(accountId: "B", [b])
XCTAssertEqual(bus.snapshot().conflicts.count, 2, "same file id, different accounts: both kept")
let coordinator = SyncCoordinator(
store: store, bus: bus, configs: SyncConfigStore(service: "test-\(UUID().uuidString)"), client: DavSyncClient())
let forB = coordinator.statusMap(bus.snapshot(), accountIdOverride: "B")["conflicts"] as? [[String: String]]
XCTAssertEqual(forB?.map { $0["relPath"] }, ["y"], "B never sees A's conflicts")
bus.removeConflict(accountId: "A", fileId: "7")
XCTAssertEqual(bus.snapshot().conflicts, [b], "removing A's leaves B's")
}
func testBackgroundSlotOnlyRunsAccountsWithPeriodicSyncEnabled() async {
seedServer()
let configs = SyncConfigStore(service: "test-\(UUID().uuidString)")
addTeardownBlock { configs.removeAll() }
let coordinator = SyncCoordinator(store: store, bus: bus, configs: configs, client: DavSyncClient(session: fakeSession))
var scheduled = config
scheduled.intervalMinutes = nil
scheduled.wifiOnly = false
configs.upsert(scheduled)
let skipped = expectation(description: "manual-only account skipped")
coordinator.startBackgroundSlot { success in
XCTAssertTrue(success)
skipped.fulfill()
}
await fulfillment(of: [skipped], timeout: 10)
XCTAssertFalse(exists("Docs/a.txt"))
scheduled.intervalMinutes = 30
configs.upsert(scheduled)
let completed = expectation(description: "periodic account synced")
coordinator.startBackgroundSlot { success in
XCTAssertTrue(success)
completed.fulfill()
}
await fulfillment(of: [completed], timeout: 15)
XCTAssertEqual(local("Docs/a.txt"), "alpha")
XCTAssertEqual(local("Docs/sub/b.txt"), "bravo")
}
func testSignOutForgetsCredentialsButTurningBackgroundOffKeepsThem() {
let service = "test-\(UUID().uuidString)"
let configs = SyncConfigStore(service: service)
addTeardownBlock { configs.removeAll() }
let coordinator = SyncCoordinator(store: store, bus: bus, configs: configs, client: DavSyncClient())
var withBackground = config
withBackground.intervalMinutes = 30
coordinator.reschedule(withBackground)
XCTAssertEqual(configs.config(for: "acct")?.intervalMinutes, 30)
coordinator.cancel(accountId: "acct", forget: false)
XCTAssertNotNil(configs.config(for: "acct"), "conflict actions and Sync now still need the credentials")
XCTAssertNil(configs.config(for: "acct")?.intervalMinutes, "but nothing is scheduled")
coordinator.cancel(accountId: "acct", forget: true)
XCTAssertNil(configs.config(for: "acct"), "signing out forgets them")
}
func testConfigStoreHoldsSeveralAccountsAndUpdatesInPlace() {
let configs = SyncConfigStore(service: "test-\(UUID().uuidString)")
addTeardownBlock { configs.removeAll() }
let second = SyncAccountConfig(
accountId: "other", serverUrl: "https://o", username: "bob", authHeader: "Basic y",
folders: ["/x"], wifiOnly: true, intervalMinutes: 60, notify: true)
XCTAssertTrue(configs.upsert(config))
XCTAssertTrue(configs.upsert(second))
XCTAssertEqual(configs.all().map(\.accountId), ["acct", "other"])
var changed = config
changed.folders = ["/Docs", "/More"]
XCTAssertTrue(configs.upsert(changed))
XCTAssertEqual(configs.config(for: "acct")?.folders, ["/Docs", "/More"])
XCTAssertEqual(configs.config(for: "other"), second, "updating one leaves the others")
XCTAssertTrue(configs.remove(accountId: "acct"))
XCTAssertEqual(configs.all().map(\.accountId), ["other"])
XCTAssertTrue(configs.remove(accountId: "never-existed"))
}
func testAQueuedWaiterThatIsCancelledLeavesTheQueueAtOnce() async throws {
let mutex = AsyncMutex()
let holderStarted = expectation(description: "holder has the lock")
let holder = Task {
try await mutex.withLock {
holderStarted.fulfill()
try await Task.sleep(nanoseconds: 1_500_000_000)
}
}
await fulfillment(of: [holderStarted], timeout: 5)
let waiter = Task { try await mutex.withLock { "ran" } }
try await Task.sleep(nanoseconds: 100_000_000)
let cancelledAt = Date()
waiter.cancel()
do {
_ = try await waiter.value
XCTFail("a cancelled waiter must not run")
} catch {
XCTAssertTrue(error is CancellationError)
XCTAssertLessThan(Date().timeIntervalSince(cancelledAt), 0.8, "it didn't wait for the 1.5s holder")
}
try await holder.value
let next = try await mutex.withLock { "next" }
XCTAssertEqual(next, "next", "the lock is free for the next one")
}
private final class Counter: @unchecked Sendable {
var running = 0
var maxRunning = 0
var finished = 0
}
func testAMutexNeverRunsTwoBodiesAtOnce() async throws {
let mutex = AsyncMutex()
let counter = Counter()
await withTaskGroup(of: Void.self) { group in
for _ in 0..<6 {
group.addTask {
_ = try? await mutex.withLock {
counter.running += 1
counter.maxRunning = max(counter.maxRunning, counter.running)
try? await Task.sleep(nanoseconds: 10_000_000)
counter.running -= 1
counter.finished += 1
}
}
}
}
XCTAssertEqual(counter.finished, 6)
XCTAssertEqual(counter.maxRunning, 1)
}
func testOnceGateCompletesOnlyOnce() {
var calls: [Bool] = []
let gate = OnceGate { calls.append($0) }
gate.finish(false)
gate.finish(true)
XCTAssertEqual(calls, [false])
}
}
+19
View File
@@ -0,0 +1,19 @@
import LocalAuthentication
/// Face ID / Touch ID with the device passcode as the fallback - the same
/// policy the app's lock uses (`local_auth` with `biometricOnly: false`), so
/// the share sheet asks for exactly what the app would.
enum DeviceAuth {
/// True only if the user actually authenticated; a cancel, a failure, or a
/// device with nothing to authenticate with all count as "not unlocked".
static func authenticate(reason: String) async -> Bool {
let context = LAContext()
var error: NSError?
guard context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &error) else { return false }
return await withCheckedContinuation { continuation in
context.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: reason) { success, _ in
continuation.resume(returning: success)
}
}
}
}
+47
View File
@@ -0,0 +1,47 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Noo</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>NSFaceIDUsageDescription</key>
<string>Noo uses Face ID to unlock hidden folders and accounts when you share a file.</string>
<key>NooKeychainAccessGroup</key>
<string>$(AppIdentifierPrefix)dev.ayushya.noo.shared</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionAttributes</key>
<dict>
<key>NSExtensionActivationRule</key>
<dict>
<key>NSExtensionActivationSupportsFileWithMaxCount</key>
<integer>50</integer>
<key>NSExtensionActivationSupportsImageWithMaxCount</key>
<integer>50</integer>
<key>NSExtensionActivationSupportsMovieWithMaxCount</key>
<integer>50</integer>
</dict>
</dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.share-services</string>
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).ShareViewController</string>
</dict>
</dict>
</plist>
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.dev.ayushya.noo</string>
</array>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)dev.ayushya.noo.shared</string>
</array>
</dict>
</plist>
+406
View File
@@ -0,0 +1,406 @@
import SwiftUI
/// State behind the share sheet's account and folder pickers.
@MainActor
final class ShareModel: ObservableObject {
enum Stage: Equatable {
case preparing
case chooseAccount
case picking
case uploading
case noAccount
case failed(String)
}
@Published var stage: Stage = .preparing
@Published var items: [SharedItem] = []
@Published var path = "/"
@Published var isLoadingFolders = false
@Published var folderError: String?
/// A short explanation shown under the list - e.g. hidden folders stayed
/// hidden because the unlock was cancelled.
@Published var notice: String?
@Published private(set) var selected: SharedAccount?
/// The sheet's two toggles. They start at the app's own setting for the
/// chosen account and can be changed for this share only.
@Published private(set) var hiddenFilter: HiddenFilter = .hide
@Published private(set) var storageFilter: StorageFilter = .cloud
/// Every child folder of [path], unfiltered; the toggles filter it live.
@Published private var allFolders: [DavFolder] = []
/// Mount points of external storage seen so far. Only the mount's root is
/// flagged by the server, so anything under one of these is external too.
private var externalRoots: Set<String> = []
let shared: SharedAccounts?
/// Set by the view controller: what each button actually does.
var onUpload: () -> Void = {}
var onSaveForLater: () -> Void = {}
var onCancel: () -> Void = {}
init(shared: SharedAccounts?) {
self.shared = shared
}
var accounts: [SharedAccount] { shared?.accounts ?? [] }
var canChangeAccount: Bool { accounts.count > 1 }
var activeAccountId: String? { shared?.active?.id }
var currentFolderName: String {
path == "/" ? "All files" : (path as NSString).lastPathComponent
}
var fileCountText: String {
items.count == 1 ? "1 file" : "\(items.count) files"
}
// MARK: - Folders, filtered by the toggles
private func isExternal(_ folder: DavFolder) -> Bool {
folder.isExternal
|| externalRoots.contains { folder.path == $0 || folder.path.hasPrefix($0 + "/") }
}
private var visibleFolders: [DavFolder] {
allFolders.filter { hiddenFilter.shows(path: $0.path) && storageFilter.shows(isExternal: isExternal($0)) }
}
/// Internal folders - and, for "All", the external ones are listed apart.
var internalFolders: [DavFolder] { visibleFolders.filter { !isExternal($0) } }
var externalFolders: [DavFolder] { visibleFolders.filter { isExternal($0) } }
// MARK: - Accounts
/// With several accounts the list comes first; with one it goes straight
/// to its folders.
func start() async {
guard let shared, let first = shared.accounts.first else {
stage = .noAccount
return
}
NSLog("[ShareExtension] %d account(s), active=%@", shared.accounts.count, shared.active?.id ?? "none")
if shared.accounts.count > 1 {
stage = .chooseAccount
} else {
await select(first)
}
}
func showAccounts() {
notice = nil
stage = .chooseAccount
}
/// Uploading to an account other than the one the app is on is "switching"
/// in the app's terms, so it asks for the same unlock when the app does.
/// The toggles start at that account's own app settings; hidden folders
/// are behind the app's "lock hidden files" unlock.
func select(_ account: SharedAccount) async {
guard let shared else { return }
notice = nil
var wantedHidden = HiddenFilter(raw: account.hiddenFilter)
// Every selection asks again, like the app does for every switch - no
// unlock is remembered. If one action needs both unlocks, one prompt
// covers both.
let needs = shared.unlockNeeds(choosing: account, showing: wantedHidden)
if needs.needsPrompt {
let reason = needs.switchesAccount
? "Unlock to upload to \(account.displayName)" : "Unlock to show hidden folders"
if await !DeviceAuth.authenticate(reason: reason) {
guard !needs.switchesAccount else {
notice = "Unlock to upload to a different account."
stage = .chooseAccount
return
}
wantedHidden = .hide
notice = "Hidden folders are locked, so they're not shown."
}
}
NSLog("[ShareExtension] selected %@ (hidden=%@ storage=%@)", account.id, wantedHidden.rawValue, account.storageScope)
selected = account
externalRoots = []
storageFilter = StorageFilter(raw: account.storageScope)
hiddenFilter = wantedHidden
stage = .picking
await open("/")
}
// MARK: - Toggles
/// Turning hidden folders *on* is what the app locks; going back to
/// hiding them never exposes anything, and neither does switching
/// between the two revealing modes.
func setHiddenFilter(_ filter: HiddenFilter) async {
guard filter != hiddenFilter else { return }
if shared?.needsUnlockToChangeHidden(from: hiddenFilter, to: filter) == true {
guard await DeviceAuth.authenticate(reason: "Unlock to show hidden folders") else {
notice = "Hidden folders are locked, so they're not shown."
return
}
}
notice = nil
hiddenFilter = filter
await open("/")
}
func setStorageFilter(_ filter: StorageFilter) async {
guard filter != storageFilter else { return }
notice = nil
storageFilter = filter
await open("/")
}
// MARK: - Browsing
/// A toggle can leave you inside a folder it now hides, so changing one
/// always returns to the top - same as [open] with "/".
func open(_ path: String) async {
guard let account = selected else { return }
self.path = path
isLoadingFolders = true
folderError = nil
defer { isLoadingFolders = false }
do {
allFolders = try await DavClient.listFolders(account: account, path: path)
externalRoots.formUnion(allFolders.filter(\.isExternal).map(\.path))
} catch {
allFolders = []
folderError = error.localizedDescription
}
}
func openParent() async {
let parent = (path as NSString).deletingLastPathComponent
await open(parent.isEmpty ? "/" : parent)
}
}
struct SharePickerView: View {
@ObservedObject var model: ShareModel
@State private var externalExpanded = true
var body: some View {
VStack(spacing: 0) {
header
Divider()
content
Divider()
footer
}
.background(Color(.systemBackground))
}
private var header: some View {
HStack {
Button("Cancel", action: model.onCancel)
Spacer()
Text("Upload to Noo").font(.headline)
Spacer()
// Balances Cancel so the title stays centred.
Button("Cancel", action: {}).hidden()
}
.padding(.horizontal)
.padding(.vertical, 12)
}
@ViewBuilder
private var content: some View {
switch model.stage {
case .preparing:
centered { ProgressView("Preparing \(model.items.isEmpty ? "" : model.fileCountText)…") }
case .uploading:
centered {
VStack(spacing: 8) {
ProgressView()
Text("Uploading \(model.fileCountText) in the background").font(.subheadline)
}
}
case .noAccount:
centered {
Text("Sign in to Noo first, then share again. You can also save these files and choose a folder from inside Noo.")
.multilineTextAlignment(.center)
.foregroundColor(.secondary)
.padding()
}
case .failed(let message):
centered {
Text(message).multilineTextAlignment(.center).foregroundColor(.secondary).padding()
}
case .chooseAccount:
accountList
case .picking:
folderList
}
}
private var accountList: some View {
List {
Section(
header: Text("Upload \(model.fileCountText) to which account?"),
footer: model.notice.map { Text($0) }
) {
ForEach(model.accounts) { account in
Button {
Task { await model.select(account) }
} label: {
HStack {
Label(account.displayName, systemImage: "person.crop.circle")
Spacer()
if account.id == model.activeAccountId {
Text("Active").font(.caption).foregroundColor(.secondary)
}
}
}
}
}
}
.listStyle(.insetGrouped)
}
// MARK: Folders
private var folderList: some View {
List {
Section(header: accountHeader, footer: model.notice.map { Text($0) }) {
filterRow
if model.path != "/" {
Button {
Task { await model.openParent() }
} label: {
Label("Up to \(parentName)", systemImage: "arrow.turn.left.up")
}
}
if model.isLoadingFolders {
HStack { Spacer(); ProgressView(); Spacer() }
} else if let error = model.folderError {
Text(error).foregroundColor(.secondary)
} else if model.internalFolders.isEmpty && model.externalFolders.isEmpty {
Text("No folders here").foregroundColor(.secondary)
} else {
ForEach(model.internalFolders, id: \.path) { folderRow($0) }
}
}
// "All" keeps external storage in its own group, like the app's list.
if !model.isLoadingFolders, model.folderError == nil, !model.externalFolders.isEmpty {
Section {
DisclosureGroup(isExpanded: $externalExpanded) {
ForEach(model.externalFolders, id: \.path) { folderRow($0) }
} label: {
Label("External storage (\(model.externalFolders.count))", systemImage: "externaldrive")
}
}
}
}
.listStyle(.insetGrouped)
}
private func folderRow(_ folder: DavFolder) -> some View {
Button {
Task { await model.open(folder.path) }
} label: {
Label(folder.name, systemImage: folder.isExternal ? "externaldrive" : "folder")
}
}
/// The two toggles, as menus - Hidden folders and External storage each
/// start at the app's setting and can be changed for this share.
private var filterRow: some View {
HStack(spacing: 8) {
Menu {
Picker("Hidden folders", selection: hiddenBinding) {
Text("Hide hidden folders").tag(HiddenFilter.hide)
Text("Only hidden folders").tag(HiddenFilter.only)
Text("Show all").tag(HiddenFilter.include)
}
} label: {
chip("eye", "Hidden", hiddenTitle)
}
Menu {
Picker("External storage", selection: storageBinding) {
Text("Cloud only").tag(StorageFilter.cloud)
Text("Only external storage").tag(StorageFilter.external)
Text("All + external storage").tag(StorageFilter.all)
}
} label: {
chip("externaldrive", "Storage", storageTitle)
}
Spacer()
}
.buttonStyle(.borderless)
}
private func chip(_ icon: String, _ name: String, _ value: String) -> some View {
Label("\(name): \(value)", systemImage: icon)
.font(.footnote.weight(.medium))
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(Capsule().fill(Color(.secondarySystemFill)))
}
private var hiddenBinding: Binding<HiddenFilter> {
Binding(get: { model.hiddenFilter }, set: { value in Task { await model.setHiddenFilter(value) } })
}
private var storageBinding: Binding<StorageFilter> {
Binding(get: { model.storageFilter }, set: { value in Task { await model.setStorageFilter(value) } })
}
private var hiddenTitle: String {
switch model.hiddenFilter {
case .hide: return "Hide"
case .only: return "Only"
case .include: return "All"
}
}
private var storageTitle: String {
switch model.storageFilter {
case .cloud: return "Cloud"
case .external: return "External"
case .all: return "All"
}
}
private var accountHeader: some View {
HStack {
Text(model.selected?.displayName ?? "")
Spacer()
if model.canChangeAccount {
Button("Change account") { model.showAccounts() }
.font(.caption)
}
}
}
private var parentName: String {
let parent = (model.path as NSString).deletingLastPathComponent
return parent.isEmpty || parent == "/" ? "All files" : (parent as NSString).lastPathComponent
}
private var footer: some View {
VStack(spacing: 8) {
if model.stage == .picking {
Button(action: model.onUpload) {
Text("Upload \(model.fileCountText) to \(model.currentFolderName)")
.font(.headline)
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
}
.buttonStyle(.borderedProminent)
.disabled(model.isLoadingFolders)
}
if model.stage == .picking || model.stage == .chooseAccount || model.stage == .noAccount {
Button("Choose a folder later in Noo", action: model.onSaveForLater)
.font(.subheadline)
}
}
.padding()
}
private func centered<Content: View>(@ViewBuilder _ content: () -> Content) -> some View {
VStack { Spacer(); content(); Spacer() }.frame(maxWidth: .infinity)
}
}
@@ -0,0 +1,145 @@
import SwiftUI
import UIKit
import UniformTypeIdentifiers
import UserNotifications
/// "Share to Noo". Like Reminders' or Notes' share sheet, the destination is
/// chosen *inside* the sheet - an extension can't open its host app - so this
/// shows a folder picker, then starts a background upload and closes:
///
/// 1. Copies whatever was shared into the App Group (`SharedInbox`).
/// 2. Reads the accounts the app left in the shared Keychain
/// (`SharedAccountStore`) - asking which one first if there are several -
/// and lists folders over WebDAV (`DavClient`), honouring the app's
/// hidden-files filter and its unlock settings (`ShareModel`).
/// 3. "Upload" queues the files on a background session that outlives this
/// process (`ShareUpload`); the app is relaunched to finish and notify.
///
/// With no account, or if the user prefers, the files are instead left in the
/// inbox for the app to offer its own destination picker next time it opens
/// (with a notification saying so).
final class ShareViewController: UIViewController {
private var model: ShareModel!
private var batchDirectory: URL?
override func viewDidLoad() {
super.viewDidLoad()
model = ShareModel(shared: SharedAccountStore.load())
model.onUpload = { [weak self] in self?.upload() }
model.onSaveForLater = { [weak self] in self?.saveForLater() }
model.onCancel = { [weak self] in self?.cancel() }
let host = UIHostingController(rootView: SharePickerView(model: model))
addChild(host)
host.view.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(host.view)
NSLayoutConstraint.activate([
host.view.topAnchor.constraint(equalTo: view.topAnchor),
host.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
host.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
host.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
])
host.didMove(toParent: self)
Task { await prepare() }
}
// MARK: - Importing
@MainActor
private func prepare() async {
guard let root = AppGroup.containerURL else {
model.stage = .failed("Noo isn't set up to receive files yet.")
return
}
let providers =
(extensionContext?.inputItems as? [NSExtensionItem])?.flatMap { $0.attachments ?? [] } ?? []
do {
let batch = try SharedInbox.makeBatchDirectory(in: root)
batchDirectory = batch
for provider in providers where provider.hasItemConformingToTypeIdentifier(UTType.item.identifier) {
if let item = try? await copy(provider, into: batch) { model.items.append(item) }
}
} catch {
model.stage = .failed("Couldn't save to Noo: \(error.localizedDescription)")
return
}
guard !model.items.isEmpty else {
model.stage = .failed("Noo can only receive files and photos.")
return
}
await model.start()
}
/// `loadFileRepresentation`'s URL only lives for the duration of its
/// callback, so the copy happens inside it.
private func copy(_ provider: NSItemProvider, into directory: URL) async throws -> SharedItem {
try await withCheckedThrowingContinuation { continuation in
provider.loadFileRepresentation(forTypeIdentifier: UTType.item.identifier) { url, error in
guard let url else {
return continuation.resume(throwing: error ?? CocoaError(.fileReadUnknown))
}
do {
let destination = LocalFiles.uniqueURL(in: directory, name: url.lastPathComponent)
try FileManager.default.copyItem(at: url, to: destination)
let size = (try? FileManager.default.attributesOfItem(atPath: destination.path))?[.size] as? Int64 ?? 0
let mime = UTType(filenameExtension: destination.pathExtension)?.preferredMIMEType
continuation.resume(
returning: SharedItem(
path: destination.path, name: destination.lastPathComponent, mimeType: mime, size: size))
} catch {
continuation.resume(throwing: error)
}
}
}
}
// MARK: - Actions
private func upload() {
guard let account = model.selected else { return }
do {
try ShareUpload.enqueue(items: model.items, account: account, remoteFolder: model.path)
model.stage = .uploading
complete(after: 1.2)
} catch {
model.stage = .failed("Couldn't start the upload: \(error.localizedDescription)")
}
}
private func saveForLater() {
guard let root = AppGroup.containerURL else { return cancel() }
do {
try SharedInbox.append(model.items, in: root)
notifyOpenNoo(count: model.items.count)
complete(after: 0)
} catch {
model.stage = .failed("Couldn't save to Noo: \(error.localizedDescription)")
}
}
private func cancel() {
if let batchDirectory { try? FileManager.default.removeItem(at: batchDirectory) }
extensionContext?.cancelRequest(withError: CocoaError(.userCancelled))
}
private func complete(after delay: TimeInterval) {
DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
self.extensionContext?.completeRequest(returningItems: nil)
}
}
private func notifyOpenNoo(count: Int) {
let content = UNMutableNotificationContent()
content.title = "Noo"
content.body =
count == 1
? "Open Noo to choose where to upload 1 file."
: "Open Noo to choose where to upload \(count) files."
content.sound = .default
UNUserNotificationCenter.current().add(
UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil))
}
}
+185
View File
@@ -0,0 +1,185 @@
import Foundation
/// A folder in the account's WebDAV tree. [path] is relative to the user's
/// root with a leading slash ("/Documents/Tax Forms"); "/" is the root.
/// [isExternal] is true for an external-storage mount point (Nextcloud's
/// `nc:mount-type` = `external`) - only the mount's root carries it, its
/// children don't, so callers track mount roots to know what's inside one.
struct DavFolder: Equatable {
let name: String
let path: String
var isExternal = false
}
/// The app's Files storage scope (`StorageScope` in Dart): internal ("cloud")
/// folders only (the default), only external-storage ones, or both.
enum StorageFilter: String {
case cloud, external, all
init(raw: String) {
self = StorageFilter(rawValue: raw) ?? .cloud
}
func shows(isExternal: Bool) -> Bool {
switch self {
case .cloud: return !isExternal
case .external: return isExternal
case .all: return true
}
}
}
/// The app's Files "hidden files" filter (`HiddenFilesFilter` in Dart):
/// leave dot-folders out (`hide`, the default), list only them (`only`), or
/// list everything (`include`). A folder counts as hidden when it - or any
/// folder above it - starts with a dot, exactly as in the app.
enum HiddenFilter: String {
case hide, only, include
init(raw: String) {
self = HiddenFilter(rawValue: raw) ?? .hide
}
static func isHidden(path: String) -> Bool {
path.split(separator: "/").contains { $0.hasPrefix(".") }
}
func shows(path: String) -> Bool {
switch self {
case .hide: return !Self.isHidden(path: path)
case .only: return Self.isHidden(path: path)
case .include: return true
}
}
}
/// Parses a WebDAV `PROPFIND` multistatus into folders. Pure (no
/// networking), so it's unit-tested. Compiled into both targets.
enum DavFolderParser {
private struct Response {
var href = ""
var isCollection = false
var mountType = ""
}
private final class Delegate: NSObject, XMLParserDelegate {
var responses: [Response] = []
private var current: Response?
private var text = ""
private func local(_ name: String) -> String {
name.split(separator: ":").last.map(String.init) ?? name
}
func parser(
_ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?,
qualifiedName qName: String?, attributes attributeDict: [String: String] = [:]
) {
switch local(elementName) {
case "response": current = Response()
case "href", "mount-type": text = ""
case "collection": current?.isCollection = true
default: break
}
}
func parser(_ parser: XMLParser, foundCharacters string: String) {
text += string
}
func parser(
_ parser: XMLParser, didEndElement elementName: String, namespaceURI: String?,
qualifiedName qName: String?
) {
switch local(elementName) {
case "href": current?.href = text.trimmingCharacters(in: .whitespacesAndNewlines)
case "mount-type": current?.mountType = text.trimmingCharacters(in: .whitespacesAndNewlines)
case "response":
if let current { responses.append(current) }
current = nil
default: break
}
}
}
/// "/nextcloud/remote.php/dav/files/alice/Tax%20Forms/" -> "/Tax Forms".
/// The server may live under a sub-path, so the files root is located by
/// its marker instead of assumed to start at the beginning.
static func relativePath(fromHref href: String) -> String? {
let decoded = href.removingPercentEncoding ?? href
let marker = "/remote.php/dav/files/"
guard let range = decoded.range(of: marker) else { return nil }
var parts = decoded[range.upperBound...].split(separator: "/", omittingEmptySubsequences: true)
guard !parts.isEmpty else { return nil }
parts.removeFirst() // the username
return "/" + parts.joined(separator: "/")
}
/// Child folders of [currentPath], sorted by name, filtered by [hidden].
/// The requested folder itself (which a Depth-1 PROPFIND also returns) is
/// always left out.
static func folders(
from xml: Data, excluding currentPath: String, hidden: HiddenFilter = .hide
) -> [DavFolder] {
let delegate = Delegate()
let parser = XMLParser(data: xml)
parser.delegate = delegate
parser.parse()
let current = currentPath == "/" ? "/" : currentPath.replacingOccurrences(of: "/+$", with: "", options: .regularExpression)
return delegate.responses
.filter(\.isCollection)
.compactMap { response -> DavFolder? in
guard let path = relativePath(fromHref: response.href), path != current else { return nil }
let name = (path as NSString).lastPathComponent
guard !name.isEmpty, hidden.shows(path: path) else { return nil }
return DavFolder(name: name, path: path, isExternal: response.mountType == "external")
}
.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
}
}
/// Lists folders over WebDAV for the Share Extension's picker, in the
/// foreground (the sheet is on screen while it runs).
enum DavClient {
private static let body = """
<?xml version="1.0"?>
<d:propfind xmlns:d="DAV:" xmlns:nc="http://nextcloud.org/ns"><d:prop><d:resourcetype/><nc:mount-type/></d:prop></d:propfind>
"""
/// Every child folder, hidden and external ones included - the sheet's
/// toggles filter that list in memory, so changing one doesn't refetch.
/// Two accounts can live on the same server, so a request must be
/// authenticated by its own `Authorization` header alone - never by a
/// session cookie or credential a previous account's request left behind
/// in the shared stores, or the second account could be answered as the
/// first.
private static let session: URLSession = {
let config = URLSessionConfiguration.ephemeral
config.httpShouldSetCookies = false
config.httpCookieAcceptPolicy = .never
config.urlCredentialStorage = nil
config.requestCachePolicy = .reloadIgnoringLocalCacheData
return URLSession(configuration: config)
}()
static func listFolders(account: SharedAccount, path: String) async throws -> [DavFolder] {
guard let url = WebDAV.fileURL(serverUrl: account.serverUrl, username: account.username, remotePath: path)
else { throw TransferError(message: "Invalid server address.") }
var request = URLRequest(url: url)
request.httpMethod = "PROPFIND"
request.setValue("1", forHTTPHeaderField: "Depth")
request.setValue("application/xml", forHTTPHeaderField: "Content-Type")
request.setValue(account.authHeader, forHTTPHeaderField: "Authorization")
request.httpBody = body.data(using: .utf8)
request.timeoutInterval = 20
let (data, response) = try await session.data(for: request)
let status = (response as? HTTPURLResponse)?.statusCode ?? 0
NSLog("[DavClient] PROPFIND %@ as %@ -> %d", url.path, account.username, status)
guard status == 207 else {
throw TransferError(message: status == 401 ? "Signed out - open Noo to sign in again." : "Server returned \(status).")
}
return DavFolderParser.folders(from: data, excluding: path, hidden: .include)
}
}
+80
View File
@@ -0,0 +1,80 @@
import Foundation
/// Starts the Share Extension's uploads on a background `URLSession` that
/// outlives the extension. The extension process is torn down as soon as its
/// sheet closes; because the session has a `sharedContainerIdentifier` and
/// the same identifier is recreated by the app at launch
/// (`TransferManager.reconnect`), the *app* is what gets relaunched to
/// receive the results, post the summary notification and clean up.
/// Compiled into both targets.
enum ShareUpload {
static let sessionIdentifier = "dev.ayushya.noo.transfers.share"
static func configuration() -> URLSessionConfiguration {
let config = URLSessionConfiguration.background(withIdentifier: sessionIdentifier)
config.sharedContainerIdentifier = AppGroup.identifier
config.sessionSendsLaunchEvents = true
config.isDiscretionary = false
config.waitsForConnectivity = true
// Authenticate by the request's own header only (see DavClient.session).
config.httpShouldSetCookies = false
config.httpCookieAcceptPolicy = .never
config.urlCredentialStorage = nil
return config
}
/// A `PUT` for [item] into [remoteFolder] - shared by the extension's
/// [enqueue] and the app's own uploads so both build requests identically.
static func uploadRequest(for item: SharedItem, account: SharedAccount, remoteFolder: String) -> URLRequest? {
guard
let url = WebDAV.fileURL(
serverUrl: account.serverUrl,
username: account.username,
remotePath: WebDAV.join(remoteFolder, item.name)
)
else { return nil }
var request = URLRequest(url: url)
request.httpMethod = "PUT"
request.setValue(account.authHeader, forHTTPHeaderField: "Authorization")
if let modified = (try? FileManager.default.attributesOfItem(atPath: item.path))?[.modificationDate] as? Date {
request.setValue(String(Int(modified.timeIntervalSince1970)), forHTTPHeaderField: "X-OC-Mtime")
}
return request
}
/// Queues every item (already copied into the App Group by the extension)
/// and returns once the system has accepted them.
static func enqueue(items: [SharedItem], account: SharedAccount, remoteFolder: String) throws {
guard !items.isEmpty else { return }
let batch = UUID().uuidString
// Small files can finish while the extension is still on screen; for
// those this delegate counts them. Whatever outlives the extension is
// reported to the app instead (see `TransferManager`).
let session = URLSession(configuration: configuration(), delegate: ShareUploadDelegate(), delegateQueue: nil)
var tasks: [URLSessionUploadTask] = []
for item in items {
guard let request = uploadRequest(for: item, account: account, remoteFolder: remoteFolder) else {
throw TransferError(message: "Invalid server address.")
}
let task = session.uploadTask(with: request, fromFile: URL(fileURLWithPath: item.path))
task.taskDescription =
(try? JSONEncoder().encode(
TransferTaskInfo(
kind: .upload, batch: batch, name: item.name, remoteFolder: remoteFolder, stagedPath: item.path)
)).flatMap { String(data: $0, encoding: .utf8) }
tasks.append(task)
}
TransferBatchStore.save(
TransferBatch(kind: .upload, remoteFolder: remoteFolder, total: tasks.count, succeeded: 0, failed: 0),
id: batch)
TransferNotifications.started(kind: .upload, batch: batch, count: tasks.count)
tasks.forEach { $0.resume() }
session.finishTasksAndInvalidate()
}
}
struct TransferError: LocalizedError {
let message: String
var errorDescription: String? { message }
}
+161
View File
@@ -0,0 +1,161 @@
import Foundation
import Security
/// One signed-in account, as much of it as the Share Extension needs to list
/// folders and upload. Compiled into both targets.
struct SharedAccount: Codable, Equatable, Identifiable {
let id: String
let serverUrl: String
let username: String
/// `Basic ...` - the app password never leaves the Keychain as plain text.
let authHeader: String
/// Shown in the picker, e.g. "alice@cloud.example.com".
let displayName: String
/// The app's Files "hidden files" filter for this account - `hide` (the
/// default), `only` or `include` (see `HiddenFilter`). The share sheet
/// follows it instead of having a setting of its own.
let hiddenFilter: String
/// The app's storage scope for this account - `cloud` (the default),
/// `external` or `all` (see `StorageFilter`).
let storageScope: String
init(
id: String, serverUrl: String, username: String, authHeader: String, displayName: String,
hiddenFilter: String = "hide", storageScope: String = "cloud"
) {
self.id = id
self.serverUrl = serverUrl
self.username = username
self.authHeader = authHeader
self.displayName = displayName
self.hiddenFilter = hiddenFilter
self.storageScope = storageScope
}
/// Tolerates data written before a field existed instead of failing the
/// whole decode (which would look like "signed out" to the extension).
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
self.init(
id: try c.decode(String.self, forKey: .id),
serverUrl: try c.decode(String.self, forKey: .serverUrl),
username: try c.decode(String.self, forKey: .username),
authHeader: try c.decode(String.self, forKey: .authHeader),
displayName: try c.decode(String.self, forKey: .displayName),
hiddenFilter: try c.decodeIfPresent(String.self, forKey: .hiddenFilter) ?? "hide",
storageScope: try c.decodeIfPresent(String.self, forKey: .storageScope) ?? "cloud"
)
}
}
/// Everything the app publishes for the extension: every account that can
/// upload, which one is active in the app, and the app-lock settings the
/// extension has to honour. The app rewrites it whenever any of that changes.
struct SharedAccounts: Codable, Equatable {
var accounts: [SharedAccount]
var activeId: String?
/// Settings -> Security: the three independent locks. `loginLockEnabled`
/// (unlock to open the app) is carried for completeness; the extension's own
/// gates are the other two.
var loginLockEnabled: Bool
var lockAccountSwitching: Bool
var lockHiddenFiles: Bool
/// The account the app is currently using, else the first one.
var active: SharedAccount? {
accounts.first { $0.id == activeId } ?? accounts.first
}
/// Uploading to an account other than the active one is "switching" in
/// the app's terms, so it needs the same unlock.
var needsUnlockToSwitchAccount: Bool { lockAccountSwitching }
/// Showing hidden folders needs the same unlock the app asks for when you
/// turn hidden files on.
var needsUnlockForHidden: Bool { lockHiddenFiles }
/// What choosing [account] (with [hidden] folders to show) asks the user
/// to unlock. One action, one prompt - when both the account switch and
/// the hidden folders need it, a single authentication covers both.
func unlockNeeds(choosing account: SharedAccount, showing hidden: HiddenFilter) -> SelectionUnlock {
SelectionUnlock(
switchesAccount: account.id != active?.id && needsUnlockToSwitchAccount,
showsHidden: hidden != .hide && needsUnlockForHidden)
}
/// Turning hidden folders on (from `hide`) is what the app locks; going
/// back to `hide`, or between the two revealing modes, never asks.
func needsUnlockToChangeHidden(from old: HiddenFilter, to new: HiddenFilter) -> Bool {
old == .hide && new != .hide && needsUnlockForHidden
}
}
/// Keeps the [SharedAccounts] in a Keychain access group both the app and the
/// extension are entitled to (`keychain-access-groups`). The group's full id
/// carries the signing team's prefix, so it's injected into each target's
/// Info.plist as `NooKeychainAccessGroup` (= `$(AppIdentifierPrefix)` +
/// `dev.ayushya.noo.shared`) instead of being hard-coded - both processes
/// then always agree on it, with or without a team.
enum SharedAccountStore {
private static let service = "dev.ayushya.noo.shared-accounts"
private static let account = "all"
static var accessGroup: String? {
Bundle.main.object(forInfoDictionaryKey: "NooKeychainAccessGroup") as? String
}
private static func baseQuery() -> [String: Any] {
var query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
]
if let group = accessGroup, !group.isEmpty {
query[kSecAttrAccessGroup as String] = group
}
return query
}
static func save(_ value: SharedAccounts) throws {
let data = try JSONEncoder().encode(value)
clear()
var query = baseQuery()
query[kSecValueData as String] = data
query[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
let status = SecItemAdd(query as CFDictionary, nil)
guard status == errSecSuccess else {
throw NSError(domain: NSOSStatusErrorDomain, code: Int(status))
}
}
static func load() -> SharedAccounts? {
var query = baseQuery()
query[kSecReturnData as String] = true
query[kSecMatchLimit as String] = kSecMatchLimitOne
var result: AnyObject?
guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess,
let data = result as? Data,
let decoded = try? JSONDecoder().decode(SharedAccounts.self, from: data),
!decoded.accounts.isEmpty
else { return nil }
return decoded
}
static func clear() {
SecItemDelete(baseQuery() as CFDictionary)
// The first version kept a single account under its own service name;
// don't leave that credential behind.
var legacy = baseQuery()
legacy[kSecAttrService as String] = "dev.ayushya.noo.shared-account"
legacy[kSecAttrAccount as String] = "active"
SecItemDelete(legacy as CFDictionary)
}
}
/// What one selection needs unlocked - see `SharedAccounts.unlockNeeds`.
struct SelectionUnlock: Equatable {
let switchesAccount: Bool
let showsHidden: Bool
var needsPrompt: Bool { switchesAccount || showsHidden }
}
+92
View File
@@ -0,0 +1,92 @@
import Foundation
/// The App Group both the app and its Share Extension belong to - the one
/// place they can exchange files.
enum AppGroup {
static let identifier = "group.dev.ayushya.noo"
static var containerURL: URL? {
FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: identifier)
}
}
/// One file the Share Extension copied into the group container.
struct SharedItem: Codable, Equatable {
let path: String
let name: String
let mimeType: String?
let size: Int64
}
/// Files the Share Extension received, waiting for the app to ask the user
/// where to upload them. The extension appends; the app consumes (reads and
/// clears) when it launches or becomes active. Compiled into both targets.
///
/// Layout under `<root>/SharedInbox/`: `pending.json` (the manifest) and one
/// `<batch-id>/` folder per share holding the copied files.
enum SharedInbox {
static let folderName = "SharedInbox"
private static let manifestName = "pending.json"
static func directory(in root: URL) -> URL {
root.appendingPathComponent(folderName, isDirectory: true)
}
/// A fresh folder for one share's files.
static func makeBatchDirectory(in root: URL) throws -> URL {
let dir = directory(in: root).appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
return dir
}
static func append(_ items: [SharedItem], in root: URL) throws {
guard !items.isEmpty else { return }
try withManifestLock(in: root) {
let existing = (try? readManifest(in: root)) ?? []
try writeManifest(existing + items, in: root)
}
}
/// Everything pending, dropping entries whose file has gone missing, and
/// clears the manifest so each share is delivered once.
static func consume(in root: URL) -> [SharedItem] {
var items: [SharedItem] = []
try? withManifestLock(in: root) {
items = ((try? readManifest(in: root)) ?? []).filter {
FileManager.default.fileExists(atPath: $0.path)
}
try? FileManager.default.removeItem(at: manifestURL(in: root))
}
return items
}
private static func manifestURL(in root: URL) -> URL {
directory(in: root).appendingPathComponent(manifestName)
}
private static func readManifest(in root: URL) throws -> [SharedItem] {
let data = try Data(contentsOf: manifestURL(in: root))
return try JSONDecoder().decode([SharedItem].self, from: data)
}
private static func writeManifest(_ items: [SharedItem], in root: URL) throws {
try FileManager.default.createDirectory(
at: directory(in: root), withIntermediateDirectories: true)
try JSONEncoder().encode(items).write(to: manifestURL(in: root), options: .atomic)
}
/// The extension and the app are separate processes that can both touch the
/// manifest, so read-modify-write goes through a file coordinator.
private static func withManifestLock(in root: URL, _ body: () throws -> Void) throws {
try FileManager.default.createDirectory(
at: directory(in: root), withIntermediateDirectories: true)
var coordinationError: NSError?
var thrown: Error?
NSFileCoordinator().coordinate(
writingItemAt: directory(in: root), options: [], error: &coordinationError
) { _ in
do { try body() } catch { thrown = error }
}
if let error = coordinationError ?? thrown.map({ $0 as NSError }) { throw error }
}
}
+80
View File
@@ -0,0 +1,80 @@
import Foundation
import UserNotifications
/// The local notifications for an upload/download batch - one "Uploading 3
/// files…" when it starts, replaced in place by "Uploaded 3 files" when it
/// ends (both use the batch's id as the request identifier, so the second
/// *replaces* the first instead of stacking). Posted from whichever process
/// sees the event - the app, or the Share Extension while it's still alive.
/// iOS can't update a notification with live byte progress from a background
/// session, so start/finish is as much as a notification can say.
/// Compiled into both targets.
enum TransferNotifications {
static func started(kind: TransferTaskInfo.Kind, batch: String, count: Int) {
let verb = kind == .upload ? "Uploading" : "Downloading"
post(id: batch, body: "\(verb) \(files(count))…", sound: false)
}
static func finished(_ batch: TransferBatch, id: String) {
let verb = batch.kind == .upload ? "upload" : "download"
let past = batch.kind == .upload ? "Uploaded" : "Downloaded"
let body: String
if batch.failed == 0 {
body = "\(past) \(files(batch.succeeded))"
} else if batch.succeeded == 0 {
body = "Couldn't \(verb) \(files(batch.failed))"
} else {
body = "\(past) \(files(batch.succeeded)), \(batch.failed) failed"
}
post(id: id, body: body, sound: true)
}
static func files(_ count: Int) -> String {
count == 1 ? "1 file" : "\(count) files"
}
private static func post(id: String, body: String, sound: Bool) {
let content = UNMutableNotificationContent()
content.title = "Noo"
content.body = body
if sound { content.sound = .default }
UNUserNotificationCenter.current().add(
UNNotificationRequest(identifier: "transfer.\(id)", content: content, trigger: nil))
}
}
/// Counting one finished upload task - shared so the result is handled the
/// same way whether the Share Extension (still alive) or the app (relaunched
/// later) receives it.
enum UploadResults {
/// Deletes the task's staged copy, records its outcome, and - if it was the
/// last file of its batch - posts the summary notification and returns the
/// finished batch.
static func handle(_ task: URLSessionTask, error: Error?) -> (info: TransferTaskInfo, batch: TransferBatch?)? {
guard let info = task.taskDescription
.flatMap({ $0.data(using: .utf8) })
.flatMap({ try? JSONDecoder().decode(TransferTaskInfo.self, from: $0) }),
info.kind == .upload
else { return nil }
if let staged = info.stagedPath { try? FileManager.default.removeItem(atPath: staged) }
let status = (task.response as? HTTPURLResponse)?.statusCode ?? 0
let ok = error == nil && (200..<300).contains(status)
let batch = TransferBatchStore.record(info, success: ok)
if let batch { TransferNotifications.finished(batch, id: info.batch) }
return (info, batch)
}
}
/// Delegate for the Share Extension's upload session while the extension is
/// still alive: files that finish before it closes are counted here, the
/// rest by the app (`TransferManager`) once the system relaunches it.
final class ShareUploadDelegate: NSObject, URLSessionTaskDelegate {
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
guard let result = UploadResults.handle(task, error: error) else { return }
// The app wasn't running to hear about this, so leave a note for it to
// refresh that folder next time it's active.
if let batch = result.batch, batch.succeeded > 0, let folder = batch.remoteFolder {
TransferBatchStore.noteFinishedUpload(folder: folder)
}
}
}
+88
View File
@@ -0,0 +1,88 @@
import Foundation
/// What a background upload/download task is, stored in the task's
/// `taskDescription` so it's still known if the app was relaunched by the
/// system to deliver the result - possibly for a task the Share Extension
/// started. Compiled into both targets.
struct TransferTaskInfo: Codable {
enum Kind: String, Codable { case upload, download }
let kind: Kind
let batch: String
let name: String
let remoteFolder: String?
/// A file to delete once the task ends (the upload's staged copy).
let stagedPath: String?
}
/// Running totals for one "upload these 3 files" / "download these 2 files"
/// request, so the one summary notification (and the Dart-side "folder
/// changed" event) fires exactly once, on the last file.
struct TransferBatch: Codable {
let kind: TransferTaskInfo.Kind
let remoteFolder: String?
let total: Int
var succeeded: Int
var failed: Int
var finished: Int { succeeded + failed }
}
/// Persists [TransferBatch]es in the App Group's `UserDefaults`, not the
/// process's own: the Share Extension starts an upload batch, and the app -
/// a different process, possibly relaunched in the background - finishes
/// counting it.
enum TransferBatchStore {
private static let lock = NSLock()
private static var defaults: UserDefaults {
UserDefaults(suiteName: AppGroup.identifier) ?? .standard
}
private static func key(_ id: String) -> String { "transfer.batch.\(id)" }
static func save(_ batch: TransferBatch, id: String) {
lock.lock()
defer { lock.unlock() }
if let data = try? JSONEncoder().encode(batch) { defaults.set(data, forKey: key(id)) }
}
private static let finishedFoldersKey = "transfer.finishedUploadFolders"
/// The Share Extension finished an upload into [folder] while the app
/// wasn't running; the app reads this on its next activation so its file
/// list can refresh.
static func noteFinishedUpload(folder: String) {
lock.lock()
defer { lock.unlock() }
var folders = defaults.stringArray(forKey: finishedFoldersKey) ?? []
if !folders.contains(folder) { folders.append(folder) }
defaults.set(folders, forKey: finishedFoldersKey)
}
static func consumeFinishedUploadFolders() -> [String] {
lock.lock()
defer { lock.unlock() }
let folders = defaults.stringArray(forKey: finishedFoldersKey) ?? []
defaults.removeObject(forKey: finishedFoldersKey)
return folders
}
/// Counts one file's outcome. Returns the batch when that was its last
/// file (and forgets it), nil while others are still running.
static func record(_ info: TransferTaskInfo, success: Bool) -> TransferBatch? {
lock.lock()
defer { lock.unlock() }
let storageKey = key(info.batch)
var batch =
defaults.data(forKey: storageKey).flatMap { try? JSONDecoder().decode(TransferBatch.self, from: $0) }
?? TransferBatch(kind: info.kind, remoteFolder: info.remoteFolder, total: 1, succeeded: 0, failed: 0)
if success { batch.succeeded += 1 } else { batch.failed += 1 }
if batch.finished >= batch.total {
defaults.removeObject(forKey: storageKey)
return batch
}
if let data = try? JSONEncoder().encode(batch) { defaults.set(data, forKey: storageKey) }
return nil
}
}
+53
View File
@@ -0,0 +1,53 @@
import Foundation
/// URL building for a Nextcloud account's WebDAV files root. Pure functions
/// (no networking), so they're unit-tested in `RunnerTests`.
enum WebDAV {
/// `urlPathAllowed` minus "/", so a file name containing a slash-like
/// character can't introduce an extra path segment.
private static let segmentAllowed: CharacterSet = {
var set = CharacterSet.urlPathAllowed
set.remove(charactersIn: "/")
return set
}()
static func encodeSegment(_ segment: String) -> String {
segment.addingPercentEncoding(withAllowedCharacters: segmentAllowed) ?? segment
}
/// `<server>/remote.php/dav/files/<user>/<remotePath>` - [remotePath] is
/// relative to the user's root, with or without a leading slash. A server
/// installed under a sub-path (`https://host/nextcloud`) keeps it.
static func fileURL(serverUrl: String, username: String, remotePath: String) -> URL? {
var base = serverUrl
while base.hasSuffix("/") { base.removeLast() }
let segments = remotePath.split(separator: "/").map { encodeSegment(String($0)) }
var url = base + "/remote.php/dav/files/" + encodeSegment(username)
if !segments.isEmpty { url += "/" + segments.joined(separator: "/") }
return URL(string: url)
}
/// `folder` + `name` with exactly one slash between them.
static func join(_ folder: String, _ name: String) -> String {
folder.hasSuffix("/") ? folder + name : folder + "/" + name
}
}
enum LocalFiles {
/// A URL in [directory] for [name] that doesn't exist yet: "a.txt", then
/// "a (1).txt", "a (2).txt"... - so a second download of the same file
/// never overwrites the first.
static func uniqueURL(in directory: URL, name: String, fileManager: FileManager = .default) -> URL {
let candidate = directory.appendingPathComponent(name)
if !fileManager.fileExists(atPath: candidate.path) { return candidate }
let ext = (name as NSString).pathExtension
let stem = (name as NSString).deletingPathExtension
var index = 1
while true {
let numbered = ext.isEmpty ? "\(stem) (\(index))" : "\(stem) (\(index)).\(ext)"
let url = directory.appendingPathComponent(numbered)
if !fileManager.fileExists(atPath: url.path) { return url }
index += 1
}
}
}
+372 -101
View File
@@ -1,6 +1,8 @@
import 'dart:async';
import 'package:dynamic_color/dynamic_color.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:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
@@ -20,17 +22,33 @@ import 'providers/shares_controller.dart';
import 'providers/sync_status_controller.dart';
import 'providers/trash_controller.dart';
import 'services/pick_intent_service.dart';
import 'services/share_account_service.dart';
import 'services/share_intent_service.dart';
import 'theme/app_theme.dart';
import 'theme/design_tokens.dart';
import 'views/lock_screen_view.dart';
import 'views/login_view.dart';
import 'views/search_view.dart';
import 'views/share_upload_view.dart';
import 'widgets/app_drawer.dart';
import 'widgets/app_tab_view_builder.dart';
import 'widgets/floating_bottom_bar.dart';
import 'widgets/app_top_bar.dart';
import 'widgets/avatar_menu.dart';
import 'widgets/bottom_nav_bar.dart';
import 'widgets/create_menu.dart';
import 'widgets/noo/core/noo_avatar.dart';
import 'widgets/noo/core/noo_button.dart';
import 'widgets/noo/core/noo_fab.dart';
import 'widgets/noo/nav/noo_bottom_bar.dart';
import 'widgets/noo/nav/noo_sidebar.dart';
import 'widgets/noo/nav/noo_toolbar.dart';
import 'widgets/noo/noo_layout.dart';
import 'widgets/noo/overlays/noo_dialog.dart';
import 'widgets/shell/shell_common.dart';
void main() {
WidgetsFlutterBinding.ensureInitialized();
// Fonts ship in assets/google_fonts; never fetch them from Google at runtime.
GoogleFonts.config.allowRuntimeFetching = false;
runApp(
MultiProvider(
// Split out of the former single `ServerProvider` god object - see
@@ -42,7 +60,14 @@ void main() {
providers: [
ChangeNotifierProvider(create: (_) => ConnectivityController()),
ChangeNotifierProvider(
create: (context) => SessionController(context.read()),
create: (context) {
final session = SessionController(context.read());
// iOS's Share Extension has no Flutter engine; sign-out must wipe
// what it was given. (Publishing happens in the shell - see
// ShareAccountSync - once there's a FilesController to read.)
session.addAccountClearedListener(ShareAccountService.clear);
return session;
},
),
ChangeNotifierProvider(create: (_) => SettingsController()),
// Before SyncStatusController, which follows its Files Cache rule
@@ -61,8 +86,7 @@ void main() {
SyncStatusController(context.read(), context.read()),
),
ChangeNotifierProvider(
create: (context) =>
PhotosController(context.read(), context.read()),
create: (context) => PhotosController(context.read(), context.read()),
),
ChangeNotifierProvider(
create: (context) =>
@@ -143,21 +167,19 @@ class _SplashView extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final colors = context.nooColors;
return Scaffold(
backgroundColor: colors.bg,
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// The monochrome adaptive-icon layer is a plain white silhouette
// on transparent, meant to be tinted rather than shown as-is -
// srcIn recolors it to the theme's foreground so it reads
// correctly in both light and dark mode.
// srcIn recolors it to fg-1 so it reads correctly in both light
// and dark mode.
ColorFiltered(
colorFilter: ColorFilter.mode(
colorScheme.onSurface,
BlendMode.srcIn,
),
colorFilter: ColorFilter.mode(colors.fg1, BlendMode.srcIn),
child: Image.asset(
'assets/icon/app_icon_monochrome.png',
width: 72,
@@ -170,7 +192,7 @@ class _SplashView extends StatelessWidget {
height: 22,
child: CircularProgressIndicator(
strokeWidth: 2.5,
color: colorScheme.primary,
color: colors.accent,
),
),
],
@@ -189,9 +211,11 @@ class MainShellView extends StatefulWidget {
class _MainShellViewState extends State<MainShellView> {
late AppTab _currentTab;
final Set<AppTab> _selectionActiveTabs = {};
late final Map<AppTab, ScrollController> _scrollControllers;
StreamSubscription<List<SharedFileRef>>? _shareSub;
StreamSubscription<PickRequest>? _pickSub;
ShareAccountSync? _shareAccountSync;
@override
void initState() {
@@ -213,6 +237,13 @@ class _MainShellViewState extends State<MainShellView> {
ShareIntentService.getInitialShare().then(_handleSharedFiles);
_shareSub = ShareIntentService.onNewShare.listen(_handleSharedFiles);
// iOS's Share Extension picks accounts/folders on its own, so it's kept
// supplied with the accounts, lock settings and hidden-files filters.
_shareAccountSync = ShareAccountSync(
context.read<SessionController>(),
context.read<FilesController>(),
)..start();
// Same cold-start-vs-already-running split as the share intent above:
// another app may have launched Noo as its GET_CONTENT picker.
PickIntentService.getPickRequest().then((request) {
@@ -246,26 +277,34 @@ class _MainShellViewState extends State<MainShellView> {
if (status.isGranted || status.isPermanentlyDenied) return;
if (!mounted) return;
final shouldRequest = await showDialog<bool>(
context: context,
final colors = context.nooColors;
final shouldRequest = await showNooDialog<bool>(
context,
title: 'Allow notifications?',
barrierDismissible: false,
builder: (context) => AlertDialog(
title: const Text('Allow notifications?'),
content: const Text(
children: [
Text(
'Noo shows upload and download progress as a notification, so '
'you can track transfers that keep running in the background.',
style: NooText.body.copyWith(color: colors.fg2),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Not now'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Allow'),
),
],
),
Row(
mainAxisAlignment: MainAxisAlignment.end,
spacing: 10,
children: [
NooButton(
variant: NooButtonVariant.secondary,
onTap: () => Navigator.of(context).pop(false),
child: const Text('Not now'),
),
NooButton(
variant: NooButtonVariant.primary,
onTap: () => Navigator.of(context).pop(true),
child: const Text('Allow'),
),
],
),
],
);
if (shouldRequest == true) {
await Permission.notification.request();
@@ -289,14 +328,21 @@ class _MainShellViewState extends State<MainShellView> {
}
_shareSub?.cancel();
_pickSub?.cancel();
_shareAccountSync?.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
final settings = context.watch<SettingsController>();
final pick = context.watch<PickController>();
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 -
// consumed here so it only fires once, then cleared after this frame
@@ -317,24 +363,289 @@ class _MainShellViewState extends State<MainShellView> {
// user's own hidden/reordered tab settings rather than respecting
// them, since picking is a separate mode from normal browsing. With
// 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
// Offline - the one tab that works without a connection (see
// spinner forever/an error state, so the nav collapses to just Offline
// - the one tab that works without a connection (see
// `FilesView(offline: true)`/`ConnectivityController`).
final visible = pickRequest != null
final overrideActive = pickRequest != null || connectivity.isOffline;
final pinnedTabs = pickRequest != null
? [AppTab.files, AppTab.photos]
: connectivity.isOffline
? [AppTab.offline]
: settings.visibleTabs;
final selectedTab = visible.contains(_currentTab)
? _currentTab
: (visible.isNotEmpty ? visible.first : AppTab.files);
final selectedIndex = visible
.indexOf(selectedTab)
.clamp(0, visible.isEmpty ? 0 : visible.length - 1);
// The drawer/sidebar's "More" section - hidden while an override above
// is active, same as the drawer itself being unavailable during pick.
final hiddenTabs = overrideActive
? const <AppTab>[]
: settings.tabOrder
.where((t) => settings.hiddenTabs.contains(t))
.toList();
final navItems = visible
.map((tab) => FloatingNavItem(label: tab.label, icon: tab.icon))
.toList();
// A tab opened from "More" isn't one of the pinned destinations, but
// should stay shown and selected (with no destination highlighted in
// the bottom bar/sidebar) until the user taps a pinned or another
// "More" tab.
final displayTabs = !overrideActive && !pinnedTabs.contains(_currentTab)
? [...pinnedTabs, _currentTab]
: pinnedTabs;
final selectedTab = displayTabs.contains(_currentTab)
? _currentTab
: (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: [
AvatarNavigationBody(
child: IndexedStack(
index: selectedIndex,
children: displayTabs
.map(
(tab) => buildAppTabView(
tab,
_scrollControllers[tab]!,
onSelectionChanged: (selecting) {
if (!mounted ||
selecting == _selectionActiveTabs.contains(tab)) {
return;
}
setState(() {
if (selecting) {
_selectionActiveTabs.add(tab);
} else {
_selectionActiveTabs.remove(tab);
}
});
},
topBar: isDesktop || pickRequest != null
? null
: AppTopBar(
style: navStyle,
tab: tab,
searchInBottomBar: showBottomBarSearch,
navMenuStyle: settings.navMenuStyle,
avatarPosition: settings.avatarPosition,
uploadButtonStyle: settings.fabStyle,
),
),
)
.toList(),
),
),
if (pick.isDownloadingForPick) const _PickingProgressOverlay(),
],
);
final canUpload =
pickRequest == null &&
(selectedTab == AppTab.files || selectedTab == AppTab.photos);
Widget scaffold;
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; a frosted bar needs it too, or
// there'd be nothing behind it to blur.
extendBody: NooBottomBar.drawsBehindBody(
bottomBarStyle,
settings.bottomBarFrosted,
),
// Android-only extended Upload FAB - iOS uses the top bar's Upload
// instead (see AppTopBar). Stays mounted across every tab (picking
// aside) and collapses to an icon-only circle off Files/Photos,
// rather than the Scaffold popping it fully in/out on every tab
// switch - see NooFab's [collapsed].
floatingActionButton:
pickRequest == null &&
navStyle == NooNavStyle.android &&
!_selectionActiveTabs.contains(selectedTab)
? NooFab(
collapsed: switch (settings.fabStyle) {
FabStyle.auto => !canUpload,
FabStyle.mini => true,
FabStyle.expanded => false,
},
barStyle: bottomBarStyle,
onTap: () => showCreateMenu(context),
)
: null,
body: TweenAnimationBuilder<double>(
tween: Tween(
end: switch (settings.fabStyle) {
FabStyle.auto => canUpload ? 1.0 : 0.0,
FabStyle.mini => 0.0,
FabStyle.expanded => 1.0,
},
),
duration: MediaQuery.disableAnimationsOf(context)
? Duration.zero
: NooMotion.base,
curve: NooMotion.ease,
child: tabStack,
builder: (context, progress, child) =>
UploadButtonAnimation(progress: progress, child: child!),
),
bottomNavigationBar: BottomNavBar(
style: navStyle,
barStyle: bottomBarStyle,
frosted: settings.bottomBarFrosted,
frostedBlur: settings.bottomBarFrostedBlur,
frostedOpacity: settings.bottomBarFrostedOpacity,
tabs: pinnedTabs,
selectedIndex: pinnedIndex,
onSearchTap: showBottomBarSearch ? () => openSearch(context) : null,
avatarInBottomBar:
pickRequest == null &&
settings.navMenuStyle == NooNavMenuStyle.avatarMenu &&
settings.avatarPosition == AvatarPosition.bottom,
onDestinationSelected: (index) {
final tappedTab = pinnedTabs[index];
if (tappedTab == _currentTab) {
if (settings.tapTabToScrollTop) {
final controller = _scrollControllers[tappedTab];
// `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(
0,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOutCubic,
);
}
}
return;
}
selectTab(tappedTab);
},
),
);
}
return PopScope(
// While picking, system back cancels the pick (and tells the caller
@@ -344,53 +655,7 @@ class _MainShellViewState extends State<MainShellView> {
onPopInvokedWithResult: (didPop, result) {
if (!didPop && pickRequest != null) pick.cancelPick();
},
child: Scaffold(
body: Stack(
children: [
IndexedStack(
index: selectedIndex,
children: visible
.map((tab) => buildAppTabView(tab, _scrollControllers[tab]!))
.toList(),
),
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.
FloatingBottomNavBar(
selectedIndex: selectedIndex,
items: navItems,
opacity: settings.bottomBarOpacity,
blurSigma: settings.bottomBarBlur,
onDestinationSelected: (index) {
final tappedTab = visible[index];
if (tappedTab == _currentTab) {
if (settings.tapTabToScrollTop) {
final controller = _scrollControllers[tappedTab];
if (controller != null && controller.hasClients) {
controller.animateTo(
0,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOutCubic,
);
}
}
return;
}
setState(() {
_currentTab = tappedTab;
});
},
onSearchTap: () {
Navigator.of(
context,
).push(MaterialPageRoute(builder: (_) => const SearchView()));
},
),
],
),
),
child: isDesktop ? scaffold : AvatarNavigationHost(child: scaffold),
);
}
}
@@ -403,21 +668,27 @@ class _PickingProgressOverlay extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
return Positioned.fill(
child: ColoredBox(
color: Colors.black.withValues(alpha: 0.35),
child: const Center(
child: Card(
child: Padding(
padding: EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text('Preparing file...'),
],
),
color: colors.scrim,
child: Center(
child: Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: colors.surface,
borderRadius: BorderRadius.circular(NooRadii.dialog),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
CircularProgressIndicator(color: colors.accent),
const SizedBox(height: 16),
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: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
/// 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 —
/// the rest are reachable through the "more" dropdown instead.
const int maxVisibleTabs = 5;
/// the rest are reachable through the "more" dropdown instead. The
/// 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 {
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 {
switch (this) {
case AppTab.files:
return Icons.folder_rounded;
return LucideIcons.folder;
case AppTab.photos:
return Icons.photo_library_rounded;
return LucideIcons.images;
case AppTab.favorites:
return Icons.favorite_rounded;
return LucideIcons.star;
case AppTab.activity:
return Icons.electric_bolt_rounded;
return LucideIcons.activity;
case AppTab.trash:
return Icons.delete_outline_rounded;
return LucideIcons.trash2; // trash-2
case AppTab.shares:
return Icons.groups_rounded;
return LucideIcons.share2; // share-2
case AppTab.recent:
return Symbols.search_activity_rounded;
return LucideIcons.clock;
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;
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: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,
/// download, delete), shown as an icon button in the sticky selection
/// toolbar each tab (Files, Photos, ...) renders inline in its own content.
class SelectionAction {
final SelectionActionKind kind;
final IconData icon;
final String label;
final VoidCallback onTap;
const SelectionAction({
required this.kind,
required this.icon,
required this.label,
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;
}
+147 -33
View File
@@ -3,15 +3,22 @@ import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/nextcloud_item.dart';
import '../services/upload_service.dart';
import 'folder_browser.dart';
import 'session_controller.dart';
enum FileSortField { name, dateCreated, dateModified, size }
/// Which storage a listing shows — always exactly one, like the list/grid
/// view toggle, not an optional filter. Shared across Files/Photos/
/// Favorites (see [applyCommonFilters]'s doc comment).
enum StorageScope { cloud, external }
/// Which storage a listing shows: [cloud] (the default - external mounts
/// hidden), only [external] ones, or [all] of both (Files shows the
/// external items in their own collapsible section then). Shared across
/// Files/Photos/Favorites (see [applyCommonFilters]'s doc comment).
enum StorageScope { cloud, external, all }
/// Which dotfile/dotfolder items a listing shows: [hide] them (the
/// default), show [only] them, or [include] them alongside everything else.
/// Files and Photos each keep their own (see [applyCommonFilters]).
enum HiddenFilesFilter { hide, only, include }
/// The Files tab's files/folders/both filter — independent of and applied
/// after [StorageScope]/favorites/hidden filtering.
@@ -71,6 +78,7 @@ class FilesController extends ChangeNotifier
static const _prefStorageScope = 'ui_storage_scope';
static const _prefFilesTypeFilter = 'ui_files_type_filter';
static const _prefShowHiddenFiles = 'ui_show_hidden';
static const _prefHiddenFilter = 'ui_hidden_filter';
static const _prefFolderSort = 'ui_folder_sort';
static const prefCachePolicy = 'ui_cache_policy';
static const prefCacheIntervalMinutes = 'ui_cache_interval_minutes';
@@ -81,7 +89,7 @@ class FilesController extends ChangeNotifier
bool _isGridView = false;
StorageScope _storageScope = StorageScope.cloud;
FilesTypeFilter _filesTypeFilter = FilesTypeFilter.all;
bool _showHiddenFiles = false;
HiddenFilesFilter _hiddenFilter = HiddenFilesFilter.hide;
final Map<String, FileSortField> _folderSortField = {};
final Map<String, bool> _folderSortAscending = {};
@@ -100,6 +108,7 @@ class FilesController extends ChangeNotifier
NextcloudUserQuota? _quota;
List<NextcloudActivity> _activities = [];
bool _isLoading = false;
bool _activating = false;
String? _errorMessage;
FilesController(this.session) {
@@ -107,6 +116,22 @@ class FilesController extends ChangeNotifier
session.addAccountClearedListener(_onAccountCleared);
session.addAccountActivatedListener(_onAccountActivated);
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
@@ -117,7 +142,7 @@ class FilesController extends ChangeNotifier
bool get isGridView => _isGridView;
StorageScope get storageScope => _storageScope;
FilesTypeFilter get filesTypeFilter => _filesTypeFilter;
bool get showHiddenFiles => _showHiddenFiles;
HiddenFilesFilter get hiddenFilter => _hiddenFilter;
FileSortField get filesSortField => sortFieldFor(_currentFolderPath);
bool get filesSortAscending => sortAscendingFor(_currentFolderPath);
@@ -130,7 +155,10 @@ class FilesController extends ChangeNotifier
CachePolicy get cachePolicy => _cachePolicy;
int get cacheIntervalMinutes => _cacheIntervalMinutes;
@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
String? get errorMessage => _errorMessage;
NextcloudUserQuota? get quota => _quota;
@@ -167,24 +195,30 @@ class FilesController extends ChangeNotifier
List<NextcloudItem> applyCommonFilters(
List<NextcloudItem> source, {
required bool showFavoritesOnly,
required bool showHidden,
required HiddenFilesFilter hidden,
bool applyStorageScope = true,
}) {
var filtered = source;
if (showFavoritesOnly) {
filtered = filtered.where((item) => item.isFavorite).toList();
}
if (!showHidden) {
filtered = filtered.where((item) => !_isHiddenItem(item)).toList();
switch (hidden) {
case HiddenFilesFilter.hide:
filtered = filtered.where((item) => !_isHiddenItem(item)).toList();
case HiddenFilesFilter.only:
filtered = filtered.where(_isHiddenItem).toList();
case HiddenFilesFilter.include:
break;
}
if (applyStorageScope) {
filtered = filtered
.where(
(item) => _storageScope == StorageScope.external
? item.isExternalStorage
: !item.isExternalStorage,
)
.toList();
switch (_storageScope) {
case StorageScope.cloud:
filtered = filtered.where((i) => !i.isExternalStorage).toList();
case StorageScope.external:
filtered = filtered.where((i) => i.isExternalStorage).toList();
case StorageScope.all:
break;
}
}
return filtered;
}
@@ -210,7 +244,7 @@ class FilesController extends ChangeNotifier
// Files itself doesn't filter by favorite - that's the dedicated
// Favorites tab's job.
showFavoritesOnly: false,
showHidden: _showHiddenFiles,
hidden: _hiddenFilter,
applyStorageScope: applyStorageScope,
);
switch (_filesTypeFilter) {
@@ -249,7 +283,7 @@ class FilesController extends ChangeNotifier
_isGridView = false;
_storageScope = StorageScope.cloud;
_filesTypeFilter = FilesTypeFilter.all;
_showHiddenFiles = false;
_hiddenFilter = HiddenFilesFilter.hide;
_cachePolicy = defaultCachePolicy;
_cacheIntervalMinutes = defaultCacheIntervalMinutes;
_isLoading = false;
@@ -266,7 +300,8 @@ class FilesController extends ChangeNotifier
/// sort, cache policy) - hooked to `addAccountReadyListener` as well as
/// activation so the Offline tab, which reads these same prefs, respects
/// 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
/// loaded from storage - lets `SyncStatusController` schedule background
@@ -299,7 +334,11 @@ class FilesController extends ChangeNotifier
(f) => f.name == filesTypeFilterName,
orElse: () => FilesTypeFilter.all,
);
_showHiddenFiles = prefs.getBool(k(_prefShowHiddenFiles)) ?? false;
_hiddenFilter = loadHiddenFilter(
prefs,
k(_prefHiddenFilter),
k(_prefShowHiddenFiles),
);
final folderSortJson = prefs.getString(k(_prefFolderSort));
if (folderSortJson != null) {
@@ -334,15 +373,24 @@ class FilesController extends ChangeNotifier
defaultCacheIntervalMinutes;
notifyListeners();
} catch (e) {
debugPrint('[FilesController] Account-activation prefs restore failed: $e');
debugPrint(
'[FilesController] Account-activation prefs restore failed: $e',
);
}
}
}
Future<void> _onAccountActivated() async {
await _restoreDisplayPrefs();
_startCacheRefreshTimerIfNeeded();
await refreshData();
_activating = true;
notifyListeners();
try {
await _restoreDisplayPrefs();
_startCacheRefreshTimerIfNeeded();
await refreshData();
} finally {
_activating = false;
notifyListeners();
}
}
@override
@@ -401,7 +449,9 @@ class FilesController extends ChangeNotifier
notifyListeners();
}
debugPrint('[FilesController] Refreshing data for path: $_currentFolderPath');
debugPrint(
'[FilesController] Refreshing data for path: $_currentFolderPath',
);
try {
final items = await service.fetchDirectory(_currentFolderPath);
@@ -549,6 +599,20 @@ class FilesController extends ChangeNotifier
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();
/// Persists a per-account browsing pref under its `acct_<id>_`-namespaced
@@ -592,10 +656,12 @@ class FilesController extends ChangeNotifier
);
}
/// Only *enabling* hidden-files visibility is gated - hiding them again
/// never exposes anything, so that direction is always allowed instantly.
Future<void> toggleShowHiddenFiles() async {
if (!_showHiddenFiles) {
/// Only *leaving* [HiddenFilesFilter.hide] is gated - going back to it
/// never exposes anything, so that direction is always allowed instantly
/// (and switching between the two revealing modes needs no second prompt).
Future<void> setHiddenFilter(HiddenFilesFilter filter) async {
if (_hiddenFilter == filter) return;
if (_hiddenFilter == HiddenFilesFilter.hide) {
if (!await session.passGate(
session.lockHiddenFiles,
'Unlock to show hidden files',
@@ -603,14 +669,61 @@ class FilesController extends ChangeNotifier
return;
}
}
_showHiddenFiles = !_showHiddenFiles;
_hiddenFilter = filter;
notifyListeners();
_persistAccountPref(
_prefShowHiddenFiles,
(p, key) => p.setBool(key, _showHiddenFiles),
_prefHiddenFilter,
(p, key) => p.setString(key, filter.name),
);
}
/// The storage scope saved for [accountId] (see [savedHiddenFilter]).
static StorageScope savedStorageScope(
SharedPreferences prefs,
String Function(String accountId, String baseKey) accountPrefKey,
String accountId,
) {
final name = prefs.getString(accountPrefKey(accountId, _prefStorageScope));
return StorageScope.values.firstWhere(
(s) => s.name == name,
orElse: () => StorageScope.cloud,
);
}
/// The hidden-files filter saved for [accountId] - not just the active
/// account's, which is all [hiddenFilter] holds. For iOS's Share Extension,
/// which follows each account's own setting.
static HiddenFilesFilter savedHiddenFilter(
SharedPreferences prefs,
String Function(String accountId, String baseKey) accountPrefKey,
String accountId,
) => loadHiddenFilter(
prefs,
accountPrefKey(accountId, _prefHiddenFilter),
accountPrefKey(accountId, _prefShowHiddenFiles),
);
/// Reads a persisted [HiddenFilesFilter], falling back to the old
/// show-hidden bool (`true` meant everything incl. hidden) so a pref
/// saved before the three-way filter keeps its meaning. [key]s are
/// already account-namespaced by the caller.
static HiddenFilesFilter loadHiddenFilter(
SharedPreferences prefs,
String key,
String legacyBoolKey,
) {
final name = prefs.getString(key);
if (name != null) {
return HiddenFilesFilter.values.firstWhere(
(f) => f.name == name,
orElse: () => HiddenFilesFilter.hide,
);
}
return (prefs.getBool(legacyBoolKey) ?? false)
? HiddenFilesFilter.include
: HiddenFilesFilter.hide;
}
void _persistFolderSort() {
final combined = <String, dynamic>{};
for (final path in {
@@ -692,6 +805,7 @@ class FilesController extends ChangeNotifier
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_cacheRefreshTimer?.cancel();
_uploadSub?.cancel();
super.dispose();
}
}
+17 -3
View File
@@ -168,9 +168,9 @@ class ItemOperations {
(c) => choices[c.item.id] == ConflictChoice.keepBoth,
);
final existingNames = needsKeepBoth
? (await files.fetchFolderListing(destFolderPath))
.map((i) => i.name)
.toSet()
? (await files.fetchFolderListing(
destFolderPath,
)).map((i) => i.name).toSet()
: <String>{};
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
/// tab itself - see `SharesController.deleteShare` for that one, which
/// additionally patches the tab's own list).
+3 -4
View File
@@ -2,7 +2,6 @@ import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import '../models/nextcloud_item.dart';
import '../services/sync_service.dart';
import 'files_controller.dart';
@@ -132,7 +131,7 @@ class OfflineController extends ChangeNotifier
notifyListeners();
try {
_syncBasePath ??= (await getExternalStorageDirectory())?.path;
_syncBasePath ??= (await SyncService.baseDirectory())?.path;
// A sync can delete the very folder being browsed (deleted on the
// server) - step out to the nearest ancestor that still exists rather
// than showing a phantom empty folder.
@@ -186,7 +185,7 @@ class OfflineController extends ChangeNotifier
Future<String?> localPathFor(NextcloudItem item) async {
final accountId = session.activeAccountId;
if (accountId == null) return null;
final base = await getExternalStorageDirectory();
final base = await SyncService.baseDirectory();
if (base == null) return null;
final relPath = item.path.startsWith('/')
? item.path.substring(1)
@@ -199,7 +198,7 @@ class OfflineController extends ChangeNotifier
String accountId,
String folderPath,
) async {
final base = await getExternalStorageDirectory();
final base = await SyncService.baseDirectory();
if (base == null) return [];
final relFolder = folderPath == '/' ? '' : folderPath.replaceFirst('/', '');
final dir = Directory(p.join(base.path, 'sync', accountId, relFolder));
+16 -9
View File
@@ -17,6 +17,7 @@ class PhotosController extends ChangeNotifier {
static const _prefShowFavoritesOnlyPhotos = 'ui_show_favorites_only_photos';
static const _prefShowHiddenPhotos = 'ui_show_hidden_photos';
static const _prefHiddenFilterPhotos = 'ui_hidden_filter_photos';
static const _prefSortField = 'ui_sort_field';
static const _prefSortAscending = 'ui_sort_ascending';
@@ -25,7 +26,7 @@ class PhotosController extends ChangeNotifier {
String? _errorMessage;
bool _showFavoritesOnly = false;
bool _showHidden = false;
HiddenFilesFilter _hiddenFilter = HiddenFilesFilter.hide;
FileSortField _sortField = FileSortField.name;
bool _sortAscending = true;
@@ -37,7 +38,7 @@ class PhotosController extends ChangeNotifier {
bool get isLoading => _isLoading;
String? get errorMessage => _errorMessage;
bool get showFavoritesOnly => _showFavoritesOnly;
bool get showHidden => _showHidden;
HiddenFilesFilter get hiddenFilter => _hiddenFilter;
FileSortField get sortField => _sortField;
bool get sortAscending => _sortAscending;
@@ -48,7 +49,7 @@ class PhotosController extends ChangeNotifier {
final filtered = files.applyCommonFilters(
media,
showFavoritesOnly: _showFavoritesOnly,
showHidden: _showHidden,
hidden: _hiddenFilter,
)..sort((a, b) => _compare(a, b));
return _sortAscending ? filtered : filtered.reversed.toList();
}
@@ -86,7 +87,11 @@ class PhotosController extends ChangeNotifier {
String k(String base) => session.accountStore.accountPrefKey(id, base);
_showFavoritesOnly =
prefs.getBool(k(_prefShowFavoritesOnlyPhotos)) ?? false;
_showHidden = prefs.getBool(k(_prefShowHiddenPhotos)) ?? false;
_hiddenFilter = FilesController.loadHiddenFilter(
prefs,
k(_prefHiddenFilterPhotos),
k(_prefShowHiddenPhotos),
);
final sortFieldName = prefs.getString(k(_prefSortField));
_sortField = FileSortField.values.firstWhere(
(f) => f.name == sortFieldName,
@@ -148,8 +153,10 @@ class PhotosController extends ChangeNotifier {
);
}
Future<void> toggleShowHidden() async {
if (!_showHidden) {
/// Gated the same way as [FilesController.setHiddenFilter].
Future<void> setHiddenFilter(HiddenFilesFilter filter) async {
if (_hiddenFilter == filter) return;
if (_hiddenFilter == HiddenFilesFilter.hide) {
if (!await session.passGate(
session.lockHiddenFiles,
'Unlock to show hidden files',
@@ -157,11 +164,11 @@ class PhotosController extends ChangeNotifier {
return;
}
}
_showHidden = !_showHidden;
_hiddenFilter = filter;
notifyListeners();
_persistAccountPref(
_prefShowHiddenPhotos,
(p, key) => p.setBool(key, _showHidden),
_prefHiddenFilterPhotos,
(p, key) => p.setString(key, filter.name),
);
}
+74 -30
View File
@@ -27,8 +27,7 @@ enum LoginFlowStatus { idle, initiating, awaitingBrowser, error }
/// full rationale.
class SessionController extends ChangeNotifier with WidgetsBindingObserver {
final ConnectivityController connectivity;
final Future<SharedPreferences> prefsFuture =
SharedPreferences.getInstance();
final Future<SharedPreferences> prefsFuture = SharedPreferences.getInstance();
final AccountStore accountStore = AccountStore();
// 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 = [];
void addAccountClearedListener(VoidCallback cb) =>
_accountClearedListeners.add(cb);
void addAccountActivatedListener(VoidCallback cb) =>
_accountActivatedListeners.add(cb);
void addAccountReadyListener(VoidCallback cb) =>
_accountReadyListeners.add(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);
if (_isLoggedIn && !_isProvisionalLogin) cb();
}
void addAccountReadyListener(VoidCallback cb) {
_accountReadyListeners.add(cb);
if (_isLoggedIn) cb();
}
void _notifyAccountCleared() {
for (final cb in _accountClearedListeners) {
cb();
@@ -141,9 +162,7 @@ class SessionController extends ChangeNotifier with WidgetsBindingObserver {
// one-time unlock at cold start would give the feature no real
// security value, since the realistic threat is someone else picking
// up an already-running, unlocked phone.
if (state == AppLifecycleState.paused &&
_loginLockEnabled &&
_isUnlocked) {
if (state == AppLifecycleState.paused && _loginLockEnabled && _isUnlocked) {
_isUnlocked = false;
notifyListeners();
}
@@ -497,7 +516,7 @@ class SessionController extends ChangeNotifier with WidgetsBindingObserver {
/// session-restore that failed, e.g. transient network trouble at cold
/// start), this still retries rather than no-op, since that's exactly the
/// case LoginView's "Continue as" tile exists to recover from. Gated
/// behind login lock when [lockAccountSwitching] is on. Returns whether
/// behind its own unlock when [lockAccountSwitching] is on. Returns whether
/// the account ended up logged in, so callers (LoginView's saved-account
/// tile) can surface a failure - e.g. a stored app password that no
/// longer works and needs the account removed/re-added.
@@ -621,10 +640,10 @@ class SessionController extends ChangeNotifier with WidgetsBindingObserver {
return true;
}
/// Turns login lock off, along with both of its sub-toggles (meaningless
/// once the base lock is gone). Requires a successful auth first, same as
/// turning it on - otherwise anyone with momentary access to an unlocked
/// phone could just switch it off.
/// Turns login lock off. Requires a successful auth first, same as turning
/// it on - otherwise anyone with momentary access to an unlocked phone
/// could just switch it off. Only this lock: the account-switching and
/// hidden-files locks are independent and stay as they are.
Future<bool> disableLoginLock() async {
if (!_loginLockEnabled) return true;
final confirmed = await AppLockService.authenticate(
@@ -632,30 +651,55 @@ class SessionController extends ChangeNotifier with WidgetsBindingObserver {
);
if (!confirmed) return false;
_loginLockEnabled = false;
_lockAccountSwitching = false;
_lockHiddenFiles = false;
_isUnlocked = false;
notifyListeners();
prefsFuture.then((p) {
p.setBool(_prefLoginLockEnabled, false);
p.setBool(_prefLockAccountSwitching, false);
p.setBool(_prefLockHiddenFiles, false);
});
prefsFuture.then((p) => p.setBool(_prefLoginLockEnabled, false));
return true;
}
void setLockAccountSwitching(bool value) {
if (!_loginLockEnabled) return;
/// Turns the "unlock to switch accounts" gate on or off. Independent of
/// login lock. Both directions need a successful auth - enabling proves the
/// device can authenticate at all (a gate nobody can pass would lock the
/// user out of switching), disabling stops anyone with momentary access to
/// an unlocked phone from just removing it. Returns whether it changed.
Future<bool> setLockAccountSwitching(bool value) async {
if (value == _lockAccountSwitching) return true;
if (!await _confirmLockChange(
value,
'Confirm to lock account switching',
'Confirm to unlock account switching',
)) {
return false;
}
_lockAccountSwitching = value;
notifyListeners();
prefsFuture.then((p) => p.setBool(_prefLockAccountSwitching, value));
return true;
}
void setLockHiddenFiles(bool value) {
if (!_loginLockEnabled) return;
/// Same as [setLockAccountSwitching], for revealing hidden files.
Future<bool> setLockHiddenFiles(bool value) async {
if (value == _lockHiddenFiles) return true;
if (!await _confirmLockChange(
value,
'Confirm to lock hidden files',
'Confirm to unlock hidden files',
)) {
return false;
}
_lockHiddenFiles = value;
notifyListeners();
prefsFuture.then((p) => p.setBool(_prefLockHiddenFiles, value));
return true;
}
Future<bool> _confirmLockChange(
bool enabling,
String enableReason,
String disableReason,
) async {
if (enabling && !await AppLockService.isDeviceSupported()) return false;
return AppLockService.authenticate(enabling ? enableReason : disableReason);
}
/// Called by the lock screen. Returns whether it actually unlocked.
@@ -668,12 +712,12 @@ class SessionController extends ChangeNotifier with WidgetsBindingObserver {
return success;
}
/// Prompts for auth if [gate] is on and login lock is configured;
/// returns true immediately (no prompt) otherwise. Shared by the
/// account-switching gate above and [FilesController]'s/`PhotosController`'s
/// hidden-files gates.
/// Prompts for auth if [gate] is on; returns true immediately (no prompt)
/// otherwise. Each gate stands on its own - it doesn't depend on login lock
/// being on. Shared by the account-switching gate above and
/// [FilesController]'s/`PhotosController`'s hidden-files gates.
Future<bool> passGate(bool gate, String reason) async {
if (!_loginLockEnabled || !gate) return true;
if (!gate) return true;
return AppLockService.authenticate(reason);
}
+239 -22
View File
@@ -1,12 +1,24 @@
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/app_tab.dart';
import '../models/selection_action.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
/// in Settings.
enum SwipeAction { none, favorite, delete, share }
/// How the mobile Upload button is sized: [auto] expands on Files/Photos and
/// shrinks to an icon elsewhere, [mini] is always icon-only, [expanded]
/// always shows the label.
enum FabStyle { auto, mini, expanded }
/// Where bulk selection actions float within the content pane.
enum SelectionBarPosition { top, bottom }
enum AvatarPosition { top, bottom }
/// Thumb/track presets for the video player's seek bar, matching the four
/// combinations offered by other Material You media players: a Material 3
/// slider-style thumb ([classic]), an animated travelling wave with a round
@@ -14,6 +26,30 @@ enum SwipeAction { none, favorite, delete, share }
/// animated wave with a tick-mark thumb ([squiggly]).
enum MediaProgressBarStyle { classic, wavy, slim, squiggly }
/// The frosted-glass bottom bar's blur sigma and surface opacity, in three
/// ready-made strengths (Settings, Appearance). Moving either slider off all
/// three leaves no preset selected (a custom mix).
enum FrostedGlassPreset {
less(blur: 10, opacity: 0.88, label: 'Less'),
standard(blur: 20, opacity: 0.72, label: 'Default'),
more(blur: 32, opacity: 0.55, label: 'More');
final double blur;
final double opacity;
final String label;
const FrostedGlassPreset({
required this.blur,
required this.opacity,
required this.label,
});
}
const double minFrostedBlur = 0;
const double maxFrostedBlur = 40;
const double minFrostedOpacity = 0.3;
const double maxFrostedOpacity = 1.0;
/// Global (not per-account) UI settings: theme, bottom-nav appearance, tab
/// configuration, swipe actions. Independent of login state - these mean
/// the same thing whether any account is signed in or not, unlike
@@ -23,13 +59,21 @@ class SettingsController extends ChangeNotifier {
static const _prefThemeMode = 'ui_theme_mode';
static const _prefUseDynamicColor = 'ui_use_dynamic_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 _prefSelectionBarPosition = 'ui_selection_bar_position';
static const _prefSelectionActionOrder = 'ui_selection_action_order';
static const _prefHiddenTabs = 'ui_hidden_tabs';
static const _prefDefaultTab = 'ui_default_tab';
static const _prefSwipeLeftAction = 'ui_swipe_left_action';
static const _prefSwipeRightAction = 'ui_swipe_right_action';
static const _prefFabStyle = 'ui_fab_style';
static const _prefBottomBarStyle = 'ui_bottom_bar_style';
static const _prefBottomBarFrosted = 'ui_bottom_bar_frosted';
static const _prefFrostedBlur = 'ui_bottom_bar_frosted_blur';
static const _prefFrostedOpacity = 'ui_bottom_bar_frosted_opacity';
static const _prefAvatarPosition = 'ui_avatar_position';
static const _prefNavMenuStyle = 'ui_nav_menu_style';
static const _prefSearchInBottomBar = 'ui_search_in_bottom_bar';
static const _prefAmoledDark = 'ui_amoled_dark';
static const _prefMediaProgressBarStyle = 'ui_media_progress_bar_style';
static const _prefTapTabToScrollTop = 'ui_tap_tab_to_scroll_top';
@@ -37,22 +81,32 @@ class SettingsController extends ChangeNotifier {
final Future<SharedPreferences> _prefsFuture =
SharedPreferences.getInstance();
Color _seedColor = AppTheme.defaultNextcloudBlue;
Color _seedColor = AppTheme.defaultAccent;
ThemeMode _themeMode = ThemeMode.system;
NooBottomBarStyle _bottomBarStyle = NooBottomBarStyle.attached;
NooNavMenuStyle _navMenuStyle = NooNavMenuStyle.drawer;
AvatarPosition _avatarPosition = AvatarPosition.top;
bool _bottomBarFrosted = false;
double _frostedBlur = FrostedGlassPreset.standard.blur;
double _frostedOpacity = FrostedGlassPreset.standard.opacity;
bool _searchInBottomBar = false;
bool _useDynamicColor = true;
bool _amoledDark = false;
MediaProgressBarStyle _mediaProgressBarStyle = MediaProgressBarStyle.wavy;
double _bottomBarOpacity = 0.55;
double _bottomBarBlur = 28;
bool _tapTabToScrollTop = true;
SelectionBarPosition _selectionBarPosition = SelectionBarPosition.top;
List<AppTab> _tabOrder = AppTab.values.toList();
Set<AppTab> _hiddenTabs = {};
AppTab _defaultTab = AppTab.files;
List<SelectionActionKind> _selectionActionOrder = SelectionActionKind.values
.toList();
SwipeAction _swipeLeftAction = SwipeAction.delete;
SwipeAction _swipeRightAction = SwipeAction.favorite;
FabStyle _fabStyle = FabStyle.auto;
// 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
@@ -79,12 +133,32 @@ class SettingsController extends ChangeNotifier {
Color get seedColor => _seedColor;
ThemeMode get themeMode => _themeMode;
NooBottomBarStyle get bottomBarStyle => _bottomBarStyle;
bool get bottomBarFrosted => _bottomBarFrosted;
double get bottomBarFrostedBlur => _frostedBlur;
double get bottomBarFrostedOpacity => _frostedOpacity;
/// The preset the current blur/opacity match exactly, or null when the
/// sliders have been moved to something custom.
FrostedGlassPreset? get frostedPreset => FrostedGlassPreset.values
.where((p) => p.blur == _frostedBlur && p.opacity == _frostedOpacity)
.firstOrNull;
AvatarPosition get avatarPosition => _avatarPosition;
NooNavMenuStyle get navMenuStyle => _navMenuStyle;
bool get searchInBottomBar => _searchInBottomBar;
bool get useDynamicColor => _useDynamicColor;
bool get amoledDark => _amoledDark;
MediaProgressBarStyle get mediaProgressBarStyle => _mediaProgressBarStyle;
double get bottomBarOpacity => _bottomBarOpacity;
double get bottomBarBlur => _bottomBarBlur;
bool get tapTabToScrollTop => _tapTabToScrollTop;
SelectionBarPosition get selectionBarPosition => _selectionBarPosition;
/// The cap a screen should actually enforce for the *regular*, user-
/// reorderable tabs - one below [defaultMaxVisibleTabs] while
/// [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
/// by the reorder/visibility settings UI.
@@ -92,12 +166,19 @@ class SettingsController extends ChangeNotifier {
Set<AppTab> get hiddenTabs => _hiddenTabs;
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.
List<AppTab> get visibleTabs =>
_tabOrder.where((t) => !_hiddenTabs.contains(t)).toList();
SwipeAction get swipeLeftAction => _swipeLeftAction;
SwipeAction get swipeRightAction => _swipeRightAction;
FabStyle get fabStyle => _fabStyle;
Future<void> _load() async {
try {
@@ -110,6 +191,42 @@ class SettingsController extends ChangeNotifier {
orElse: () => ThemeMode.system,
);
}
_selectionBarPosition = SelectionBarPosition.values.firstWhere(
(position) =>
position.name == prefs.getString(_prefSelectionBarPosition),
orElse: () => SelectionBarPosition.top,
);
final bottomBarStyleName = prefs.getString(_prefBottomBarStyle);
if (bottomBarStyleName != null) {
_bottomBarStyle = NooBottomBarStyle.values.firstWhere(
(s) => s.name == bottomBarStyleName,
orElse: () => _bottomBarStyle,
);
}
_avatarPosition = AvatarPosition.values.firstWhere(
(position) => position.name == prefs.getString(_prefAvatarPosition),
orElse: () => AvatarPosition.top,
);
final navMenuStyleName = prefs.getString(_prefNavMenuStyle);
if (navMenuStyleName != null) {
_navMenuStyle = NooNavMenuStyle.values.firstWhere(
(s) => s.name == navMenuStyleName,
orElse: () => _navMenuStyle,
);
}
_bottomBarFrosted =
prefs.getBool(_prefBottomBarFrosted) ?? _bottomBarFrosted;
_frostedBlur = (prefs.getDouble(_prefFrostedBlur) ?? _frostedBlur).clamp(
minFrostedBlur,
maxFrostedBlur,
);
_frostedOpacity =
(prefs.getDouble(_prefFrostedOpacity) ?? _frostedOpacity).clamp(
minFrostedOpacity,
maxFrostedOpacity,
);
_searchInBottomBar =
prefs.getBool(_prefSearchInBottomBar) ?? _searchInBottomBar;
_useDynamicColor =
prefs.getBool(_prefUseDynamicColor) ?? _useDynamicColor;
_amoledDark = prefs.getBool(_prefAmoledDark) ?? _amoledDark;
@@ -122,9 +239,6 @@ class SettingsController extends ChangeNotifier {
}
final seedColorValue = prefs.getInt(_prefSeedColor);
if (seedColorValue != null) _seedColor = Color(seedColorValue);
_bottomBarOpacity =
prefs.getDouble(_prefBottomBarOpacity) ?? _bottomBarOpacity;
_bottomBarBlur = prefs.getDouble(_prefBottomBarBlur) ?? _bottomBarBlur;
_tapTabToScrollTop =
prefs.getBool(_prefTapTabToScrollTop) ?? _tapTabToScrollTop;
@@ -143,6 +257,25 @@ class SettingsController extends ChangeNotifier {
_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);
if (savedHiddenNames != null) {
_hiddenTabs = savedHiddenNames
@@ -187,6 +320,14 @@ class SettingsController extends ChangeNotifier {
);
}
final fabName = prefs.getString(_prefFabStyle);
if (fabName != null) {
_fabStyle = FabStyle.values.firstWhere(
(f) => f.name == fabName,
orElse: () => _fabStyle,
);
}
notifyListeners();
} catch (e) {
debugPrint('[SettingsController] Preference restore failed: $e');
@@ -223,18 +364,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) {
_tapTabToScrollTop = value;
notifyListeners();
@@ -247,6 +376,76 @@ class SettingsController extends ChangeNotifier {
_prefsFuture.then((p) => p.setString(_prefThemeMode, mode.name));
}
void setAvatarPosition(AvatarPosition position) {
if (_avatarPosition == position) return;
_avatarPosition = position;
notifyListeners();
_prefsFuture.then((p) => p.setString(_prefAvatarPosition, position.name));
}
void setSelectionBarPosition(SelectionBarPosition position) {
if (_selectionBarPosition == position) return;
_selectionBarPosition = position;
notifyListeners();
_prefsFuture.then(
(p) => p.setString(_prefSelectionBarPosition, position.name),
);
}
void setBottomBarStyle(NooBottomBarStyle style) {
if (_bottomBarStyle == style) return;
_bottomBarStyle = style;
notifyListeners();
_prefsFuture.then((p) => p.setString(_prefBottomBarStyle, style.name));
}
void setFrostedBlur(double value) {
final v = value.clamp(minFrostedBlur, maxFrostedBlur);
if (_frostedBlur == v) return;
_frostedBlur = v;
notifyListeners();
_prefsFuture.then((p) => p.setDouble(_prefFrostedBlur, v));
}
void setFrostedOpacity(double value) {
final v = value.clamp(minFrostedOpacity, maxFrostedOpacity);
if (_frostedOpacity == v) return;
_frostedOpacity = v;
notifyListeners();
_prefsFuture.then((p) => p.setDouble(_prefFrostedOpacity, v));
}
void setFrostedPreset(FrostedGlassPreset preset) {
setFrostedBlur(preset.blur);
setFrostedOpacity(preset.opacity);
}
void setBottomBarFrosted(bool value) {
if (_bottomBarFrosted == value) return;
_bottomBarFrosted = value;
notifyListeners();
_prefsFuture.then((p) => p.setBool(_prefBottomBarFrosted, value));
}
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) {
_tabOrder = order;
notifyListeners();
@@ -255,6 +454,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
/// remaining visible tab. If the tab being hidden is the current default,
/// the default falls back to the next visible tab.
@@ -324,4 +534,11 @@ class SettingsController extends ChangeNotifier {
notifyListeners();
_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 {
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;
String? _errorMessage;
bool _sharedWithMe = false;
@@ -18,13 +27,17 @@ class SharesController extends ChangeNotifier {
session.addAccountActivatedListener(_onAccountActivated);
}
List<NextcloudShare> get shares => _shares;
List<NextcloudShare> get shares =>
_sharedWithMe ? _withMeShares : _byMeShares;
bool get isLoading => _isLoading;
String? get errorMessage => _errorMessage;
bool get sharedWithMe => _sharedWithMe;
void _onAccountCleared() {
_shares = [];
_withMeShares = [];
_byMeShares = [];
_withMeLoaded = false;
_byMeLoaded = false;
_isLoading = false;
_errorMessage = null;
_sharedWithMe = false;
@@ -35,19 +48,29 @@ class SharesController extends ChangeNotifier {
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 {
final service = session.service;
if (!session.isLoggedIn || service == null) return;
final gen = session.sessionGeneration;
final wantsWithMe = _sharedWithMe;
_isLoading = true;
_errorMessage = null;
notifyListeners();
try {
final shares = await service.fetchShares(sharedWithMe: _sharedWithMe);
final shares = await service.fetchShares(sharedWithMe: wantsWithMe);
if (gen != session.sessionGeneration) return;
_shares = shares;
if (wantsWithMe) {
_withMeShares = shares;
_withMeLoaded = true;
} else {
_byMeShares = shares;
_byMeLoaded = true;
}
} catch (e) {
if (gen != session.sessionGeneration) return;
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) {
if (_sharedWithMe == value) return;
_sharedWithMe = value;
notifyListeners();
fetchAll();
if (!(value ? _withMeLoaded : _byMeLoaded)) fetchAll();
}
Future<bool> deleteShare(NextcloudShare share) async {
@@ -73,7 +98,11 @@ class SharesController extends ChangeNotifier {
if (service == null) return false;
final success = await service.deleteShare(share.id);
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();
}
return success;
+10
View File
@@ -1,3 +1,4 @@
import 'package:flutter/foundation.dart';
import 'package:local_auth/local_auth.dart';
/// Thin wrapper around `local_auth`. Deliberately doesn't implement its own
@@ -8,9 +9,17 @@ import 'package:local_auth/local_auth.dart';
class AppLockService {
static final LocalAuthentication _auth = LocalAuthentication();
/// Replace the platform prompt in tests (null = the real one).
@visibleForTesting
static Future<bool> Function(String reason)? debugAuthenticate;
@visibleForTesting
static Future<bool> Function()? debugIsDeviceSupported;
/// Whether this device can do *some* form of local auth - biometric
/// enrolled, or at minimum a device PIN/pattern/password set up.
static Future<bool> isDeviceSupported() async {
if (debugIsDeviceSupported != null) return debugIsDeviceSupported!();
try {
final canCheckBiometrics = await _auth.canCheckBiometrics;
if (canCheckBiometrics) return true;
@@ -24,6 +33,7 @@ class AppLockService {
/// throws) on cancellation, failure, or any platform error, so callers
/// can treat every non-true result the same way: stay locked/blocked.
static Future<bool> authenticate(String reason) async {
if (debugAuthenticate != null) return debugAuthenticate!(reason);
try {
return await _auth.authenticate(
localizedReason: reason,
+4 -1
View File
@@ -38,6 +38,9 @@ class DownloadService {
.toList(),
);
await _channel.invokeMethod('startDownload', {...args, 'files': filesJson});
await invokeOrExplain(_channel, 'startDownload', 'Downloading', {
...args,
'files': filesJson,
});
}
}
+55
View File
@@ -1,3 +1,4 @@
import 'package:flutter/services.dart';
import '../providers/session_controller.dart';
/// The "get this account's auth header, or bail" guard every native-
@@ -21,3 +22,57 @@ Map<String, String> baseChannelArgs(SessionController session) {
'authHeader': requireAuthHeader(session),
};
}
/// Thrown by user-triggered native services (upload, download, sync now...)
/// on a platform whose native side isn't implemented yet - the message is
/// shown to the user as-is, e.g. in a snackbar.
class NativeServiceUnavailable implements Exception {
final String feature;
const NativeServiceUnavailable(this.feature);
@override
String toString() => "$feature isn't available on this platform yet.";
}
/// Calls [method], treating a missing native implementation (no
/// `MethodChannel` handler registered, as on iOS until each service is
/// built there) as "nothing to report": returns null instead of throwing
/// `MissingPluginException`. For cold-start checks, scheduling and other
/// calls nobody is waiting on.
Future<T?> invokeIfAvailable<T>(
MethodChannel channel,
String method, [
Object? arguments,
]) async {
try {
return await channel.invokeMethod<T>(method, arguments);
} on MissingPluginException {
return null;
}
}
/// Like [invokeIfAvailable], but for an action the user just asked for:
/// throws [NativeServiceUnavailable] naming [feature] rather than silently
/// doing nothing.
Future<T?> invokeOrExplain<T>(
MethodChannel channel,
String method,
String feature, [
Object? arguments,
]) async {
try {
return await channel.invokeMethod<T>(method, arguments);
} on MissingPluginException {
throw NativeServiceUnavailable(feature);
}
}
/// [channel]'s broadcast stream mapped through [convert], with the
/// "no native implementation" error dropped - the stream just never emits.
Stream<T> quietEvents<T>(EventChannel channel, T Function(dynamic) convert) {
return channel
.receiveBroadcastStream()
.handleError((Object _) {}, test: (e) => e is MissingPluginException)
.map(convert);
}
+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
/// 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.
@@ -134,6 +147,19 @@ class NextcloudService {
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 {
try {
final davPath = '$_cleanServerUrl/remote.php/dav/files/$username/';
@@ -356,7 +382,7 @@ class NextcloudService {
final size = int.tryParse(sizeStr ?? '0') ?? 0;
final lastMod = _parseDavDate(lastModStr) ?? DateTime.now();
final created = _parseDavDate(createdStr);
final created = _parseDavCreationDate(createdStr);
final itemType = NextcloudItem.deduceType(name, isCollection, mimeType);
final validId = (fileId != null && fileId.isNotEmpty) ? fileId : name;
@@ -671,7 +697,7 @@ class NextcloudService {
final size = int.tryParse(sizeStr ?? '0') ?? 0;
final lastMod = _parseDavDate(lastModStr) ?? DateTime.now();
final created = _parseDavDate(createdStr);
final created = _parseDavCreationDate(createdStr);
final itemType = NextcloudItem.deduceType(name, false, mimeType);
final validId = (fileId != null && fileId.isNotEmpty) ? fileId : name;
@@ -835,7 +861,7 @@ class NextcloudService {
final size = int.tryParse(sizeStr ?? '0') ?? 0;
final lastMod = _parseDavDate(lastModStr) ?? DateTime.now();
final created = _parseDavDate(createdStr);
final created = _parseDavCreationDate(createdStr);
final itemType = NextcloudItem.deduceType(name, isCollection, mimeType);
final validId = (fileId != null && fileId.isNotEmpty) ? fileId : name;
@@ -1000,7 +1026,7 @@ class NextcloudService {
final size = int.tryParse(sizeStr ?? '0') ?? 0;
final lastMod = _parseDavDate(lastModStr) ?? DateTime.now();
final created = _parseDavDate(createdStr);
final created = _parseDavCreationDate(createdStr);
final itemType = NextcloudItem.deduceType(name, false, mimeType);
final validId = (fileId != null && fileId.isNotEmpty) ? fileId : name;
@@ -1458,28 +1484,17 @@ class NextcloudService {
final rawList = data['ocs']?['data'];
if (rawList is! List) return [];
return rawList.map<NextcloudShare>((raw) {
final path = (raw['path'] ?? '/').toString();
final segments = path.split('/').where((s) => s.isNotEmpty).toList();
final name = segments.isNotEmpty ? segments.last : path;
final isFolder = (raw['item_type'] ?? '').toString() == 'folder';
final mimeType = raw['mimetype'] as String?;
return NextcloudShare(
id: (raw['id'] ?? '').toString(),
path: path,
name: name,
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();
// Shares out to `_shareFromJson` (the same parser `fetchSharesForPath`/
// `fetchInheritedShares` use) rather than its own duplicated, thinner
// inline parsing - that copy never set `url`/`token`/`permissions`/
// `expireDate` at all, so a public-link share always came back with
// `url: null` here and the Shares tab's own "Copy link" action could
// never show up for it.
return rawList
.map<NextcloudShare>(
(raw) => _shareFromJson(raw, sharedWithMe: sharedWithMe),
)
.toList();
}
/// Creates a public link share for [path] and returns its share URL.
@@ -1523,6 +1538,22 @@ class NextcloudService {
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}) {
final path = (raw['path'] ?? '/').toString();
final segments = path.split('/').where((s) => s.isNotEmpty).toList();
+7 -4
View File
@@ -1,5 +1,6 @@
import 'package:flutter/services.dart';
import '../models/pick_request.dart';
import 'native_channel.dart';
/// Talks to `MainActivity.kt`'s hand-rolled GET_CONTENT picker handling
/// (mirrors `ShareIntentService`'s split between a cold-start check and a
@@ -14,7 +15,8 @@ class PickIntentService {
/// Non-null if the app was launched cold as another app's file/photo
/// picker.
static Future<PickRequest?> getPickRequest() async {
final result = await _methodChannel.invokeMethod<Map<dynamic, dynamic>>(
final result = await invokeIfAvailable<Map<dynamic, dynamic>>(
_methodChannel,
'getPickRequest',
);
return result == null ? null : PickRequest.fromMap(result);
@@ -23,7 +25,8 @@ class PickIntentService {
/// Emits whenever the app is asked to act as a picker while already
/// running.
static Stream<PickRequest> get onNewPickRequest {
return _newPickChannel.receiveBroadcastStream().map(
return quietEvents(
_newPickChannel,
(event) => PickRequest.fromMap(event as Map<dynamic, dynamic>),
);
}
@@ -34,7 +37,7 @@ class PickIntentService {
List<String> localPaths,
List<String> mimeTypes,
) {
return _methodChannel.invokeMethod('finishPick', {
return invokeIfAvailable(_methodChannel, 'finishPick', {
'paths': localPaths,
'mimeTypes': mimeTypes,
});
@@ -42,6 +45,6 @@ class PickIntentService {
/// Backs out of picking mode with no result, closing the picker.
static Future<void> cancelPick() {
return _methodChannel.invokeMethod('cancelPick');
return invokeIfAvailable(_methodChannel, 'cancelPick');
}
}
+204
View File
@@ -0,0 +1,204 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import '../providers/files_controller.dart';
import '../providers/session_controller.dart';
import 'native_channel.dart';
/// One account as the iOS Share Extension needs it - plain data, so the
/// payload builder is testable without a session.
class ShareAccountEntry {
final String id;
final String serverUrl;
final String username;
final String password;
/// The account's Files "hidden files" filter: `hide`, `only` or `include`.
final String hiddenFilter;
/// The account's Files storage scope: `cloud`, `external` or `all`.
final String storageScope;
const ShareAccountEntry({
required this.id,
required this.serverUrl,
required this.username,
required this.password,
required this.hiddenFilter,
required this.storageScope,
});
}
/// Tells the native side which accounts can upload, which one is active, and
/// the app-lock settings, so iOS's Share Extension - a separate process with
/// no Flutter engine - can offer an account picker, list folders and upload
/// as them without opening the app (`ios/Runner/Native/NativeServices.swift`'s
/// `share_account`, stored in a Keychain group shared with the extension).
/// Android has no equivalent (its share intent opens the app itself), so there
/// the channel simply doesn't exist and everything here is a no-op.
class ShareAccountService {
static const _channel = MethodChannel('dev.ayushya.noo/share_account');
static String basicAuth(String username, String password) =>
'Basic ${base64Encode(utf8.encode('$username:$password'))}';
/// The JSON `SharedAccounts` (`ios/Shared/SharedAccount.swift`) decodes.
static String buildPayload({
required List<ShareAccountEntry> accounts,
required String? activeId,
required bool loginLockEnabled,
required bool lockAccountSwitching,
required bool lockHiddenFiles,
}) {
return jsonEncode({
'accounts': [
for (final a in accounts)
{
'id': a.id,
'serverUrl': a.serverUrl,
'username': a.username,
'authHeader': basicAuth(a.username, a.password),
'displayName':
'${a.username}@${Uri.tryParse(a.serverUrl)?.host ?? a.serverUrl}',
'hiddenFilter': a.hiddenFilter,
'storageScope': a.storageScope,
},
],
'activeId': activeId,
'loginLockEnabled': loginLockEnabled,
'lockAccountSwitching': lockAccountSwitching,
'lockHiddenFiles': lockHiddenFiles,
});
}
/// Publishes every saved account that can actually upload (has a stored
/// password and isn't signed out), with the active one's hidden-files
/// filter and storage scope taken live from [files] - the saved prefs are
/// written asynchronously, so they can lag a change that just happened.
static Future<void> publish(
SessionController session,
FilesController files,
) async {
final activeId = session.activeAccountId;
if (activeId == null) return clear();
final prefs = await session.prefsFuture;
final store = session.accountStore;
final signedOut = store.loadSignedOut(prefs);
final entries = <ShareAccountEntry>[];
for (final account in session.accounts) {
if (signedOut.contains(account.id)) {
debugPrint('[ShareAccounts] skipping ${account.id}: signed out');
continue;
}
final password = await store.readPassword(account.id);
if (password == null) {
debugPrint(
'[ShareAccounts] skipping ${account.id}: no stored password',
);
continue;
}
final isActive = account.id == activeId;
final filter = isActive
? files.hiddenFilter
: FilesController.savedHiddenFilter(
prefs,
store.accountPrefKey,
account.id,
);
final scope = isActive
? files.storageScope
: FilesController.savedStorageScope(
prefs,
store.accountPrefKey,
account.id,
);
entries.add(
ShareAccountEntry(
id: account.id,
serverUrl: account.serverUrl,
username: account.username,
password: password,
hiddenFilter: filter.name,
storageScope: scope.name,
),
);
}
debugPrint(
'[ShareAccounts] publishing ${entries.length} of '
'${session.accounts.length} saved accounts, active=$activeId '
'(${signedOut.length} signed out)',
);
if (entries.isEmpty) return clear();
await invokeIfAvailable(_channel, 'setAccounts', {
'accounts': buildPayload(
accounts: entries,
activeId: activeId,
loginLockEnabled: session.loginLockEnabled,
lockAccountSwitching: session.lockAccountSwitching,
lockHiddenFiles: session.lockHiddenFiles,
),
});
}
/// Forgets everything (sign-out) so the extension stops offering uploads.
static Future<void> clear() => invokeIfAvailable(_channel, 'clearAccount');
}
/// Keeps [ShareAccountService]'s published data current while the app is
/// running: republishes when an account becomes ready or when anything the
/// extension depends on changes - the account list or active account, the
/// lock settings, or the active account's hidden-files filter.
class ShareAccountSync {
final SessionController session;
final FilesController files;
String? _lastSignature;
bool _disposed = false;
Future<void> _queue = Future.value();
ShareAccountSync(this.session, this.files);
void start() {
session.addAccountReadyListener(_publish);
session.addListener(_onChange);
files.addListener(_onChange);
}
void dispose() {
_disposed = true;
session.removeListener(_onChange);
files.removeListener(_onChange);
}
String _signature() => [
session.activeAccountId,
session.accounts.map((a) => a.id).join(','),
session.loginLockEnabled,
session.lockAccountSwitching,
session.lockHiddenFiles,
files.hiddenFilter.name,
files.storageScope.name,
].join('|');
void _onChange() {
if (_signature() != _lastSignature) _publish();
}
/// Runs one at a time, in order, so a slow publish can't land after a
/// newer one and leave the extension with stale data.
void _publish() {
if (_disposed) return;
_lastSignature = _signature();
_queue = _queue.then((_) async {
if (_disposed) return;
try {
await ShareAccountService.publish(session, files);
} catch (_) {
// Best effort: the extension just keeps the previous data.
}
});
}
}

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