From 56784ef212fac79511bc97389e19d2302847b0c5 Mon Sep 17 00:00:00 2001 From: ayushya Date: Sat, 26 Sep 2026 01:51:58 -0400 Subject: [PATCH 01/29] Rebuild UI on the Noo design system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Co-Authored-By: Claude Sonnet 5 --- .claude/agents/flutter-kgp-doctor.md | 70 + .claude/context/architecture.md | 171 +- .../context/design-system/DESIGN_SYSTEM.md | 38 + .claude/context/standards.md | 60 +- .claude/context/styling.md | 204 ++- .gitea/workflows/release.yml | 103 ++ .gitignore | 4 + assets/icon/app_icon_monochrome.png | Bin 16102 -> 37625 bytes integration_test/store_screenshots_test.dart | 325 ++++ lib/main.dart | 341 +++- lib/models/app_tab.dart | 20 +- lib/providers/settings_controller.dart | 21 - lib/theme/app_theme.dart | 83 +- lib/theme/design_tokens.dart | 428 +++++ lib/views/account_view.dart | 1584 +---------------- lib/views/activity_view.dart | 219 ++- lib/views/favorites_view.dart | 541 +++--- lib/views/file_viewer_screen.dart | 1009 ++--------- lib/views/files_view.dart | 1128 +++++++----- lib/views/lock_screen_view.dart | 43 +- lib/views/login_view.dart | 424 ++--- lib/views/login_webview_view.dart | 23 +- lib/views/move_copy_destination_picker.dart | 461 ++--- lib/views/photos_view.dart | 593 +++--- lib/views/recent_view.dart | 371 ++-- lib/views/search_view.dart | 325 +++- lib/views/share_upload_view.dart | 499 ++---- lib/views/shares_view.dart | 365 ++-- lib/views/trash_view.dart | 461 +++-- lib/widgets/app_drawer.dart | 83 + lib/widgets/app_top_bar.dart | 64 + lib/widgets/bottom_nav_bar.dart | 36 + lib/widgets/breadcrumbs.dart | 56 +- lib/widgets/create_menu.dart | 119 ++ lib/widgets/details/details_activity_tab.dart | 136 +- lib/widgets/details/details_info_tab.dart | 84 +- lib/widgets/details/details_sheet.dart | 239 ++- lib/widgets/details/details_versions_tab.dart | 170 +- lib/widgets/files/file_breadcrumb_row.dart | 85 + lib/widgets/files_controls_row.dart | 246 +-- lib/widgets/floating_bottom_bar.dart | 151 -- lib/widgets/item_icon.dart | 81 +- lib/widgets/manage_synced_folders_sheet.dart | 111 +- lib/widgets/media_grid_tile.dart | 98 - lib/widgets/move_copy_conflict_sheet.dart | 269 +-- lib/widgets/noo/core/noo_avatar.dart | 61 + lib/widgets/noo/core/noo_badge.dart | 47 + lib/widgets/noo/core/noo_button.dart | 143 ++ lib/widgets/noo/core/noo_chip.dart | 92 + lib/widgets/noo/core/noo_fab.dart | 47 + lib/widgets/noo/core/noo_progress_bar.dart | 27 + lib/widgets/noo/core/noo_search_field.dart | 84 + .../noo/core/noo_segmented_control.dart | 95 + lib/widgets/noo/core/noo_toggle.dart | 54 + lib/widgets/noo/files/noo_file_kind.dart | 188 ++ lib/widgets/noo/files/noo_file_row.dart | 193 ++ lib/widgets/noo/files/noo_file_table.dart | 243 +++ lib/widgets/noo/files/noo_file_tile.dart | 74 + lib/widgets/noo/files/noo_status_icon.dart | 57 + lib/widgets/noo/files/noo_swipe_action.dart | 242 +++ lib/widgets/noo/lists/noo_banner.dart | 56 + lib/widgets/noo/lists/noo_grouped_list.dart | 79 + lib/widgets/noo/lists/noo_settings_row.dart | 106 ++ lib/widgets/noo/lists/noo_summary_card.dart | 79 + lib/widgets/noo/lists/noo_tab_order_row.dart | 62 + lib/widgets/noo/media/noo_activity_item.dart | 116 ++ lib/widgets/noo/media/noo_grid_card.dart | 179 ++ lib/widgets/noo/media/noo_photo_group.dart | 130 ++ lib/widgets/noo/media/noo_photo_tile.dart | 164 ++ lib/widgets/noo/media/noo_stat_card.dart | 98 + lib/widgets/noo/nav/noo_bottom_bar.dart | 178 ++ lib/widgets/noo/nav/noo_drawer.dart | 365 ++++ lib/widgets/noo/nav/noo_nav_style.dart | 28 + lib/widgets/noo/nav/noo_sidebar.dart | 320 ++++ lib/widgets/noo/nav/noo_toolbar.dart | 142 ++ lib/widgets/noo/nav/noo_top_bar.dart | 296 +++ lib/widgets/noo/noo_layout.dart | 27 + lib/widgets/noo/overlays/noo_dialog.dart | 214 +++ .../noo/overlays/noo_overlay_header.dart | 66 + lib/widgets/noo/overlays/noo_share_parts.dart | 158 ++ lib/widgets/noo/overlays/noo_sheet.dart | 50 + lib/widgets/noo/overlays/noo_text_field.dart | 87 + lib/widgets/profile_avatar_button.dart | 15 +- lib/widgets/segmented_icon_toggle.dart | 17 +- lib/widgets/selectable_thumbnail.dart | 53 - .../settings/settings_account_card.dart | 154 ++ lib/widgets/settings/settings_accounts.dart | 128 ++ lib/widgets/settings/settings_appearance.dart | 266 +++ lib/widgets/settings/settings_dialogs.dart | 92 + lib/widgets/settings/settings_file_sync.dart | 132 ++ .../settings/settings_files_cache.dart | 142 ++ lib/widgets/settings/settings_section.dart | 106 ++ lib/widgets/settings/settings_security.dart | 94 + lib/widgets/settings/settings_swipe.dart | 84 + lib/widgets/settings/settings_tabs.dart | 184 ++ lib/widgets/share_sheet.dart | 575 +++--- lib/widgets/shell/shell_common.dart | 313 ++++ lib/widgets/sort_menu_button.dart | 55 +- lib/widgets/swipeable_item.dart | 142 -- lib/widgets/sync_status_badge.dart | 34 - lib/widgets/tabs/tab_day_groups.dart | 109 ++ lib/widgets/tabs/tab_location.dart | 10 + lib/widgets/tabs/tab_state_slivers.dart | 96 + lib/widgets/viewer/media_action_bar.dart | 98 + lib/widgets/viewer/media_image_preview.dart | 134 ++ lib/widgets/viewer/media_pdf_preview.dart | 98 + lib/widgets/viewer/media_text_preview.dart | 88 + .../viewer/media_unsupported_preview.dart | 69 + lib/widgets/viewer/media_video_preview.dart | 310 ++++ lib/widgets/viewer/viewer_icon_button.dart | 48 + pubspec.lock | 99 +- pubspec.yaml | 18 +- test/widget_test.dart | 2 +- test/widgets/noo/core_test.dart | 265 +++ test/widgets/noo/lists_test.dart | 224 +++ test/widgets/noo/noo_test_utils.dart | 67 + test/widgets/noo/overlays_test.dart | 336 ++++ test_driver/integration_test.dart | 33 + tool/demo_server/README.md | 48 + tool/demo_server/docker-compose.yml | 16 + tool/demo_server/requirements.txt | 2 + tool/demo_server/seed.py | 348 ++++ tool/finalize_screenshots.py | 43 + tool/screenshots.sh | 273 +++ tool/upload_play_bundle.py | 58 + 125 files changed, 15263 insertions(+), 6804 deletions(-) create mode 100644 .claude/agents/flutter-kgp-doctor.md create mode 100644 .gitea/workflows/release.yml create mode 100644 integration_test/store_screenshots_test.dart create mode 100644 lib/theme/design_tokens.dart create mode 100644 lib/widgets/app_drawer.dart create mode 100644 lib/widgets/app_top_bar.dart create mode 100644 lib/widgets/bottom_nav_bar.dart create mode 100644 lib/widgets/create_menu.dart create mode 100644 lib/widgets/files/file_breadcrumb_row.dart delete mode 100644 lib/widgets/floating_bottom_bar.dart delete mode 100644 lib/widgets/media_grid_tile.dart create mode 100644 lib/widgets/noo/core/noo_avatar.dart create mode 100644 lib/widgets/noo/core/noo_badge.dart create mode 100644 lib/widgets/noo/core/noo_button.dart create mode 100644 lib/widgets/noo/core/noo_chip.dart create mode 100644 lib/widgets/noo/core/noo_fab.dart create mode 100644 lib/widgets/noo/core/noo_progress_bar.dart create mode 100644 lib/widgets/noo/core/noo_search_field.dart create mode 100644 lib/widgets/noo/core/noo_segmented_control.dart create mode 100644 lib/widgets/noo/core/noo_toggle.dart create mode 100644 lib/widgets/noo/files/noo_file_kind.dart create mode 100644 lib/widgets/noo/files/noo_file_row.dart create mode 100644 lib/widgets/noo/files/noo_file_table.dart create mode 100644 lib/widgets/noo/files/noo_file_tile.dart create mode 100644 lib/widgets/noo/files/noo_status_icon.dart create mode 100644 lib/widgets/noo/files/noo_swipe_action.dart create mode 100644 lib/widgets/noo/lists/noo_banner.dart create mode 100644 lib/widgets/noo/lists/noo_grouped_list.dart create mode 100644 lib/widgets/noo/lists/noo_settings_row.dart create mode 100644 lib/widgets/noo/lists/noo_summary_card.dart create mode 100644 lib/widgets/noo/lists/noo_tab_order_row.dart create mode 100644 lib/widgets/noo/media/noo_activity_item.dart create mode 100644 lib/widgets/noo/media/noo_grid_card.dart create mode 100644 lib/widgets/noo/media/noo_photo_group.dart create mode 100644 lib/widgets/noo/media/noo_photo_tile.dart create mode 100644 lib/widgets/noo/media/noo_stat_card.dart create mode 100644 lib/widgets/noo/nav/noo_bottom_bar.dart create mode 100644 lib/widgets/noo/nav/noo_drawer.dart create mode 100644 lib/widgets/noo/nav/noo_nav_style.dart create mode 100644 lib/widgets/noo/nav/noo_sidebar.dart create mode 100644 lib/widgets/noo/nav/noo_toolbar.dart create mode 100644 lib/widgets/noo/nav/noo_top_bar.dart create mode 100644 lib/widgets/noo/noo_layout.dart create mode 100644 lib/widgets/noo/overlays/noo_dialog.dart create mode 100644 lib/widgets/noo/overlays/noo_overlay_header.dart create mode 100644 lib/widgets/noo/overlays/noo_share_parts.dart create mode 100644 lib/widgets/noo/overlays/noo_sheet.dart create mode 100644 lib/widgets/noo/overlays/noo_text_field.dart delete mode 100644 lib/widgets/selectable_thumbnail.dart create mode 100644 lib/widgets/settings/settings_account_card.dart create mode 100644 lib/widgets/settings/settings_accounts.dart create mode 100644 lib/widgets/settings/settings_appearance.dart create mode 100644 lib/widgets/settings/settings_dialogs.dart create mode 100644 lib/widgets/settings/settings_file_sync.dart create mode 100644 lib/widgets/settings/settings_files_cache.dart create mode 100644 lib/widgets/settings/settings_section.dart create mode 100644 lib/widgets/settings/settings_security.dart create mode 100644 lib/widgets/settings/settings_swipe.dart create mode 100644 lib/widgets/settings/settings_tabs.dart create mode 100644 lib/widgets/shell/shell_common.dart delete mode 100644 lib/widgets/swipeable_item.dart delete mode 100644 lib/widgets/sync_status_badge.dart create mode 100644 lib/widgets/tabs/tab_day_groups.dart create mode 100644 lib/widgets/tabs/tab_location.dart create mode 100644 lib/widgets/tabs/tab_state_slivers.dart create mode 100644 lib/widgets/viewer/media_action_bar.dart create mode 100644 lib/widgets/viewer/media_image_preview.dart create mode 100644 lib/widgets/viewer/media_pdf_preview.dart create mode 100644 lib/widgets/viewer/media_text_preview.dart create mode 100644 lib/widgets/viewer/media_unsupported_preview.dart create mode 100644 lib/widgets/viewer/media_video_preview.dart create mode 100644 lib/widgets/viewer/viewer_icon_button.dart create mode 100644 test/widgets/noo/core_test.dart create mode 100644 test/widgets/noo/lists_test.dart create mode 100644 test/widgets/noo/noo_test_utils.dart create mode 100644 test/widgets/noo/overlays_test.dart create mode 100644 test_driver/integration_test.dart create mode 100644 tool/demo_server/README.md create mode 100644 tool/demo_server/docker-compose.yml create mode 100644 tool/demo_server/requirements.txt create mode 100644 tool/demo_server/seed.py create mode 100644 tool/finalize_screenshots.py create mode 100644 tool/screenshots.sh create mode 100644 tool/upload_play_bundle.py diff --git a/.claude/agents/flutter-kgp-doctor.md b/.claude/agents/flutter-kgp-doctor.md new file mode 100644 index 0000000..b9a7e03 --- /dev/null +++ b/.claude/agents/flutter-kgp-doctor.md @@ -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. diff --git a/.claude/context/architecture.md b/.claude/context/architecture.md index 907d60b..7743f8b 100644 --- a/.claude/context/architecture.md +++ b/.claude/context/architecture.md @@ -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 @@ -100,8 +114,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 @@ -175,15 +190,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 +224,85 @@ 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`: -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, an inline search field, a `plus` action on Files only (no + other tab has a create/upload flow), and the account avatar; Android + gets a compact title row with `search`/avatar actions, relying on an + extended `NooFab` ("Upload", Files/Photos only) instead of a top-bar + icon for upload. `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). +- **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 +322,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.) diff --git a/.claude/context/design-system/DESIGN_SYSTEM.md b/.claude/context/design-system/DESIGN_SYSTEM.md index f70aeae..6e49ad2 100644 --- a/.claude/context/design-system/DESIGN_SYSTEM.md +++ b/.claude/context/design-system/DESIGN_SYSTEM.md @@ -10,6 +10,7 @@ Reference files in this project: - `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 the screens this doc originally had no recipe for: Media viewer, Search, the Upload/Move/Copy destination picker, and the Details sheet's tab switch. 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. --- @@ -295,6 +296,43 @@ Sidebar items are 38px tall with radius 12, an 18px icon and a 14/500 label. The 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): a black stage + regardless of theme, like a native photo/video viewer - not `bg`. A + translucent, blurred top bar (back, filename, meta) and bottom bar float + over the media; 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. 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. +- **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. Breadcrumb row, then a folder-only list (no files: this + screen only browses folders) using the standard file row/tile at the + folder kind. Bottom bar: a meta line ("Moving 3 items") above a full-width + 52px primary CTA ("Upload here" / "Move here" / "Copy here"). --- diff --git a/.claude/context/standards.md b/.claude/context/standards.md index 01d8b21..d46951d 100644 --- a/.claude/context/standards.md +++ b/.claude/context/standards.md @@ -75,8 +75,47 @@ 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`. +## Play Store screenshots + +`bash tool/screenshots.sh` (Git Bash is fine on Windows) regenerates the store +listing screenshots **without any real data**: it runs the real, unmodified app +against a throwaway Docker Nextcloud (`tool/demo_server/`) seeded with invented +content, on a wiped 1080x1920 emulator with a demo-mode status bar, and drives +it with `integration_test/store_screenshots_test.dart` (host-side driver: +`test_driver/integration_test.dart`, which saves `adb screencap` images). +Output lands in `store_listing/screenshots/` (gitignored) after +`tool/finalize_screenshots.py` flattens alpha and checks Play's size/aspect +limits (each side 320-3840 px, long side at most 2x the short side - a stock +1080x2400 phone screen is 2.22:1 and would be rejected). + +- The test logs in by writing the demo user's account/app password into + storage via `AccountStore` before calling `main()`, so it skips the browser + login flow; it also fixes the theme (light, non-dynamic colour), the visible + tabs and the first-run notification prompt so shots are reproducible. +- It finds things by tab icon (`AppTab.icon` inside `FloatingBottomNavBar`), + tooltips, the `ValueKey('files')`/`ValueKey('offline')` on the two + `FilesView`s, and the fake content's names (defined in + `tool/demo_server/seed.py`) - keep those in sync if you rename either side. +- `flutter test` only runs `test/`, so this never runs as part of the normal + suite; `integration_test` is a dev-only dependency. +- Always review the images before uploading - see + `tool/demo_server/README.md` for the known places real data could appear. + ## Local install/deploy Never use `flutter install` to push a build to a test device — it always @@ -99,15 +138,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 diff --git a/.claude/context/styling.md b/.claude/context/styling.md index c3e3283..a13fe00 100644 --- a/.claude/context/styling.md +++ b/.claude/context/styling.md @@ -1,12 +1,20 @@ # Styling -**Migration in progress:** this file documents the current (pre-rework) -Material 3 theme. The target look is +**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. Update this file to describe the new system as -each area gets reworked, rather than leaving stale Material 3 guidance next -to a design system that's already superseded it. +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 @@ -42,62 +50,148 @@ 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`, `NooSearchField`, `NooAvatar`, `NooBadge`, `NooProgressBar` | +| `lists/` | `NooGroupedList`, `NooSettingsRow`, `NooTabOrderRow`, `NooBanner`, `NooSummaryCard` | +| `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` | +| `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). +- `NooSwipeAction` only reveals its action. The user has to tap the block to + trigger it; a full swipe never deletes. +- Window chrome (macOS traffic lights, the Windows 40px title bar) isn't + built yet. `NooSidebar.windowControls` is the slot for it. + ## 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. +- `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. + `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` once "Decide per item" is picked, + and `NooButton`s for overwrite-all/keep-both/decide-per-item/confirm. Its + `show(BuildContext, List)` 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. +- `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. - [`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 diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..5393067 --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,103 @@ +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: 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-.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" + + - 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: Decode Play Console service account key + # Skipped (rather than failing the whole run) until the secret is + # set up - see tool/upload_play_bundle.py's doc comment for what + # that setup involves and its one hard prerequisite: Google won't + # accept API calls for an app that's never had a single manual + # upload through the Play Console web UI. + if: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_BASE64 != '' }} + run: echo "$SERVICE_ACCOUNT_JSON_BASE64" | base64 -d > "${{ runner.temp }}/play-service-account.json" + env: + SERVICE_ACCOUNT_JSON_BASE64: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_BASE64 }} + + - name: Upload App Bundle to Play Console library + # Deliberately doesn't touch any release track - see + # tool/upload_play_bundle.py. Uploaded bundles show up under + # Release > App bundle explorer in Play Console, ready to promote + # to a track by hand whenever it's actually time to ship. + if: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_BASE64 != '' }} + run: | + pip install --quiet google-api-python-client google-auth + python3 tool/upload_play_bundle.py "${{ env.AAB_PATH }}" + env: + GOOGLE_PLAY_PACKAGE_NAME: dev.ayushya.noo + GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_PATH: ${{ runner.temp }}/play-service-account.json diff --git a/.gitignore b/.gitignore index 2751c11..bee0f47 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,7 @@ app.*.map.json /android/app/debug /android/app/profile /android/app/release + +# Play Store screenshot tooling (tool/screenshots.sh) +/store_listing/screenshots/ +/tool/demo_server/.venv/ diff --git a/assets/icon/app_icon_monochrome.png b/assets/icon/app_icon_monochrome.png index fce50186084e7ae930c68e632e37dafe539dcf3d..bf95404c701334719f35cbb00a1f79185a450365 100644 GIT binary patch literal 37625 zcmeEu^;^_i)UO95q`MnLQX1(LM7l*l8bng0k&y0Ex*LIqrde_$e?DAxOe%<9W z&-3rW_?;D$oi6ofZHV8ir^x>^vE;lh!(RG(D~m+V;2SHjKd~(-wK9~uA$XZ_AiChV z-e*h_^#A|+zgPhavhw68Y~!Tl50R1me=V%7g*~We78e&c`FVMHUo%Q|+Pr@K#4~)Q zO}0~!ytR)}$CpbR3HhwT?mx`)CoRp*#!SdZ@2KzJzelE~rl!}`)#ZkVqw|NtKW5w8WlB|<^x>Aa z_OAAmCQH1akdQnBUZ^5VywH$^z5Tu=JfGnHfq?7kR;(?7gqN-v&03;vo9x36EnR{Z>_`|8!J5?edF zhHUH5-}LU5JfiwNocMu+B&HSfBW{SC$kqY>nR6#cKBcROmCvhER#rZppP&CWIXU?O z?vtCFn{i*he98OozyFx=LZ2G%nFjcg3666SrZ69$p1Qoy*3MvMXKzpNy1qOKgPXDW z_}rXm*2orerS2|GOEg@-*}p6O1N5zZPEaC5z5)dg2hNU9PQ14G3=9ma;IX3M+A13x z8;fOUXG<<+&riZoelKYjW%H&OiZyX8=h47s15-^aA{^j~Rd zX(CXr1z*fM)G-qNcqy$5H!E!a`D3-Sw`X_vNIkv1!d}a5p&A>5nPo*KB{F{9f4A)t z(z`i%aJ7y4BK%tXCpo{+F-t2toSmIjX9~NPK704>-Dti0(eR=<5)#tG_wV0#Ms1jf zW+Wt88oOD)=?N!D5?J8Rcr82s6250{YrD%AKz4L=RAy#ju}(}xR5dm+arUNin&TJM zMk=1Etk~MNkzCuTdHkPw76F)T#L{8?wk&ax=Uh+XfL8*0%Dkmx_ zEPO!Qo(qdfT=uFi!EtLi@6YnG3FTK`oScegW0`gB7PN?D+$_2vO_s`oqoePJHE)fZ z(p~4>2ZJ!l8@891mxCpVK%*&!u%rhv$Og!>FL)~ zf#}3o>B6p$A8~M${i>>RY5Fa&_`1=zNA3q(6pZc5K+|QYbGQ(SGhj}E#haVqc;Pco$T+!-Gk{gR4 z=bCp-eXu|p@__npo2m!8p1Qg^P8h{|H#fHoai1G?7Z(?4Z|{bTdJd9DaY?N`5y|qC zh^G0NFq&rIWqH`n{dB&buC#6>A|v}5Ar)wn6vNTohsD*>M59vs<%{KQ6s7P@x%EWp z5mW(Ly0U8#c6_o7^qD>mP5N~I_G34uvVu%+kAnnuy#_KOqJ+KOT}qX*&JS*^8#PSH z&2Pys9=D=@D7ZXtBRH%0$ZFBzXLC@~W!-T*Ha155wW#Pj7NyYfQc+ohN0e?|M75%y zaUT+lJb8RR5fKp^DJiMXMK8*f-To97c;gZiSCd9ZAGZ!?XO25zb_Fr7(02;I38Xna z@jK*W+UEVY*7+sCn4X1(>7HBqXNpHxq8(Nf-05MM3#n4SYJ%K~zkdBQyYF**WltgG z^yN!!?S;aa2*w&bRP?`@c8K@l&)PivHd~2NJb{i;1`wB zr}tOT3$KnnL|J~C8lWyUX5%99*Z-%dr&puLe|q}+l}nY=!qCu=0f33Sn;Zik6Ys1m zG!*sFr*!e%X77lJiJxF&V~bN$XQBewU{X;T{6bF1mcOqoZzqIj%(N3a2*ckA3msi= z@6R8y#qDi!GE&mo{omQrQ!ih>Y}N8B3?_f%%DL_*%Gt5wXbAK7l%wH%IRS;rZIQU4 zq2b6f0{45OOwR*AywMseOcIsI?|Sc8g&_AV+a3M=I9f9UAp*bg2& zS7(VgMhk4Ao~I}oOOf|C`nHic9%XE2b9K6VyC46;Ku||bZRlACW-vafV>4O=mjbTA z=Wf%#ufAJE9{QqCO?u4OR(s9Bgbg&Xp6qE$iz$%F2(G!8xCwL3($<#U*3y#62JS^) zzp^lMa5NfdX^qW}kLQnBRbGpVie9VgT2*@dF0bq=LFmOIKnpC9$}V?(_wJh`47Rth zlQFQcPSIekF|o0uWfVwZwdL~ENu&NeG!Y=!pmdDn-FM+p871gQNo3{V5O07Q65pGx zJ!*rE{xj>mf-0s?f@l41GAEoq`a;oYGsV5O5-onS2=(ssXLWT|DHQ8TiYYfyt9w?3 zl%JB45)wN*`&vm!$ryY-2CzemkB{%<4u16X$e)~SPn@hUv#9r(dTl@(vbI~#i#+)k zxgL>qpN@`>0_N5$L`1}RxPk_NcxlDN#3VB^GMWMJECCkcva_?J#n<9rM?k}`UUg{2 zpK0{D?G<*}5lBi-o|+#Ui&&2zCd+V^Ui+=t3^&(IG#G03^h9URlijCB*{>O6$ zJn}sCH~Z`B>x)}k%+V1M$9k}V!nBICMI$*M{|Y8z#2<-Gh_?Q$U1U9wCV14rT2_D9 zbFk8x>fzxbi+Nw65v#8RhPA2sB+i>d*I9>WtI#L@uEJK#^gqn0#c>?$CD9JT$&@Z> z`1tW7{);jLQ5a&n+E6(-gi$m(j_qRk*m~Zs*kShTcx|+jF|u zzJtTVZ{Z;!Z$l#^Pom+bBqygsK~3FA)T3C>_#@4jh3Ml*CEY&xQRM5v3@@*lDqDNc z!|q2Duq%Qa?>*@;BeBrMX|;knUT?VCL$CL|IIL@Bc9oWvh6NFglM;(xYkjOyxF=PDNwn!-5xJ#c>MUWjydP07m3BU@=;EF{J7Zvke8&6 z0}!egyz8)Af_f7}rz zb%D0>*AZ&vZ-6H3-`hLjIu#HQ5O;ol9swkYPjeW}tEod*?=?nEJ_8S`Fg&>UO^Ll% z(d@{G<3yoGL5i@ja3g`T8A%Wq+E86{79KS*J9M z2L}f{;Icj=suWh-=@mrQ(!TI|CN0d&YHs##Z}!)r>^y%mChZ3p8$b4!iOa02s@jez zi;E*5rA%e=!D!{g_hWl^_)bJr)b{pB{MHdDBulPu@VcBLWm}t<(c9vpqB@WDzGNSf z+kZ#hU1|8y=$-VXGbVkArCbB-{IP&LN#jU9ieUWWa(wmPCJr?qj8gofTO|Pr8QGh8 zJD}_dJVa3OI}8f>ykX=it7p+vUe7gor7is8Xpt-G>+9!y&RePXbSkaluXGHEd49zl ze0wv(SOz=mY8!PcY@$Dn`-5Ir^H|Xf@$>n!hD2nJ7_%7ZP>fbZ)D&`D>EJq--AiMb z^$#)m*GcNT{^a|jquboBh~LmV1C~&CdwG9v#cdKTlPciPd2aOcyy2zQbn4slP6d@T zfrHm(nae*z=RYxrOy%oDIV`mVPH^o5F%%{Z%*=%^W9*E?oYnk4n6!`Gpk>kHF7vq(VoN`)g6nzPqAUIJ z8NUfhP%SgBk)WVp|HTrvPm;mQm+sn{Lix&zrZIHrRN=3=R7FoGO_yMb+-f6))mBEt zFNJOgYzMN*s0*Mnr&>*t^boD<8G3Bxu|u9JMhOAbHl1++Db{G{}JI8!`13_@^q*Mx3w3_;g+A1CXmcLCuUj^F@~g_osMQ&ZFn4xN+@%NyJnt)36ihG?9xQy2cN>wo6eaLFpn zmUf6FULPqJaBmI5%vctp&iI^p5fP7zO269wWWh5mF3EV!T_ci`siN`U8pm0Gy6_A6 z6ZS0~lAz=@5?#3dWiG+4pNA|pe>6?T`O0Z}ZlatVbW;1cUL3BKYC8Dp`f?`tPc|Tt zhOxdVb!v4cL`O&!dLe&g6{mZ9z3<~gYyJ=ci-!l}C~xPnkB?7XBYYKm*w0);Iyi?9 zD=@^b0u_Zc2y-f>iVdR-Y8vhE=A_K$7qu*!yJ~({l#U$9%g|tK>YKR~K5G%n$5CrR z#ukFhDIA&)pDoMU+3h`67t7ZfT3!q+ZiOw|7&J~AS1ze7!phNW$Rlf1GC7bg>_bB) zk?;052d}8CY_LU$mu)v|&~z|;$$;Ry=RK)0d#2ccfcphXsr)5qz`S$5F8wy`*wgd0 zmy~R7P!YRW9sBf1WjqY?QSEY#xv9>@EwqaCvR|juTsR6k)l z_QN1)Fv%m~|3G4qAVtJBysp+zP+Yu|r1Peg1Uq8h--{+9fXYAzgOZ5I!hUkvzru4< zSDmORJUqOAqVeXW2DA*7Hg`7DmQV(TIJp3i2#&^ohre`GBM0fQGvY~F`{*`NRA^}k zY}*+`Fe&oQVnY!LTf}rC!knxx^-DSJ(j|Oj)%>s!n0oXK47^(?vY$F!ApIoB;(w&u z`1`dZQ>?KCF7NL}qU2YG*MDB*e*gAOj91Gy^i^Di*UiO7V@C?NLnP^YgYnQ!trd^p zfPl%{ZNpm+gkLLN)@)IWQs-@$vCF@6^0z>q}FTJ)#zWrq`UG=M;B zU=^`UxB07vosCVT-f?3fIy@}QqixX4qJR#-)}I9cxCecGebJX!SI+c<$*aDS1^|*r zfa7OIoP@9Xa$%U}#RSTuJ!E9;z-8wGz^U8z{Ir|VHssUJ7svCug=;$vRn?eu<0^Wd z_gUM@-GuLIJqLIOMew6#4o$WRP1U!Vn^9jzcFF1gu#Dvq^S6F8pzLOrEaQ(B_?I)Q zt*yaCyQGe6%nCd;eNCp7#kal_2Wy5#-G>3#Z4B;2(9be+uJcQF*xgs zi=6s#zvF(>+Huicm0w70cA)$p}c zCQ5mA_oo+8_V)M7LPA31U_InD#yyAL$GW@0HrY*1PToyVPnZ78Hic*^hn3YfYIU;M z{pr!*YjblidFGQ23xnlwXxb4 zt|LRkReR6urt%u6je+#Wo4>8t(Z|OakEp&8_Jw@ow(2r^yz_Ud)%lTf+GN4VmXM-S z;iF_sEG%WY=g$w?TP)O#(gb*gjga_zk@1ohE%0%0a8|drNF)Diiu*(ErUEX5mj$So zmDUqKpG%TyGrzWY5`%OY0#8{LTI~s}7MsIXP(`*)y-J}bax0St6TvL0b3!wk4N=K; z%k}1_WuH$wZFnyUdJ-}v3#KNF+X|4$UX@LJA2zXL39czAE!`7wSZ)&r=r4o%9~r(p zLwm3uK&i#@@^ZN+PpUZ%PXz-6tc{Q?)>L)5ij6eqC(Dhc)%Epfxd{+iq zjQ^UI;ssMmQB%_pP#lnAvs#8osREI!OyCHrCl5y9Ub1iJLG?O6)u5V0a%iOgF_{6WUGPTt637c zWLREBMMcTCZ-;30oEjemyKs(9PfrgH4jeuZcf1q0yW9#=Pnfn>0d9ZzsB*qAd1AOM zp#eQq9cU!Bbz+a~K~wWAMi(j(Kll<=iE(je&aSRc&<8QeJ}F1DGchrNdOVz7=RHUT zPl-7wJoF&fg?A}Y2@(BUhbmGwNXDwr#M7s+l9CBSkd@utYZ(Ew7l4$n=H{)gCCK#P z!C7uj&afIC*f&_{;=wp2)X-?}3k%mR?N#P$&Ns2oFU-x&Il)rDA|xba=Y2C^o}1L2 zkZ5IZPtC%@Vzmi6KHtfX+~Nd9?-2%A+nag^efC39pc{G*|DkdII*$-Izk-C zRJ$y~qb5jzzBfKTZsjEODv^sSN#_}EHO!(hkRNPE2~%39#<4*c90o7u6hvO9tSZV( zu@0TAwquWLnoGar6*jBN>4A(4Dt2;>?qIa<`my5D=WWO zR<05DeMi+c!f2i4p2RXy^d%%|<-{MdyF2#B z>8Ak}R^R{B{4qVFwPxAG3d+1Nn2Evy0+aSTH2!B+yeX%!xt;Gw?oGu(Rx=4}fyXZc z#9F_I=5THc@tAFJB>n)$Rvs!hsr~UkJG!dMP=6UDEI}_Xuj@N&5Zai1@mToT>B-5K zuAbh>r5|%;@jC8G`Ux3XK^w zPb@X$D763mYdNnqb#x|w*e^8PY8Pte{cYn^1;g=;-QkTSn((sN{Cn^!DSjCY9vMaq zaX)`I1%~B$_oK`mQSX1}3tfo*it~!0OYYNuePCqTfvzVFeLaT6-H4>a57(y)98$;e zGQ%7A=^6%>H|`d=P^n-d^K5`e zW&13OV#*nw_b2*j;g)5PMr}chn5&7#JaZO$g(Ag+y1iKG@vGtJcxxn+QrM+fy=V-} zc(M6ri-*L5J7x*do{fj+yT$FzwT~4@uGznKv~~_h=UPeJv|2pu60UTL+NpLRt^gmvybbVGOO<{(m+p<(a}j#FVVIJOuwM9AfPm=woXawcH%`Y7&4Bb}H3{!uGAf*; z(5}qv**PohH}$4?_N2Z3gh_hn9(M4Kgky4#HVedtpYj!)eb0Ab;0auH3Yp`x#0dJ09#juebpRb=xZ5Y3qW zcl=hIlLFBW_uZ%^uk=b_>jM;&;GvZQCPbsD^ow$Y@9J6lF^5r@i1(ROIi??aU3`mP zODA>Hl|EkS$oim4PpoKe_Zi2Gn>22yyjTz#9(W>??j9cUzv}DLz)aHzo@xfYQ_iOv zV0m?G36L9qMaEAs`0LA~aLC4}mGfPICtg19>;Bf(R?Z4IHeY_Z9hls-hZF0y^Zq+9OI&jSU}_W8PfTK= z6RD+y*iE>%x90)^b-Cpl>i4FwcI(gTg}JcE&lZWFg3cJ=?&fyCwyqB4d7k@S)uN?u;Ssz9hREEvHIcIvnk z`<(Th`8#*&)H}fpMJX(J9lHpKh~|HbAvlton3y=z0!|Oeu?s6;!XmoUw=;a|7RkR3 zkLcp=$uVvgtF(P%XZQF{&@H(0B@6*jE=)~5s2$VP*0zE&P@B71h~w?u?E{8e(r^!X zN-YRc2>Agt1wFW<1WGM^z{!%zj_FGmeqz%eMo10>t_q%Uu)>`oK=IvZW76A z&K_UyM-7fadPPP?=2;ZA_{{-4v3FU0zu(E^sc(T9iTp)g0B1u{$G2Y*;}fSGqw&*= ztE-v1pFgMTYHOcEq{5WnX7cR<$Ov|>a7h`b93ecEs<57qVAU-3t{BW24*i<)-4PY> zIW=wVaZsK|CPqg`i_{B|nfjPyW!m)C$AROJW&7$;)MJ*21Q;!^te8XG#ttn|YHxLw z0TUJVFFG0;nyaJZ(H$=QB**a1&~V`k5JK3?vyKB8IiM3*Iy;~6!6q#n+42CG0Xd_u zEI@PhAjb4wUS59m&Xrg1lnf|n(!t;AJND39B@GXiaHBZk@%Qu7=kg&0!D4rRpW5%D z5UBAgq>N^8*6XG=R4t>c$e9INH#U7LeC8rOrb0VrpJLWP5#>V}3~I7ymB8kE%+B5q zmZ2rMqqa%i*qWXwCz{}liluO`93VloSRZVvzgHn70jH}hWgjsDc?HW;Nk zbg=>;_CAAqXY`5pH@d)U%v;rV>XN0B3KA@dW0GBR|D>hYS!%{$4A}u)c?p??cBpU_ zDj_E(^6?Vo0&)s#W0rJcZ4-?_w++%Df^~J;PY)hE*h}TN)g>h-uWN#bVStF-Tz^0N zQ%}+LJh?ZsUE7U>22(m=QD#Y(vr9FLd|_pvt}$C#TNf1<7Iq7QztRZj@!^A+JBoE8zt2Sceb{)P*j1}VL$d&EA{%{zZ?h)iO$W=BD~Vq_oSqxOh0IC zF*|j;B94E+6_42IM)WtbcsBNdbEI;CMy~58`8+B1x^EyWQcqWyb_T*?4vmvI4H(UZ zg%^+>@qDBe@btzscLdh**mc3{EDV^$9ovirV=|UIxeqf^wezs*sUUA+GjBXe&w<+^ zI#TcjPrSU=T^1xIGt=jCvDrWPbbrBcY-s4+Iot`GNb#z>o>QtCqfPHjo3o}0MU|rH7 zvb}a(kSJ<$&AmPZ!6P;uR5^Pvo)$E~cN~GVjRu5*Ug_zD?{skl5;1@7LY^Fjy}_dV zr)%{I@&k1vOF~FiJR0ok3Nfg6I|zeOU=C=P87+|R9^yYmHK~yY$PyRa$WRR7jnP?1N_QfWjkK5VJ;?c+?3KZWOzV|)nKfSR|rHy6ClBfuK|n_iPo-V0A-|Uyj>4-MxH|LodMWv+;e~&V6J=?(_B!q`^`64%f$*Yv8 zHv58!fFDLi)txu00=#}BXdH{bN0O@V|`#Uz_wzs&{$Ey zRRBpb0daBai>qZb(Tdpu^4_kexkYEFf=8!2wSjFO!B)Gf32|BcCdP*mQHt@>{ zo{}pERPH($)SNB6KWL`kvNN^L;&om51-`$%K0^T=d7l_|)kRP?Qiqf379uT3FW*cD zMbBnM0-Ei{X&m_JGqC%dAzh&Ce!Mx9XklIB@l!GhccT_Y?BSieYcuuD9PKIQ=5%>| z3d+rx3IY=g7lpsTT{z=C?6SVglWl4_1%>xI#SxOswcq)Ae-m_lYxiokxibeS@4P(w z{rO)7+&nzOUla+EL#7&BB3d-#Rw3iH;&!!LJvalSu2ms{&0YQ&Z;>yi)0s8eJ!g!M zQKQ0?v71e53B<$q_JbLs!Yb)PS~8?n%Xqj^R3s>@ob)V25}|=XK|*Y=e)77lbVR0V zml<4RevOmdu9SN-_oum;EX()54I*pDw|21||Av4+oHh9(qP%l-BnJn>*(AiGikY)@ zKjI_^h(K6GL_`~80hV8Y!_NA*M!U$i72!fJyQV`!d0i|jlx-)|O3t8hlgN~#ohx1R z{b?p-W8R;&h2pzI2v2lVgMY5m+_oH{Zt4M8w33Hy_$tM}cB^I@V2J*l z1hIEe{ZUhawJH}%&;}uMAEc(WKy}UhcQNSWeSH^HG?59IS`AebNF{o7`!`rzv2nlQ zs$q9!1>e8DD<~Z)|LSsh*`;EKly;zw)KNd^;Q)rYZrTFR$$Q1qnzns^_qPEA5D5IX zJqKO>2wraGouF&tV13%g7~Ec#ktzT5jH;*iF1XCh+|zVvh`9sziBjW(PM2+t1g(Z+}WsQ8w;WlA5#e<=<#O(X;R|PPwf}t#69O zhumuR>Tz#c&lCf9hQ(;hYB=KM=OEPh@oqQ10hwZPX{o{%u6%o3%u`w!Z4>-p(eejD z<}v0qAjAKDo#DRz9!6~Rus#BNPkNi+>poQEhhzk-EG){`;G4fc{XQthn$J%%PNDpH zU)GV@!OuH>Wo{ zIT>Bb^ve7`b`#UT1TLDoRRTNyX1AzP4%JsA90b#x@k7T}!@s zvJq|ltut6}$b{%UJTp2bub29!iOIw&b|8ZeBk)J;%X)ej^2JPe>pu~fvb zkN@07I*6N_o44E|M8*ok&_WD``&*ngOTbN3 zw?rEf_$Vv6jgY?gzqEY)7D4m?((|JHg_?gBK_3U^#sDE%Z^w`AbE`K3fyHd;Zs&h zbxX+JjJQSVmZJCI_}~BfVABD@`NkKgv_F%-+lLdO>m;kd?Yv+HuRGt00<)wTLuN-z6`BLHw#PzHd> zDoIJnFjzT8Xs%8gaGB6TpDz2AW3^}pbv|hKn<>no=B=mds$yjY!2mq=+pW8Ol?>7j zEylZ1mU(p)WO)|9gJ_?B{e-OAo)A!Db@?i87^iTct1*N7qn8VsEZG$~f}A8zbH`kHF+x*8O_+{{IU-fESu{6v>vQr+BH$OyYnVw0=J z_+Id#$N5t5gtYrm(+BEJ&CfLC{2>&*KifY<{Fwl>hijk(JXi^2!RNpa=+V$lft7+f zkW(UU4ZLmx@_$P&`;*Z?$Z-Nkj}iueIjk2o96!NQkqQV3lDz=t3qj=jOz}OxKYaK= z;4p`a>0M(lKRy+SCt_*)Ns;kLPEoO*JPZg}7`x6C!p ztWNcM;*i^E_s-^a9Cu2Uc3A`@H>|=jEB&jbZLpPfUlOzQiKdZHCv;xa1o z(Ev0XyJ=0T(vs)I(LoTEpua=refvGx%5!v*>{jq>XjWHIz~L_i$XP`g_K z_1A#N$AA75@^We*YJtY-?l`;$Sw7B-q<@K!fgFOUFcZJr_ey&wsm4Vg*9a z74)ZnPffjaU2O7AocBEHKZ~Le97dB4C!9R*^FWd{=9ZsKdSah(e6f+~V+mfkA^L*{ zFa0{(2rdLI0*FMT38|h24Ts`Cn(qha+Xhau$gb1_C85&!cz!@j;Lq6_`C(B-Gc**j zH*%hS0GG_~EPI`5ofZ~+m+N+MWo6|XM6gF^?CN&kRPcXJ3UJ8HPt5ngoO~jND1T?1 z+W$J}AlEsMSPdFtKve(%oLXUN5X8t%>g&yChA-ijOaF*#oz>zi7nhI0JqtC9fWYhd zGsvfi>4NbM;^ybOz(c18W9zYfN+vhP%jjU{i6vQXRu7kzZjP|Gtnu=~pjj+#Y;4?l zg?HY!@EyWp2kR53bzw)R|FIIE_H#8j{Sv=F^?jq!5Q6=?qoxttUQzwU*tF^sW^M+M zJ{yK~kk8aB*SQl7#lOQ=9s!seNud?v9g95WOw~{p&!Jg?+`66)sr}!tGrba?CqIAk zN0O285$chy#=7}$55m^Lj(g!fGh~s-2J~^rErEZa{pSN=)bjAS%ma=@0BC{a#fu3E zw9c@NcKs#NA;eZ&DXA7pXtqT{!ovGD_V(ghdV0bV_}4tIe3vM~$5!uT?Z9Y=REZxn zCY2jU4dPuH42i|AN2wZv24@W(@&kedWClliQl?6||!iiffP3j1<};PCJV zbaAndO-`OCz#Pr{{JHq-VTn6Ph0Bg3{c)S;Mjpr;t#pcLj0p8qPft*4Eap zff#;g&LcAM@x&Ih4PB-UG`rz-`H(Tc7Zj%{&#RdQgVWVdDmS|NPsI;~^c)j* z=%${YU&5v>qZIRW^8q(l%h{RlV_*y~@7_Vwj`e`zhD9e@S>M9Kf-wZ>GJrI30gIWd z0m`zm82>R?J|n4i;I_*Flicuc+g%^dw415gJ%S9Zt2$%J!$-8qsV|F~79C=pLy)o- zuIA6|0D{gmf}2_y^;I;9RZ+s=Lw3Brwzi8CWFLKc!M1?VxcwWDPP8>Ovvkiltz+Iw zplh{vKEnH$Y=`k^sapa6)AP6U5P~rWe|a7*dovg=y6FiCF5+V!?~f)=>xV^PAj?NE zGcldp07~<@fINZY?5>v9)`wjNX;178 zj=yRZ?R9r`ofG|9c*~x~+$m*>t;CvDu?ww7JtSsD+yb&Nr|k?)9W z*ullu`Oh_e0BP~-VoJi0I=JR0q zMmxm@LXUjGNa5B$6U>Qh>EIibzz+^e9YUqu49G8qR=U|bBJ1{k_EIDNzKeh23_dE2 z6kH{|qLU`8hN=AiFL^V?7lELuC_7(-<)hlr+l=`C9%6u zI1GB%Unk>whF3i=8t@3o$b_u+Oh2@UU?_diyIPc>CG&i(_EJDtc&1ptc6j$m<3;~8 z)ch|*bDvyCD>f30x9MDOrIXEHzxMgJ^L>!=qiX+X1=@5Fql3k#|Z=Y-wkHO3vzfOVwqH0*wJnl?Bwo3op|I)MT)9vO)@L(!af#@hd+qo9+o9e9uC2LYOf{Mxl4G<&!E_vgF2 zw-y5I_^~idyc}ldr z^76OTUOtYP5hhHPkd)enobYYcWBr=n54JCo$PK9-M0~lMzXciIUQSqZvP*CW_EW5c z)?n-NIC&Y~<#cT@wgz-J5kAdCMhFB%Ehb1e$IRIGG)e3eBVlQ?#}T(+Qc+PEe&ohT z;BlBY|JjVK%FA}Icora?NBzIU-m?~Iyurd+WMs61;oGFw5(1yd4g zwJdTlB$wZB5DX zjLeGiNT2jJca&qp_Xq978-}&-*1U#HIf;2|7KtWypuf*Fo>rE%t0r0g{KctmRHFsj zR{5X1BL*^qy_)pld)AfHr+3E$cJIQ#o^RiBR#Gjk-lJb5)C1RWg~R8fSBo>EovU3F zNtQcIkAy2Js!1eIz3{2K`e}=W2LI505oOTt{s~Oe; zuSl#pJ3Ozq4Wo1Wp7M(o6R6dwitnL_pGyioNDx$C#}qBQ%e5k}t?llLUN*;NH7jEL zS&&)c?R!4wb9*f*$8Bk2(?>X1Tf0tbDL#1y62fgQWMN#n+P%M`IA4~YrwQ-Xv=WEK zahtR^_fGCVVkB6)GPYAv9O>L}l=|R6LYB{|>);Lj`^wSn=W-=y`zD@r7pH6#mFVU0 zZuP>Rv?;pEbDct?rQlHNZRq6WH9ogDhOvBRQ85uDD+4_}mpIz&j0^TBuGRcFK{8fn z)Hi>Fg>f?SsDIR|_sG0#qPeYZyx#Q%nU{qr+w`jlerwP6-w^N1kxYv$1mq|g&EgL4 zCu$eXN4j7tfBeLkPedY4g07q)a={(gh`G@f8F-ov9!+dWkVSqDLb?*wk87JkXQp_0 zTd;OC)(10vh_!n)@-Qp-vn%Gb9MBVQK&ZJsf{wJUc0f_)OUxC|_n?F_1z)P2VAQY9 z^MX5WYzYkLby%1cYnW-5EG%lO;Y1qS|Ea5KIql8WIIKK@v;`TcS}NNom$dr5g`52h z+R2?+#MIRLDKM*|5WbnbCoyFaQ4PdQBmS|{4XfY=;?+KdxVL^F8Z{idEd67YVf{S4 z5zcd`;XhIyRbcoi?&RuP4G!aR(Waoqq}E*0V75FT2;)Ye2kIRi9)^*Tlb7XUF$eOw z_f@=Ydy86ZsgvFrMS0b#qrGK@TVlL}(u2NTXW0;5oIPdsK#9+U1UuWPc+T&UELBo% zfp=TV46-UgyzIh~| z*7}bGs|vN!*Z|{!pc8xBSG*3o2I}mmW)gJP&TO^Z)d;QAZGg|KIqjv;OCztvmfv%A zW$P1a3r)k*zQNa(p;x8XagF_TPkmX$iAwjDWqKmX`BPVL?V%ex8txgx-JU zo_`T#_@@SaZjK0#P5JpK^%P%s&~4scWZq86^;lQEU`w*H^ey|+N9Dd6`x~x_N*f!8 zqnl|?WxZ)<)PAbZ3+2>6 z8Q^l4u4w>UF*=d1J)<F{4C6kF z-a#A+jt0bskgd!n=CDwz1*U0Ec^bfzQvyNp>i$=VSNme0Gd}{1fyv<5S`N`Xe6*X>2el z^o|ZTi^Z6SS?|N?p-LcSd7wJlkq{_$Pv`bY|3STbXNk?VHA0C&@>EiV&Imm-N&`3z zJ^@GPddI9za4^Big(js-eUDoKm3WB{oJ$_2c3$i-kk&;AQSfcY?oAfM+kCRoy%2sNTj5VS)~_`>g-c-lbb8SN=Ap5yz~l0$qYw}2ikc}oLMp4p%D>rYzFlL?Qkx1C~lzP zM+YjUI_M4CE_<^Vt#^rZ%QtUG33{txetB=EX$Vy0e)288tDi_DQiyE0-d_qI4McJ`7zKu()sF=wBy+0)^2%ikFF# zu74Ms(}^GJTV|aY?6~)1EIwHIdG}@s79h9Sn>GuXjZSj)$f_FEoPs%32!ks#7;ApC z+iTU?u^6{V&B#Pe;h)iqQhn0OHzWCFkYNap6tN$5B`nDS;AeZa2BZ6o=k>skPv6>T zI%b71h{K2p3AgTatQ$xp?7ISILM`LDlrlET@&Y)RFA78awi;X}8jtz7RZ5U!$)CXu zK899sTamo{Qd}(JbMx;)JwUr#VU4M|1Zg9)WjKikNGD=MD!O+j zWI4;rK;;wtpk`?NMgHc*OX{Q@m5H84LbT9hRmUzM2PAJ?3z?XxS9O*hRa0sN} zT`Ou_fK}21IA-b-Op)x!nCSCm8vwTXxLsvVDSCh7=*ZgjBPKsP%F+xpMt!+)7^;^y*vDh z-(6V2Z3JSPit%bvQj)Xlob$vAg!#D1GK-zlzL6oxCSVfuf&(-Q@t_e9pZ(U6A}Cdj zyukByF>LU>_yi{m){X2U6)O8gx_-8ws1<5TXL_B!9051ySQY{h&s0V76Os`$JunV` z067cZgQ*|iMe{V7)j@2}BENGo;O*SPg;w$bNy#{SodHWl6zoX|yX zH6C>CZ(Pb-Czpay^EPweUk^S4HSUGX8sAG93!a>R<=F(f^71`%U?kFNCK6^^M;bpZ zQZ-r@(S&?VE0FFDl>7G?mbpwtIn+YRu{I%}7yQ_ue)2vvO(g3|D>hL@Y0t~8g1kHV z>K6P7Aqi(Br2{9Uwc+;=L>futF!ZiYO`Ulw)oC^!7-~!jC*MK6*BXPfff1c=Bk=ja z+3{_Qgt?Ip??tfoCG=S*gEtkABTm_KI1M&;_a+;yo=Hg&JX2oNs4En4I**D(t6T6;>q_~CNGd5sx&WiU0u55$fjyG% z|Frkr?_Bn8+&7Z4WzXznkIam0p=?>%q(}*cLb9{TY9os5k&#L^8Bs=yWEN#5mDKaP zzQ^+iJb%E`areW09OC1;-s3!9>ufl!ml$Y%8FlPpMtXYdgFm@-`=zk;?iovaK^F5^ zK!}|sZfeK8;15Nvl+GCPQf$(Hz_6P$Zr6|QHuEM~JJcg@Q1K7F{2-6|rJq*Sr;Z%I z5J~fS(6CVU%hu$-zsdi8ru++H`n)1AI2lkWO|6KS9~yLNPHk5%UhlgTN6P5*>gCJS zi*R2&1TfA)dB1vI-al^_tuJ2zlmrz6{rzf&gEvaru{QaU9X}dKvDA@0Sl58VMz{-!*TX6O*w?PT;FFifFeN?Qo zy9i{blYxOjA{+owbJ}Gz3EwD$izue`7ZHcV#>B*g;2EB)^Tn*`GFvep?_MWbhGZCm zILgN7jjc>XJWt(?_jVvE`})Iz>57Cjg+<(EMWl>Q?))UvTqHlZjvvhcaIzH@6vC3l ztyIJ1#Ik3y67xX^PC!tsikJnBseiaOG<2rgSAUS`jzUw?5W4k4qFnePS97!KT>+BS8>&YG}>s?$S>ex=2kJ&tErD`7_Q{tMmd zq~WSio;!7yQ~U6n)<}$g8M? z)Y8|=Seb_rM7DEG?v=G$vR5NAqSiSHqgZ z3@RocUsx`Lh)NBg?ZQWS*9KqNE#Wt~CadG`Q9T4!n3+KdNNI!<)-VTE)<2;bWe!#zhSo1=-<9bvAzgT(%2md)9LixoK`!4p-W*hwQPO2Y z#ZuOy1Ciqroc}DbF)@+_UB>(Q0FT<_+`4t#6v(Rz+v?3Lv-d|~B|3?ADR>R0r($iR z^A?*XvN0r{2xBp+2co-;YgHN8^nv2DG}R38ke&TS zO6>>WKRP&AQCd{AQ$l$m(7LcsoYpV@60XYuJjus2k28<%TvbDL^>2jezT2<4d-qP{ zo{sZ&z7iSv_1XHZWx-p@{&Ra@54Vmh9DGe1={sbmEFjR9>FzJyWqF&GrV7XFCP^L3XmnGS!VwzeXH>O=wH5G=dkSi#_puUK9*QfQHvS;4J zEt2|eGw4ZTPvLMFlogLw7z%xxXq=a-$bwWTUi`e<=3mOl6Re!Sw{@hjk0@f32|#nn zd8FLL^Jt{-+DTsEf-gj+q@;XLo#ME~cH+XjYIbju5UjIcFxLk~icaZ0Z z^u6S>@nvMnKwP%yHwm*yr-JqPb*2wNgK}B%Yy&UwMl@iz;`F-hUF!38*P=7jyZJD{ z6+q6af8VOq2QPek{|+jj^cD6_Q8vG!;_-;|#C+^}(&sOPBbeRJzHaM={E) z&+@QkVW!}B40N{jv9VoeC#5WIke)igVFd!v4+N;NslG)<*_5lC)6Oxbb(cHge}5gd ztho!Pt{88eZ3XVzdX4WdwY?F{;nUnBtr$5&zJ3c!KN~Mo%5<>4r_{byrrQ~MF={%< zMbBNhu$OyNIVvcDcexuUhP7n&!Q6CNDZ2?AA(^O^P8oU^H7ayA-Tfl*lVq=2NbOH( zFD@2Br1bvUZR1*N3d)z*cfVjb|2v3p`d@XBL};aR5syqjB7FcrNoD>W1`2x~^RY#0 z<*h()OGiabea+TLIKP8OZ9g;g85g0zJ-Y4`5V4Pgb`PssL6au*cg%>z3rJG+oYQtu z{u5MHSyNkUiQ4XOAFA@^VxTZ0``gCV&wW>~b+APSz!#(kiAb|e)fk_?)YHc!6tiI` zBqUS@Uga1)J4f)qLJ1dykfC4#6YzdAN2?EXj}GsfhDeSZ5=_8%h5y)OB!rz@1Dbhw=!^}BJNXo0~YsD9e~`w3Y~Y z+dLBU&L2=zbh@v*S;X-LIzK*$NJcq&Q*4_^Yc{@r4}f;e{CbnrE*&6DO+*gcsc4ze zq&&Z2h|~jM)&C@*pWIxEMzr{0M3ukGz3%2GPlj5BB;jU?XjV6(`yg#W)k85;SX_MA z0$ck%R;2S6^AxldnmVP3*>fL#Y2A?@t}B~ME!Ou08}y)>LhcOB>W0W(d}-n6=Ya1& zUvn$T72KVnJPS472mt~f18GJ#9d7Zq>}44|Am5t;7kP}29NB1dtCi06NX*?uaBlz$ z8k3Te$iI)fCy8=u@&KmUM&t4XYvi=JTC4jIC88(aqE!?Ff-9!x>0_S=fyW$V4M*Wc zg7Y+XkJ$xjxO5i#a^()9K=e<{^{1arRoPddqK}e*OFfcRKrSQqPKT-uSaQO|O6unQ zZTF*>MQTW)png17myFQ8MgbWAo0P8Tyx-;{MBwKn`F@qME(Jl}`|w z_@en<#RFn-Vjaq7&M_8_B&Fh`dNaO#DB|RS>&fs^a_^LC;Tc~dzs;t?fNGW3Cqv>< zZI1Z0U00}O!TbK~zo8he0YMC1o=!|7yQPHQ{a5`bPoK7ODG!_RyXZE+BgBeOjo|Lt zb3a;qg|Nk+LxeJXR&&jLGg8_NT(3Ru#eb75*|~wHUyUxCyF#T`HdcQ8;A`|X?o02< zG|$CfmBFR@9BcXf%vV2c!#U4$yTRqF;JB2N8D+O`h(NejK)PWeZ5B`KqlJQO13_Bc zfX_Wmoqt2RXCKwm zuRmScd8W3$eiPmVM_37N6@9NDBWgA_4KwQ2^eQj%om8Z&4sOG@I>z{$UlD3-@=mfB zOHq52r8h61`iQ#aOe0!h7i}ST>RnrRr7X^#Jqx#7&J8xbnN)c>7FXT7=oxOq)azQ3 z;N>$Pyw-v%y&W`ee&A)@y{eLzm4CcKpxakiRUwDbqdS@y$j2cK<3#@xz!zIN;dhlK z+}=L6FiJ$hI`Iq@s1Tc~ml~UxG#WY0H1Ze{=-b1 z-KAY0T|Q^h=X{1AXA`Z)BUmAiT5JlX88G2{pCi7vBb8?rWgkjkYvGK;9n5n@BGO5E z>v%kT?Qdu#6k@LImy{fZ&OrS!din10?W7?GX>nOubIl3Xz-bRQ20t#WO)Zy6T6g0c zAz8$|i_-%+vmN=bTqLX`^61!9rGHN{k;e#PVSeMmo|gO4CtWK{&B?+I@TV^G+O?ba zwVoA~?ryvJ5T{7{v8QX9u?Ps9qZEoCjI8f6Ok}6)20`!@@AX5`Z6Tmx1O^n3LqxZJ zcfAey&aq=!Wxh#=xGw2c(_ef*%is2&SuRe~P1NGI%S%dROP(C0xBm-mk&f!0mA3FF z^S3WrTZq;a-ajB`@xnmI8Bw~&yi2Bh38y9=$qB?A4r!h6?g^xK(adpkGIdr{d+DU& zThN-gyZ4vF(@Tx;XD=(MintjWv8U(cG+^NrmSfE-*f4g^7UrxP8cv0w4|7HyN`(xi zkAZZ^V-u1!t4Q#s~@pC248rET5Bj5(g6tvMxQ67$t+{>jZnyJu)=*fl(% z@B4qYM0MR7-b!%SWH!fR)!~IhsXWtHHn`iqO z{v%c9f*q~2#g`nmt+rX3zB-LvJL!1)`o9Q3K{o)A!U8OuWsJA)1gLDs4c%+|j|stn z?{WCY530+h`B75#<)nKm?A*P#ZPsP!YcTB+%YaFa&}UNC^E!Oj-7X|%YAv>=mc2#) zl_2k8s)N*koxQu-=FpJOFD;3)-j>^{l1r3#trB>IuwBJtqvUhLsYiQ9p6A|Rw(N+R zFh)@%gg$F4^{@e@kF2$f(79AAE?`LLCllj843%(k4S05@a;GDfii?Vnk4D_Z+*_Ha@(4@=M;M{ zNm~hLgceioYGdo}uiPWoWy{XR)y{Tms6$D@E+Y3+M115x9BB>e+ zYA@Vxq!!V9e+Nk*IVmZ0b`@uB_j`&5|4Jv7Bd6t?(1Pb~?^V{}f1Q@vcek)6WEB+^ zqa7qqkaxbP7qm^?T_&2p#LUd&%q!d z$1Jm~E&HB}s?#-`8)Z2eN0Mi)XwCj9&LC$Tk4GUybseOT+wQP}mW9Z$`InA>SDAE~ z_M*&AHgkN1BVriDu#ErAoKudiV%N5fAgz(tjbIR{Q?iZtAc|0yIj;lP&FK1=OkbG` zXY@cv+|f?!zjVDyRwR=L@<`ez0D(@>?p6OYU~rLAKZ-O-rs(d^l`sJJHaI?DN&XiK<#q(mz@&V7_!%4-m{m#9Qu2b;!WqEXFhbB_BTPT-U9F1!a zeTKYVp-w=qiK`yFfo1M5lv)yN^a)WuMXLcZ77z@U4)!OtxbO}AG9 zvtvQ`p*cORIN?prrRC)@zJwdh;}Wj-__dsX(g^+SqMICV&01+eV;YLG@JOYUov!}7Dkt}<==2Mk22EARaWw=cN5=3luz?@rqZo2;H> zmvL;td+N1!6s)E7_1R*6??r3vPCG#-lk4*Mynt355i)9mzwC5OeLD1u%&4)xBgZ)F zp=F7vMU5-Nl_**CzVDiypIu^foj7WtZ4dBJ=iuaxxZ)G)|%^qT^vMat<&D!;74y(5`l z7&x42z0CCB_@J13SLu1@p?{ycPC*B1=?SPc?L{+_L*^7RuE~Xj!uf9fa0ChEG-CKs zxaQ8BI#mKsuc(`Z)AbAJqs>v#x=Q3EP5sv)9h0iRV>W-}R4kO5R_Jnqzc=T6l^?3v z)J=<)(wag#=>TqNdoVa`_f17o)jC;SSorm#9MF%!B|wVT`M8lO$NJ%{Ay+*mM83#6 zx=z%?BHn$tvGwyz8%(_~7&>|8aFTRf`BjO7H4ik0gM_H)Te{^QHgZoJ9uXkAgfzSu zxA}Q424^V9+uyxAXds-?m>Z&iw4x1zD~q2$S1m*g0UPpEhAI{!k6M%v;GDy?UROnF>_9C6TLW-Ob4WTfvCRth|4}tL zc#&T147PeB)Zj{0o8URIBGj_HVampp)79gz6Eq37fjV?C%XS7Q`0 zWu0f*jR1pr{+_O!CtW03TI^iIQX=-e={T}R6#47Xt z{ltWNB{;4N=m#|zE`}XZ$kpb$hw6}3+@V2QO;YmcRdHKN`)d9g$A!1)KF`}WS5{h> zVk!RaqwDj$GR@ty*D-~OaAcQY?H?|)x*0!iw?>XgW?eI<<9HP{Rg!fLVdB!#(n3IP z-t_dGJfPP$r8DOlw?T2BVK9|95(NiAdXS&0yAIDVsfMG&dlcytMCtUzaq{FxZ zY=~AsNlB>#`!n};{Yvt?z5~uV4ns&5wZT6laM`%CNh?if!rR zue|*h=jK?r(9L`BXfVf4^pWaua;itOv`SZz&@^W8&a(1I^Zrr>&PELEZ z6q0cK9D5+U?fxoze|zK+{_acPU0U1?dU5f7zz{mZ?29R=zZ7y*L(Q8mVAKIbXu!`N5l=aAR1~2s(f?i~T%&Pyf16!$N{Z8MaO*ywS+jGMEKi)h zck<(dA=%vDk**>P{w^q~enSDfx6OggYD`+_THF@8UQLLD?ZKveyi(r3@*~KvW`nOc z9V~AjTGSm>?~*>;DU&`iimD|l`G-KtV3oW0kA)6%6I_PJ(T+WVrs$Qcp-&Ez4I=H& z85rzRWWHH=E350V2E*MYoj2feT?cR=O$;GRtzqeylE~k)=b>?UK{R?VJU{7CvBNe3e6gf6v1n zp|nWO6!#L0ZUCk3`=)wUgYa;oUOcl;k5cKT*BiEwKj^wf+uF4AOhxpVa~Aq1saH2r zZ(*@8!fuf%_%6$FeI%cm?YNgiRA;9ioDWA7MkhYXxD99NdUpV6l*Q}^?vc-pvQIWN zN93f>2uZSl5x6Ok(Gw%#)E6OU`1cmvBV+FF?)OmYn$pQ;nTcI4 z7f39|mL4R4n?{FnE?}85_7Kg!c<~|ue$=K8`bHtQAzRA)6H!kU@cDm1z+#J)V6>v7 zbtS`gW#7?i+yQ!!j#sE&{NbZ%Yx`#9iiS;7vt(a&o_E}m)%%qA_%z}m77-U0H^9w` z=z{{e`hzSxL8sQ?9#cftiUKf=5u&3ktE&sNSvAd`iNAz{ienu7RWas^+{4cD2;yR| z$zi;)v}&Qp->eKrQVyQ}#0~7z@R@FgfY)3{6ND?$s#Mz-DcFJPQ{7NJGoi=CdB=Dm zmEH-F4R7#GUY42{>&fl0J?hJsFGTI_L3rBXlKiF^qsP``cvBjjI$48m1=o9oznlo& zc<7W=G)SNDM>Z>dAs9;i?y$|rlc#$J2L7_m2A=&;rAcdA##wJy#$s4{7U4P1LT1wR zwQvIIQ$)oF&3q3I`brnJ5*d&#Z__4tCL=nTZgYt04Dcb1=JkPt+e&p4YY{F$yl}4i zw67+17WO2ch!m)Y^XLpl42-b43AubW6H55eqT^f!+QJJf&FG+tN(o}xv~*HV@nwSprhDQs_}fsf&sB+cyR|wn%r!vcaSe3uBoTI}e#CLeruy=040%2?C&pw#mC@mrXlBTNyQTt%fX zGk7S=$j4{t^x{~>vwg^K3wV0P;)?`@T@JPG6-+~NjtyKK?Y9SkP_r`pIWQ;X%Dx>1 zu?oMPmM-5wK+7N@`ukV!+CeE5vZO~N+!1hz+StRx<2o!VayXcR*>Wnw-7-{G9IsC! zy?&60|GfiVfc?l1|7jyCZA%dl>znVuJsxK`B6KOTB=$w=tv`~WB(R6a~ z9|pfl{k#La$P}2)9bd#P$<~Qd>|~?%K{+FaJM$7+&@aDjqGx4q+}$hc3#h__MEecN`6s34|{2K7(`j z8G+zyd5e7X4x}_r+&pGw^%;Km&|*l5Qp8!^9M1Y3GK23CN9$pH@^imcdDz71jFTZU z3PQcDZ&4vhO|Qtw_JD`)8mk%$BVz_MQJ16yf4ujh$x^D1DDQ^?CW)|2BOPTlcYVu+ z0@n=jRoMJ7QTK{UyF`uh8NXU~~fbXM@We0%ob!2(2f$L!^= z56$gjSq>$6nCjzqJMZ!1iy~rT4;yg-#@$x*__`zNzxBOrY~~gg+6J~0B0lYXTd zv#djlyh^ZQLb=jKC5j4YRA`jZz-Ob*{_6$wOX<+{b=3q_r7N|M`brQO=@o|4y^aRV>m-erM3u#kgOg^MdqpqqJ58q~&{$Dx3Zrw;XA>n@wW zv5SSg^#BAjgDMC*xq}f^Vmt+j5^jqET5Q&(Hmf7>b)C0xaJZSMDpPKw;V)w;i7S2p zS2f$M`p<8N18>iSF__o3-RTLE&)*~tm+t_FX2O5GIq`4L?PWy|^+Xos>GOz%o+M6@ z`n&1f-RsthLAGQu9d}?zc0i)WrDw6R`btT*sDMEQJ;$YKL`cvYFeAflM&_ZNtBcjJ z_`s3bnVAQO5(p$ko@q=%`=;~24(|)!nik8K?-v%57g+P05U^u0FJ_81+crKey<4v- zNZ_VY?&hVQg@`=|;dH4(!t&F^x`fuSB;QNBrF`!FD%`}Hy3>R!XqeCkxO}i+%g9wW zwPBa7!k~$cHad+<7vLD?*e_wx7KfWI8woz9a6<0TRr;eOR>>acLY0WJC=c3+PCRS5 z{KC^d3|1j~do3j(f%DMPy27Ou8)hs}apP)5M-E9kRk7tMEP?}XEqbom<+gNoCrAp1 zGO@B=An=#)vIh^8ViS*9cn6s)?b+IeKvm-K4Qs|J7K8(P%5C=LW?zeG08X2)L@LM@ zPsl=^hWR^gy8-CO-X)7@^i{xRHIs#Ih(EZ^j`HVKn@nZT1%{E=ufHQ|cuC#Z!y zO9G8Hm6Wj=(=NWZZ)o%xl9HKt#i|PnJysBX z!VJGF`6s{J>XzUcxtOF!*cTgwD0MwWb_;amJ5_S64}Q^70zXOtSnC42Bb&H|-%;=Cmsc!y zu{2jEaVo0cPD`7@#mANk^*XCU&0P}GF=4QYVGvHPLMhfh-s4BQJmz)a3-(5tj7Ko_ z4G+`~n_#j7&Kx~DKpRz3e0GtGk$(OsKG4sZ0(|0I*l=6mm1r(rR@ztE?kjP?7vhv| zMW6A?wZp7HSo^3>ytC~(_W5bAty3kPee{R}or4a=wrw_-K^Owo#-jlZqb z$z)oXpJU0!SZGV6_U}brz=ywFLH)%3C`p7 zzL)YB2^rKr=pD8NXVToyRafs`*lzd+>~QSa=9A?@mp^iUe7svvUH!Gk)oRCkKsg+Q zJ~fl@anoZgN4%X4TgC_N7OFP>HarXs1UK1(b(nlHFz`+DhHvkWPmjdvY5k_8%VW)p zYmWf>h`V~#E_R;5k>Q2(2Wme}k#E!6=;Ba9Gpnq*3lA@yOWaN#eWtC|g?{l%3YYSl zaMLo{UZIRGjW@2(_?f_wc3ou_yUoMKcIa2hX+)a)cAgmiEpW8%4v$D8MWg2TIjD-_ zn{XFAjrnEhw?DedjnNE!DmI-}@+#dw`-c>sb$}kzU<@wyLk4fSmSM4t@G0m*EXrj> z?q*F8^D~|ioQjUVJ{_HGC$)QGFrN|QZiSUrv}t#BEDl+;opbd%3Lr?EBLWI|L zBvMDt3I-IekzWrikX!mh1j^jBF_{5gjPKxTiTm78_<@c63lnOw}0 z{Br{4?Px+vOXWRU8jlFwqTK=Im(Ef#X#2xgbzTfd?&YG(MJ&3^NO%|Up2$7iF`quW}?~jpmv@X6Q9CCCgqDdLWM%?lt+2NAklY-}c zi!J!l_;JNfQPzt~&KkRppcdY!Le7kyOFzv{GCOaVvaCVlXe}Y7e+$LeZr&_)fZf^C zazjBOIC3J^J2`q4`M3wc#=ZR7G7~5-cz-57&#()Ip^9asFBJlfD7Y5q@|}XMTWqI_ zNR_NL(umOq?4lM3?O zBn-1CivAZ_`N68pZ!JJrYhC$TOAUsmWQYHm-b+_(St8ry*eR%MszvTTOZdZv3g2No zh-Gn3fuwMM(#s2%`Myv7+n(eEnq-oCljGu_{N}62LvYHiX}~JW3heHaHW{~8xlceY ztBmbVH?pS7KxFnF5ORrJ*n8NZ^)%37VyeNP52)jk%LCmj-d@Qgi5PWg8P*x)^$3YC zn@(cpZFch6NFw>Bfsq}oX7XpRB>X!n(eU(MgV*VPLcq9T`d>g&qSb${b6@`FhMd(J zdwsKWcNvWUZlIfv;oBP(ywjQ0yhqrVbaLm3H;NcUu~?IRPl4a?e!3MrQT=S-BmHEXpXbK(9AhpNUn z6t_pToWI;XWUaE>95ONc%HbiYt4yqPJ1wYgIlVVG{QW;SXZS)9^`3@~yX&L&XF`+2 zPrbkX&YnR{Mx+Mrj91GL*;gb)-rP^dqe;oVN%#sjm)@*1ipX3un6}oOBbU9x|Iuc^ z0+9Uea=zdnMK2Yr=^RYNGY$o%t7pu_!?ctTugQlx^al}Lu{%kxU{7=llaA1XSD#-K zl5AEU!YnFgJF;u=qk61Y9|iS})tg$uYQJVO(jG{~Egl-v`JTRy(zYu|s=1N~!e8x^ z8*G1dSK4aK{Ciks^8CTcUq{VdT&C2`o*HK!(|)nnka|K$ohniyR7&I|ZKTP!x6hqf z)ILKO`$C4LWqnUY#Qqq$vR@o*Z~o_JL*2kdN#-t^Pu53$h)J}4o5+n)^n3n5xfLaX z6Y|Pg_;`cJKHc2yEH-wav!s-Vp58f@zBf%kKkN+cppk>k)##R$_rT8=ba=Kcqu(rC z9O8|7@Vp{yC^n9|-SX#Q>dMw{^Ph(BqQzVL_@kJ5_zCZ13**{&L2XM2AGp=9Y1xu?ltx2EhOX=_k z(@{nfRR%s$bD1}nWsU~NpX_P4E!DiDML9>}HPFj-nC^gqCA3mOgr_pW|As~AR|7{s z#0j1H_hC8g0!bo27u`-xi)-saldN0OiaS02H-Tcowz4^yChWzhW%i*gB7K)MEe9pL zo`13g(_g}dqur4NMRDjwK>FsteU84bT#HD#Mj?CU@9(i^6DI2}a|U17#Hz@6??tb? zB2s3}EUi=}Rd{G6CVsvhvJ+ZW<#WFxEuqRp&GISbeNH8xX7ztUc_XLZ{cjMA%`3~> zr&P)_^RnY#@-P`8T5hCj&hg0emzUcd`X~SXs3*p@ML1t74{~rj9{WZ$D~^(EUKkeC z-~DI8{J%jS_D?CxI5(tBr)?JUpMQsP4~VnpO@E4#5!3UjNPg~x=-*yKy=~k3U3TOY zD}&qXpU+S(%@6}wzWv==rqgPmx--a9<~K$kxl@(~$jH(*Bz0j0hzCd=XjI! z#GOAg`(EfQdpGUeEt9~~`#-NZVaS-ap>*{PF5HeM6U?X3*m^UVC2%iJ+%apc>iLG| zk4e<9AO6jU|2-kVPvVwDQsl_F`VaEkb&cOcDOsrwSL2NnnFZ_~8E{WD_!W%(-QEfp zDyNg{2w^23Fg;skO%~buzu{8pG}5~gy7U-Ze5T%B`s@&=VHia_R%U-cO7V(+ynjf6 zzc2Ml(}Mb*Ff@=ctPe8k)NVyaiBw>oL&GZH)!iBU^lpDjW#|3H%Bo)~W0#*+@Y%Ti?t=gAXpj!moKPRbh>US=7su28xzLE9I0K~nb4ZisUpZuU z#P~1#KHo;Fh7*rOn|sozkwr1XJMtz9jQruW8JST!?eA=5Bh`B>y1Ew2cFsn;6dR6W zkPSw1+B028x#Lp)QLh%Z$62r001%Uj8tN=Lt8puCp8OoCP)s*Tuy1+s6n~_W@LmciSrouGO=>F=b?_}DQuM`dk>9>55|9a9MoHf=petf^~ zVwG0!ADhER5}DmZQf+EBFfHc*8I@BAQDwzbL!SdtZ~Xt158O zdh)~L3;$TtcNlo~GblFc*?ZR}WeDBzwy0`4FPc%Nsb40D3wkFJ_5`cPH&y~y zmck)_%rsQS&HiG_*x6*tntW4lVAF3qlQ`9YNo0VmltO30ikL!6W=yrj&;zd_%J=lz zCefl-6O#6v_;Q(-g;l=vy-CW@MQL^(q{Okau*~fRipt5&{e%}x#VwHF;!o}ad*_@t zTVirHJfiHtS!?(FG?D~!$tY-NQ1Q??pDof$mCGyVNhbE%5!u|OP$<2gdA!I{Guluy zvrpk@xc)m>u%e+O7+J+;(EEn+iHLZ8LlE{++_v+_F_Y~lTzX~bptYxaibV?Kbk)1k zjY?Qd;>I``E@KHbf@LH}UQ%agSC^C_X4Qc`JDB3AyCyOg&nLK9ReoIQhkceuGZ4Iwq+~UxOG~~ zPF3rIm-bK`mu&@cqn==5d{u03i&z>N=u)?U& zT+!y+jQo+hX4kX}9v-v5aHx5sC!5E0dMohjTY2Oib3|qHYQ@tFY;k^Ac$~Ay#Tjxe zhCM_v-H6Q`^v@%(8^rY7e?Wes!P*xiZ`lugNj&Zz&6xSFmlnA zaX9pPWaK#E=lF&~lL{i2BrvAO!N7b#adjO(EDS97I1<>#5nb$ddcb&xbt(2!8TjJ~ zND*KgD|9Hfbmi$#mX@aP_1^gNYkgUsi>+3?&;>D zv`^kTuJ3H}scPOGj2s+a*jQO(gVnMHqxad0F)H8JpcevpY$_p(=y$(Y~5#_yv+9(Y-W z(0_EGt!)HBJPW{bZ19f85DGQ13q2&>V=iu#L<@)u&* zD#(1zAa9Al%9|iW5U(UMULR-wC*VUkrv71VZ4OvjTJrk%n#n4U5KpSJn@+#BVTjZP z{mn@NWZZ;Hqdtx@I-Q~~ShJgyQKG79=iW=|J9pRF9Unu-4gRDAFk=P4f-&?fqnI5+ zqflm@Fns7OAq>S>ST)s0SZ^f84JoWKTy!>}_#6s9SB(Vs^u3_5FM|x?3&v0)##J`g zSU9c6eK_q%k*SDjaC;YqtG@^Q5ujrmjFG>JHg#_7q_01wl$!d-cv5S`?%d}-_5|Z8 z|E>ObcV=(T;NV!oZgn|!Z8`@3^ALNK6HpL0j*oB!9L5YiR5MWCGfRxhtQVnb)YGSK+uPBtTv-LxLlrJGM64x` z-!tFuD+ILU99E|XaKj+Dvk&Yf6%hg2Ot%=$KbfHL+tH%1)Hi^7 zn428nSLWj*-C_&-(9-hX%(T%m_hHA&UZ2QfJg)Jyt*2T=Yw;mE+%yLB=OD_|2Y=j$ zSh-}JiHR#KD{(o8Wlt)_KpdmcxBuJZvwxXHH%x&Zcgypf$d+|0&v?+j&K6yLmSGg$ zZJ1~jFb~}Rb|9?d&*_-6h)DiRq7=KTPXFw#I`XqC$}NgHaa6`82XFMD=A-Fu3A?_O z&h<21p{HZm7|%ypXzy}cZ%rphHW2zdNOfnS?B<@*5IOG_w(x25#;Mt8Wpc`ge5z5( z73c}Jzu??c4~ET?hnn8iX2e`t)ziI9SWvJVW%NyAC|0`el zju>?c_kdD&UjIvU7A^BH&FR;g5gxbp48`cBc?fn8Y+q>5$sQ(gtuE)&ATne5+&5ZE zqUl|dU4TfcW43NrClHNG5`u#6yPst3Q7vk_NH3w&mRg`xfFwmX|kVsO68&H{bMXF^wt8b2xh6I5~637X9)!BsL$^0HSpZ0DAE$P!M4>u{7*8 z)`t##-dEansHv_&*t29TNv2c4I{W_O#;}pp>F3ww{LFqD+b()Yt~~jeRAyJ#%e_I} zVg19xwk|R^M|D`fMRn?bX*W3UXx%cexpjJuT7Sx+c74R7WsLi{2^pp}97%H>X#FP} zp=YC@H)oP)E~b8EtgwUM?s?4M5l;=b(CpRHQTvT2G$}XADWVk#TmrE~D}UjErVYbP zz&8+oN9NDFO1yk4A9XXe)E^ni=NldVj=|7!1*t9*896yYie)nSz1*+%SGeiYFEqx7 zXioRkD&0Sjb!md~4c9|Tx@59=8_<%E;Ztn%M}0Amf2>#IRNT8WmZqz#sjq*3jCdfW z3}2%gxURnh4tEROZPOTX4t6IPoZ0Y`{YN_|igW4QQMBv>u&URt!w}`&I$e2Q=Fs~u zd(14zXPC+OB;I!X?gS=Q1kiZuFH#u$Azm8t_f#tCBN?=Gw&Yv?-yKe{AP4EVRVfA7e0SB z13o4WF{|UD5qiCeg9g#c_YYdVzs|t8!{DTus}@XB_}JRMD8@30Ov0}iW^?UR$dvN+ z6KoYXQ|k^TgcJ{JlqlY9HNcl;>lG+tMECbh@Hm+Wc}O54g1ba3I;vGZ`>S*8VV-3$ zvgb{~@yCjE?w>vAOxGbkzu0BS<|e%JqO<1?G970`FklA!zd3kCJlx$?S(un=Q%j=g z?wsYJ?@M-((0+dg>LfxEu!Q042RD}|@7P;g$86}TD(!N9$z}U<={)%N3L7}mJw5xS#KnCoN=vzp zZ!5ps*V&bB8&m+}-cgti|AP7`erR15BCeot{*{e{Rp0Tg!C@Iy#h8S8LRvt?5%m%- zZoFA*!pIpP&r$o|hsrv7IT@RX+}&0FVr5xf&dOj`@1x=K`ijzSFUG8vtAIXAQKZ$F47eogQ@@BO5C9%EN{p-4?fpcl@j2t$R^fs z8Z2S6&@Vi-_(0S0)_sqEya*Q*0;FzV3Wz9jb(oChqAT{Z_f>AP$pz!1q*YP+v^JsC zt)qhag_xK469B&gq+Ao9=jXEpM;+QuU23!Qu9y9~Y3-WhDq+{~AVjO;)#m_iY;?$= zbi44m8WmxwLVQLkW}F2!j21MQNQV7hPRta)+=a!mgwTRp5K6g?mD>7up~3sc`a)`G zct@K%op;u&D)&7%T$p+8bz}pxR>D#*M_AV#cv?=mp~yW0C0O>4u6T~Q2f^+qkI^Ctv0C2B*Y~p zt>JR~(faPwgn55g`2ZPU z9}qVa>iB`t_=V@7n!DFd=T&ECZT{*%vC3lg=K>SsHt}R$S_>E@LeI`#yo-ir19LU0 zQxX!4h-vTm%!GhU~gsMIU?$+kz@cl5;W&_Y8*2ojmQVi*xp zf#+COTIz!q>8md!9CAL%298lKtfx8yf}<&>FsOi&i%T5ZrPafjSz=S^{2Y#mad{Md zt8QPZ{4-*_*Qn>i?mu3O{rS&kEq?lOnuF`fouVC1sQ65wTl|K{=8X=^9x%suJaQ55 zwZgH%ANTT#W0=PeXpxC~aI;g&+qFsA)pj35661dO{BN5VQzJTSQ{P{WK<)oj5-uWP z3#*GoWJ1wRQKsDwvM@d{DdyG)91Jxm1XRSts3g9uV>ZN9;dnPu@fJhxFLsn9ZI!I8 z38;iqY?_1oLa=c1>+3Hx;KLv(#GYP-F~=?~M)g)zx^7XEx#|y-bGvgPIPtk`41xFo z%=UPVtRe^Xus=b!+4z~5S=i0EL~n)=i$l>!7&>gN8~z7j@uN6!6;so!er|1D1kd*C z*YDr?^=6kKL^uamG+(V($bf`9f9A>%Nh3zW_&^hN5)EL1VRU;LixnpHP|_&jWC%o; zx($4W&9w6a-99g4Zbg-P)YuAeeNMc4XODHVz=8Z&W*nUC%*>fwKz&r8O$`<9oeMhR z8js)cj<|$Gv_lr!k%Gv8P>AO&kY{`Wyd4y$ba#Kv1tok`)^z)gFvekX5;XCJzZoUf~|_Z~SV-+HBu(&#>?@8yG(R_Xt_y6%kK zeV>OTmBzzLM>wM&3Xt0M&CP6b%sPRKdxsw4R*>-4U3drY9+UBs?i{XTt@5nI%dLg{ z_bI-09fYz^U>~e5DWT+?seb(A$?-`T2;V_H<*YPFdB%vE;T)6mZx?b;&A2jk>D0Sd zTMcC6{DXoTOd$0Gv8^TTZ0{ zF4<9NuW^WY06Na87cIfD_y8*Nn0(Z6OV2Nn%vH2^OqRx#njB55+%YZ2&)=#HDIj6> z4+I6kN$AA$rvwY93S>n4)shsCDe&a`a7aF8OY0#?KXU5r)wx`}ARx|-_hIi^M}fZx zKS9&=8#gXmi88CUPleVP4_v}{qnE+4JvRQ`O#V;^qROv9e-UUZ!n9}KzJ2-#nCDG1 zcyADX{RaV3&cEWPbIcx6SJx}9M5 z!u=6~LOsZTqe0`h#y6a1+|YNYIG!Ef8A7lL=VqDv=kMB^vE2C zjLoqh?m^E7u1t}y@OVjy@Ut14>IFO;Tl714+5U;6hV;2``c4ZJeRuT=8uKmzC%O)1hnGb5u<#6tk-A6(~!cmZyR!2fYu0s4_po3p} zo^vuIA>kn@NI!e&j=Yn3Z`NGKBgD8O!0zsO0TOj3GkB>AEKrZwtAq0T`jm!N-*iWq202ME(ox)ulmg?dC6g%9b3_!;2n zhx!I@#P6q{rP}RuR?&Xw<)SBrv$_xqJuo{2%>L-j4-teYF%n#W;^Ia@5MUsap`--L zJ4~!-#O(4pa;Q8;qNS`g%(sFk1FrgQCR}obPj2Z*K=THS%HNJP8U;0)5T3SfZTM{{(^w%Ao3Px z{+E{o;Fks&fWW77PJRSIrxpuTdGfSLK>~7%7c0Q0mV&4f&_I=SOsiHQLm3OTTMUwD zt4L64_4*9r5qyy`CjfY5$iq3ej7BJd+MYWq9$cu(G7tiir^q-{5-gJ@2u37AfEclv zI=q`@^&0_@h^R95&RUJYK5H_Q?(_(Nj4FU)EKA3Qw==jT0_n4X%4UZY`Lx(IQ8(~d+Z@ETneGrqlpBpq+W&koX|acjSgdq7t4 ztrDK(^JJh3r#s;DL;EvY0)kKmgz;oqL7apqsq!o>E}m6jP$j z5SV`f#N%RE4Z;8tkX1mf4q(&V5|4q6w@+(^6-7H4{`t*C6 z%Xh~7MhMo-|=4=B5^ebtO zz}AT{sjv#L)8-b}+gr6cKLEuDNGoqwrbH1Y_NlIc)?V05mg_Y68-y(@G~~AYG+j3Hpy$K|xO=Ga4n4zfeX373 zcfSb-=Ig=__t@}%2*-nGZd{B;=iLPP5QvgqQNRe&Y^Z*Sl3lsMRRX<<&L!cb8&adf zdHj-ECwe|#UV0iWB@7_l2=8)eDgq|ryOD=}^*DP{}ZQ{4DDVv~<6 zVfa2PAWJGs%x^b(H^P8Qg~XnTfP^0)SikK%QP7R2hwDv>Cl+ZDy7#HxGp|ni`;%YP zw3~B}{Vv3K+T*)p_T+Mt*Y#pstuS4B{UU1#SpS(|jQp_-=yY|Y3Rv@=IF&z>+4wX9 z_iC80`S#?jk>*?i{uEx8U@8vRvVsNcsnc;%0+0_Tj*FEPQL=-@U zA<$%THdXf~fHs(XmA^nSQO3-@KIv>mO*nH(?%WNO*7p0DIwdL@SR`$!q4gqWhTm`q{ zMg$hfkVx4Fo-AThIhV5H_qbg6{`sdYFo&%j+e!_#3$kL_e5+_AoWrCIrJiRD)@Fq? zA#an#=4y*T}jy5{o*)hZDBY*8Mm5h-(BIBNmV7uyVbY+3L*hsC|w z?|IW=%(~K|Vav8miJ8J#eFwW%u=h@G%L1pA;pv+fr=3m0X`c!Sw1;YP&X@IsaWji9 z(PAyPt`_&n`#Xrk%CSMjU>o{XD1dwP-+Y2eA%8q3`SkA(uo!Q?Gqhxw)rEh1>R21iM2}Z@V#O7*0_0$OPVL_RrEv{~IwrMYzRZ zlBg&CKEU~p^Rr~Ll_IoHuKHnx@J5T(14c;UdYn#pT_l%;Z_if)OphVquNqD@Qr;1& zV#TS(LnLcD{nbRs3-{NR5C5peN}Phjv%ygAsNY!Cr0~Tcd{0@XMK6-oP<~3euO5nX z(?7xxybxNgufr^7A-3kNWub6gD;rgfy-2t}gxyazO7apC4vtw}Y)g&QSfog6ivjb~ zf5HPKG67e9H@k|aHxRgT)IItHrkcm%k?GbKTox^5FIxX%cv8>K{iMWGtV7JNFP=a2 zIC8@rIfmCj$Dz~s^JN-dNS^Rm{d(r3kaps}GpYnmLg?W^KX$6~YgV0^lh^-#Z5}*V znz$e+1}Bpic`p7yY-?ud#o0#rp*ln?T-MvMiM@~a#nV0hZ>d4!sjzHUy9{C_*xL(} zj*Qe-5kpAfh=}c>^*GZLiFOZBh*@&vPVEolSThq@;>T3-reZcI1_?&LL^O6;Q3L|L z{stNNB`IQSx6K#w6$aSkWYqW~)gd1D;^#OO+piLkq6-&?Ojd+6 z`aW2YK*E*x|A`dz#{6N`RX5iyTQ}`Uo2>}nc}h^M3Y5*x<>Ha|3+9#zKEqqR|Gj`C z-KaL;cQpD%d;k37vrc;U=^IC4+BLPM9$#^Nwq`_V3~lo=Zakb{oql+=X7$0Wc!!#7 z)2r(v-E8q?+dAF-M#eN_t&F}nS9ec!@!Cg;v71i#sF%;!1FEfG(#PoYW$FGrifh3p zqEuYYuuDv0-z=A)w%IZmzH)3YVLhXZ&{g^L`fDjSm*`L?SS!1sDmi`<%_W)*iKE&d z#C4e$pN?Y~ZPeiWTv*;i{USZ4)tG89m8b~W)P@|<#Zaw^Ec4rAz65qi$)w_L)a<>9 zEAko?*lVgD?;4}e3`4yp_AS_6=4Rna#~^$eD{+C^W!d`M(*8jbV--tsovaewcB7&v z0sR5)B!}qdai&@F5rS_bSycYa1RJQ}P-iSPo=wd;{YsC@eS}w3ZMLzmlIXqd1ci`% zF8|su{CRNqlj9=S#4p27jtja3uSmdw*QS;}avybfxM|FdWqR8osq-7%&omdV#pL*s zqtVj1)V#{>(9Ta}vc3cjoK4t!O!huRI;tYMrre zj0EP5?z27C6!fD+gc8;wYxtE;iaE^ww&a@C+p_OoEubtXVp3?1k1U`<90$l6ycqxH zVHzZT&*)q`yPk4BZJgAEpbR9V+xjK`u6bf!mgn`p^i6He_&JlN=(d9pa>P{K!SS;% zr7PriZaJqEb#F+n6mpz^r**}W8PoV*VO>#$-{{mzJxQsC_)kbRzitXhj`(!S5%2)J-a1uV965sATWzu;;EK|q++WPtkCj&p^wK= zT$kLN2fMGw(oa^) z6!&p*f)qqF(uhg!8;{Cv#m&9^I|Zp}ro1fN-plkbwP~@}wsD-qzZc)Sm7(S!>NZ?= zNdf0Td!p&&U6{=c-Z$f$w+{CEjEMUFYrT@&u>-diF~q8n!_XIfX_HjpfS2393X*0Z~SR1{!-GL`P!jhP|}Qm7e3d|E;E_U;aX z5S;1^eh1ZvYyqS(&O~4^a{?TZtV=*LLCm|YmzY9x#}!1fT0!dHsHAS=)7A{IQLbT6 zZQRacBnH?XExsxLPu5QdNZJysa6}qwWCaMRvKRdbt|05jQqe)FRhwmCxSd1=*p63b z%>53JTrd=?bMC5RAQvH0GvY`oC>$ud#luz_Wcm0HRPSW1M(qQ__qlKC-sWRMh(J3w zjFbE}&{mVpQ9#j3s&2lO?*Ec$*vI)ff!sbH5!&k~>KY-`Y^Mf|OU(*-~ z6CaMb$W5hq5i-B|SsMI`OhwTDEvB>BYnh>T4Ta?}H*<7|VJXR4^uG&8seBGEKz?#0 zeShm`nJ-FE%d_$CZK+Sc`O>*5g-~GTJtUZVe4|PE@Oj5V-HZR%;gc~af-au($lkZq z5ezS@?ELd-z_oToM3A955}-fS-dupf<7K zbQ82OJc1KB5tPw_)^zXMq(k5(vBV#vF?V7ZLxXW8Kl(OF#a~HpFNk)mL>bWZ(-Qvy zUJjSYBT>I)n_S@>QmtA1(>EKGv%=)sI(W$LHXV%{Y?7rWP5<~NboXf-cKyJ~WH>tD!#w&$%%nzr=Kvw8l^<$nWMe> zDzTxtk%m`Yfd?PK+kBb})KNNBeNJk1gdxg+9iSEMm zec4XkmSAmWi#uo(H(BngvTibaLH6MW!P|Z4s7B5nJhC@5^*4i`%D)TafZ*xhJo;(y zM&gM2^oZ~8Sev(tT2@j%^ZAUOjwEI@81;%n=g@#F+}F_h&5k2h?W@C#VGpeljELD+zp7Oy+zN} zcvKg{DTEZI>UlTCNWAgW8qIpRnp(@^yJy!AtAd=@GAG~FQx$#`m#wK~m-5vKYqj2v zgULtl^{&v_oJY}D)=5;heS6~14N+?5HBzE>DJ0DK{9&;##vnN4(1%&|)HP3m?j*aR%vLDPckf6s4Ci8}8rPw9zsKt%Y){PEJ!Mi0w#yFiMEj!Z zY|WjXJIMdXqWU@PxK^Q6trUO1TRn38sW%&M+@`VlyVrd7O?FUfVoIf~U5(&zrtz!| zk@;S>a?lq^m+44Sim+p2@N+hYzp+T3O)i zh6$tgey^5#DsRzsMP$Dh2>ZsHI<&umf!mv4)ttglsy4Ey3(r4*G298e~yfwVM zywRSCZIbd~jjTq;;cGXF2eRC@iHpM=FD6;1nk=XkWesYAcQ;p#U-DcKE@5kNe-ej2 zs);%0NM|oNz`08eb-vT_#Ca@v^Wrz5G@%KZ^hwDwo zJGq|m$X=Csaf`(R5jX1I!rn9VSMyTdK|DvgJ+Nkyp=H5nizfYddp&q-{NIvfR;OqA zjRiBqX9Hh}-wz4NJ7N{@eds`li)?@m+2D?gshXcov#T6M@yIzTedO#kwC z`K)E3bCa)IRDR#Fh{b%M@`x@{r_jSUHT3BD-@$sF9WNrXPNn>08Q{Wx1}Jl6O=t}` zWa8i41fxuixQ&e++APU@Yo&yemwDO*B6|`)(C=M}p(K~RolT4Jfpyf;q7>zC`u~|o zs#4T9W0FoH4m?XVx}bo+0$FIwXl0JLq;J&q1C{!(AJ$1g*2)AUJ{RO!6brr}< zoDvB`+Wb+FoV0*c^pzf}P*b?qxv!xAGin6m9K`XHH$vqa0djc%T&?2c=GB8!5Aiz~ z37m+D_9T|y-E&celfbq0I0)J@-cv@*r}~ke^h-{WWY{vKHxYlE4K*ufpHXMC>>yLf zk)-oT5rFtwe{p)Fu(gcRQAG}O_&0|yieVQ=`l6C>X^_8TKE#u;!t8tHC338}v`4aVoJTY+G-PQwx$ zK4IOeb_!k_OdE?U~oMv{HOZP5VxN|K3}gTN)yA2w zLYT57D+;?%hgmkL;a&i7l9F%h5RRChZ#)sIw}2&%5kaw-9J8$ zW|172kI4tOS)`I>?Y$8V=5D^P|L=Qu>Qj4yP_OkX^CYa5Cfndg{0A>K;f4QTXspl| zfl}tIs20`(U2PVet1Gy|O%-0%CQ01p-A5s#BNRMJe<}iLZWib{bl#t^&+3>nLM(nj zpPR;gbxNI5BQv(p6N_?Q^-g5JHo%UX(xwC4z>>wVQa{SKc$A=tF7(ck0`AKQ)Kk$$A@6 zN-2zbP3NgZQ)KDeLzI%ymnq~GvciVWDIE;kCR5qu_rJzPt66Ma{*Dj3F~>M1olx2; zGKsl%&(~|AsnfX>QBoDGk;#^GFVH|;OD4scx}2(|gsSH2(8iyN7c^BWcl(G$D$J{V zF^DRJN_;zmG_Ren;CV9rvW`w~?^3-BJF<|G_`QDG z&4By>`=-vm9vR|1=J$O4j!Wd$Pn8%Sm@WQIyq|0S!-3)FL-g&Y(@KqigvUkl)h}E( zFFNy3;j>~O`?zrBtfXiR4Xx>Adrsf0r6xR|6vnyp#G=R%(pC8eMHkvp@cH;=rop<7W|)-Bb%h`W-a)QlW<_Iv%Ht zz9qB)k*ZHUw-^YaCOt|>BH=>4kdrvh^S_8FDw?^By39HhtFf}LBa%N6l_bxQtdHb_ zGDV8HTxdB*nNaSs)P9+e9%+lmBk7nIN+-`nzQjmSKD|b#)f$^&Qi=#XTkFI@Zaq`2X`|e^`*E-=Hi)J*DRF3@FDnC-0 z<(7Tfsj@TDoI^xfsc|3OC?+*WC&|yNPF@{KD!|V5?>?S!JT2MT&)hT7RAI+CJ}QGz zz85F!*Lb?V3taJyHy>^V0XvjN_NC{4?*gcf{JRq`3$=g2CpB3LerkfKHOyhOT*c-uoR)ES*v&_{I* zLFRC-uJQBeY=6r@+dM#mY%73zd0$Z~Re`uN_luJgbAR@7%~)5r0toVL|H?hZOkTFN z`i}AocU?~>t5x@#9(Os6|1$YO8KLwh_`3J%mrXIXuZa0tx;#G%Mx2VV_bD$jiR_B- zY~OQ+0xCVPhpsO?LOjOKNYC-%j&M4jCTN@%1KvOCUrONq8+D5=UI?QBgDX-7D9sjd zxHL9Zz*#ap_R1n8q|GS*kdii=@p??MB?i651-EaEM|LxNVaI{VeRF@;4m19W(J3Jp zyogi4@GEhdYdfRwM);Z0eP*)$#`k@6$&Kl0OP={Nq_xbOI1Ue|;!9Au8`DdUHALAx z>S=b5Uzke(_wCoV*sD1FlD&CEue)x>Ww*JD-n2X>9n587ta+Xqb!nG)LXg(8`*bPi z`Jw%xC|c0qLe~Sd>*L=jkTcrtXMBSuXb|1SA~WA6Y^tQ@8JZ8HinVFX)|@DB?oqOy z4EYN^f=u($w|~&2<#=pTNmhCN8r;y@U|v$h4Nm-xbi{YUn6Qr|q{}TL_C&oJ+kaY4 zHPNJVv&+TLm5o!plD8^?jNt~65|hK;HEF-THggYT1r6G$URK@!eXlCpkLg2^%Y;+} zgooCfhR&-Rh%iR5`aP3(P-8jJwJ|6Cl=E9ss8%?m0W`~44XbkJ{woIS;j zr=?Y7RR)^B!cqtEmcl6c>dy$He1n;TP_1}sOuLYSe(Vm8B+if$NtSI~)rVbAdd_%U z`P<)sJDzjHkZq+H%Bk~ZYkR(O6%N}p@jVXJKPYYPAP0$;Np%I3 z)d$=^5}Fb5MH4zl@6BtJcfFfoa_eL9J)TuWq8@Lb_*cGNC;1{>O$$;vWVmo9Qp>V( z#o3`t_M?j)Pm@O}h7tJ-_sFVzel7f-J(Bhid{BLl3haBk1c@SB)^8z7k!xbJ3d()`fX<|oQk&Sk&#~XY&P<+50NTQt?izLgM-rL;3>Hbcp zQfRc0bBo>Gfen}b!owg|&j3Xe9en!a^HcnYkxCh%p`%zQQ%Z5rBhvi-&_ey*bNX-W z&AS22g9HNc8{D+`mZd8iQe;&nDd&sNP$r{cX-WN!hN)|o?H8#*^^4!SYw!g!jk@#b zogq@qCvA$MDyQkg5KaBxF+l^QvKf?RVnV7$QYe#W18F}#pmMWvAFOfnXGpeSCyXg! z(^}|H;Rh{pqu8Ieal#B!C2FO^D|T$=<_1uI`h7ATy}02uYN~AeiZ*5P_vYu#TXYjw zt#L1l$LhVpo$%(`RN|7;aEz5_d0TiUPllwV^}enHpAoHWE!eRnUK%r|XC%a9_Rvcy z%3_e_V9nN@{=|84YE1IkYM+O6Y<2jd=SLdh@5L*ZKr|udcEr zfA~ggCC>aAH9*26ixZLV2-S>a-}_N|{?jl7_=k|5Aes)#V}dUStbV#bwmJ4S8mgvo zeEcV4%wE-gWSeoR|D0<}GMI;%@bWzG$O85g(~MZlb)ih_jNp6VKEJ~&|+3IJp;#SF}9S0a~^(K#HUDoLA$1}tnl&s z3>M1|-qAhtdJXQ_5|@sEjYM`3ez|_u>N|SbP`CKAKc2Hg5tT%Cpd&@@(~4!P=UIp1 zyY*LVNy+yyI{XUcbm`?`h6S|5noB=bu@EPzM7C7iSvl;QvrN|eZz<(5e`Krdsd$GD zxJr2UuZ5{}f);OC^Xt8&Q$w;USfeoEYm~{<(iBlzkt@rMt!^|fz3AMsv654?(dcoO z;~0afXF&zxaSi)>Rd~M{<}2GgG*OZ;`N=r_aGqVZlS;Bxu(XqIXDHrSo}PQn=f_!! z65;pUBUVb{H8Y(qdQ_0Ju7dfIQP)-0a*Ls#k}onpdEIpQ3;4*};^#p0kRE;D)CjHh zej{b%0#y$EAsx(kci@Gjt!azz*MrS4OD-dyNMjl4t8XR9+p791{hMw@9#emO#9H!@ zXOQGILa+gdEB*JE`-dppSh4d@l`s-Ws%-NuaCZpaHHnS+ebw zksVVLL%QjsKx}aDJH&})Gi3&&*|DLpJ(8<|Vm6&$?hMEA22&f;ILKhdV|=Zzz_wi0 z^V3SaczImhl8TWbUrd!tA7}6iW=d~taV(tRD6Ae6_il&ok_ClEP5l6c>Cha{< znc6C!NYi{8XP*~FhU}M{proE!clIkDtY@-p1-K?fQE8K{_bZXf<%76c<33*v6F+2Z z4^?Facf1hDgj_KGDKiE^rB3?keM;7)H9dWQq>M4$roZ>WhmzxhRd$%&4^%S6=O~kS zJyFs}*+FOZ_agnq>#%Y)k3qy{TQ?RosPpI@^>N(2h*E{fp9_2s8&|G=X_v&u8NbWZ zZcqkU`RZ?`;h7t}6-Fyf1(Ygp&EfO(53RbGn(B=1hKX4-@11ih zEGU{sL?MNxg_a3L{kHK({+nVPCr-RkyB~=A>Klvr1LyBIf#U{VvN1Qpijj@Je6zoA z@I|_|^@y?~Tl8AsB?De)EN&TQ9e=~6+|YB`L}YM5k5kN@9Cr9(b|*4aLm^AA_m{k= zvGgJT;gc|5NZKH5AvE1U(}I+;|4P|+eeAGJN@hOD-1zP6swkVU>m&A-hnh(=NT=AJ z#avQ#-uKW+w3UhytD0Jwv^Q75JkF_Cu?${-Ioi?e`f-hOU?o>#CkJzCaE7w z&e~s~H6BBvZKbc=wuf07vVR!<47zsLJO{_aWXNmg9$M8458RWX0O|vn_q8WAD38dA z9a!jUN`77>xaluyOs_hjWI>lSM4*yDn&-8s?&Dlh@!Hjp+Uy=ILoqIE4}ap>tRgae z0vALd`nJzi?C?>m{c6of4!F&>rg`E>iI>&H&EbWnmd6sk|Q`xCvkO0>bEX)3LFle($^HAK5Edeh8t;;IxiAHckg zVU|*AmV8vw6$I;5t}nv7@~|pt3_}vm$tR|)R%a~ml%=fh@R`^Ne&vocg=gccpDGp+ z&onELxO%{YC#-!kCmu~N7=_$#X;~Uv!XqLhmG)TUgkdavgrGMrq)tuTSBJn#-iOIE ziH$-jRS^gtzdb-rpTf-pGV$D>8}zHaOVhXp&aXG0wuB^)s;#_bNrYfeY5ZJlrw%<( z++*(6&9z}`A2AciZG&94R$I5dnZz=1#Z%#+lzX>B7s8ALS!a8Og z0}t2ovxnJPUr@3>c<(00{hGw(!F*)(-FL`$jFQ)mP%rg!{nCejD$g7Y$s#{ri*b8j z!#InXxs0+aw*;??)4le&u|7gSrYO;$(}_?$yV65;+m{rqSukd3NtjTmr)c+;IS%kG zkVEbIEpc2N_QsMIx=@W?NSAlXUOlvi&qJxf$e18K>)MiVTDo#TYV{!_{8?Hj-fI8g z9*criufrA{R({P}Z;Kdqeab8;?~g?uob)WDW1SbJth!UlFecL9Dgs~SP{-7cpMJNq z_*!ROWbDoMjpMOwFVA5ep*;s#EZ5cZlY#*^S)~<0`mA3U1x|x_zs3bP=2&8>%S<}r zc4tbml?8ggK4V(#+hgxy>1z0ByLkQpVulKlBW3o#ln8#!*N^63Aj?gDX%Q`5@lYXR z%0JMZC$e`W8_pp^>XuhdI?2McU1=n8#LC~c=KX3cqu{fOSdE`h4PjZiRKEKa-qw@t zMw7s5LdDJV4tTQxuCAxyRq<}>+Y;n-Qhi^@VMqq%lxZectIVh6kMU$?)F>UT#Wb9S7f8%o5@Ox4&MGr{n5Lw#DWd7Mlc+9Ma2q^<(lvB7%<*t&U@jB9XCsB2dH zlirb?qHxLb9YK&li5Xb%n&|9YTKB<);6fearCGNDtmWe_ypj4>2@&+7v^qv{n@X3T>6u!(H2N;mlBO>kqv z&W_*d*Ep5*h4YX!bVN>J@Xf$^F4do9v?wd2Zwr`@=vHeH?FOr!`MKxkWMTNPM6~^~_C(#eE$b zyyN(+sqh1#emE>ZpOtBOH{)?6CE9ydjI}C#8md&=IJYR{dd6=GVonxGjzTVLBj`J9 zAoF!7t1##SCybqNc7Z^FfwGO1<^5A{fxfm`hjEE3tTcn^NT)}?p|nldxb{JwHt^3@ zC)qiZ5Lu5o@g)@o*HJrx02xS^S%;CMEe=W!3|`P(=yo8bYzPz!OfKG zx454i18gVoJFif`XUmdT%#iEIO#YeU)~^rLRHw?S0-i#XsSgv3XMGu)=Zue8^vun` zeokAquM$R1dtz4c^X~`z&esa232tT{AxB&lX5KF?*eRu#kIlZguu$i{(g}Ur^dsyu z45t|?@(+KuQ9}@NYG9>tA+(mR9-Z^q)@vh1Xu<#DmRYLtIv8j%yDx7BHHS&yzQflW%MCHuZ~| z?T4B|a;GP^1ZEuw7SODMZEA2WVX53u{)qoLSn-x;6 zQLANbwb|G6Ke&dJg<8mbLwAEFoKny3NZ_|8(G(0|L|BBfLwlFjMA9g8*`Ta@hhd5V-(;$sQ-qG<42eY+x`;YiiVdKRgANRD&`k;_jUmBXIIzU&4 zEb6mYMbeh_`X5ELFWKltr>~-VbpJyds&S_T{vVtj<9GFFSl$)(ewKWSwHtpt!9&pIa{EO7;&G#Xerxu?; zW3W^grQT;hOnJ1kNmPKm?vfB!GVNhmgA-{U{8Sj(ztIeR^Qvc{C2#r&*YaVU%)Ic6 z>oE?N2*}VYtP*5$n?cp5{5T!iBdSiqvaA{_g0^z6m3dXqxd?{e%V6t->r9JSAG$oy z*5JqeG1$PB@k22fvqreLo$2%ux~?J?A0hbQqdXN%7235V$)kEUozAYyW?tPff|a${ zX8YBZurEvIbLQM&#^vfW+NPCH#R&t=6t5jvaq&}S(wZLQer!ywfA)e1QxDv)AX-+E zXe8QWU4^IZH|J-E2DmexDF#135=>mzSj&bN1g;#595#rj1&d+5PfxK|b@|li8xNZ# z5{o>{46apUaL#eb0@uw)niD?g))GD;78b7~UXdm7Bs5vCFyblar_iHL|5z+c*z4Wc zw6gTnC+zziCSn=Ty*WEU-Du!{BJ&`ut_b4?IPF_BN zr;82C;L+8gsA&1e^v4WYQeu>mg>~-yhaMP0(Lt?rqbj+Lmhm-LBNKyCPBoJp7gFqF zj*PfxJ!bC+d#}@lFx`p=;lqw4NK`n0upyYD;P zGa7q~rN@EkG(WyC*(>}O&KZ_9jHV39@@EjU=a3LeLNzxu+*F1$oS;GM{9;T34XkQ~Nw?!vfzLVsPGUmk$!#n4K z%ULZ=JvC9KQvdX3ofbmHvRbDHT*+s3^HT-gRA7b7;V9 zIJm0s&M(QnX&-!N#uc$yaQsNV<*iuhtWB$|<{YsWD9~zk1TGY;k(?xrAc_GYq%~(D zcU!tu3NlR1<&PYNAE^8k6bXx+(3Me-Jk-N606O4hl zYIIxTH^F!GB)E-wT?v8SH3ZdwJa|xYkTacdKMYYrga$4p*+}Yp5v^S@5>Nyljy%M7 zWnLnPPMKJ(#!k>+M>paIh~{58XSdx#>X1xMBU*EZ&v(^y=s-Fgcl5jdpcA0RZ&Qc9 zOll(rM70_sQvA9$P=}Z<7EBj;dC%zJKP^0nGCbHNHrs8{8yisLE9$)29Zy6L+GC&K zJD!t(=W8j4HUdQJjT$JxD1RW@u6T#VD1zrTBQ+8NsVh+7v2EFuYJ(vD;%~_JkyS;# z44l4e+2ID$aT}Z?^r7~T0#LYqd_tU#qk-r=8Sw=VuYcvzBZhJ+0^)_%_n@S^{+<6- zk7s9cE6vD5e`l1*mwjD#<$(nMyUtJ$`L;l?9uzw6m^`Ee!U4FmfmY`)b=NfSN*Udv zfL?DkO@eQ${&EnFFGPyxcUBz2Cy7PgrtpN)-9q(0`yeUtk;6eN84sdtK}5s!uGa_+ zXvv0Dt$>)(D!SECW6}?&&Ymq5fx!P!P3GESKZqzXM8;&50u_4L4>|7q;y>l*YifuF zH*?JYU9S0G_frOyjC@3~ly_Q$1DDb-Cpx;5TLU;_;ghlUa#Uh@U^l8|!XHMwhv=BX0v5P$vl9$AQ0UEVfOaVu*%sKC_i{&Dl)UOEdzBNk@5|D6DK zKmbH%!Yu+u43J2Ys#JBpnA)6Y+;wwCfRWGuXv!_qFG@zU=;it+g?DXX|Jwukk(B_a39tyuFh~B}YCQ4)__6S; z3Ib!Wn9vcM6E%@F7ebAQ|8_wkw7?Do&?FOC7v8EQfwJa+SR{U^Fa=@ByHOc@=rXCJ@1p(ZD@uvjL|3AHCS$F`@k9h-k9jLb8=CGCvcX&;ybqX;D z4>(#UNfc=jE=BDx1NJGlxLRxA@rW+YAHWtk6^d*KME#urpUC_ywgLq41AikHnViBC z>;Gpd?&2dO+JapW&BdOsCQ)}bL-ZD-$-@u}1+>^|WPvJJz7G+2a04?P(trY zkmG@8FvME#HgMgpz*Y)CuOa<>dOOzW8jL-%w7vhIy5d$qqEzUGBWj0@dH^9+q2G@n zzy|qN6|6?L4hEnv0O)`9Av*aIa;_tQ{?E-Smji;J<3wZwPski@VFim`8PVD~Bu{nw zaO4?qzXw`jI(OJ@qOO^@78aG`BIW>Q{-1rV>oTpNFZjRRU~Gs5>A#3Tx$-$%>>VNo z|3?$&B!!ac-5hAZK6a3q|91WQ-`qn&y1D5dW@rN4>gb3R6a|ToWg8u3d<*=5grp$z LT)INiDB%AAa)|Zj diff --git a/integration_test/store_screenshots_test.dart b/integration_test/store_screenshots_test.dart new file mode 100644 index 0000000..0622abc --- /dev/null +++ b/integration_test/store_screenshots_test.dart @@ -0,0 +1,325 @@ +// Drives the real app through its tabs and states against the fake-data demo +// server and captures the Play Store screenshots. Not part of the normal test +// suite (`flutter test` only runs `test/`) - run it through +// `tool/screenshots.sh`, which starts the demo server, an emulator and the +// driver in `test_driver/integration_test.dart` that saves the images. +// +// The app starts already logged in: the demo user's credentials are written +// straight into storage before `main()` runs, so Login Flow v2's browser +// step is skipped. +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:noo/main.dart' as app; +import 'package:noo/models/app_tab.dart'; +import 'package:noo/models/saved_account.dart'; +import 'package:noo/providers/files_controller.dart'; +import 'package:noo/providers/settings_controller.dart'; +import 'package:noo/providers/sync_status_controller.dart'; +import 'package:noo/services/account_store.dart'; +import 'package:noo/theme/design_tokens.dart'; +import 'package:noo/widgets/bottom_nav_bar.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +const _serverUrl = String.fromEnvironment( + 'DEMO_SERVER_URL', + defaultValue: 'http://localhost', +); +const _username = String.fromEnvironment('DEMO_USERNAME', defaultValue: 'Alex'); +const _appPassword = String.fromEnvironment('DEMO_APP_PASSWORD'); + +void main() { + final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets( + 'Play Store screenshots', + (tester) async { + expect( + _appPassword, + isNotEmpty, + reason: 'Pass the demo user\'s app password via ' + '--dart-define=DEMO_APP_PASSWORD=... (tool/screenshots.sh does).', + ); + await _seedLoggedInStorage(); + + app.main(); + await tester.pump(); + await _pumpUntil(tester, find.byType(app.MainShellView)); + + // Android only: required before `takeScreenshot()` can capture + // anything at all. + if (Platform.isAndroid) await binding.convertFlutterSurfaceToImage(); + + Future shoot(String name) async { + // Let scroll/transition animations and image fades finish first. + await tester.pump(const Duration(seconds: 1)); + await binding.takeScreenshot(name); + } + + // ---- 01 Files (list) ------------------------------------------------ + final filesTab = find.byKey(const ValueKey('files')); + await _pumpUntil( + tester, + find.descendant(of: filesTab, matching: find.text('Documents')), + ); + await shoot('01_Files'); + + // ---- 02 Files (grid, inside a folder of photos) -------------------- + final files = _read(tester); + await files.navigateToAbsoluteFolder('/Trips'); + await _pumpUntil( + tester, + find.descendant(of: filesTab, matching: find.text('Lake sunrise.jpg')), + ); + // The controls row scrolls horizontally and doesn't fit every control + // on a narrow screen, so "Grid view" can start off past the clipped + // edge - not just off the *current* tab (which `.hitTestable()` alone + // would handle), but off the *visible part of this tab's own row*. + // `_tapInTab` scrolls it into view first. + await _tapInTab(tester, filesTab, find.byTooltip('Grid view')); + await tester.pump(const Duration(seconds: 4)); // thumbnails + await shoot('02_Files_Grid'); + + // ---- 07 File viewer ------------------------------------------------- + await _tapInTab(tester, filesTab, find.text('Lake sunrise.jpg')); + await tester.pump(const Duration(seconds: 4)); + await shoot('07_Viewer'); + tester.state(find.byType(Navigator).first).pop(); + await tester.pump(const Duration(seconds: 1)); + + // Back to the root folder, list view. + await _tapInTab(tester, filesTab, find.byTooltip('List view')); + await files.navigateToAbsoluteFolder('/'); + await tester.pump(const Duration(seconds: 2)); + + // ---- 03 Photos ------------------------------------------------------ + await _goToTab(tester, AppTab.photos); + await tester.pump(const Duration(seconds: 5)); // grid + thumbnails + await shoot('03_Photos'); + + // ---- 04 Favorites --------------------------------------------------- + await _goToTab(tester, AppTab.favorites); + await _pumpUntil(tester, find.text('Project brief.pdf')); + await shoot('04_Favorites'); + + // ---- 05 Activity ---------------------------------------------------- + await _goToTab(tester, AppTab.activity); + await tester.pump(const Duration(seconds: 3)); + await shoot('05_Activity'); + + // ---- 06 Shares ------------------------------------------------------ + await _goToTab(tester, AppTab.shares); + await tester.pump(const Duration(seconds: 3)); + await shoot('06_Shares'); + + // ---- 08 Sync status panel ------------------------------------------ + // Sync two folders to the device; the header chip flips to "Synced" + // once the native worker has finished, and tapping it opens the panel. + await _goToTab(tester, AppTab.files); + await _read( + tester, + ).addSyncedPaths({'/Documents': true, '/Trips': true}); + await _pumpUntil(tester, find.text('Synced'), timeout: 180); + await _tapInTab(tester, filesTab, find.text('Synced')); + await tester.pump(const Duration(seconds: 1)); + await shoot('08_Sync_Status'); + await _tapInTab(tester, filesTab, find.byTooltip('Collapse')); + + // ---- 09 Offline ----------------------------------------------------- + _setVisibleTabs(tester, [ + AppTab.files, + AppTab.photos, + AppTab.favorites, + AppTab.shares, + AppTab.offline, + ]); + await tester.pump(const Duration(milliseconds: 500)); + await _goToTab(tester, AppTab.offline); + await _pumpUntil( + tester, + find.descendant( + of: find.byKey(const ValueKey('offline')), + matching: find.text('Documents'), + ), + timeout: 120, + ); + await shoot('09_Offline'); + + // ---- 10/11 Settings ------------------------------------------------- + await _tap(tester, find.byTooltip('Settings').hitTestable().first); + await _pumpUntil(tester, find.text('Accounts')); + await shoot('10_Settings'); + // Wait for it, rather than assuming it's already built - ensureVisible + // itself has no retry/timeout, so on any transient delay (e.g. still + // mid-route-transition) it fails immediately instead of giving the + // frame a moment to catch up. + await _pumpUntil(tester, find.text('Files Cache')); + await tester.ensureVisible(find.text('Files Cache')); + await tester.pump(const Duration(milliseconds: 300)); + await shoot('11_Settings_Sync'); + tester.state(find.byType(Navigator).first).pop(); + await tester.pump(const Duration(seconds: 1)); + + // ---- 12/13 Dark theme ------------------------------------------------- + final settings = _read(tester); + settings.setThemeMode(ThemeMode.dark); + await _goToTab(tester, AppTab.files); + await tester.pump(const Duration(seconds: 2)); + await shoot('12_Files_Dark'); + await _goToTab(tester, AppTab.photos); + await tester.pump(const Duration(seconds: 3)); + await shoot('13_Photos_Dark'); + + // ---- 14/15 Dark theme, AMOLED (pure black) ----------------------------- + settings.setAmoledDark(true); + await tester.pump(const Duration(seconds: 1)); + await _goToTab(tester, AppTab.files); + await tester.pump(const Duration(seconds: 2)); + await shoot('14_Files_Dark_Amoled'); + await _goToTab(tester, AppTab.photos); + await tester.pump(const Duration(seconds: 3)); + await shoot('15_Photos_Dark_Amoled'); + }, + timeout: const Timeout(Duration(minutes: 15)), + ); +} + +/// Starts from empty storage, then writes exactly one saved account (the +/// demo user, with the app password from `--dart-define`) and the settings +/// that make screenshots reproducible: light theme, a fixed brand colour +/// (dynamic colour would pick up the emulator's wallpaper), five bottom-nav +/// tabs, and no first-run notification prompt. +Future _seedLoggedInStorage() async { + await const FlutterSecureStorage().deleteAll(); + final prefs = await SharedPreferences.getInstance(); + await prefs.clear(); + + final store = AccountStore(); + final account = SavedAccount( + id: SavedAccount.makeId(_serverUrl, _username), + serverUrl: _serverUrl, + username: _username, + ); + await store.saveAccounts(prefs, [account]); + await store.saveActiveAccountId(prefs, account.id); + await store.writePassword(account.id, _appPassword); + + await prefs.setBool('notification_permission_asked', true); + await prefs.setString('ui_theme_mode', ThemeMode.light.name); + await prefs.setBool('ui_use_dynamic_color', false); + await prefs.setStringList('ui_tab_order', [ + for (final t in AppTab.values) t.name, + ]); + await prefs.setStringList('ui_hidden_tabs', [ + AppTab.trash.name, + AppTab.recent.name, + AppTab.offline.name, + ]); +} + +T _read(WidgetTester tester) => Provider.of( + tester.element(find.byType(app.MainShellView)), + listen: false, +); + +Future _tap(WidgetTester tester, Finder finder) async { + await tester.tap(finder); + await tester.pump(const Duration(milliseconds: 600)); +} + +/// Taps the one widget matching [matching] inside [tab] (e.g. `filesTab`) - +/// for chrome that's duplicated across tabs (`IndexedStack` keeps every +/// visible tab's widget tree mounted, not just the active one) and/or can be +/// scrolled out of its own row's clipped viewport (the controls row, +/// `FilesControlsRow`, doesn't fit every control on a narrow screen). +/// Scoping to [tab] picks the right one of several identical matches; +/// `ensureVisible` scrolls it into view before tapping, which plain +/// `.hitTestable()` can't distinguish from "on the wrong tab" - both look +/// like "zero hit-testable matches" and throw the same unhelpful +/// `Bad state: No element`. +Future _tapInTab(WidgetTester tester, Finder tab, Finder matching) async { + final target = find.descendant(of: tab, matching: matching).first; + await tester.ensureVisible(target); + await tester.pump(const Duration(milliseconds: 300)); + await tester.tap(target, warnIfMissed: false); + await tester.pump(const Duration(milliseconds: 600)); +} + +Future _goToTab(WidgetTester tester, AppTab tab) async { + // Only one bottom nav bar exists (outside the IndexedStack), so no + // cross-tab duplicate to worry about - but it can itself overflow a + // narrow screen, so still scroll the target into view rather than relying + // on hitTestable() alone (see `_tapInTab`'s doc comment). + final target = find + .descendant( + of: find.byType(BottomNavBar), + matching: find.byIcon(tab.icon), + ) + .first; + await tester.ensureVisible(target); + await tester.pump(const Duration(milliseconds: 300)); + await tester.tap(target, warnIfMissed: false); + await tester.pump(const Duration(milliseconds: 600)); + + // Confirm the tap actually landed, rather than trusting it silently - a + // scroll-imprecise tap (see above) that misses its target doesn't throw, + // it just does nothing, which would otherwise leave every later + // screenshot quietly showing whatever tab was already selected instead of + // failing loudly. Every destination shows its label all the time now (see + // `BottomNavBar`'s doc comment), so label presence can't signal selection + // - the icon's color can: it's `accentText` only for the active tab. + await _pumpUntilTrue(tester, () { + if (target.evaluate().isEmpty) return false; + final icon = tester.widget(target); + final colors = tester.element(target).nooColors; + return icon.color == colors.accentText; + }, timeout: 10); +} + +/// Shows exactly [tabs] in the bottom nav (hiding the rest first, so the +/// five-tab cap is never hit). +void _setVisibleTabs(WidgetTester tester, List tabs) { + final settings = _read(tester); + for (final tab in AppTab.values) { + if (!tabs.contains(tab)) settings.setTabHidden(tab, true); + } + for (final tab in tabs) { + settings.setTabHidden(tab, false); + } +} + +/// Waits (in real time - this is a live binding, and the app is talking to a +/// real server) until [finder] matches something. +Future _pumpUntil( + WidgetTester tester, + Finder finder, { + int timeout = 60, +}) async { + final deadline = DateTime.now().add(Duration(seconds: timeout)); + while (finder.evaluate().isEmpty) { + if (DateTime.now().isAfter(deadline)) { + throw TestFailure('Timed out after ${timeout}s waiting for $finder'); + } + await tester.pump(const Duration(milliseconds: 250)); + } +} + +/// Like [_pumpUntil], but for a condition that isn't just "does a finder +/// match something" (e.g. a widget property). +Future _pumpUntilTrue( + WidgetTester tester, + bool Function() predicate, { + int timeout = 60, +}) async { + final deadline = DateTime.now().add(Duration(seconds: timeout)); + while (!predicate()) { + if (DateTime.now().isAfter(deadline)) { + throw TestFailure('Timed out after ${timeout}s waiting for condition'); + } + await tester.pump(const Duration(milliseconds: 250)); + } +} diff --git a/lib/main.dart b/lib/main.dart index 3be9d34..93d27ec 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:dynamic_color/dynamic_color.dart'; import 'package:flutter/material.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'; @@ -22,12 +23,24 @@ import 'providers/trash_controller.dart'; import 'services/pick_intent_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/bottom_nav_bar.dart'; +import 'widgets/create_menu.dart'; +import 'widgets/noo/core/noo_avatar.dart'; +import 'widgets/noo/core/noo_button.dart'; +import 'widgets/noo/core/noo_fab.dart'; +import 'widgets/noo/nav/noo_nav_style.dart'; +import 'widgets/noo/nav/noo_sidebar.dart'; +import 'widgets/noo/nav/noo_toolbar.dart'; +import 'widgets/noo/noo_layout.dart'; +import 'widgets/noo/overlays/noo_dialog.dart'; +import 'widgets/shell/shell_common.dart'; void main() { WidgetsFlutterBinding.ensureInitialized(); @@ -143,21 +156,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 +181,7 @@ class _SplashView extends StatelessWidget { height: 22, child: CircularProgressIndicator( strokeWidth: 2.5, - color: colorScheme.primary, + color: colors.accent, ), ), ], @@ -246,26 +257,34 @@ class _MainShellViewState extends State { if (status.isGranted || status.isPermanentlyDenied) return; if (!mounted) return; - final shouldRequest = await showDialog( - context: context, + final colors = context.nooColors; + final shouldRequest = await showNooDialog( + 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(); @@ -294,9 +313,15 @@ class _MainShellViewState extends State { @override Widget build(BuildContext context) { + final colors = context.nooColors; final settings = context.watch(); final pick = context.watch(); final connectivity = context.watch(); + final session = context.watch(); + final quota = context.watch().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().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 +342,184 @@ class _MainShellViewState extends State { // 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 [] + : 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; + + // 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. + final tabStack = Stack( + children: [ + IndexedStack( + index: selectedIndex, + children: displayTabs + .map((tab) => buildAppTabView(tab, _scrollControllers[tab]!)) + .toList(), + ), + if (pick.isDownloadingForPick) const _PickingProgressOverlay(), + ], + ); + + final canUpload = + pickRequest == null && + (selectedTab == AppTab.files || selectedTab == AppTab.photos); + + Widget scaffold; + if (NooLayout.isDesktop(context)) { + scaffold = Scaffold( + backgroundColor: colors.bg, + body: 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), + ), + if (hiddenTabs.isNotEmpty) const NooSidebarDivider(), + 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 navStyle = NooLayout.navStyle(context); + scaffold = Scaffold( + backgroundColor: colors.bg, + drawerScrimColor: colors.scrim, + appBar: pickRequest == null + ? AppTopBar(style: navStyle, tab: selectedTab) + : null, + drawer: pickRequest == null ? const AppDrawer() : null, + // Android-only extended Upload FAB - iOS uses the top bar's `plus` + // instead (see AppTopBar). + floatingActionButton: canUpload && navStyle == NooNavStyle.android + ? NooFab(onTap: () => showCreateMenu(context)) + : null, + body: tabStack, + bottomNavigationBar: BottomNavBar( + style: navStyle, + tabs: pinnedTabs, + selectedIndex: pinnedIndex, + 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 +529,7 @@ class _MainShellViewState extends State { 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: scaffold, ); } } @@ -403,21 +542,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), + ), + ], ), ), ), diff --git a/lib/models/app_tab.dart b/lib/models/app_tab.dart index c4295c0..8fc5181 100644 --- a/lib/models/app_tab.dart +++ b/lib/models/app_tab.dart @@ -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 @@ -41,24 +41,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 } } } diff --git a/lib/providers/settings_controller.dart b/lib/providers/settings_controller.dart index f6c12ff..f330885 100644 --- a/lib/providers/settings_controller.dart +++ b/lib/providers/settings_controller.dart @@ -23,8 +23,6 @@ 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 _prefHiddenTabs = 'ui_hidden_tabs'; static const _prefDefaultTab = 'ui_default_tab'; @@ -43,8 +41,6 @@ class SettingsController extends ChangeNotifier { bool _amoledDark = false; MediaProgressBarStyle _mediaProgressBarStyle = MediaProgressBarStyle.wavy; - double _bottomBarOpacity = 0.55; - double _bottomBarBlur = 28; bool _tapTabToScrollTop = true; List _tabOrder = AppTab.values.toList(); @@ -82,8 +78,6 @@ class SettingsController extends ChangeNotifier { bool get useDynamicColor => _useDynamicColor; bool get amoledDark => _amoledDark; MediaProgressBarStyle get mediaProgressBarStyle => _mediaProgressBarStyle; - double get bottomBarOpacity => _bottomBarOpacity; - double get bottomBarBlur => _bottomBarBlur; bool get tapTabToScrollTop => _tapTabToScrollTop; /// Every tab in the user's configured order, including hidden ones — used @@ -122,9 +116,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; @@ -223,18 +214,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(); diff --git a/lib/theme/app_theme.dart b/lib/theme/app_theme.dart index fe42c81..5db2bb6 100644 --- a/lib/theme/app_theme.dart +++ b/lib/theme/app_theme.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:google_fonts/google_fonts.dart'; +import 'design_tokens.dart'; class AppTheme { static const Color defaultNextcloudBlue = Color(0xFF0082C9); @@ -30,6 +31,49 @@ class AppTheme { Color(0xFF2E7D32), // Emerald Green ]; + // Design-system typefaces (DESIGN_SYSTEM.md 1.3): Schibsted Grotesk for + // headings, Instrument Sans for UI/body text. Applied over Flutter's + // default TextTheme slots app-wide - screens not yet reworked to the + // design system's own named type scale (`NooText`) still benefit from + // the new typefaces immediately instead of staying on Inter until their + // turn comes. + static TextTheme _textTheme(Brightness brightness) { + final base = brightness == Brightness.light + ? ThemeData.light().textTheme + : ThemeData.dark().textTheme; + return GoogleFonts.instrumentSansTextTheme(base).copyWith( + displayLarge: GoogleFonts.schibstedGrotesk( + textStyle: base.displayLarge, + fontWeight: FontWeight.w600, + ), + displayMedium: GoogleFonts.schibstedGrotesk( + textStyle: base.displayMedium, + fontWeight: FontWeight.w600, + ), + displaySmall: GoogleFonts.schibstedGrotesk( + textStyle: base.displaySmall, + fontWeight: FontWeight.w600, + ), + headlineLarge: GoogleFonts.schibstedGrotesk( + textStyle: base.headlineLarge, + fontWeight: FontWeight.w600, + ), + headlineMedium: GoogleFonts.schibstedGrotesk( + fontWeight: FontWeight.w600, + letterSpacing: -0.5, + ), + headlineSmall: GoogleFonts.schibstedGrotesk( + textStyle: base.headlineSmall, + fontWeight: FontWeight.w600, + ), + titleLarge: GoogleFonts.schibstedGrotesk( + fontWeight: FontWeight.w600, + letterSpacing: -0.2, + ), + titleMedium: GoogleFonts.schibstedGrotesk(fontWeight: FontWeight.w500), + ); + } + static ThemeData light( Color seedColor, { ColorScheme? dynamicScheme, @@ -41,26 +85,19 @@ class AppTheme { seedColor: seedColor, brightness: Brightness.light, ); + final nooColors = (useDynamicColor && dynamicScheme != null) + ? NooColors.fromDynamicScheme(dynamicScheme) + : NooColors.light; return ThemeData( useMaterial3: true, brightness: Brightness.light, colorScheme: colorScheme, + extensions: [nooColors], scaffoldBackgroundColor: colorScheme.surface, pageTransitionsTheme: _pageTransitionsTheme, sliderTheme: _sliderTheme, - textTheme: GoogleFonts.interTextTheme(ThemeData.light().textTheme) - .copyWith( - headlineMedium: GoogleFonts.inter( - fontWeight: FontWeight.w600, - letterSpacing: -0.5, - ), - titleLarge: GoogleFonts.inter( - fontWeight: FontWeight.w600, - letterSpacing: -0.2, - ), - titleMedium: GoogleFonts.inter(fontWeight: FontWeight.w500), - ), + textTheme: _textTheme(Brightness.light), cardTheme: CardThemeData( elevation: 0, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), @@ -71,7 +108,7 @@ class AppTheme { elevation: 0, scrolledUnderElevation: 0, centerTitle: false, - titleTextStyle: GoogleFonts.inter( + titleTextStyle: GoogleFonts.schibstedGrotesk( fontSize: 22, fontWeight: FontWeight.w600, color: colorScheme.onSurface, @@ -92,6 +129,9 @@ class AppTheme { seedColor: seedColor, brightness: Brightness.dark, ); + var nooColors = (useDynamicColor && dynamicScheme != null) + ? NooColors.fromDynamicScheme(dynamicScheme) + : NooColors.dark; if (amoled) { // True OLED black across every surface tone Material 3 hands out - @@ -106,27 +146,18 @@ class AppTheme { surfaceContainerHighest: Colors.black, surfaceDim: Colors.black, ); + nooColors = nooColors.withAmoled(); } return ThemeData( useMaterial3: true, brightness: Brightness.dark, colorScheme: colorScheme, + extensions: [nooColors], scaffoldBackgroundColor: colorScheme.surface, pageTransitionsTheme: _pageTransitionsTheme, sliderTheme: _sliderTheme, - textTheme: GoogleFonts.interTextTheme(ThemeData.dark().textTheme) - .copyWith( - headlineMedium: GoogleFonts.inter( - fontWeight: FontWeight.w600, - letterSpacing: -0.5, - ), - titleLarge: GoogleFonts.inter( - fontWeight: FontWeight.w600, - letterSpacing: -0.2, - ), - titleMedium: GoogleFonts.inter(fontWeight: FontWeight.w500), - ), + textTheme: _textTheme(Brightness.dark), cardTheme: CardThemeData( elevation: 0, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), @@ -137,7 +168,7 @@ class AppTheme { elevation: 0, scrolledUnderElevation: 0, centerTitle: false, - titleTextStyle: GoogleFonts.inter( + titleTextStyle: GoogleFonts.schibstedGrotesk( fontSize: 22, fontWeight: FontWeight.w600, color: colorScheme.onSurface, diff --git a/lib/theme/design_tokens.dart b/lib/theme/design_tokens.dart new file mode 100644 index 0000000..58c9d2c --- /dev/null +++ b/lib/theme/design_tokens.dart @@ -0,0 +1,428 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +/// Semantic color tokens from `.claude/context/design-system/DESIGN_SYSTEM.md` +/// section 1.1 - read that file before changing any value here. Registered +/// on [ThemeData.extensions], so call sites reach these via +/// `Theme.of(context).extension()!` (or a `context.nooColors` +/// getter, once one exists) instead of the Material [ColorScheme] roles. +/// +/// Two independent sources feed this, matching the app's color settings: +/// the fixed [light]/[dark] palettes below (the design system's own +/// default look), or [NooColors.fromDynamicScheme] when the user has +/// Material You dynamic color turned on - there's no third option (no +/// custom accent picker), since the design system explicitly rules out +/// "add new colors". [withAmoled] layers on top of either source when the +/// user's AMOLED toggle is on. +@immutable +class NooColors extends ThemeExtension { + final Color bg; + final Color surface; + final Color surface2; + final Color surface3; + final Color line; + final Color fg1; + final Color fg2; + final Color fg3; + final Color accent; + final Color accentText; + final Color accentSoft; + final Color danger; + final Color dangerSoft; + final Color dangerFill; + final Color success; + final Color successSoft; + final Color warning; + final Color warningSoft; + final Color info; + final Color infoSoft; + final Color scrim; + + const NooColors({ + required this.bg, + required this.surface, + required this.surface2, + required this.surface3, + required this.line, + required this.fg1, + required this.fg2, + required this.fg3, + required this.accent, + required this.accentText, + required this.accentSoft, + required this.danger, + required this.dangerSoft, + required this.dangerFill, + required this.success, + required this.successSoft, + required this.warning, + required this.warningSoft, + required this.info, + required this.infoSoft, + required this.scrim, + }); + + static const light = NooColors( + bg: Color(0xFFF3EFE6), + surface: Color(0xFFFFFFFF), + surface2: Color(0xFFF6F5F2), + surface3: Color(0xFFEAE8E2), + line: Color(0xFFEAE8E2), + fg1: Color(0xFF0E0D0B), + fg2: Color(0xFF5A5852), + fg3: Color(0xFF7C7A72), + accent: Color(0xFF8D0DE3), + accentText: Color(0xFF8D0DE3), + accentSoft: Color(0xFFECDFFF), + danger: Color(0xFFA8202A), + dangerSoft: Color(0xFFFDE7E7), + dangerFill: Color(0xFFA8202A), + success: Color(0xFF3E7A23), + successSoft: Color(0xFFEAF5DF), + warning: Color(0xFF8C5A0E), + warningSoft: Color(0xFFFBEFD0), + info: Color(0xFF2940A8), + infoSoft: Color(0xFFE6ECFE), + scrim: Color(0x730E0D0B), + ); + + static const dark = NooColors( + bg: Color(0xFF0E0D0B), + surface: Color(0xFF25241F), + surface2: Color(0xFF3D3C38), + surface3: Color(0xFF5A5852), + line: Color(0xFF3D3C38), + fg1: Color(0xFFF3EFE6), + fg2: Color(0xFFBDBAB1), + fg3: Color(0xFF9E9B92), + accent: Color(0xFF8D0DE3), + accentText: Color(0xFFCBA5FD), + accentSoft: Color(0xFF2F0454), + danger: Color(0xFFF49E9E), + dangerSoft: Color(0xFF560F18), + dangerFill: Color(0xFFA8202A), + success: Color(0xFFB7DD9F), + successSoft: Color(0xFF1E3F10), + warning: Color(0xFFF0C26C), + warningSoft: Color(0xFF4A3208), + info: Color(0xFFB6C3FB), + infoSoft: Color(0xFF1C2B73), + scrim: Color(0x99000000), + ); + + /// Avatar/photo-placeholder fills (`DESIGN_SYSTEM.md` 1.1) - a fixed + /// secondary palette, same in light and dark, always paired with + /// [avatarTextColor]. Content-identity colors, not theme, so they don't + /// change with brightness or dynamic color (same reasoning as file-type + /// tinting in `styling.md`). + static const avatarPalette = [ + Color(0xFF8DCDE2), + Color(0xFF96B0FD), + Color(0xFFE5D8BD), + Color(0xFFEEEDB3), + Color(0xFFDA9AC7), + Color(0xFFA7D296), + Color(0xFFE9A6A7), + Color(0xFF85D1BD), + Color(0xFFCBA5FD), + ]; + static const avatarTextColor = Color(0xFF0E0D0B); + + /// Maps a Material You dynamic [ColorScheme] onto these roles for users + /// who've turned dynamic color on, instead of the fixed [light]/[dark] + /// palettes. `success`/`successSoft`/`warning`/`warningSoft`/`info`/ + /// `infoSoft` have no Material dynamic equivalent and are content-identity + /// colors regardless (file-type tiles) rather than brand colors, so they + /// always come from the fixed palette matching [cs]'s brightness. + factory NooColors.fromDynamicScheme(ColorScheme cs) { + final fixed = cs.brightness == Brightness.dark ? dark : light; + return NooColors( + bg: cs.surface, + surface: cs.surfaceContainerLow, + surface2: cs.surfaceContainerHigh, + surface3: cs.surfaceContainerHighest, + line: cs.outlineVariant, + fg1: cs.onSurface, + fg2: cs.onSurfaceVariant, + fg3: cs.onSurfaceVariant, + accent: cs.primary, + accentText: cs.primary, + accentSoft: cs.primaryContainer, + danger: cs.error, + dangerSoft: cs.errorContainer, + dangerFill: cs.error, + success: fixed.success, + successSoft: fixed.successSoft, + warning: fixed.warning, + warningSoft: fixed.warningSoft, + info: fixed.info, + infoSoft: fixed.infoSoft, + scrim: cs.scrim, + ); + } + + /// True OLED black across every surface tone - not just [bg] - so cards, + /// bars and sheets all go black too instead of the dark palette's warm + /// dark-grey tones. Mirrors the AMOLED handling this replaces in + /// `AppTheme.dark` (see git history), just against these tokens instead + /// of Material [ColorScheme] roles. + NooColors withAmoled() => copyWith( + bg: Colors.black, + surface: Colors.black, + surface2: Colors.black, + surface3: Colors.black, + ); + + @override + NooColors copyWith({ + Color? bg, + Color? surface, + Color? surface2, + Color? surface3, + Color? line, + Color? fg1, + Color? fg2, + Color? fg3, + Color? accent, + Color? accentText, + Color? accentSoft, + Color? danger, + Color? dangerSoft, + Color? dangerFill, + Color? success, + Color? successSoft, + Color? warning, + Color? warningSoft, + Color? info, + Color? infoSoft, + Color? scrim, + }) { + return NooColors( + bg: bg ?? this.bg, + surface: surface ?? this.surface, + surface2: surface2 ?? this.surface2, + surface3: surface3 ?? this.surface3, + line: line ?? this.line, + fg1: fg1 ?? this.fg1, + fg2: fg2 ?? this.fg2, + fg3: fg3 ?? this.fg3, + accent: accent ?? this.accent, + accentText: accentText ?? this.accentText, + accentSoft: accentSoft ?? this.accentSoft, + danger: danger ?? this.danger, + dangerSoft: dangerSoft ?? this.dangerSoft, + dangerFill: dangerFill ?? this.dangerFill, + success: success ?? this.success, + successSoft: successSoft ?? this.successSoft, + warning: warning ?? this.warning, + warningSoft: warningSoft ?? this.warningSoft, + info: info ?? this.info, + infoSoft: infoSoft ?? this.infoSoft, + scrim: scrim ?? this.scrim, + ); + } + + @override + NooColors lerp(ThemeExtension? other, double t) { + if (other is! NooColors) return this; + return NooColors( + bg: Color.lerp(bg, other.bg, t)!, + surface: Color.lerp(surface, other.surface, t)!, + surface2: Color.lerp(surface2, other.surface2, t)!, + surface3: Color.lerp(surface3, other.surface3, t)!, + line: Color.lerp(line, other.line, t)!, + fg1: Color.lerp(fg1, other.fg1, t)!, + fg2: Color.lerp(fg2, other.fg2, t)!, + fg3: Color.lerp(fg3, other.fg3, t)!, + accent: Color.lerp(accent, other.accent, t)!, + accentText: Color.lerp(accentText, other.accentText, t)!, + accentSoft: Color.lerp(accentSoft, other.accentSoft, t)!, + danger: Color.lerp(danger, other.danger, t)!, + dangerSoft: Color.lerp(dangerSoft, other.dangerSoft, t)!, + dangerFill: Color.lerp(dangerFill, other.dangerFill, t)!, + success: Color.lerp(success, other.success, t)!, + successSoft: Color.lerp(successSoft, other.successSoft, t)!, + warning: Color.lerp(warning, other.warning, t)!, + warningSoft: Color.lerp(warningSoft, other.warningSoft, t)!, + info: Color.lerp(info, other.info, t)!, + infoSoft: Color.lerp(infoSoft, other.infoSoft, t)!, + scrim: Color.lerp(scrim, other.scrim, t)!, + ); + } +} + +/// Reads the app's [NooColors] - registered on every [ThemeData] built by +/// `AppTheme`, so this is never actually null in a running app. +extension NooColorsContext on BuildContext { + NooColors get nooColors => Theme.of(this).extension()!; +} + +/// The 4px spacing scale from the Noo Design System project's +/// `tokens/spacing.css` (claude.ai/design, project id +/// bb69c00d-f400-445c-9683-355668c0bfe1 - the authoritative source now; +/// `.claude/context/design-system/` is the earlier, less precise mockup +/// export). +class NooSpace { + const NooSpace._(); + static const double xxs = 4; + static const double xs = 8; + static const double sm = 12; + static const double smd = 14; + static const double md = 16; + static const double lg = 20; + static const double xl = 24; + static const double xxl = 32; +} + +/// Corner radii from `tokens/spacing.css`. `pill` covers every button, +/// chip, segmented control, toggle, search field and badge. +class NooRadii { + const NooRadii._(); + static const double pill = 999; + static const double card = 20; + static const double gridCard = 18; + static const double input = 14; + static const double sidebarItem = 12; + static const double sheetTop = 28; + static const double dialog = 24; + static const double dropdown = 10; + static const double screenCornerAndroid = 36; + static const double screenCornerIOS = 48; + static const double windowMac = 12; + static const double windowWin = 8; +} + +/// Fixed heights/widths from `tokens/spacing.css` - button sizes, row +/// heights, the desktop sidebar width. Named to match the component that +/// uses each one (`NooButton`'s `size` picks from the `button*` set). +class NooSizes { + const NooSizes._(); + static const double buttonCta = 52; + static const double buttonCard = 44; + static const double buttonField = 40; + static const double buttonToolbar = 36; + static const double buttonCompact = 32; + static const double buttonXs = 28; + static const double rowMobile = 64; + static const double rowDesktop = 52; + static const double settingsRow = 52; + static const double sidebarItem = 38; + static const double toolbar = 64; + static const double sidebarWidth = 256; +} + +/// Motion constants from `tokens/spacing.css`'s `--ease`/`--dur-*`/ +/// `--press-scale`. +class NooMotion { + const NooMotion._(); + static const Curve ease = Cubic(0.7, 0, 0.15, 1); + static const Duration fast = Duration(milliseconds: 180); + static const Duration base = Duration(milliseconds: 280); + static const Duration slow = Duration(milliseconds: 480); + static const double pressScale = 0.98; +} + +/// The one shadow the design system allows, from `tokens/spacing.css`'s +/// `--shadow-dialog` - desktop dialogs only. Nothing else in the app should +/// use a `BoxShadow`. +const nooDialogShadow = BoxShadow( + color: Color(0x241E002F), // rgba(30,0,47,.14) + blurRadius: 48, + offset: Offset(0, 24), +); + +/// The named type scale from `DESIGN_SYSTEM.md` 1.3 - mobile sizes only for +/// now (desktop rework hasn't started). Schibsted Grotesk for headings, +/// Instrument Sans for UI text, per the spec. Color is deliberately not set +/// here - callers pull it from [NooColors] since the same style (e.g. +/// `meta`) is reused across surfaces with different foreground colors. +class NooText { + const NooText._(); + + static TextStyle get largeTitle => GoogleFonts.schibstedGrotesk( + fontSize: 34, + height: 0.9, + fontWeight: FontWeight.w600, + letterSpacing: -34 * 0.03, + ); + + static TextStyle get stat => GoogleFonts.schibstedGrotesk( + fontSize: 32, + height: 0.9, + fontWeight: FontWeight.w600, + letterSpacing: -32 * 0.03, + ); + + static TextStyle get title => GoogleFonts.schibstedGrotesk( + fontSize: 22, + fontWeight: FontWeight.w500, + letterSpacing: -22 * 0.02, + ); + + static TextStyle get cardTitle => GoogleFonts.schibstedGrotesk( + fontSize: 20, + fontWeight: FontWeight.w500, + letterSpacing: -20 * 0.02, + ); + + /// Desktop `GroupedList` title / share dialog headings - 17px, between + /// [cardTitle] and [groupHeading]. + static TextStyle get sectionTitle => GoogleFonts.schibstedGrotesk( + fontSize: 17, + fontWeight: FontWeight.w500, + letterSpacing: -17 * 0.02, + ); + + static TextStyle get groupHeading => GoogleFonts.schibstedGrotesk( + fontSize: 18, + fontWeight: FontWeight.w500, + letterSpacing: -18 * 0.02, + ); + + static TextStyle get bodyL => GoogleFonts.instrumentSans( + fontSize: 16, + height: 1.2, + fontWeight: FontWeight.w400, + ); + + static TextStyle get body => GoogleFonts.instrumentSans( + fontSize: 15, + height: 1.3, + fontWeight: FontWeight.w400, + ); + + static TextStyle get label => GoogleFonts.instrumentSans( + fontSize: 13, + fontWeight: FontWeight.w600, + ); + + static TextStyle get meta => GoogleFonts.instrumentSans( + fontSize: 13, + height: 1.2, + fontWeight: FontWeight.w400, + ); + + /// Bottom-nav label, idle state (Android: 12/500 fg-2 - see + /// `Mobile Screen.dc.html`'s `aNav` block). Use [navLabelActive] for the + /// selected destination. + static TextStyle get navLabel => + GoogleFonts.instrumentSans(fontSize: 12, fontWeight: FontWeight.w500); + + /// Bottom-nav label, active state (Android: 12/600 fg-1, not accent-text - + /// only the icon's pill uses accent-soft/accent-text). + static TextStyle get navLabelActive => + GoogleFonts.instrumentSans(fontSize: 12, fontWeight: FontWeight.w600); + + static TextStyle get button => + GoogleFonts.instrumentSans(fontSize: 15, fontWeight: FontWeight.w600); + + static TextStyle get buttonSm => + GoogleFonts.instrumentSans(fontSize: 14, fontWeight: FontWeight.w600); + + static const mono = TextStyle( + fontFamily: 'monospace', + fontFamilyFallback: ['ui-monospace', 'Menlo'], + fontSize: 14, + height: 1, + ); +} diff --git a/lib/views/account_view.dart b/lib/views/account_view.dart index db83ffc..a294ac7 100644 --- a/lib/views/account_view.dart +++ b/lib/views/account_view.dart @@ -1,1006 +1,101 @@ import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; -import '../models/app_tab.dart'; -import '../models/saved_account.dart'; -import '../providers/files_controller.dart'; -import '../providers/session_controller.dart'; -import '../providers/settings_controller.dart'; -import '../providers/sync_status_controller.dart'; -import '../services/sync_service.dart'; -import '../theme/app_theme.dart'; -import '../widgets/app_tab_view_builder.dart'; -import '../widgets/frosted_glass_container.dart'; -import '../widgets/seek_bar_painter.dart'; -import '../widgets/synced_header_scaffold.dart' show formatQuota; -import 'login_view.dart'; +import '../theme/design_tokens.dart'; +import '../widgets/noo/nav/noo_top_bar.dart'; +import '../widgets/noo/nav/noo_toolbar.dart'; +import '../widgets/noo/noo_layout.dart'; +import '../widgets/settings/settings_account_card.dart'; +import '../widgets/settings/settings_accounts.dart'; +import '../widgets/settings/settings_appearance.dart'; +import '../widgets/settings/settings_file_sync.dart'; +import '../widgets/settings/settings_files_cache.dart'; +import '../widgets/settings/settings_security.dart'; +import '../widgets/settings/settings_swipe.dart'; +import '../widgets/settings/settings_tabs.dart'; +/// Settings, pushed on top of the shell (DESIGN_SYSTEM.md 4's 8-section +/// order: account card, accounts, security, file sync, files cache, +/// appearance, tabs, swipe on a file). One column of [SettingsSection]s on +/// mobile; a 2-column grid of cards on desktop - see each +/// `widgets/settings/*.dart` file for a section's own content and any +/// setting that had to be slotted in or grouped under "Advanced appearance". class AccountView extends StatelessWidget { const AccountView({super.key}); + static const _sections = [ + SettingsAccountCard(), + SettingsAccountsSection(), + SettingsSecuritySection(), + SettingsFileSyncSection(), + SettingsFilesCacheSection(), + SettingsAppearanceSection(), + SettingsTabsSection(), + SettingsSwipeSection(), + ]; + @override Widget build(BuildContext context) { - final theme = Theme.of(context); - final colorScheme = theme.colorScheme; - final session = context.watch(); - final settings = context.watch(); - final files = context.watch(); - final quota = files.quota; + final colors = context.nooColors; + final desktop = NooLayout.isDesktop(context); return Scaffold( - appBar: AppBar(title: const Text('Settings')), + backgroundColor: colors.bg, + appBar: desktop + ? const NooToolbar(title: 'Settings') + : NooTopBar( + style: NooLayout.navStyle(context), + title: 'Settings', + leading: const NooTopBarBack(), + ), body: SafeArea( - child: ListView( - padding: const EdgeInsets.fromLTRB(20, 8, 20, 32), - physics: const BouncingScrollPhysics(), - children: [ - // User Profile & Storage Card - Container( - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - color: colorScheme.primaryContainer.withValues(alpha: 0.4), - borderRadius: BorderRadius.circular(28), - border: Border.all( - color: colorScheme.primary.withValues(alpha: 0.2), - ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - CircleAvatar( - radius: 28, - backgroundColor: colorScheme.primary, - child: Text( - (quota?.userName ?? session.username) - .substring(0, 1) - .toUpperCase(), - style: theme.textTheme.headlineSmall?.copyWith( - color: colorScheme.onPrimary, - fontWeight: FontWeight.bold, - ), - ), - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - quota?.userName ?? session.username, - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: 2), - Text( - quota?.email ?? session.username, - style: theme.textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ), - ], - ), - const SizedBox(height: 16), - const Divider(height: 1), - const SizedBox(height: 16), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - 'Storage Quota', - style: theme.textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - Text( - quota != null ? formatQuota(quota) : 'Loading…', - style: theme.textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - fontWeight: FontWeight.w500, - ), - ), - ], - ), - const SizedBox(height: 10), - ClipRRect( - borderRadius: BorderRadius.circular(6), - child: LinearProgressIndicator( - value: quota != null && quota.totalBytes > 0 - ? quota.usagePercentage - : 0.1, - minHeight: 10, - backgroundColor: colorScheme.surfaceContainerHighest, - valueColor: AlwaysStoppedAnimation( - colorScheme.primary, - ), - ), - ), - const SizedBox(height: 8), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: Text( - Uri.tryParse(session.serverUrl)?.host ?? - session.serverUrl, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - fontSize: 11, - ), - ), - ), - Text( - quota != null - ? (quota.totalBytes > 0 - ? '${(quota.usagePercentage * 100).toStringAsFixed(1)}% used' - : 'Unlimited storage') - : '', - style: theme.textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - fontSize: 11, - ), - ), - ], - ), - if (quota != null && quota.groups.isNotEmpty) ...[ - const SizedBox(height: 16), - const Divider(height: 1), - const SizedBox(height: 16), - Text( - 'Groups', - style: theme.textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 10), - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - for (final group in quota.groups) - Container( - padding: const EdgeInsets.symmetric( - horizontal: 14, - vertical: 8, - ), - decoration: BoxDecoration( - color: colorScheme.surfaceContainerHigh, - borderRadius: BorderRadius.circular(20), - ), - child: Text( - group, - style: theme.textTheme.bodyMedium, - ), - ), - ], - ), - ], - const SizedBox(height: 16), - const Divider(height: 1), - const SizedBox(height: 16), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - IconButton( - icon: const Icon(Icons.refresh_rounded), - tooltip: 'Refresh cached data', - onPressed: () async { - await files.refreshData(); - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Refreshed WebDAV data'), - behavior: SnackBarBehavior.floating, - ), - ); - } - }, - ), - IconButton( - icon: const Icon(Icons.logout_rounded), - tooltip: 'Logout', - color: Colors.orange.shade700, - onPressed: () => _handleLogout(context, session), - ), - IconButton( - icon: const Icon(Icons.delete_outline_rounded), - tooltip: 'Remove account', - color: colorScheme.error, - onPressed: () => - _confirmRemoveActive(context, session), - ), - ], - ), - ], - ), - ), - const SizedBox(height: 24), - - // Accounts Section - Text( - 'Accounts', - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: 10), - const _AccountsCard(), - const SizedBox(height: 24), - - // Security Section - Text( - 'Security', - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: 10), - const _SecurityCard(), - const SizedBox(height: 24), - - // Device Sync Section - Text( - 'Device Sync', - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: 10), - const _DeviceSyncCard(), - const SizedBox(height: 24), - - // Files Cache - Text( - 'Files Cache', - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: 4), - Text( - 'How often folder listings and synced files refresh from the server', - style: theme.textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 10), - const _CacheSettingsCard(), - const SizedBox(height: 24), - - - // Material You Design Settings - Text( - 'Material You Aesthetics', - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: 10), - Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Accent Color', - style: theme.textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 4), - Text( - settings.useDynamicColor - ? 'Matching your wallpaper' - : 'Custom color', - style: theme.textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 12), - Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: [ - _AccentSwatch( - isSelected: settings.useDynamicColor, - onTap: () => settings.setUseDynamicColor(true), - borderColor: colorScheme.onSurface, - background: colorScheme.surfaceContainerHighest, - child: Icon( - Icons.wallpaper_rounded, - color: colorScheme.onSurfaceVariant, - size: 20, - ), - ), - ...AppTheme.seedColors.map((color) { - final isSelected = - !settings.useDynamicColor && - settings.seedColor == color; - return _AccentSwatch( - isSelected: isSelected, - onTap: () => settings.setSeedColor(color), - borderColor: colorScheme.onSurface, - background: color, - child: isSelected - ? const Icon( - Icons.check_rounded, - color: Colors.white, - size: 20, - ) - : null, - ); - }), - ], - ), - const SizedBox(height: 20), - const Divider(height: 1), - const SizedBox(height: 16), - Text( - 'Theme Mode', - style: theme.textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 12), - SizedBox( - width: double.infinity, - child: SegmentedButton( - segments: const [ - ButtonSegment( - value: ThemeMode.system, - icon: Icon(Icons.brightness_auto_rounded), - label: Text('System'), - ), - ButtonSegment( - value: ThemeMode.light, - icon: Icon(Icons.light_mode_rounded), - label: Text('Light'), - ), - ButtonSegment( - value: ThemeMode.dark, - icon: Icon(Icons.dark_mode_rounded), - label: Text('Dark'), - ), - ], - selected: {settings.themeMode}, - onSelectionChanged: (set) => - settings.setThemeMode(set.first), - ), - ), - const SizedBox(height: 8), - SwitchListTile( - contentPadding: EdgeInsets.zero, - title: const Text('AMOLED Black'), - subtitle: const Text( - 'Use pure black backgrounds in dark mode', - ), - value: settings.amoledDark, - onChanged: settings.setAmoledDark, - ), - ], - ), - ), - ), - const SizedBox(height: 24), - - // UI Settings - Text( - 'UI', - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: 10), - const _BottomBarAppearanceCard(), - const SizedBox(height: 12), - Card( - child: SwitchListTile( - title: const Text('Tap Tab to Scroll to Top'), - subtitle: const Text( - 'Tapping the current bottom bar tab scrolls its list back to the top', - ), - value: settings.tapTabToScrollTop, - onChanged: settings.setTapTabToScrollTop, - ), - ), - const SizedBox(height: 24), - - // Bottom Nav Tabs - Text( - 'Bottom Bar Tabs', - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: 4), - Text( - 'Drag to reorder, tap the star to set the default, toggle to show or hide', - style: theme.textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 10), - const _TabSettingsCard(), - const SizedBox(height: 24), - - // Swipe Actions - Text( - 'Files Swipe Actions', - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: 4), - Text( - 'Choose what swiping a file left or right does in list view', - style: theme.textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 10), - const _SwipeActionsCard(), - const SizedBox(height: 24), - - // Media Player - Text( - 'Media Player', - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: 4), - Text( - 'Choose the seek bar style used when playing videos', - style: theme.textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 10), - const _MediaPlayerCard(), - ], - ), + top: false, + child: desktop ? _DesktopGrid(sections: _sections) : _MobileList(sections: _sections), ), ); } +} - /// Logout keeps the account saved (see [SessionController.logout]) so - /// it's harmless/reversible from a one-tap resume on the login screen - - /// doesn't need a confirmation dialog the way [_confirmRemoveActive] does. - Future _handleLogout( - BuildContext context, - SessionController session, - ) async { - final navigator = Navigator.of(context); - await session.logout(); - navigator.popUntil((route) => route.isFirst); - } +class _MobileList extends StatelessWidget { + final List sections; + const _MobileList({required this.sections}); - Future _confirmRemoveActive( - BuildContext context, - SessionController session, - ) async { - final host = Uri.tryParse(session.serverUrl)?.host ?? session.serverUrl; - final hasOtherAccounts = session.accounts.length > 1; - final confirmed = await showDialog( - context: context, - builder: (dialogContext) { - return AlertDialog( - title: const Text('Remove Account'), - content: Text( - hasOtherAccounts - ? 'Remove ${session.username} ($host)? Another saved account will become active.' - : 'Remove ${session.username} ($host)? You can add it again later.', - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(dialogContext, false), - child: const Text('Cancel'), - ), - FilledButton( - style: FilledButton.styleFrom( - backgroundColor: Theme.of(dialogContext).colorScheme.error, - ), - onPressed: () => Navigator.pop(dialogContext, true), - child: const Text('Remove'), - ), - ], - ); - }, + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.fromLTRB(NooSpace.sm, NooSpace.sm, NooSpace.sm, NooSpace.xxl), + physics: const BouncingScrollPhysics(), + children: [ + for (final section in sections) ...[section, const SizedBox(height: NooSpace.xl)], + ], ); - if (confirmed != true) return; - final id = session.activeAccountId; - if (id == null) return; - if (!context.mounted) return; - final navigator = Navigator.of(context); - await session.removeAccount(id); - // Only pop back to the root route if that was the last account and the - // session actually ended - if it fell back to another saved account, - // Settings just keeps showing (now for that account) instead. - if (!session.isLoggedIn) { - navigator.popUntil((route) => route.isFirst); + } +} + +/// Splits the sections between two columns rather than a strict grid, since +/// each card's content height varies a lot (the tab reorder list and the +/// accounts list can both run much taller than, say, Security) - a fixed +/// grid would either clip content or leave large gaps. +class _DesktopGrid extends StatelessWidget { + final List sections; + const _DesktopGrid({required this.sections}); + + @override + Widget build(BuildContext context) { + final left = []; + final right = []; + for (var i = 0; i < sections.length; i++) { + (i.isEven ? left : right).add(sections[i]); } - } -} -/// Lists every saved account, letting the user switch to an inactive one, -/// remove any of them, or add another via [LoginView] pushed in "add -/// account" mode. -class _AccountsCard extends StatelessWidget { - const _AccountsCard(); - - Future _confirmRemove( - BuildContext context, - SessionController session, - SavedAccount account, - ) async { - final isActive = account.id == session.activeAccountId; - final host = Uri.tryParse(account.serverUrl)?.host ?? account.serverUrl; - final confirmed = await showDialog( - context: context, - builder: (dialogContext) { - return AlertDialog( - title: const Text('Remove Account'), - content: Text( - isActive && session.accounts.length > 1 - ? 'Remove ${account.username} ($host)? Another saved account will become active.' - : 'Remove ${account.username} ($host)? You can add it again later.', - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(dialogContext, false), - child: const Text('Cancel'), - ), - FilledButton( - style: FilledButton.styleFrom( - backgroundColor: Theme.of(dialogContext).colorScheme.error, - ), - onPressed: () => Navigator.pop(dialogContext, true), - child: const Text('Remove'), - ), - ], - ); - }, - ); - if (confirmed == true) { - if (!context.mounted) return; - final navigator = Navigator.of(context); - await session.removeAccount(account.id); - // Only when this was the last saved account does isLoggedIn drop to - // false and main.dart swap the root route to LoginView underneath - - // pop back to it then, rather than leaving Settings stranded on top. - // Removing a non-active account, or falling back to another one, - // both keep the user logged in, so Settings should just stay put. - if (!session.isLoggedIn) { - navigator.popUntil((route) => route.isFirst); - } - } - } - - @override - Widget build(BuildContext context) { - final session = context.watch(); - final accounts = session.accounts; - - return Card( - child: Column( - children: [ - for (final account in accounts) ...[ - _AccountRow( - account: account, - isActive: account.id == session.activeAccountId, - onTap: account.id == session.activeAccountId - ? null - : () => session.switchAccount(account.id), - onRemove: () => _confirmRemove(context, session, account), - ), - const Divider(height: 1, indent: 16, endIndent: 16), - ], - ListTile( - leading: const Icon(Icons.add_circle_outline_rounded), - title: const Text('Add account'), - onTap: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (_) => const LoginView(isAddingAccount: true), - ), - ); - }, - ), - ], - ), - ); - } -} - -class _AccountRow extends StatelessWidget { - final SavedAccount account; - final bool isActive; - final VoidCallback? onTap; - final VoidCallback onRemove; - - const _AccountRow({ - required this.account, - required this.isActive, - required this.onTap, - required this.onRemove, - }); - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - final host = Uri.tryParse(account.serverUrl)?.host ?? account.serverUrl; - final initial = account.username.isNotEmpty - ? account.username[0].toUpperCase() - : '?'; - - return ListTile( - leading: CircleAvatar( - backgroundColor: colorScheme.primary, - child: Text( - initial, - style: TextStyle( - color: colorScheme.onPrimary, - fontWeight: FontWeight.bold, - ), - ), - ), - title: Text(account.username), - subtitle: Text(host), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (isActive) - Icon(Icons.check_circle_rounded, color: colorScheme.primary), - IconButton( - icon: const Icon(Icons.delete_outline_rounded), - tooltip: 'Remove account', - onPressed: onRemove, - ), - ], - ), - onTap: onTap, - ); - } -} - -/// Login lock: gates opening the app, switching accounts, and revealing -/// hidden files behind the device's own PIN/biometric credential (see -/// AppLockService - this app never stores or handles a PIN itself). -class _SecurityCard extends StatelessWidget { - const _SecurityCard(); - - Future _handleLoginLockChanged( - BuildContext context, - SessionController session, - bool value, - ) async { - final success = value - ? await session.setupLoginLock() - : await session.disableLoginLock(); - if (!success && context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - value - ? "Could not set up login lock - make sure this device has a PIN, pattern, password, or biometric configured" - : 'Could not turn off login lock', - ), - behavior: SnackBarBehavior.floating, - ), - ); - } - } - - @override - Widget build(BuildContext context) { - final session = context.watch(); - - return Card( - child: Column( - children: [ - SwitchListTile( - secondary: const Icon(Icons.lock_outline_rounded), - title: const Text('Login Lock'), - subtitle: const Text( - "Require this device's PIN or biometric to open Noo", - ), - value: session.loginLockEnabled, - onChanged: (value) => - _handleLoginLockChanged(context, session, value), - ), - const Divider(height: 1, indent: 16, endIndent: 16), - SwitchListTile( - secondary: const Icon(Icons.swap_horiz_rounded), - title: const Text('Lock account switching'), - subtitle: const Text('Unlock to switch between saved accounts'), - value: session.lockAccountSwitching, - onChanged: session.loginLockEnabled - ? session.setLockAccountSwitching - : null, - ), - const Divider(height: 1, indent: 16, endIndent: 16), - SwitchListTile( - secondary: const Icon(Icons.visibility_off_rounded), - title: const Text('Lock hidden files'), - subtitle: const Text('Unlock to reveal hidden files and folders'), - value: session.lockHiddenFiles, - onChanged: session.loginLockEnabled - ? session.setLockHiddenFiles - : null, - ), - ], - ), - ); - } -} - -class _DeviceSyncCard extends StatefulWidget { - const _DeviceSyncCard(); - - @override - State<_DeviceSyncCard> createState() => _DeviceSyncCardState(); -} - -class _DeviceSyncCardState extends State<_DeviceSyncCard> { - bool _syncingNow = false; - - Future _syncNow( - SessionController session, - SyncStatusController sync, - ) async { - setState(() => _syncingNow = true); - try { - await SyncService.syncNow(session, sync); - } catch (e) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Could not start sync: $e'), - behavior: SnackBarBehavior.floating, - ), - ); - } - } finally { - if (mounted) setState(() => _syncingNow = false); - } - } - - @override - Widget build(BuildContext context) { - final session = context.watch(); - final sync = context.watch(); - final folders = sync.syncedPaths; - final everything = sync.syncEverything; - - return Card( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SwitchListTile( - secondary: const Icon(Icons.cloud_sync_rounded), - title: const Text('Sync everything'), - subtitle: const Text( - 'Mirror the whole account instead of picking folders', - ), - value: everything, - onChanged: sync.setSyncEverything, - ), - const Divider(height: 1, indent: 16, endIndent: 16), - ListTile( - leading: const Icon(Icons.offline_pin_rounded), - title: const Text('View offline files'), - subtitle: Text( - everything - ? 'Every folder in this account is synced' - : folders.isEmpty - ? 'Nothing synced yet' - : '${folders.length} synced folder${folders.length == 1 ? '' : 's'}/file${folders.length == 1 ? '' : 's'}', - ), - trailing: const Icon(Icons.chevron_right_rounded), - onTap: () => Navigator.push( - context, - MaterialPageRoute( - builder: (_) => - Scaffold(body: buildAppTabView(AppTab.offline, ScrollController())), - ), - ), - ), - const Divider(height: 1, indent: 16, endIndent: 16), - SwitchListTile( - secondary: const Icon(Icons.notifications_outlined), - title: const Text('Background sync notifications'), - subtitle: const Text( - 'Show a notification when a background sync updates files. ' - 'Conflicts and "Sync now" always notify.', - ), - value: sync.syncNotifications, - onChanged: sync.setSyncNotifications, - ), - const Divider(height: 1, indent: 16, endIndent: 16), - SwitchListTile( - secondary: const Icon(Icons.signal_cellular_alt_rounded), - title: const Text('Sync on cellular'), - subtitle: const Text('Off = background sync only runs on Wi-Fi'), - value: sync.syncOnCellular, - onChanged: sync.setSyncOnCellular, - ), - const Divider(height: 1, indent: 16, endIndent: 16), - ListTile( - leading: _syncingNow - ? const Padding( - padding: EdgeInsets.all(2), - child: SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator(strokeWidth: 2), - ), - ) - : const Icon(Icons.sync_rounded), - title: const Text('Sync now'), - enabled: (everything || folders.isNotEmpty) && !_syncingNow, - onTap: () => _syncNow(session, sync), - ), - ], - ), - ); - } -} - -class _BottomBarAppearanceCard extends StatefulWidget { - const _BottomBarAppearanceCard(); - - @override - State<_BottomBarAppearanceCard> createState() => - _BottomBarAppearanceCardState(); -} - -class _BottomBarAppearanceCardState extends State<_BottomBarAppearanceCard> { - // Mirrors the controller values locally so the slider thumb and the live - // preview track the drag gesture on every frame. Driving them straight off - // `settings.bottomBar*` instead would tie the slider's own responsiveness - // to a full Provider-wide rebuild — including the expensive BackdropFilter - // blur in the preview — which can't keep up with fast drag ticks and makes - // the thumb appear stuck until the next unrelated rebuild. - double? _opacity; - double? _blur; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final colorScheme = theme.colorScheme; - final settings = context.watch(); - final opacity = _opacity ?? settings.bottomBarOpacity; - final blur = _blur ?? settings.bottomBarBlur; - - return Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( + return SingleChildScrollView( + padding: const EdgeInsets.all(NooSpace.xl), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 1040), + child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - 'Bottom Bar Appearance', - style: theme.textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 4), - Text( - 'Adjust the glass effect of the floating bottom bar', - style: theme.textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 16), - - // Live preview - Container( - height: 140, - width: double.infinity, - clipBehavior: Clip.antiAlias, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(20), - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [ - colorScheme.primary, - colorScheme.tertiary, - colorScheme.secondary, - ], - ), - ), - child: Stack( - children: [ - Positioned.fill( - child: CustomPaint(painter: _PreviewPatternPainter()), - ), - Align( - alignment: Alignment.bottomCenter, - child: Padding( - padding: const EdgeInsets.only(bottom: 16), - child: FrostedGlassContainer( - opacity: opacity, - blurSigma: blur, - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 18, - vertical: 12, - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.folder_rounded, - color: colorScheme.onSurfaceVariant, - ), - const SizedBox(width: 16), - Icon( - Icons.photo_library_rounded, - color: colorScheme.onSurfaceVariant, - ), - const SizedBox(width: 16), - Icon( - Icons.history_rounded, - color: colorScheme.onSurfaceVariant, - ), - ], - ), - ), - ), - ), - ), - ], - ), - ), - const SizedBox(height: 20), - - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text('Opacity', style: theme.textTheme.bodyMedium), - Text( - '${(opacity * 100).round()}%', - style: theme.textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ], - ), - Slider( - value: opacity, - min: 0.1, - max: 1.0, - onChanged: (value) => setState(() => _opacity = value), - onChangeEnd: settings.setBottomBarOpacity, - ), - - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text('Blur', style: theme.textTheme.bodyMedium), - Text( - blur.round().toString(), - style: theme.textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ], - ), - Slider( - value: blur, - min: 0, - max: 40, - onChanged: (value) => setState(() => _blur = value), - onChangeEnd: settings.setBottomBarBlur, - ), + Expanded(child: _Column(children: left)), + const SizedBox(width: NooSpace.xl), + Expanded(child: _Column(children: right)), ], ), ), @@ -1008,524 +103,17 @@ class _BottomBarAppearanceCardState extends State<_BottomBarAppearanceCard> { } } -class _PreviewPatternPainter extends CustomPainter { - @override - void paint(Canvas canvas, Size size) { - final paint = Paint() - ..color = Colors.white.withValues(alpha: 0.25) - ..style = PaintingStyle.stroke - ..strokeWidth = 10; - for (var x = -size.height; x < size.width; x += 24) { - canvas.drawLine( - Offset(x, size.height), - Offset(x + size.height, 0), - paint, - ); - } - } - - @override - bool shouldRepaint(covariant _PreviewPatternPainter oldDelegate) => false; -} - -class _AccentSwatch extends StatelessWidget { - final bool isSelected; - final VoidCallback onTap; - final Color borderColor; - final Color background; - final Widget? child; - - const _AccentSwatch({ - required this.isSelected, - required this.onTap, - required this.borderColor, - required this.background, - this.child, - }); +class _Column extends StatelessWidget { + final List children; + const _Column({required this.children}); @override Widget build(BuildContext context) { - return GestureDetector( - onTap: onTap, - child: Container( - width: 44, - height: 44, - decoration: BoxDecoration( - color: background, - shape: BoxShape.circle, - border: isSelected ? Border.all(color: borderColor, width: 3) : null, - ), - child: child, - ), - ); - } -} - -class _SwipeActionsCard extends StatelessWidget { - const _SwipeActionsCard(); - - String _label(SwipeAction action) { - switch (action) { - case SwipeAction.none: - return 'None'; - case SwipeAction.favorite: - return 'Favorite'; - case SwipeAction.delete: - return 'Delete'; - case SwipeAction.share: - return 'Share'; - } - } - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final settings = context.watch(); - - return Card( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), - child: Column( - children: [ - _buildRow( - context, - theme, - label: 'Swipe right', - value: settings.swipeRightAction, - onChanged: settings.setSwipeRightAction, - ), - const Divider(height: 1), - _buildRow( - context, - theme, - label: 'Swipe left', - value: settings.swipeLeftAction, - onChanged: settings.setSwipeLeftAction, - ), - ], - ), - ), - ); - } - - Widget _buildRow( - BuildContext context, - ThemeData theme, { - required String label, - required SwipeAction value, - required ValueChanged onChanged, - }) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 8), - child: Row( - children: [ - Expanded( - child: Text( - label, - style: theme.textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - ), - DropdownButton( - value: value, - underline: const SizedBox.shrink(), - borderRadius: BorderRadius.circular(12), - items: SwipeAction.values - .map((a) => DropdownMenuItem(value: a, child: Text(_label(a)))) - .toList(), - onChanged: (a) { - if (a != null) onChanged(a); - }, - ), - ], - ), - ); - } -} - -String _seekBarStyleLabel(MediaProgressBarStyle style) { - switch (style) { - case MediaProgressBarStyle.classic: - return 'Default'; - case MediaProgressBarStyle.wavy: - return 'Wavy'; - case MediaProgressBarStyle.slim: - return 'Slim'; - case MediaProgressBarStyle.squiggly: - return 'Squiggly'; - } -} - -class _MediaPlayerCard extends StatelessWidget { - const _MediaPlayerCard(); - - Future _openPicker( - BuildContext context, - SettingsController settings, - ) async { - final selected = await showDialog( - context: context, - builder: (_) => - _SeekBarStyleDialog(current: settings.mediaProgressBarStyle), - ); - if (selected != null) settings.setMediaProgressBarStyle(selected); - } - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final colorScheme = theme.colorScheme; - final settings = context.watch(); - - return Card( - child: ListTile( - contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), - title: Text( - 'Seek bar style', - style: theme.textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - subtitle: Text(_seekBarStyleLabel(settings.mediaProgressBarStyle)), - trailing: SizedBox( - width: 64, - height: 24, - child: SeekBarPreview( - style: settings.mediaProgressBarStyle, - playedColor: colorScheme.primary, - trackColor: colorScheme.outlineVariant, - ), - ), - onTap: () => _openPicker(context, settings), - ), - ); - } -} - -/// A grid of the four [MediaProgressBarStyle] presets, each shown as a live -/// preview of the real seek bar painter, matching the preset-picker pattern -/// other Material You media players use for this same setting. -class _SeekBarStyleDialog extends StatelessWidget { - final MediaProgressBarStyle current; - - const _SeekBarStyleDialog({required this.current}); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - - return Dialog( - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)), - child: Padding( - padding: const EdgeInsets.fromLTRB(20, 20, 20, 12), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Seek Bar Style', - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: 16), - GridView.count( - crossAxisCount: 2, - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - mainAxisSpacing: 12, - crossAxisSpacing: 12, - childAspectRatio: 1.35, - children: [ - for (final style in MediaProgressBarStyle.values) - _SeekBarStyleOption( - style: style, - isSelected: style == current, - onTap: () => Navigator.pop(context, style), - ), - ], - ), - const SizedBox(height: 4), - Align( - alignment: Alignment.centerRight, - child: TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('Cancel'), - ), - ), - ], - ), - ), - ); - } -} - -class _SeekBarStyleOption extends StatelessWidget { - final MediaProgressBarStyle style; - final bool isSelected; - final VoidCallback onTap; - - const _SeekBarStyleOption({ - required this.style, - required this.isSelected, - required this.onTap, - }); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final colorScheme = theme.colorScheme; - - return Material( - color: isSelected - ? colorScheme.primaryContainer.withValues(alpha: 0.4) - : colorScheme.surfaceContainerHigh, - borderRadius: BorderRadius.circular(20), - child: InkWell( - borderRadius: BorderRadius.circular(20), - onTap: onTap, - child: Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(20), - border: Border.all( - color: isSelected - ? colorScheme.primary - : colorScheme.outlineVariant, - width: isSelected ? 2 : 1, - ), - ), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Expanded( - child: Center( - child: SizedBox( - width: 100, - height: 28, - child: SeekBarPreview( - style: style, - playedColor: colorScheme.primary, - trackColor: colorScheme.outlineVariant, - ), - ), - ), - ), - const SizedBox(height: 8), - Text( - _seekBarStyleLabel(style), - style: theme.textTheme.labelLarge?.copyWith( - fontWeight: FontWeight.w600, - color: isSelected - ? colorScheme.primary - : colorScheme.onSurface, - ), - ), - ], - ), - ), - ), - ); - } -} - -class _CacheSettingsCard extends StatelessWidget { - const _CacheSettingsCard(); - - String _label(CachePolicy policy) { - switch (policy) { - case CachePolicy.never: - return 'Never cache'; - case CachePolicy.interval: - return 'Refresh periodically'; - case CachePolicy.manual: - return 'Refresh manually only'; - } - } - - String _description(CachePolicy policy) { - switch (policy) { - case CachePolicy.never: - return 'Every visit to a folder fetches it fresh; synced files are ' - 'checked each time you open the app'; - case CachePolicy.interval: - return 'Reuse a folder\'s listing until it\'s a few minutes old; ' - 'synced files update in the background on the same schedule'; - case CachePolicy.manual: - return 'Reuse a folder\'s listing until you pull to refresh; synced ' - 'files only update when you pull down or tap Sync now'; - } - } - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final colorScheme = theme.colorScheme; - final files = context.watch(); - - return Card( - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: Column( - children: [ - RadioGroup( - groupValue: files.cachePolicy, - onChanged: (value) { - if (value != null) files.setCachePolicy(value); - }, - child: Column( - children: [ - for (final policy in CachePolicy.values) - RadioListTile( - value: policy, - title: Text(_label(policy)), - subtitle: Text(_description(policy)), - ), - ], - ), - ), - if (files.cachePolicy == CachePolicy.interval) - Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Divider(), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - 'Refresh interval', - style: theme.textTheme.bodyMedium, - ), - Text( - '${files.cacheIntervalMinutes} min', - style: theme.textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ], - ), - Slider( - value: files.cacheIntervalMinutes.toDouble(), - min: 1, - max: 60, - divisions: 59, - label: '${files.cacheIntervalMinutes} min', - onChanged: (value) => - files.setCacheIntervalMinutes(value.round()), - ), - if (files.cacheIntervalMinutes < 15) - Text( - 'While the app is closed, synced files refresh at most ' - 'every 15 min (an Android limit).', - style: theme.textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ), - ], - ), - ), - ); - } -} - -class _TabSettingsCard extends StatelessWidget { - const _TabSettingsCard(); - - @override - Widget build(BuildContext context) { - final settings = context.watch(); - - return Card( - clipBehavior: Clip.antiAlias, - child: ReorderableListView( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - onReorderItem: (oldIndex, newIndex) { - final order = List.from(settings.tabOrder); - final tab = order.removeAt(oldIndex); - order.insert(newIndex, tab); - settings.setTabOrder(order); - }, - children: [ - for (final tab in settings.tabOrder) - _TabConfigRow( - key: ValueKey(tab), - tab: tab, - isVisible: !settings.hiddenTabs.contains(tab), - isDefault: settings.defaultTab == tab, - onVisibilityChanged: (value) { - final error = settings.setTabHidden(tab, !value); - if (error != null && context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(error), - behavior: SnackBarBehavior.floating, - ), - ); - } - }, - onSetDefault: () => settings.setDefaultTab(tab), - ), - ], - ), - ); - } -} - -class _TabConfigRow extends StatelessWidget { - final AppTab tab; - final bool isVisible; - final bool isDefault; - final ValueChanged onVisibilityChanged; - final VoidCallback onSetDefault; - - const _TabConfigRow({ - required super.key, - required this.tab, - required this.isVisible, - required this.isDefault, - required this.onVisibilityChanged, - required this.onSetDefault, - }); - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - - return ListTile( - leading: Icon( - tab.icon, - color: isVisible - ? colorScheme.onSurfaceVariant - : colorScheme.outlineVariant, - ), - title: Text( - tab.label, - style: TextStyle(color: isVisible ? null : colorScheme.outline), - ), - subtitle: isDefault ? const Text('Default tab') : null, - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - icon: Icon( - isDefault ? Icons.star_rounded : Icons.star_outline_rounded, - color: isDefault - ? Colors.amber.shade700 - : colorScheme.outlineVariant, - ), - tooltip: 'Set as default tab', - onPressed: isVisible && !isDefault ? onSetDefault : null, - ), - Switch(value: isVisible, onChanged: onVisibilityChanged), - ], - ), + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final child in children) ...[child, const SizedBox(height: NooSpace.xl)], + ], ); } } diff --git a/lib/views/activity_view.dart b/lib/views/activity_view.dart index fb8e8e0..8121ee0 100644 --- a/lib/views/activity_view.dart +++ b/lib/views/activity_view.dart @@ -1,11 +1,31 @@ import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import 'package:provider/provider.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import '../models/nextcloud_item.dart'; import '../providers/files_controller.dart'; -import '../widgets/more_tabs_button.dart'; -import '../widgets/profile_avatar_button.dart'; -import '../widgets/synced_header_scaffold.dart'; +import '../providers/session_controller.dart'; +import '../theme/design_tokens.dart'; +import '../widgets/noo/lists/noo_grouped_list.dart'; +import '../widgets/noo/media/noo_activity_item.dart'; +import '../widgets/noo/media/noo_photo_tile.dart'; +import '../widgets/noo/noo_layout.dart'; +import '../widgets/tabs/tab_day_groups.dart'; +import '../widgets/tabs/tab_state_slivers.dart'; +/// The whole-account activity feed - lives on [FilesController] (fetched +/// alongside the current Files folder) rather than its own controller, see +/// `FilesController.refreshData`. Grouped by calendar day per +/// `DESIGN_SYSTEM.md` §4; desktop centers the feed at 760px. +/// +/// [NextcloudActivity] only carries a rendered `title`/`subject` string +/// pair plus an `author` username - the server response has no structured +/// actor/verb/object breakdown or file reference, so the bold "actor" +/// segment is the author and the rest of the sentence (already fully +/// formed server-side) rides as one plain run rather than being split +/// further, and there's no reliable file to show in the trailing file +/// tile slot - see this screen's rebuild report for what a richer feed +/// would need from the service layer. class ActivityView extends StatelessWidget { final ScrollController scrollController; @@ -13,129 +33,88 @@ class ActivityView extends StatelessWidget { @override Widget build(BuildContext context) { - final theme = Theme.of(context); - final colorScheme = theme.colorScheme; + final colors = context.nooColors; final files = context.watch(); + final session = context.watch(); final activities = files.activities; + final isDesktop = NooLayout.isDesktop(context); + final groups = groupByCalendarDay( + activities, + (a) => a.timestamp, + ); + + Widget feed; + if (files.isLoading && activities.isEmpty) { + feed = tabLoadingSliver; + } else if (activities.isEmpty) { + feed = tabEmptySliver( + context, + icon: LucideIcons.activity, + message: 'No recent activity', + ); + } else { + feed = SliverPadding( + padding: EdgeInsets.symmetric(horizontal: NooLayout.gutter(context)), + sliver: SliverList( + delegate: SliverChildBuilderDelegate((context, index) { + final group = groups[index]; + return Padding( + padding: const EdgeInsets.only(bottom: 18), + child: NooGroupedList( + label: group.label, + children: [ + for (final act in group.items) + Material( + color: colors.surface, + child: NooActivityItem( + actor: act.author, + verb: act.title, + tail: act.subject.isNotEmpty && act.subject != act.title + ? act.subject + : null, + time: DateFormat.jm().format(act.timestamp), + avatarColor: NooPhotoTile.paletteColor(act.author.hashCode), + currentUser: + act.author.toLowerCase() == session.username.toLowerCase(), + ), + ), + ], + ), + ); + }, childCount: groups.length), + ), + ); + } final List contentSlivers = [ - const SliverToBoxAdapter(child: SizedBox(height: 16)), - - if (files.isLoading && activities.isEmpty) - const SliverFillRemaining( - hasScrollBody: false, - child: Center(child: CircularProgressIndicator()), - ) - else if (activities.isEmpty) - SliverFillRemaining( - hasScrollBody: false, - child: Center( - child: Text( - 'No recent activity', - style: theme.textTheme.titleMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ), - ) - else - SliverPadding( - padding: const EdgeInsets.symmetric(horizontal: 20), - sliver: SliverList( - delegate: SliverChildBuilderDelegate((context, index) { - final act = activities[index]; - - return Padding( - padding: const EdgeInsets.only(bottom: 12), - child: Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: colorScheme.surfaceContainerLow, - borderRadius: BorderRadius.circular(20), - border: Border.all( - color: colorScheme.outlineVariant.withValues(alpha: 0.2), - ), - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: colorScheme.primaryContainer, - shape: BoxShape.circle, - ), - child: Icon( - act.icon, - color: colorScheme.onPrimaryContainer, - size: 20, - ), - ), - const SizedBox(width: 14), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Text( - act.title, - style: theme.textTheme.titleMedium - ?.copyWith( - fontWeight: FontWeight.w600, - fontSize: 14, - ), - ), - ), - const SizedBox(width: 8), - Text( - DateFormat.jm().format(act.timestamp), - style: theme.textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - fontSize: 11, - ), - ), - ], - ), - const SizedBox(height: 4), - Text( - act.subject, - style: theme.textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - fontSize: 13, - ), - ), - const SizedBox(height: 6), - Text( - 'By ${act.author} • ${DateFormat.MMMd().format(act.timestamp)}', - style: theme.textTheme.bodySmall?.copyWith( - color: colorScheme.primary, - fontWeight: FontWeight.w500, - fontSize: 11, - ), - ), - ], - ), - ), - ], - ), - ), - ); - }, childCount: activities.length), - ), - ), - - const SliverToBoxAdapter(child: SizedBox(height: 100)), - const SliverFillRemaining(hasScrollBody: false, child: SizedBox()), + const SliverToBoxAdapter(child: SizedBox(height: NooSpace.md)), + feed, + ...tabBottomInsetSlivers, ]; - return SyncedHeaderScaffold( - scrollController: scrollController, - actions: const [MoreTabsButton(), ProfileAvatarButton()], - contentSlivers: contentSlivers, + final scrollView = CustomScrollView( + controller: scrollController, + slivers: contentSlivers, + ); + + return ColoredBox( + color: colors.bg, + child: RefreshIndicator( + color: colors.accent, + backgroundColor: colors.surface, + onRefresh: () => context.read().refreshData(), + // "Desktop limits it to 760px wide" (DESIGN_SYSTEM.md §4) - centered + // rather than left-aligned, so a wide window doesn't stretch the + // feed's short sentences edge to edge. + child: isDesktop + ? Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 760), + child: scrollView, + ), + ) + : scrollView, + ), ); } } diff --git a/lib/views/favorites_view.dart b/lib/views/favorites_view.dart index 03c98c4..9883f47 100644 --- a/lib/views/favorites_view.dart +++ b/lib/views/favorites_view.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:intl/intl.dart'; -import 'package:material_symbols_icons/symbols.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:provider/provider.dart'; import 'package:share_plus/share_plus.dart'; import '../models/app_tab.dart'; @@ -13,16 +13,22 @@ import '../providers/item_operations.dart'; import '../providers/session_controller.dart'; import '../providers/settings_controller.dart'; import '../services/download_service.dart'; +import '../theme/design_tokens.dart'; import '../widgets/details/details_sheet.dart'; -import '../widgets/item_icon.dart'; -import '../widgets/more_tabs_button.dart'; -import '../widgets/profile_avatar_button.dart'; -import '../widgets/segmented_icon_toggle.dart'; -import '../widgets/selectable_thumbnail.dart'; +import '../widgets/files_controls_row.dart'; +import '../widgets/noo/files/noo_file_kind.dart'; +import '../widgets/noo/files/noo_file_row.dart'; +import '../widgets/noo/files/noo_file_table.dart'; +import '../widgets/noo/files/noo_file_tile.dart'; +import '../widgets/noo/lists/noo_grouped_list.dart'; +import '../widgets/noo/lists/noo_settings_row.dart'; +import '../widgets/noo/media/noo_grid_card.dart'; +import '../widgets/noo/noo_layout.dart'; +import '../widgets/noo/overlays/noo_sheet.dart'; +import '../widgets/noo/core/noo_button.dart'; import '../widgets/share_sheet.dart'; -import '../widgets/sort_menu_button.dart'; import '../widgets/sticky_header_delegate.dart'; -import '../widgets/synced_header_scaffold.dart'; +import '../widgets/synced_header_scaffold.dart' show formatBytes; import 'file_viewer_screen.dart'; import 'move_copy_destination_picker.dart'; @@ -35,9 +41,11 @@ import 'move_copy_destination_picker.dart'; /// wide list also means the breadcrumb-vs-content mismatch a filter /// toggle had - the trail showing wherever Files was last browsing while /// the content showed something else entirely - simply can't happen). -/// Shares Files' own sort/hidden/storage-scope/grid-list display prefs -/// (via `FilesController.applyFilesDisplayPrefs`) rather than a separate -/// parallel settings dimension. +/// Shares Files' own sort/hidden/type-filter/storage-scope/grid-list +/// display prefs (via `FilesController.applyFilesDisplayPrefs`, and +/// `FilesControlsRow` for the controls themselves) rather than a separate +/// parallel settings dimension. Renders content only - the shell owns the +/// top bar, bottom bar, drawer and FAB. class FavoritesView extends StatefulWidget { final ScrollController scrollController; @@ -79,7 +87,14 @@ class _FavoritesViewState extends State /// scroll (row already fits). Future _playScrollHint(ScrollController scrollController) async { await Future.delayed(const Duration(milliseconds: 500)); - if (!mounted || !scrollController.hasClients) return; + // `hasClients` only means a position is attached, not that it's + // finished its first layout - reading maxScrollExtent before that + // throws (min/maxScrollExtent are still null internally). + if (!mounted || + !scrollController.hasClients || + !scrollController.position.hasContentDimensions) { + return; + } final maxExtent = scrollController.position.maxScrollExtent; if (maxExtent <= 0) return; final double peak = maxExtent < 36 ? maxExtent : 36; @@ -348,174 +363,87 @@ class _FavoritesViewState extends State ); } - Widget _buildControlsRow(FilesController files) { - return SizedBox( - height: 44, - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( + /// A single item's actions (the same list the multi-select bar offers, + /// just for one item) - mirrors `FilesView._showItemActionsSheet`. + void _showItemActionsSheet(BuildContext context, NextcloudItem item) { + final actions = _buildSelectionActions([item]); + showNooSheet( + context, + children: [ + NooGroupedList( children: [ - IconButton( - icon: Icon( - files.filesSortAscending - ? Icons.arrow_upward_rounded - : Icons.arrow_downward_rounded, - size: 20, + for (final action in actions) + NooSettingsRow( + icon: action.icon, + label: Text(action.label), + onTap: () { + Navigator.pop(context); + action.onTap(); + }, ), - visualDensity: VisualDensity.compact, - tooltip: files.filesSortAscending ? 'Ascending' : 'Descending', - onPressed: files.toggleFilesSortOrder, - ), - SizedBox( - width: 130, - child: SortMenuButton( - field: files.filesSortField, - onChanged: files.setFilesSortField, - ), - ), - ToggleIconButton( - icon: files.showHiddenFiles - ? Icons.visibility_rounded - : Icons.visibility_off_rounded, - isSelected: files.showHiddenFiles, - onTap: () => files.toggleShowHiddenFiles(), - tooltip: 'Show hidden files', - ), - const SizedBox(width: 4), - SegmentedIconGroup( - children: [ - ToggleIconButton( - icon: Symbols.circles_rounded, - isSelected: files.storageScope == StorageScope.cloud, - onTap: () => files.setStorageScope(StorageScope.cloud), - tooltip: 'Cloud storage', - ), - ToggleIconButton( - icon: Symbols.hard_drive_rounded, - isSelected: files.storageScope == StorageScope.external, - onTap: () => files.setStorageScope(StorageScope.external), - tooltip: 'External storage', - ), - ], - ), - const SizedBox(width: 8), - SegmentedIconGroup( - children: [ - ToggleIconButton( - icon: Icons.view_list_rounded, - isSelected: !files.isGridView, - onTap: () => files.setGridView(false), - tooltip: 'List view', - ), - ToggleIconButton( - icon: Icons.grid_view_rounded, - isSelected: files.isGridView, - onTap: () => files.setGridView(true), - tooltip: 'Grid view', - ), - ], - ), ], ), - ), + ], ); } - Widget _buildListTile( + /// A real image/video thumbnail for [item]'s file tile/card, sized so + /// `NooFileTile`'s `FittedBox` (which needs a concretely-sized child to + /// scale) always has one to work with; null when there's nothing to show + /// (falls back to the kind's soft-color tile). + Widget? _thumbnailFor( BuildContext context, - NextcloudItem item, - List siblings, - ) { - final theme = Theme.of(context); - final colorScheme = theme.colorScheme; - final session = context.watch(); - final isSelected = _selectedIds.contains(item.id); - - return Padding( - padding: const EdgeInsets.only(bottom: 8), - child: ClipRRect( - borderRadius: BorderRadius.circular(16), - child: Material( - color: isSelected - ? colorScheme.primaryContainer.withValues(alpha: 0.5) - : colorScheme.surfaceContainerLow, - child: InkWell( - onTap: () { - if (_isSelecting) { - _toggleSelection(item); - } else { - _openFavorite(context, item, siblings); - } - }, - onLongPress: () => _toggleSelection(item), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - child: Row( - children: [ - SelectableThumbnail( - isSelected: isSelected, - size: 44, - checkmarkSize: 24, - child: ItemThumbnail( - item: item, - service: session.service, - size: 44, - borderRadius: 12, - iconSize: 22, - ), - ), - const SizedBox(width: 14), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - item.name, - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w600, - fontSize: 14, - ), - ), - const SizedBox(height: 2), - Text( - item.isFolder - ? 'Folder' - : '${formatBytes(item.size)} • ${DateFormat.yMMMd().format(item.lastModified)}', - style: theme.textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ), - ], - ), - ), - ), - ), - ), - ); - } - - Widget _buildGridCard( - BuildContext context, - NextcloudItem item, - List siblings, - ) { - final theme = Theme.of(context); - final session = context.watch(); - final isSelected = _selectedIds.contains(item.id); + NextcloudItem item, { + required double extent, + }) { final isMedia = (item.type == NextcloudItemType.image || item.type == NextcloudItemType.video) && item.previewUrl != null; - final iconColor = getIconColor(context, item.type); + if (!isMedia) return null; + final session = context.watch(); + return Image.network( + item.previewUrl!, + width: extent, + height: extent, + headers: session.service?.authHeaders, + fit: BoxFit.cover, + filterQuality: FilterQuality.low, + gaplessPlayback: true, + errorBuilder: (ctx, err, stack) => const SizedBox.shrink(), + ); + } - return ClipRRect( - borderRadius: BorderRadius.circular(20), - child: Material( - color: theme.colorScheme.surfaceContainerLow, - child: InkWell( + String _metaFor(NextcloudItem item) { + return item.isFolder + ? 'Folder' + : '${formatBytes(item.size)} · ${DateFormat.yMMMd().format(item.lastModified)}'; + } + + Widget _buildRow( + BuildContext context, + NextcloudItem item, + List siblings, + ) { + final isSelected = _selectedIds.contains(item.id); + final kind = NooFileKind.from( + name: item.name, + mimeType: item.mimeType, + isDirectory: item.isFolder, + ); + + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: ClipRRect( + borderRadius: BorderRadius.circular(NooRadii.card), + child: NooFileRow( + kind: kind, + name: item.name, + meta: _metaFor(item), + favorite: item.isFavorite, + iosStyle: NooLayout.iosStyle(context), + selected: isSelected, + thumbnail: _thumbnailFor(context, item, extent: NooSizes.rowMobile), onTap: () { if (_isSelecting) { _toggleSelection(item); @@ -524,66 +452,87 @@ class _FavoritesViewState extends State } }, onLongPress: () => _toggleSelection(item), - child: SelectableThumbnail( - isSelected: isSelected, - checkmarkSize: 32, - child: isMedia - ? Image.network( - item.previewUrl!, - headers: session.service?.authHeaders, - fit: BoxFit.cover, - filterQuality: FilterQuality.low, - gaplessPlayback: true, - errorBuilder: (ctx, err, stack) => Container( - color: theme.colorScheme.secondaryContainer, - child: Icon( - getItemIcon(item.type), - color: theme.colorScheme.onSecondaryContainer, - size: 32, - ), - ), - ) - : Padding( - padding: const EdgeInsets.all(14), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Container( - width: 40, - height: 40, - decoration: BoxDecoration( - color: iconColor.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(12), - ), - child: Icon( - getItemIcon(item.type), - color: iconColor, - size: 24, - ), - ), - Text( - item.name, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w600, - fontSize: 13, - ), - ), - ], - ), - ), - ), + onMore: () => _showItemActionsSheet(context, item), ), ), ); } + Widget _buildTableRow( + BuildContext context, + NextcloudItem item, + List siblings, + ) { + final isSelected = _selectedIds.contains(item.id); + final kind = NooFileKind.from( + name: item.name, + mimeType: item.mimeType, + isDirectory: item.isFolder, + ); + + return NooFileTableRow( + kind: kind, + name: item.name, + col2: item.isFolder ? null : DateFormat.yMMMd().format(item.lastModified), + col3: item.isFolder ? null : formatBytes(item.size), + favorite: item.isFavorite, + selected: isSelected, + thumbnail: _thumbnailFor(context, item, extent: NooFileTileSize.desktop.extent), + onTap: () { + if (_isSelecting) { + _toggleSelection(item); + } else { + _openFavorite(context, item, siblings); + } + }, + // Desktop has no long-press gesture; right-click is this row's + // equivalent entry point into multi-select. + onSecondaryTap: () => _toggleSelection(item), + onMore: () => _showItemActionsSheet(context, item), + ); + } + + Widget _buildGridCard( + BuildContext context, + NextcloudItem item, + List siblings, + bool isDesktop, + ) { + final colors = context.nooColors; + final isSelected = _selectedIds.contains(item.id); + final kind = NooFileKind.from( + name: item.name, + mimeType: item.mimeType, + isDirectory: item.isFolder, + ); + final thumbnailHeight = isDesktop ? 118.0 : 104.0; + + return NooGridCard( + name: item.name, + meta: item.isFolder ? 'Folder' : formatBytes(item.size), + placeholderColor: kind.background(colors), + icon: kind.icon, + iconColor: kind.foreground(colors), + thumbnailHeight: thumbnailHeight, + thumbnail: _thumbnailFor(context, item, extent: thumbnailHeight), + selected: isSelected, + verticalOverflowIcon: !NooLayout.iosStyle(context), + onTap: () { + if (_isSelecting) { + _toggleSelection(item); + } else { + _openFavorite(context, item, siblings); + } + }, + onLongPress: () => _toggleSelection(item), + onMore: () => _showItemActionsSheet(context, item), + ); + } + @override Widget build(BuildContext context) { - final theme = Theme.of(context); - final colorScheme = theme.colorScheme; + final colors = context.nooColors; + final isDesktop = NooLayout.isDesktop(context); final favoritesController = context.watch(); final filesController = context.watch(); @@ -600,18 +549,26 @@ class _FavoritesViewState extends State .where((i) => _selectedIds.contains(i.id)) .toList(); + final controlsRow = Padding( + padding: const EdgeInsets.fromLTRB( + NooSpace.sm, + NooSpace.sm, + NooSpace.sm, + NooSpace.xs, + ), + child: FilesControlsRow(folderPath: filesController.currentFolderPath), + ); + final contentSlivers = [ SliverPersistentHeader( pinned: !_isSelecting, delegate: StickyHeaderDelegate( - height: 60, - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 0), - child: _buildControlsRow(filesController), - ), + height: _isSelecting ? 56 : 64, + child: _isSelecting + ? _buildSelectionBar(context, selectedItems) + : controlsRow, ), ), - const SliverToBoxAdapter(child: SizedBox(height: 8)), if (favoritesController.isLoading && favorites.isEmpty) const SliverFillRemaining( hasScrollBody: false, @@ -626,32 +583,25 @@ class _FavoritesViewState extends State child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon( - Icons.error_outline_rounded, - size: 64, - color: colorScheme.error, - ), + Icon(LucideIcons.circleAlert, size: 56, color: colors.danger), const SizedBox(height: 16), Text( 'Could not load favorites', - style: theme.textTheme.titleLarge?.copyWith( - color: colorScheme.error, - fontWeight: FontWeight.bold, - ), + style: NooText.cardTitle.copyWith(color: colors.danger), ), const SizedBox(height: 8), Text( favoritesController.errorMessage!, textAlign: TextAlign.center, - style: theme.textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - ), + style: NooText.body.copyWith(color: colors.fg3), ), const SizedBox(height: 20), - FilledButton.icon( - onPressed: favoritesController.fetchAll, - icon: const Icon(Icons.refresh_rounded), - label: const Text('Retry'), + NooButton( + variant: NooButtonVariant.secondary, + size: NooButtonSize.field, + icon: LucideIcons.refreshCw, + onTap: favoritesController.fetchAll, + child: const Text('Retry'), ), ], ), @@ -665,17 +615,11 @@ class _FavoritesViewState extends State child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon( - Icons.favorite_border_rounded, - size: 64, - color: colorScheme.outlineVariant, - ), + Icon(LucideIcons.star, size: 56, color: colors.fg3), const SizedBox(height: 12), Text( 'No favorites yet', - style: theme.textTheme.titleMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - ), + style: NooText.cardTitle.copyWith(color: colors.fg2), ), ], ), @@ -683,30 +627,70 @@ class _FavoritesViewState extends State ) else if (filesController.isGridView) SliverPadding( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), + padding: EdgeInsets.symmetric( + horizontal: NooLayout.gutter(context), + vertical: NooSpace.xs, + ), sliver: SliverGrid( - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 2, - childAspectRatio: 1.1, - crossAxisSpacing: 12, - mainAxisSpacing: 12, + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: isDesktop ? 5 : 2, + crossAxisSpacing: isDesktop ? 16 : 10, + mainAxisSpacing: isDesktop ? 16 : 10, + childAspectRatio: isDesktop ? 0.92 : 0.85, ), delegate: SliverChildBuilderDelegate((context, index) { - return _buildGridCard(context, favorites[index], siblings); + return _buildGridCard(context, favorites[index], siblings, isDesktop); }, childCount: favorites.length), ), ) + else if (isDesktop) + SliverPadding( + padding: EdgeInsets.symmetric(horizontal: NooLayout.gutter(context)), + sliver: SliverList( + delegate: SliverChildBuilderDelegate((context, index) { + if (index == 0) { + return NooFileTableHeader( + col2Label: 'Modified', + col3Label: 'Size', + sortColumn: switch (filesController.filesSortField) { + FileSortField.name => 0, + FileSortField.dateModified => 1, + FileSortField.size => 2, + FileSortField.dateCreated => null, + }, + sortAscending: filesController.filesSortAscending, + onSort: (column) { + final field = [ + FileSortField.name, + FileSortField.dateModified, + FileSortField.size, + ][column]; + if (field == filesController.filesSortField) { + filesController.toggleFilesSortOrder(); + } else { + filesController.setFilesSortField(field); + } + }, + ); + } + return _buildTableRow( + context, + favorites[index - 1], + siblings, + ); + }, childCount: favorites.length + 1), + ), + ) else SliverPadding( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), + padding: EdgeInsets.symmetric(horizontal: NooLayout.gutter(context)), sliver: SliverList( delegate: SliverChildBuilderDelegate((context, index) { - return _buildListTile(context, favorites[index], siblings); + return _buildRow(context, favorites[index], siblings); }, childCount: favorites.length), ), ), const SliverToBoxAdapter(child: SizedBox(height: 100)), - const SliverFillRemaining(hasScrollBody: false, child: SizedBox()), ]; return PopScope( @@ -714,28 +698,30 @@ class _FavoritesViewState extends State onPopInvokedWithResult: (didPop, result) { if (!didPop && _isSelecting) _clearSelection(); }, - child: SyncedHeaderScaffold( - scrollController: widget.scrollController, - actions: const [MoreTabsButton(), ProfileAvatarButton()], - onRefresh: favoritesController.fetchAll, - selectionBar: _isSelecting - ? _buildSelectionBar(context, theme, selectedItems) - : null, - contentSlivers: contentSlivers, + child: ColoredBox( + color: colors.bg, + child: RefreshIndicator( + color: colors.accent, + backgroundColor: colors.surface, + onRefresh: favoritesController.fetchAll, + child: CustomScrollView( + controller: widget.scrollController, + slivers: contentSlivers, + ), + ), ), ); } - /// Replaces the top bar entirely while selecting (see - /// `SyncedHeaderScaffold.selectionBar`) - mirrors Files/Photos' identical - /// selection bar. + /// Replaces the controls row's own sticky slot while selecting - mirrors + /// Files/Photos' identical selection bar. Widget _buildSelectionBar( BuildContext context, - ThemeData theme, List selectedItems, ) { + final colors = context.nooColors; return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), + padding: const EdgeInsets.symmetric(horizontal: NooSpace.sm), child: Row( children: [ SizedBox( @@ -746,7 +732,7 @@ class _FavoritesViewState extends State mainAxisSize: MainAxisSize.min, children: [ IconButton( - icon: const Icon(Icons.close_rounded, size: 20), + icon: Icon(LucideIcons.x, size: 20, color: colors.fg1), tooltip: 'Cancel selection', onPressed: _clearSelection, visualDensity: VisualDensity.compact, @@ -754,8 +740,9 @@ class _FavoritesViewState extends State const SizedBox(width: 8), Text( '${selectedItems.length} selected', - style: theme.textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w700, + style: NooText.cardTitle.copyWith( + fontSize: 17, + color: colors.fg1, ), ), ], @@ -771,7 +758,7 @@ class _FavoritesViewState extends State children: [ for (final action in _buildSelectionActions(selectedItems)) IconButton( - icon: Icon(action.icon, size: 20), + icon: Icon(action.icon, size: 20, color: colors.fg1), tooltip: action.label, onPressed: action.onTap, visualDensity: VisualDensity.compact, diff --git a/lib/views/file_viewer_screen.dart b/lib/views/file_viewer_screen.dart index cffd66d..2a62a7f 100644 --- a/lib/views/file_viewer_screen.dart +++ b/lib/views/file_viewer_screen.dart @@ -1,24 +1,30 @@ -import 'dart:convert'; -import 'dart:io'; -import 'dart:math' as math; -import 'dart:typed_data'; import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:open_file/open_file.dart'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; -import 'package:pdfx/pdfx.dart'; import 'package:provider/provider.dart'; -import 'package:video_player/video_player.dart'; import '../models/nextcloud_item.dart'; import '../providers/item_operations.dart'; import '../providers/session_controller.dart'; -import '../providers/settings_controller.dart'; import '../services/download_service.dart'; +import '../theme/design_tokens.dart'; import '../widgets/details/details_sheet.dart'; import '../widgets/frosted_glass_container.dart'; import '../widgets/marquee_title.dart'; -import '../widgets/seek_bar_painter.dart'; +import '../widgets/noo/core/noo_button.dart'; +import '../widgets/noo/nav/noo_top_bar.dart'; +import '../widgets/noo/noo_layout.dart'; +import '../widgets/noo/overlays/noo_dialog.dart'; +import '../widgets/noo/overlays/noo_overlay_header.dart'; +import '../widgets/noo/overlays/noo_sheet.dart'; import '../widgets/share_sheet.dart'; +import '../widgets/viewer/media_action_bar.dart'; +import '../widgets/viewer/media_image_preview.dart'; +import '../widgets/viewer/media_pdf_preview.dart'; +import '../widgets/viewer/media_text_preview.dart'; +import '../widgets/viewer/media_unsupported_preview.dart'; +import '../widgets/viewer/media_video_preview.dart'; const _textPreviewExtensions = { '.txt', @@ -266,33 +272,69 @@ class _FileViewerScreenState extends State { }); } + /// Delete confirmation, restyled as [showNooDialog] (desktop) / + /// [showNooSheet] (mobile) instead of an [AlertDialog] - same pattern as + /// `confirmRemoveAccount` in `settings_dialogs.dart`. + Future _confirmDelete(String name) async { + var confirmed = false; + void cancel() => Navigator.pop(context); + void confirm() { + confirmed = true; + Navigator.pop(context); + } + + Widget buttons() => Row( + children: [ + Expanded( + child: NooButton( + variant: NooButtonVariant.secondary, + size: NooButtonSize.card, + fullWidth: true, + onTap: cancel, + child: const Text('Cancel'), + ), + ), + const SizedBox(width: NooSpace.sm), + Expanded( + child: NooButton( + variant: NooButtonVariant.danger, + size: NooButtonSize.card, + fullWidth: true, + onTap: confirm, + child: const Text('Delete'), + ), + ), + ], + ); + + final message = 'Delete "$name" from the server? This cannot be undone.'; + final colors = context.nooColors; + if (NooLayout.isDesktop(context)) { + await showNooDialog( + context, + title: 'Delete file', + children: [ + Text(message, style: NooText.body.copyWith(color: colors.fg2)), + buttons(), + ], + ); + } else { + await showNooSheet( + context, + children: [ + NooOverlayHeader(title: 'Delete file', onClose: cancel), + Text(message, style: NooText.body.copyWith(color: colors.fg2)), + buttons(), + ], + ); + } + return confirmed; + } + Future _deleteCurrentItem(ItemOperations ops) async { final item = _currentItem; - final confirmed = await showDialog( - context: context, - builder: (dialogContext) { - return AlertDialog( - title: const Text('Delete file'), - content: Text( - 'Delete "${item.name}" from the server? This cannot be undone.', - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(dialogContext, false), - child: const Text('Cancel'), - ), - FilledButton( - style: FilledButton.styleFrom( - backgroundColor: Theme.of(dialogContext).colorScheme.error, - ), - onPressed: () => Navigator.pop(dialogContext, true), - child: const Text('Delete'), - ), - ], - ); - }, - ); - if (confirmed != true || !mounted) return; + final confirmed = await _confirmDelete(item.name); + if (!confirmed || !mounted) return; setState(() => _isBusy = true); final success = await ops.deleteItem(item.path); @@ -325,12 +367,19 @@ class _FileViewerScreenState extends State { @override Widget build(BuildContext context) { final session = context.read(); - final settings = context.read(); final ops = context.read(); - final colorScheme = Theme.of(context).colorScheme; + final colors = context.nooColors; + // The stage behind the media itself is pure black rather than `bg` - + // the near-universal "letterbox" convention of photo/video viewers + // (matting an image/video in the app's warm neutral background reads + // as unfinished, and black also hides any letterboxing from + // `BoxFit.contain`/`AspectRatio` at the screen edges). PDFs/text/ + // unsupported-file previews aren't "viewed" the same way - they're + // read, so they stay on the normal `bg` like any other screen. + final stageColor = _isSwipeable ? Colors.black : colors.bg; return Scaffold( - backgroundColor: colorScheme.surface, + backgroundColor: stageColor, body: GestureDetector( behavior: HitTestBehavior.opaque, onTap: () => setState(() => _controlsVisible = !_controlsVisible), @@ -338,7 +387,7 @@ class _FileViewerScreenState extends State { children: [ Positioned.fill( child: Container( - color: colorScheme.surface, + color: stageColor, child: _buildBody(context, session), ), ), @@ -349,10 +398,8 @@ class _FileViewerScreenState extends State { top: 0, child: LinearProgressIndicator( value: _downloadProgress, - color: colorScheme.primary, - backgroundColor: colorScheme.onSurface.withValues( - alpha: 0.16, - ), + color: colors.accent, + backgroundColor: colors.fg1.withValues(alpha: 0.16), ), ), AnimatedSlide( @@ -365,33 +412,32 @@ class _FileViewerScreenState extends State { child: Padding( padding: const EdgeInsets.fromLTRB(20, 12, 20, 0), child: FrostedGlassContainer( - opacity: settings.bottomBarOpacity, - blurSigma: settings.bottomBarBlur, child: Padding( padding: const EdgeInsets.symmetric( horizontal: 6, vertical: 6, ), - child: Row( - children: [ - _ActionIconButton( - icon: Icons.arrow_back_rounded, - tooltip: 'Back', - onTap: () => Navigator.pop(context), - ), - const SizedBox(width: 4), - Expanded( - child: MarqueeTitle( - text: _currentItem.name, - style: Theme.of(context).textTheme.titleSmall - ?.copyWith( - color: colorScheme.onSurface, - fontWeight: FontWeight.w600, - ), + child: IconTheme.merge( + data: IconThemeData(color: colors.fg1, size: 24), + child: Row( + children: [ + NooTopBarButton( + icon: LucideIcons.arrowLeft, + tooltip: 'Back', + onTap: () => Navigator.pop(context), ), - ), - const SizedBox(width: 10), - ], + const SizedBox(width: 4), + Expanded( + child: MarqueeTitle( + text: _currentItem.name, + style: NooText.label.copyWith( + color: colors.fg1, + ), + ), + ), + const SizedBox(width: 10), + ], + ), ), ), ), @@ -407,15 +453,13 @@ class _FileViewerScreenState extends State { offset: _controlsVisible ? Offset.zero : const Offset(0, 1.4), child: IgnorePointer( ignoring: !_controlsVisible, - child: _MediaActionBar( + child: MediaActionBar( isFavorite: _currentItem.isFavorite, isBusy: _isBusy, // Favorite/delete/download-to-device all need a live // server - hidden rather than shown-and-failing while // browsing an already-local file from the Offline tab. showServerActions: !_isOffline, - opacity: settings.bottomBarOpacity, - blurSigma: settings.bottomBarBlur, onShare: () => ShareSheet.show(context, _currentItem), onFavorite: () => _toggleFavorite(ops), onDelete: () => _deleteCurrentItem(ops), @@ -436,7 +480,6 @@ class _FileViewerScreenState extends State { if (!_isSwipeable) { return _buildStaticPreview(context, session); } - final settings = context.read(); return PageView.builder( controller: _pageController, @@ -446,20 +489,18 @@ class _FileViewerScreenState extends State { final mediaItem = _mediaItems[index]; if (!_isOffline) { if (mediaItem.type == NextcloudItemType.image) { - return _ImagePreview( + return MediaImagePreview( key: ValueKey(mediaItem.id), url: session.service!.fileUrl(mediaItem.path), headers: session.service!.authHeaders, ); } - return _VideoPreview( + return MediaVideoPreview( key: ValueKey(mediaItem.id), url: session.service!.fileUrl(mediaItem.path), headers: session.service!.authHeaders, isActive: index == _currentIndex, controlsBottomOffset: _controlsVisible ? 108 : 24, - opacity: settings.bottomBarOpacity, - blurSigma: settings.bottomBarBlur, ); } // Offline: each swiped-to item resolves its own local path on @@ -472,29 +513,35 @@ class _FileViewerScreenState extends State { future: widget.localPathResolver!(mediaItem), builder: (context, snapshot) { if (snapshot.connectionState != ConnectionState.done) { - return const Center(child: CircularProgressIndicator()); + return Center( + child: CircularProgressIndicator( + color: context.nooColors.accent, + ), + ); } final localPath = snapshot.data; if (localPath == null) { return Center( child: Icon( - Icons.cloud_off_rounded, - color: Theme.of(context).colorScheme.onSurfaceVariant, + LucideIcons.cloudOff, + color: context.nooColors.fg3, size: 64, ), ); } if (mediaItem.type == NextcloudItemType.image) { - return _ImagePreview(url: null, headers: const {}, localPath: localPath); + return MediaImagePreview( + url: null, + headers: const {}, + localPath: localPath, + ); } - return _VideoPreview( + return MediaVideoPreview( url: null, headers: const {}, localPath: localPath, isActive: index == _currentIndex, controlsBottomOffset: _controlsVisible ? 108 : 24, - opacity: settings.bottomBarOpacity, - blurSigma: settings.bottomBarBlur, ); }, ); @@ -510,20 +557,20 @@ class _FileViewerScreenState extends State { return const SizedBox.shrink(); default: if (_isPdf) { - return _PdfPreview( + return MediaPdfPreview( item: widget.item, session: session, localPathResolver: widget.localPathResolver, ); } if (_isText) { - return _TextPreview( + return MediaTextPreview( item: widget.item, session: session, localPathResolver: widget.localPathResolver, ); } - return _UnsupportedPreview( + return MediaUnsupportedPreview( item: widget.item, isBusy: _isBusy, onOpenExternally: () => _openExternally(session), @@ -532,805 +579,3 @@ class _FileViewerScreenState extends State { } } } - -/// The floating action bar (share/favorite/open/download/delete), sized and -/// positioned to match the app-wide frosted-glass bottom navigation bar. -class _MediaActionBar extends StatelessWidget { - final bool isFavorite; - final bool isBusy; - final bool showServerActions; - final double opacity; - final double blurSigma; - final VoidCallback onShare; - final VoidCallback onFavorite; - final VoidCallback onDelete; - final VoidCallback onOpenExternally; - final VoidCallback onDownload; - final VoidCallback onDetails; - - const _MediaActionBar({ - required this.isFavorite, - required this.isBusy, - required this.showServerActions, - required this.opacity, - required this.blurSigma, - required this.onShare, - required this.onFavorite, - required this.onDelete, - required this.onOpenExternally, - required this.onDownload, - required this.onDetails, - }); - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - - return SafeArea( - child: Padding( - padding: const EdgeInsets.only(bottom: 20, left: 20, right: 20), - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 480), - child: FrostedGlassContainer( - opacity: opacity, - blurSigma: blurSigma, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 6), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - _ActionIconButton( - icon: Icons.share_rounded, - tooltip: 'Share', - onTap: isBusy ? null : onShare, - ), - if (showServerActions) - _ActionIconButton( - icon: isFavorite - ? Icons.favorite_rounded - : Icons.favorite_border_rounded, - tooltip: 'Favorite', - color: isFavorite ? Colors.red : null, - onTap: onFavorite, - ), - _ActionIconButton( - icon: Icons.open_in_new_rounded, - tooltip: 'Open externally', - onTap: isBusy ? null : onOpenExternally, - ), - if (showServerActions) - _ActionIconButton( - icon: Icons.download_rounded, - tooltip: 'Download', - onTap: isBusy ? null : onDownload, - ), - if (showServerActions) - _ActionIconButton( - icon: Icons.delete_outline_rounded, - tooltip: 'Delete', - color: colorScheme.error, - onTap: isBusy ? null : onDelete, - ), - _ActionIconButton( - icon: Icons.info_outline_rounded, - tooltip: 'Details', - onTap: onDetails, - ), - ], - ), - ), - ), - ), - ), - ); - } -} - -class _ActionIconButton extends StatelessWidget { - final IconData icon; - final String tooltip; - final VoidCallback? onTap; - final Color? color; - - const _ActionIconButton({ - required this.icon, - required this.tooltip, - required this.onTap, - this.color, - }); - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - final fg = onTap == null - ? colorScheme.onSurface.withValues(alpha: 0.4) - : (color ?? colorScheme.onSurface); - - return Tooltip( - message: tooltip, - child: InkWell( - onTap: onTap, - customBorder: const CircleBorder(), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), - child: Icon(icon, color: fg, size: 22), - ), - ), - ); - } -} - -class _ImagePreview extends StatefulWidget { - final String? url; - final Map headers; - - /// When set, read the image from this on-disk path instead of [url] - - /// already-resolved by the caller (see - /// `FileViewerScreen.localPathResolver`'s doc comment). - final String? localPath; - - const _ImagePreview({ - super.key, - required this.url, - required this.headers, - this.localPath, - }); - - @override - State<_ImagePreview> createState() => _ImagePreviewState(); -} - -class _ImagePreviewState extends State<_ImagePreview> - with SingleTickerProviderStateMixin { - static const _doubleTapScale = 3.0; - - final TransformationController _transformController = - TransformationController(); - late final AnimationController _animController; - Animation? _animation; - Offset _doubleTapPosition = Offset.zero; - - @override - void initState() { - super.initState(); - _animController = - AnimationController( - vsync: this, - duration: const Duration(milliseconds: 220), - )..addListener(() { - if (_animation != null) { - _transformController.value = _animation!.value; - } - }); - } - - @override - void dispose() { - _animController.dispose(); - _transformController.dispose(); - super.dispose(); - } - - void _onDoubleTapDown(TapDownDetails details) { - _doubleTapPosition = details.localPosition; - } - - void _onDoubleTap() { - final isZoomedIn = _transformController.value.getMaxScaleOnAxis() > 1.01; - final Matrix4 endMatrix; - if (isZoomedIn) { - endMatrix = Matrix4.identity(); - } else { - final p = _doubleTapPosition; - endMatrix = Matrix4.identity() - ..translateByDouble( - -p.dx * (_doubleTapScale - 1), - -p.dy * (_doubleTapScale - 1), - 0, - 1, - ) - ..scaleByDouble(_doubleTapScale, _doubleTapScale, _doubleTapScale, 1); - } - _animation = Matrix4Tween( - begin: _transformController.value, - end: endMatrix, - ).animate(CurveTween(curve: Curves.easeOut).animate(_animController)); - _animController.forward(from: 0); - } - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - return SizedBox.expand( - child: GestureDetector( - onDoubleTapDown: _onDoubleTapDown, - onDoubleTap: _onDoubleTap, - child: InteractiveViewer( - transformationController: _transformController, - minScale: 0.8, - maxScale: 5.0, - child: Center( - child: widget.localPath != null - ? Image.file( - File(widget.localPath!), - fit: BoxFit.contain, - errorBuilder: (context, error, stack) => Center( - child: Icon( - Icons.broken_image_outlined, - color: colorScheme.onSurfaceVariant, - size: 64, - ), - ), - ) - : Image.network( - widget.url!, - headers: widget.headers, - fit: BoxFit.contain, - loadingBuilder: (context, child, progress) { - if (progress == null) return child; - return Center( - child: CircularProgressIndicator( - color: colorScheme.primary, - ), - ); - }, - errorBuilder: (context, error, stack) => Center( - child: Icon( - Icons.broken_image_outlined, - color: colorScheme.onSurfaceVariant, - size: 64, - ), - ), - ), - ), - ), - ), - ); - } -} - -class _VideoPreview extends StatefulWidget { - final String? url; - final Map headers; - final bool isActive; - - /// When set, read the video from this on-disk path instead of [url] - - /// already-resolved by the caller (see - /// `FileViewerScreen.localPathResolver`'s doc comment). - final String? localPath; - - /// Distance from the bottom of the screen the transport controls should - /// sit at. Passed in by the parent so the controls can track the floating - /// action bar: resting just above it when visible, sliding down to hug - /// the screen edge when the bar (and rest of the chrome) is hidden. - final double controlsBottomOffset; - - /// Opacity/blur for the transport controls' frosted-glass background, - /// matching the app-wide bottom bar and media action bar styling. - final double opacity; - final double blurSigma; - - const _VideoPreview({ - super.key, - required this.url, - required this.headers, - this.localPath, - this.isActive = true, - this.controlsBottomOffset = 24, - this.opacity = 0.55, - this.blurSigma = 28, - }); - - @override - State<_VideoPreview> createState() => _VideoPreviewState(); -} - -class _VideoPreviewState extends State<_VideoPreview> { - VideoPlayerController? _controller; - String? _error; - - @override - void initState() { - super.initState(); - _init(); - } - - Future _init() async { - try { - final controller = widget.localPath != null - ? VideoPlayerController.file(File(widget.localPath!)) - : VideoPlayerController.networkUrl( - Uri.parse(widget.url!), - httpHeaders: widget.headers, - ); - await controller.initialize(); - if (!mounted) { - controller.dispose(); - return; - } - setState(() => _controller = controller); - if (widget.isActive) controller.play(); - } catch (e) { - if (mounted) setState(() => _error = e.toString()); - } - } - - @override - void didUpdateWidget(covariant _VideoPreview oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.isActive != widget.isActive) { - if (widget.isActive) { - _controller?.play(); - } else { - _controller?.pause(); - } - } - } - - @override - void dispose() { - _controller?.dispose(); - super.dispose(); - } - - String _formatDuration(Duration d) { - final minutes = d.inMinutes.remainder(60); - final seconds = d.inSeconds.remainder(60).toString().padLeft(2, '0'); - return '$minutes:$seconds'; - } - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - final progressBarStyle = context - .watch() - .mediaProgressBarStyle; - if (_error != null) { - return Center( - child: Text( - 'Could not play video: $_error', - style: TextStyle(color: colorScheme.onSurfaceVariant), - ), - ); - } - final controller = _controller; - if (controller == null || !controller.value.isInitialized) { - return Center( - child: CircularProgressIndicator(color: colorScheme.primary), - ); - } - - return SizedBox.expand( - child: Stack( - alignment: Alignment.center, - children: [ - Center( - child: AspectRatio( - aspectRatio: controller.value.aspectRatio, - child: VideoPlayer(controller), - ), - ), - AnimatedPositioned( - duration: const Duration(milliseconds: 220), - curve: Curves.easeInOutCubic, - left: 16, - right: 16, - bottom: widget.controlsBottomOffset, - child: AnimatedBuilder( - animation: controller, - builder: (context, _) { - final colorScheme = Theme.of(context).colorScheme; - final fg = colorScheme.onSurface; - // Same frosted-glass treatment (and user opacity/blur - // settings) as the back button and media action bar, so the - // transport controls match the rest of the app's chrome. - return FrostedGlassContainer( - borderRadius: 24, - opacity: widget.opacity, - blurSigma: widget.blurSigma, - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 4, - vertical: 4, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Row( - children: [ - _ActionIconButton( - icon: controller.value.isPlaying - ? Icons.pause_rounded - : Icons.play_arrow_rounded, - tooltip: controller.value.isPlaying - ? 'Pause' - : 'Play', - onTap: () => controller.value.isPlaying - ? controller.pause() - : controller.play(), - ), - Expanded( - child: Text( - '${_formatDuration(controller.value.position)} / ${_formatDuration(controller.value.duration)}', - textAlign: TextAlign.center, - style: TextStyle(color: fg, fontSize: 13), - ), - ), - _ActionIconButton( - icon: controller.value.volume == 0 - ? Icons.volume_off_rounded - : Icons.volume_up_rounded, - tooltip: controller.value.volume == 0 - ? 'Unmute' - : 'Mute', - onTap: () => controller.setVolume( - controller.value.volume == 0 ? 1 : 0, - ), - ), - ], - ), - Padding( - padding: const EdgeInsets.fromLTRB(12, 0, 12, 8), - child: _WavySeekBar( - controller: controller, - playedColor: fg, - trackColor: fg.withValues(alpha: 0.3), - style: progressBarStyle, - ), - ), - ], - ), - ), - ); - }, - ), - ), - ], - ), - ); - } -} - -/// A Material You "expressive" wavy seek bar: the played portion of the -/// track animates as a travelling sine wave while playing and settles flat -/// when paused, matching the Android 16+ media player style. Drag or tap -/// anywhere on it to seek. -class _WavySeekBar extends StatefulWidget { - final VideoPlayerController controller; - final Color playedColor; - final Color trackColor; - final MediaProgressBarStyle style; - - const _WavySeekBar({ - required this.controller, - required this.playedColor, - required this.trackColor, - required this.style, - }); - - @override - State<_WavySeekBar> createState() => _WavySeekBarState(); -} - -class _WavySeekBarState extends State<_WavySeekBar> - with SingleTickerProviderStateMixin { - late final AnimationController _waveController = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 900), - ); - double? _dragRatio; - - @override - void dispose() { - _waveController.dispose(); - super.dispose(); - } - - void _seekToRatio(double ratio) { - final duration = widget.controller.value.duration; - widget.controller.seekTo(duration * ratio.clamp(0.0, 1.0)); - } - - @override - Widget build(BuildContext context) { - final value = widget.controller.value; - final animatesWave = - widget.style == MediaProgressBarStyle.wavy || - widget.style == MediaProgressBarStyle.squiggly; - final isPlaying = animatesWave && value.isPlaying && _dragRatio == null; - if (isPlaying && !_waveController.isAnimating) { - _waveController.repeat(); - } else if (!isPlaying && _waveController.isAnimating) { - _waveController.stop(); - } - - final durationMs = value.duration.inMilliseconds; - final baseRatio = durationMs > 0 - ? value.position.inMilliseconds / durationMs - : 0.0; - final ratio = (_dragRatio ?? baseRatio).clamp(0.0, 1.0); - - return SizedBox( - height: 28, - child: LayoutBuilder( - builder: (context, constraints) { - void updateFromDx(double dx) { - setState( - () => _dragRatio = (dx / constraints.maxWidth).clamp(0.0, 1.0), - ); - } - - return GestureDetector( - behavior: HitTestBehavior.opaque, - onHorizontalDragStart: (details) => - updateFromDx(details.localPosition.dx), - onHorizontalDragUpdate: (details) => - updateFromDx(details.localPosition.dx), - onHorizontalDragEnd: (_) { - if (_dragRatio != null) _seekToRatio(_dragRatio!); - setState(() => _dragRatio = null); - }, - onTapUp: (details) => - _seekToRatio(details.localPosition.dx / constraints.maxWidth), - child: AnimatedBuilder( - animation: _waveController, - builder: (context, _) { - return CustomPaint( - size: Size(constraints.maxWidth, 28), - painter: SeekBarPainter( - style: widget.style, - progress: ratio, - phase: _waveController.value * 2 * math.pi, - animate: isPlaying, - playedColor: widget.playedColor, - trackColor: widget.trackColor, - ), - ); - }, - ), - ); - }, - ), - ); - } -} - -class _PdfPreview extends StatefulWidget { - final NextcloudItem item; - final SessionController session; - - /// When set, read bytes from the local path it resolves to instead of - /// fetching from the server - see - /// `FileViewerScreen.localPathResolver`'s doc comment. - final Future Function(NextcloudItem item)? localPathResolver; - - const _PdfPreview({ - required this.item, - required this.session, - this.localPathResolver, - }); - - @override - State<_PdfPreview> createState() => _PdfPreviewState(); -} - -class _PdfPreviewState extends State<_PdfPreview> { - PdfControllerPinch? _controller; - String? _error; - - @override - void initState() { - super.initState(); - _load(); - } - - Future _load() async { - try { - final Uint8List bytes; - if (widget.localPathResolver != null) { - final localPath = await widget.localPathResolver!(widget.item); - if (localPath == null) { - if (mounted) { - setState(() => _error = 'No longer available offline'); - } - return; - } - bytes = await File(localPath).readAsBytes(); - } else { - bytes = Uint8List.fromList( - await widget.session.service!.fetchBytes(widget.item.path), - ); - } - if (!mounted) return; - setState(() { - _controller = PdfControllerPinch( - document: PdfDocument.openData(bytes), - ); - }); - } catch (e) { - if (mounted) setState(() => _error = e.toString()); - } - } - - @override - void dispose() { - _controller?.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - if (_error != null) { - return Center( - child: Text( - 'Could not load PDF: $_error', - style: TextStyle(color: colorScheme.onSurfaceVariant), - ), - ); - } - if (_controller == null) { - return Center( - child: CircularProgressIndicator(color: colorScheme.primary), - ); - } - // pdfx's own default minScale (1.0 = the page's true/100% size) is - // often *larger* than the fit-to-width size a wide page first renders - // at, since that initial render is just normal box layout, not the - // InteractiveViewer transform pinching engages on first touch - so the - // moment you touch the page it snaps up to 1.0 and, with the default - // floor, can never pinch back down past it. A low floor here lets you - // zoom back out past that to the fit-width view you started at. - return PdfViewPinch(controller: _controller!, minScale: 0.3); - } -} - -class _TextPreview extends StatefulWidget { - final NextcloudItem item; - final SessionController session; - - /// When set, read bytes from the local path it resolves to instead of - /// fetching from the server - see - /// `FileViewerScreen.localPathResolver`'s doc comment. - final Future Function(NextcloudItem item)? localPathResolver; - - const _TextPreview({ - required this.item, - required this.session, - this.localPathResolver, - }); - - @override - State<_TextPreview> createState() => _TextPreviewState(); -} - -class _TextPreviewState extends State<_TextPreview> { - String? _content; - String? _error; - - @override - void initState() { - super.initState(); - _load(); - } - - Future _load() async { - try { - final List bytes; - if (widget.localPathResolver != null) { - final localPath = await widget.localPathResolver!(widget.item); - if (localPath == null) { - if (mounted) { - setState(() => _error = 'No longer available offline'); - } - return; - } - bytes = await File(localPath).readAsBytes(); - } else { - bytes = await widget.session.service!.fetchBytes(widget.item.path); - } - if (mounted) { - setState(() => _content = utf8.decode(bytes, allowMalformed: true)); - } - } catch (e) { - if (mounted) setState(() => _error = e.toString()); - } - } - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - if (_error != null) { - return Center( - child: Text( - 'Could not load file: $_error', - style: TextStyle(color: colorScheme.onSurfaceVariant), - ), - ); - } - if (_content == null) { - return Center( - child: CircularProgressIndicator(color: colorScheme.primary), - ); - } - return Container( - color: colorScheme.surface, - padding: const EdgeInsets.all(20), - child: SingleChildScrollView( - child: SelectableText( - _content!, - style: TextStyle( - color: colorScheme.onSurface, - fontFamily: 'monospace', - fontSize: 13, - height: 1.5, - ), - ), - ), - ); - } -} - -class _UnsupportedPreview extends StatelessWidget { - final NextcloudItem item; - final bool isBusy; - final VoidCallback onOpenExternally; - final VoidCallback onOpenDetails; - - const _UnsupportedPreview({ - required this.item, - required this.isBusy, - required this.onOpenExternally, - required this.onOpenDetails, - }); - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - return Center( - child: Padding( - padding: const EdgeInsets.all(32), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.insert_drive_file_rounded, - color: colorScheme.onSurfaceVariant, - size: 72, - ), - const SizedBox(height: 16), - Text( - item.name, - textAlign: TextAlign.center, - style: TextStyle( - color: colorScheme.onSurface, - fontWeight: FontWeight.w600, - fontSize: 16, - ), - ), - const SizedBox(height: 8), - Text( - 'No inline preview for this file type.', - style: TextStyle( - color: colorScheme.onSurfaceVariant, - fontSize: 13, - ), - ), - const SizedBox(height: 28), - FilledButton.icon( - onPressed: isBusy ? null : onOpenExternally, - icon: const Icon(Icons.open_in_new_rounded), - label: const Text('Open with...'), - ), - const SizedBox(height: 12), - OutlinedButton.icon( - onPressed: onOpenDetails, - icon: const Icon(Icons.info_outline_rounded), - label: const Text('Details'), - ), - ], - ), - ), - ); - } -} diff --git a/lib/views/files_view.dart b/lib/views/files_view.dart index 45569c2..1037369 100644 --- a/lib/views/files_view.dart +++ b/lib/views/files_view.dart @@ -1,14 +1,16 @@ +import 'dart:async'; import 'dart:io'; -import 'package:file_picker/file_picker.dart'; import 'package:file_saver/file_saver.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:intl/intl.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:path/path.dart' as p; import 'package:provider/provider.dart'; import 'package:share_plus/share_plus.dart'; import '../models/nextcloud_item.dart'; import '../models/selection_action.dart'; +import '../models/sync_status.dart'; import '../providers/files_controller.dart'; import '../providers/folder_browser.dart'; import '../providers/item_operations.dart'; @@ -18,24 +20,31 @@ import '../providers/session_controller.dart'; import '../providers/settings_controller.dart'; import '../providers/sync_status_controller.dart'; import '../services/download_service.dart'; -import '../services/share_intent_service.dart'; -import '../widgets/breadcrumbs.dart'; +import '../services/nextcloud_service.dart'; +import '../theme/design_tokens.dart'; import '../widgets/details/details_sheet.dart'; +import '../widgets/files/file_breadcrumb_row.dart'; import '../widgets/files_controls_row.dart'; import '../widgets/item_icon.dart'; import '../widgets/manage_synced_folders_sheet.dart'; -import '../widgets/media_grid_tile.dart'; -import '../widgets/more_tabs_button.dart'; -import '../widgets/profile_avatar_button.dart'; -import '../widgets/selectable_thumbnail.dart'; +import '../widgets/noo/core/noo_button.dart'; +import '../widgets/noo/files/noo_file_kind.dart'; +import '../widgets/noo/files/noo_file_row.dart'; +import '../widgets/noo/files/noo_file_table.dart'; +import '../widgets/noo/files/noo_file_tile.dart'; +import '../widgets/noo/files/noo_status_icon.dart'; +import '../widgets/noo/files/noo_swipe_action.dart'; +import '../widgets/noo/lists/noo_grouped_list.dart'; +import '../widgets/noo/lists/noo_settings_row.dart'; +import '../widgets/noo/lists/noo_summary_card.dart'; +import '../widgets/noo/media/noo_grid_card.dart'; +import '../widgets/noo/noo_layout.dart'; +import '../widgets/noo/overlays/noo_sheet.dart'; import '../widgets/share_sheet.dart'; import '../widgets/sticky_header_delegate.dart'; -import '../widgets/swipeable_item.dart'; -import '../widgets/sync_status_badge.dart'; -import '../widgets/synced_header_scaffold.dart'; +import '../widgets/synced_header_scaffold.dart' show formatBytes; import 'file_viewer_screen.dart'; import 'move_copy_destination_picker.dart'; -import 'share_upload_view.dart'; /// The Files tab - and, with [offline] set, the Offline tab, which is the /// same view over the device-sync mirror instead of the server: the Files @@ -44,7 +53,7 @@ import 'share_upload_view.dart'; /// thumbnails; [offline] only swaps the data source (`FolderBrowser`), /// reads images from the local file instead of a server preview, and turns /// off everything that needs the server (selection and its bulk actions, -/// swipe actions, sync badges, the "+" menu, pick mode). +/// swipe actions, the "+" menu, pick mode). class FilesView extends StatefulWidget { final ScrollController scrollController; final bool offline; @@ -122,6 +131,101 @@ class _FolderEnterAnimationState extends State<_FolderEnterAnimation> } } +/// The 40px round icon button used in the controls row for a +/// screen-specific action ("Manage synced folders") that doesn't fit +/// `FilesControlsRow`'s chip set - same circular-hit-target treatment as +/// `NooFileRow`'s own overflow button. +class _HeaderIconButton extends StatelessWidget { + final IconData icon; + final String tooltip; + final VoidCallback onTap; + + const _HeaderIconButton({ + required this.icon, + required this.tooltip, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final colors = context.nooColors; + return SizedBox.square( + dimension: 40, + child: Tooltip( + message: tooltip, + child: Material( + color: colors.surface, + shape: const CircleBorder(), + child: InkWell( + customBorder: const CircleBorder(), + onTap: onTap, + child: Icon(icon, size: 18, color: colors.fg1), + ), + ), + ), + ); + } +} + +/// The thumbnail-area content for an image/video grid card: the actual +/// preview fills the area (cropped to cover, decoded at the painted size), +/// falling back to [fallback] (the file-kind icon) on error, with a play +/// badge for video - replaces `MediaGridTile` now that grid cards come from +/// `NooGridCard` instead of a hand-rolled column. Shared by the Files and +/// Offline tabs; they differ only in where the pixels come from +/// ([localFile] - the device-sync mirror vs. a server preview). +class _GridThumbnail extends StatelessWidget { + final NextcloudItem item; + final NextcloudService? service; + final File? localFile; + final Widget fallback; + + const _GridThumbnail({ + required this.item, + required this.service, + required this.localFile, + required this.fallback, + }); + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final cachePixels = + (constraints.maxWidth * MediaQuery.of(context).devicePixelRatio) + .round(); + final provider = ResizeImage( + localFile != null + ? FileImage(localFile!) as ImageProvider + : NetworkImage(item.previewUrl!, headers: service?.authHeaders), + width: cachePixels, + height: cachePixels, + ); + return Stack( + fit: StackFit.expand, + children: [ + Image( + image: provider, + fit: BoxFit.cover, + filterQuality: FilterQuality.low, + gaplessPlayback: true, + errorBuilder: (ctx, err, stack) => Center(child: fallback), + ), + if (item.type == NextcloudItemType.video) + const Center( + child: Icon( + LucideIcons.circlePlay, + color: Colors.white, + size: 36, + ), + ), + ], + ); + }, + ); + } +} + class _FilesViewState extends State with SingleTickerProviderStateMixin { final Set _selectedIds = {}; @@ -181,7 +285,14 @@ class _FilesViewState extends State // scroll extent) settle before nudging it, and reads more like a // deliberate hint than something that happens to fire on load. await Future.delayed(const Duration(milliseconds: 500)); - if (!mounted || !scrollController.hasClients) return; + // `hasClients` only means a position is attached, not that it's + // finished its first layout - reading maxScrollExtent before that + // throws (min/maxScrollExtent are still null internally). + if (!mounted || + !scrollController.hasClients || + !scrollController.position.hasContentDimensions) { + return; + } final maxExtent = scrollController.position.maxScrollExtent; if (maxExtent <= 0) return; final double peak = maxExtent < 36 ? maxExtent : 36; @@ -250,6 +361,26 @@ class _FilesViewState extends State setState(_selectedIds.clear); } + /// Routes a tap on an item, in picking, selecting or plain-browsing mode + /// alike - shared by the mobile row, the desktop table row and the grid + /// card so all three navigate/select/open exactly the same way. + void _handleItemTap( + BuildContext context, + NextcloudItem item, { + required bool picking, + }) { + final browser = _browserOf(context); + if (picking) { + _handlePickTap(context, item); + } else if (_isSelecting) { + _toggleSelection(item); + } else if (item.isFolder) { + browser.navigateToFolder(item.path); + } else { + _openFile(context, item); + } + } + /// Routes a tap on an item while Noo is acting as another app's picker: /// folders are still browsable, a matching file either toggles selection /// (multi-select requests) or immediately finishes the pick, and a @@ -278,77 +409,79 @@ class _FilesViewState extends State } /// The bulk actions shown in the sticky selection toolbar for the - /// currently-selected items. + /// currently-selected items, and reused for a single item's overflow + /// sheet (see [_showItemActions]). List _buildSelectionActions( BuildContext context, List selected, ) { - final pick = context.watch(); - final sync = context.watch(); + // read, not watch: this builds a one-shot action list, and - unlike its + // other call site inside build() - `_showItemActions` calls this from + // an onTap/onPressed callback, well outside any build method. `watch` + // there throws ("Tried to listen to a value exposed with provider... + // outside of a widget's build method"), which - thrown from inside a + // tap handler - just aborts silently before `showNooSheet` ever runs: + // the exact "the menu does nothing" bug. + final pick = context.read(); + final sync = context.read(); if (pick.isPicking) { return [ SelectionAction( - icon: Icons.check_rounded, + icon: LucideIcons.check, label: 'Use ${selected.length} item(s)', onTap: () => pick.confirmPick(selected), ), ]; } + final allFavorited = selected.every((i) => i.isFavorite); + final allSynced = selected.every((i) => sync.isPathSynced(i.path)); return [ SelectionAction( - icon: selected.every((i) => i.isFavorite) - ? Icons.favorite_rounded - : Icons.favorite_border_rounded, - label: selected.every((i) => i.isFavorite) - ? 'Remove from favorites' - : 'Favorite', + icon: allFavorited ? LucideIcons.starOff : LucideIcons.star, + label: allFavorited ? 'Remove from favorites' : 'Favorite', onTap: () => _favoriteSelected(context, selected), ), SelectionAction( - icon: Icons.share_rounded, + icon: LucideIcons.share2, label: 'Share', onTap: () => selected.length == 1 ? ShareSheet.show(context, selected.single) : _shareSelected(context, selected), ), SelectionAction( - icon: Icons.download_rounded, + icon: LucideIcons.download, label: 'Download', onTap: () => _downloadSelected(context, selected), ), SelectionAction( - icon: Icons.delete_outline_rounded, + icon: LucideIcons.trash2, label: 'Delete', onTap: () => _confirmDeleteSelected(context, selected), ), SelectionAction( - icon: Icons.copy_rounded, + icon: LucideIcons.copy, label: 'Copy', onTap: () => _moveOrCopySelected(context, selected, copy: true), ), SelectionAction( - icon: Icons.drive_file_move_rounded, + icon: LucideIcons.folderInput, label: 'Move', onTap: () => _moveOrCopySelected(context, selected, copy: false), ), if (selected.length == 1) SelectionAction( - icon: Icons.drive_file_rename_outline_rounded, + icon: LucideIcons.filePen, label: 'Rename', onTap: () => _renameItem(selected.single), ), SelectionAction( - icon: selected.every((i) => sync.isPathSynced(i.path)) - ? Icons.sync_rounded - : Icons.sync_outlined, - label: selected.every((i) => sync.isPathSynced(i.path)) - ? 'Stop syncing to device' - : 'Sync to device', + icon: LucideIcons.hardDriveDownload, + label: allSynced ? 'Stop syncing to device' : 'Sync to device', onTap: () => _toggleSyncSelected(context, sync, selected), ), if (selected.length == 1) SelectionAction( - icon: Icons.info_outline_rounded, + icon: LucideIcons.info, label: 'Details', onTap: () => DetailsSheet.show(context, selected.single), ), @@ -452,14 +585,85 @@ class _FilesViewState extends State } } + /// `SyncItemStatus` (this app's device-sync model) -> `NooSyncStatus` + /// (the design system's row/table status-icon set). There's no "shared" + /// signal on `NextcloudItem` today, so that half of the design's status + /// set never lights up here - see the report for the promotion note. + List _nooStatuses(SyncItemStatus status) { + switch (status) { + case SyncItemStatus.syncing: + return const [NooSyncStatus.syncing]; + case SyncItemStatus.synced: + return const [NooSyncStatus.synced]; + case SyncItemStatus.conflict: + return const [NooSyncStatus.error]; + case SyncItemStatus.none: + return const []; + } + } + + /// Real image/video thumbnail for a row/table tile, reusing + /// `item_icon.dart`'s existing preview-loading (server or, offline, the + /// local mirror file) rather than re-deriving it - null falls back to + /// `NooFileTile`'s plain kind icon. + Widget? _rowThumbnail( + BuildContext context, + NextcloudItem item, + SessionController session, + NooFileTileSize size, + ) { + final isMedia = _offline + ? item.type == NextcloudItemType.image + : (item.type == NextcloudItemType.image || + item.type == NextcloudItemType.video) && + item.previewUrl != null; + if (!isMedia) return null; + return ItemThumbnail( + item: item, + service: session.service, + localFile: _localFileFor(context, item), + size: size.extent, + borderRadius: size.radius, + iconSize: size.iconSize, + ); + } + + /// The user's configured swipe action for one side of a row, or null if + /// that side is off or set to an action the design's two-slot + /// `NooSwipeAction` has no room for (`SwipeAction.share` - still reachable + /// via the row's overflow menu / the "Share" bulk action). + NooSwipeActionSpec? _swipeSpec( + SwipeAction action, + NextcloudItem item, + ItemOperations ops, + ) { + switch (action) { + case SwipeAction.favorite: + return NooSwipeActionSpec( + kind: NooSwipeActionKind.favorite, + onTriggered: () => ops.toggleItemFavorite(item), + ); + case SwipeAction.delete: + return NooSwipeActionSpec( + kind: NooSwipeActionKind.delete, + onTriggered: () => _confirmAndDeleteViaSwipe(item), + ); + case SwipeAction.share: + case SwipeAction.none: + return null; + } + } + @override Widget build(BuildContext context) { - final theme = Theme.of(context); - final colorScheme = theme.colorScheme; + final colors = context.nooColors; // Display prefs (grid/list, filters, sort) live on FilesController for // both tabs; `browser` is the tab's own folder listing. final files = context.watch(); final browser = _browserOf(context); + final sync = context.watch(); + final isDesktop = NooLayout.isDesktop(context); + final gutter = NooLayout.gutter(context); if (!_requestedInitialLoad) { _requestedInitialLoad = true; @@ -479,46 +683,71 @@ class _FilesViewState extends State .where((i) => _selectedIds.contains(i.id)) .toList(); - final controlsRow = Padding( - // 16, not 20 - matches the SliverAppBar toolbar's own default - // horizontal content inset when it's selecting, so the two rows' - // content lines up instead of the controls row looking shifted in. - padding: const EdgeInsets.fromLTRB(16, 16, 16, 12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // The scroll-hint nudge (see _playScrollHint) plays on this row - // once, right after it first appears. - FilesControlsRow( - folderPath: browser.currentFolderPath, - showStorageScope: !_offline, - scrollController: _controlsScrollController, - ), - if (hasBreadcrumbs) ...[ - const SizedBox(height: 10), - SizedBox( - height: 32, - child: Breadcrumbs( - pathStack: browser.pathStack, - onTap: (index) => browser.navigateToPathIndex(index), - ), + final topRow = _isSelecting + ? _buildSelectionBar(context, selectedItems) + : Padding( + padding: const EdgeInsets.fromLTRB( + NooSpace.sm, + NooSpace.sm, + NooSpace.sm, + NooSpace.xs, ), - ], - ], - ), - ); + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + // The scroll-hint nudge (see _playScrollHint) plays on + // this row once, right after it first appears. + Expanded( + child: FilesControlsRow( + folderPath: browser.currentFolderPath, + showStorageScope: !_offline, + scrollController: _controlsScrollController, + ), + ), + if (_offline) ...[ + const SizedBox(width: NooSpace.xs), + _HeaderIconButton( + icon: LucideIcons.folderSync, + tooltip: 'Manage synced folders', + onTap: () => ManageSyncedFoldersSheet.show(context), + ), + ], + ], + ), + if (hasBreadcrumbs) ...[ + const SizedBox(height: 10), + SizedBox( + height: 32, + child: FileBreadcrumbRow( + pathStack: browser.pathStack, + onTap: (index) => browser.navigateToPathIndex(index), + ), + ), + ], + ], + ), + ); final List contentSlivers = [ - // Sticky while browsing; once selecting, the selection bar takes over - // the very top of the screen instead (see `selectionBar` below), so - // this is free to scroll away rather than staying pinned under it. + // Pinned in both states - while browsing this is the controls row + // (+ breadcrumbs), while selecting it's the selection bar (see + // `topRow` above): either way it's the one thing that always stays + // at the very top of the scroll view. SliverPersistentHeader( - pinned: !_isSelecting, + pinned: true, delegate: StickyHeaderDelegate( - height: hasBreadcrumbs ? 114 : 72, - child: controlsRow, + // 20 (topRow's own top+bottom padding) + 44 (FilesControlsRow's + // fixed height) [+ 10 gap + 32 breadcrumbs height, if present]. + // Getting this wrong overflows the sliver header by exactly the + // shortfall - a real bug this shipped with once already. + height: _isSelecting ? 56 : (hasBreadcrumbs ? 106 : 64), + child: topRow, ), ), + if (_offline) + SliverToBoxAdapter(child: _buildOfflineSummary(context, sync)), // Files List / Grid if (browser.isLoading) const SliverFillRemaining( @@ -534,32 +763,25 @@ class _FilesViewState extends State child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon( - Icons.error_outline_rounded, - size: 64, - color: colorScheme.error, - ), + Icon(LucideIcons.circleAlert, size: 56, color: colors.danger), const SizedBox(height: 16), Text( _offline ? 'Could not read local files' : 'WebDAV Sync Error', - style: theme.textTheme.titleLarge?.copyWith( - color: colorScheme.error, - fontWeight: FontWeight.bold, - ), + style: NooText.cardTitle.copyWith(color: colors.danger), ), const SizedBox(height: 8), Text( browser.errorMessage!, textAlign: TextAlign.center, - style: theme.textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - ), + style: NooText.body.copyWith(color: colors.fg3), ), const SizedBox(height: 20), - FilledButton.icon( - onPressed: browser.reload, - icon: const Icon(Icons.refresh_rounded), - label: Text(_offline ? 'Retry' : 'Retry Connection'), + NooButton( + variant: NooButtonVariant.secondary, + size: NooButtonSize.field, + icon: LucideIcons.refreshCw, + onTap: browser.reload, + child: Text(_offline ? 'Retry' : 'Retry Connection'), ), ], ), @@ -574,20 +796,16 @@ class _FilesViewState extends State mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( - _offline - ? Icons.offline_pin_outlined - : Icons.folder_open_rounded, - size: 64, - color: colorScheme.outlineVariant, + _offline ? LucideIcons.hardDriveDownload : LucideIcons.folder, + size: 56, + color: colors.fg3, ), const SizedBox(height: 12), Text( _offline && !hasBreadcrumbs ? 'Nothing downloaded yet' : 'Folder is empty', - style: theme.textTheme.titleMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - ), + style: NooText.cardTitle.copyWith(color: colors.fg2), ), ], ), @@ -596,13 +814,13 @@ class _FilesViewState extends State else if (files.isGridView) SliverPadding( key: const ValueKey('files-grid'), - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), + padding: EdgeInsets.fromLTRB(gutter, NooSpace.xs, gutter, NooSpace.lg), sliver: SliverGrid( - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 2, - childAspectRatio: 1.1, - crossAxisSpacing: 12, - mainAxisSpacing: 12, + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: isDesktop ? 5 : 2, + childAspectRatio: isDesktop ? 1.05 : 0.92, + crossAxisSpacing: isDesktop ? 16 : 10, + mainAxisSpacing: isDesktop ? 16 : 10, ), delegate: SliverChildBuilderDelegate((context, index) { final item = browser.items[index]; @@ -615,10 +833,17 @@ class _FilesViewState extends State }, childCount: browser.items.length), ), ) - else + else if (isDesktop) ...[ SliverPadding( - key: const ValueKey('files-list'), - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), + key: const ValueKey('files-table-header'), + padding: EdgeInsets.fromLTRB(gutter, NooSpace.xs, gutter, 0), + sliver: SliverToBoxAdapter( + child: _buildDesktopHeader(context, files, browser.currentFolderPath), + ), + ), + SliverPadding( + key: const ValueKey('files-table'), + padding: EdgeInsets.fromLTRB(gutter, 0, gutter, NooSpace.lg), sliver: SliverList( delegate: SliverChildBuilderDelegate((context, index) { final item = browser.items[index]; @@ -626,20 +851,32 @@ class _FilesViewState extends State key: ValueKey('${browser.currentFolderPath}::${item.id}'), index: index, fromRight: navigatingDeeper, - child: _buildListTile(context, item), + child: _buildDesktopRow(context, item), + ); + }, childCount: browser.items.length), + ), + ), + ] else + SliverPadding( + key: const ValueKey('files-list'), + padding: const EdgeInsets.fromLTRB( + NooSpace.sm, + NooSpace.xs, + NooSpace.sm, + NooSpace.lg, + ), + sliver: SliverList( + delegate: SliverChildBuilderDelegate((context, index) { + final item = browser.items[index]; + return _FolderEnterAnimation( + key: ValueKey('${browser.currentFolderPath}::${item.id}'), + index: index, + fromRight: navigatingDeeper, + child: _buildMobileRow(context, item, index, browser.items.length), ); }, childCount: browser.items.length), ), ), - - // Fixed clearance so the last item isn't hidden behind the floating - // nav bar, regardless of list length. - const SliverToBoxAdapter(child: SizedBox(height: 100)), - // For a short list this stretches the white card's background down to - // the screen edge (matching the empty/loading/error states, which - // already use SliverFillRemaining); for a long list that already fills - // the viewport it contributes nothing extra. - const SliverFillRemaining(hasScrollBody: false, child: SizedBox()), ]; return PopScope( @@ -652,43 +889,96 @@ class _FilesViewState extends State browser.navigateUp(); } }, - child: SyncedHeaderScaffold( - scrollController: widget.scrollController, - onRefresh: browser.reload, - actions: [ - if (_offline) - IconButton( - icon: const Icon(Icons.sync_rounded), - tooltip: 'Manage synced folders', - onPressed: () => ManageSyncedFoldersSheet.show(context), - ) - else - IconButton( - icon: const Icon(Icons.add_rounded), - tooltip: 'New', - onPressed: () => _showCreateMenu(context), - ), - const MoreTabsButton(), - const ProfileAvatarButton(), - ], - selectionBar: _isSelecting - ? _buildSelectionBar(context, theme, selectedItems) - : null, - contentSlivers: contentSlivers, + child: ColoredBox( + color: colors.bg, + child: RefreshIndicator( + color: colors.accent, + backgroundColor: colors.surface, + onRefresh: () { + unawaited(sync.syncOnPull()); + return browser.reload(); + }, + child: CustomScrollView( + controller: widget.scrollController, + slivers: contentSlivers, + ), + ), ), ); } - /// Replaces the top bar entirely while selecting (see - /// `SyncedHeaderScaffold.selectionBar`) - a close button, the "N - /// selected" count, and the horizontally-scrollable bulk actions. + /// The Offline tab's summary card (`DESIGN_SYSTEM.md`'s "Banner / summary + /// card", Offline variant): a status headline, a folder/file count + /// caption, a conflict callout when there is one, and "Sync now". + /// + /// `SyncStatusController` doesn't track local bytes used or a last-sync + /// timestamp anywhere today (no getter exposes either), so the literal + /// "used GB" stat / progress bar / "last sync" meta the mockup shows + /// aren't backed by real data - inventing them would mean fabricating + /// numbers, so this shows the status/count summary that data actually + /// supports instead. See the report for what a literal match would need. + Widget _buildOfflineSummary(BuildContext context, SyncStatusController sync) { + final gutter = NooLayout.gutter(context); + final hasConflicts = sync.syncConflicts.isNotEmpty; + final stat = hasConflicts + ? 'Sync issue' + : sync.isSyncingNow + ? 'Syncing…' + : (sync.syncEverything || sync.syncedPaths.isNotEmpty) + ? 'Synced' + : 'Sync off'; + + final String caption; + if (sync.syncEverything) { + caption = 'Whole account synced to this device'; + } else { + final folders = sync.syncedFolderCount; + final files = sync.syncedPaths.length - folders; + if (folders == 0 && files == 0) { + caption = 'Nothing synced yet - use "Sync to device" in Files'; + } else { + final parts = [ + if (folders > 0) '$folders folder${folders == 1 ? '' : 's'}', + if (files > 0) '$files file${files == 1 ? '' : 's'}', + ]; + caption = '${parts.join(' & ')} synced to this device'; + } + } + + final meta = hasConflicts + ? '${sync.syncConflicts.length} item${sync.syncConflicts.length == 1 ? '' : 's'} couldn\'t sync · tap a row to retry' + : null; + + return Padding( + padding: EdgeInsets.fromLTRB(gutter, 0, gutter, NooSpace.sm), + child: NooSummaryCard( + stat: stat, + caption: caption, + meta: meta, + tone: hasConflicts ? NooSummaryCardTone.danger : NooSummaryCardTone.normal, + action: NooButton( + variant: NooButtonVariant.tonal, + size: NooButtonSize.compact, + icon: LucideIcons.refreshCw, + disabled: sync.isSyncingNow, + onTap: () => sync.syncOnPull(), + child: const Text('Sync now'), + ), + ), + ); + } + + /// Takes over the controls row's own sticky slot while selecting - a + /// close button, the "N selected" count, and the horizontally-scrollable + /// bulk actions (in accent-text, per `DESIGN_SYSTEM.md`'s pinned + /// selection bar). Widget _buildSelectionBar( BuildContext context, - ThemeData theme, List selectedItems, ) { + final colors = context.nooColors; return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), + padding: const EdgeInsets.symmetric(horizontal: NooSpace.sm), child: Row( children: [ SizedBox( @@ -699,7 +989,7 @@ class _FilesViewState extends State mainAxisSize: MainAxisSize.min, children: [ IconButton( - icon: const Icon(Icons.close_rounded, size: 20), + icon: Icon(LucideIcons.x, size: 20, color: colors.fg1), tooltip: 'Cancel selection', onPressed: _clearSelection, visualDensity: VisualDensity.compact, @@ -707,8 +997,9 @@ class _FilesViewState extends State const SizedBox(width: 8), Text( '${selectedItems.length} selected', - style: theme.textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w700, + style: NooText.cardTitle.copyWith( + fontSize: 17, + color: colors.fg1, ), ), ], @@ -731,7 +1022,11 @@ class _FilesViewState extends State selectedItems, )) IconButton( - icon: Icon(action.icon, size: 20), + icon: Icon( + action.icon, + size: 20, + color: colors.accentText, + ), tooltip: action.label, onPressed: action.onTap, visualDensity: VisualDensity.compact, @@ -745,6 +1040,38 @@ class _FilesViewState extends State ); } + /// A single item's actions (the same list the multi-select bar offers, + /// just for one item) - the overflow menu every file row/table row/grid + /// card opens, so acting on one item doesn't need a long-press into + /// selection mode first. + /// + /// One sheet on every platform: a precisely-anchored desktop context + /// menu would need the tapped button's screen position, but + /// `NooFileRow`/`NooFileTableRow`/`NooGridCard`'s `onMore` is a plain + /// `VoidCallback` with no position, and those components aren't mine to + /// change - see the report. + void _showItemActions(BuildContext context, NextcloudItem item) { + final actions = _buildSelectionActions(context, [item]); + showNooSheet( + context, + children: [ + NooGroupedList( + children: [ + for (final action in actions) + NooSettingsRow( + icon: action.icon, + label: Text(action.label), + onTap: () { + Navigator.pop(context); + action.onTap(); + }, + ), + ], + ), + ], + ); + } + Future _favoriteSelected( BuildContext context, List items, @@ -828,7 +1155,7 @@ class _FilesViewState extends State await FileSaver.instance.saveFile( name: baseName, filePath: localPaths[i]!, - ext: ext, + fileExtension: ext, ); } messenger.showSnackBar( @@ -943,10 +1270,20 @@ class _FilesViewState extends State ); } - Widget _buildListTile(BuildContext context, NextcloudItem item) { - final theme = Theme.of(context); - final colorScheme = theme.colorScheme; - final browser = _browserOf(context); + /// Mobile 64px row (`NooFileRow`) - swipeable when browsing normally, + /// plain when selecting/picking/offline (matching the old + /// `SwipeableItem`'s gating exactly). Rounds the top corners of the + /// first row and the bottom corners of the last one, with a 1px `line` + /// divider between rows, so the whole list reads as one radius-20 + /// surface card rather than `NooGroupedList`'s own (non-lazy) `Column` - + /// this list needs to stay a lazily-built `SliverList` for long folders. + Widget _buildMobileRow( + BuildContext context, + NextcloudItem item, + int index, + int count, + ) { + final colors = context.nooColors; final pick = context.watch(); final sync = context.watch(); final session = context.watch(); @@ -955,110 +1292,146 @@ class _FilesViewState extends State final isSelected = _selectedIds.contains(item.id); // Pick mode and selection are server-side features - never on Offline. final picking = !_offline && pick.isPicking; + final status = _offline ? SyncItemStatus.none : sync.syncStatusFor(item); + final isConflict = status == SyncItemStatus.conflict; - // No border radius here — the outer ClipRRect below is the only place - // that rounds this tile's corners. Rounding it here too would give the - // tile its own independent rounded edge, which becomes visible as a - // stray floating corner while it slides during a swipe. - final card = Material( - color: isSelected - ? colorScheme.primaryContainer.withValues(alpha: 0.5) - : colorScheme.surfaceContainerLow, - child: InkWell( - onTap: () { - if (picking) { - _handlePickTap(context, item); - } else if (_isSelecting) { - _toggleSelection(item); - } else if (item.isFolder) { - browser.navigateToFolder(item.path); - } else { - _openFile(context, item); - } - }, - onLongPress: _offline || (picking && !pick.pickRequest!.allowMultiple) - ? null - : () => _toggleSelection(item), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - child: Row( - children: [ - Stack( - clipBehavior: Clip.none, - children: [ - SelectableThumbnail( - isSelected: isSelected, - size: 44, - checkmarkSize: 24, - child: ItemThumbnail( - item: item, - service: session.service, - localFile: _localFileFor(context, item), - size: 44, - borderRadius: 12, - iconSize: 22, - ), - ), - // Everything on the Offline tab is synced by definition. - if (!_offline) - Positioned( - right: -2, - bottom: -2, - child: SyncStatusBadge( - status: sync.syncStatusFor(item), - ), - ), - ], - ), - const SizedBox(width: 14), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - item.name, - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w600, - fontSize: 14, - ), - ), - const SizedBox(height: 2), - Text( - item.isFolder - ? 'Folder' - : '${formatBytes(item.size)} • ${DateFormat.yMMMd().format(item.lastModified)}', - style: theme.textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ), - ], - ), - ), + final row = NooFileRow( + kind: NooFileKind.from( + name: item.name, + mimeType: item.mimeType, + isDirectory: item.isFolder, ), + name: item.name, + size: !item.isFolder && !isConflict ? formatBytes(item.size) : null, + modified: !item.isFolder && !isConflict + ? DateFormat.yMMMd().format(item.lastModified) + : null, + meta: isConflict + ? "Couldn't sync · Tap to retry" + : (item.isFolder ? 'Folder' : null), + statuses: _nooStatuses(status), + favorite: item.isFavorite, + iosStyle: NooLayout.iosStyle(context), + selected: isSelected, + thumbnail: _rowThumbnail(context, item, session, NooFileTileSize.row), + onTap: () => _handleItemTap(context, item, picking: picking), + onLongPress: _offline || (picking && !pick.pickRequest!.allowMultiple) + ? null + : () => _toggleSelection(item), + onMore: !_isSelecting && !picking + ? () => _showItemActions(context, item) + : null, ); - final content = _isSelecting || picking || _offline - ? card - : SwipeableItem( - itemKey: ValueKey('file-${item.id}'), - itemName: item.name, - settings: settings, - onFavorite: () => ops.toggleItemFavorite(item), - onShare: () => ShareSheet.show(context, item), - onDelete: () => _deleteViaSwipe(item), - child: card, + final swipeable = _isSelecting || picking || _offline + ? row + : NooSwipeAction( + startAction: _swipeSpec(settings.swipeRightAction, item, ops), + endAction: _swipeSpec(settings.swipeLeftAction, item, ops), + child: row, ); - return Padding( - padding: const EdgeInsets.only(bottom: 8), - child: ClipRRect(borderRadius: BorderRadius.circular(16), child: content), + final isFirst = index == 0; + final isLast = index == count - 1; + return Column( + children: [ + ClipRRect( + borderRadius: BorderRadius.vertical( + top: isFirst ? const Radius.circular(NooRadii.card) : Radius.zero, + bottom: isLast ? const Radius.circular(NooRadii.card) : Radius.zero, + ), + child: swipeable, + ), + if (!isLast) Container(height: 1, color: colors.line), + ], ); } - Future _deleteViaSwipe(NextcloudItem item) async { + /// Desktop table header - sort state comes from the same per-folder + /// `FilesController` prefs the mobile sort chip uses, so both tabs and + /// both layouts always agree. + Widget _buildDesktopHeader( + BuildContext context, + FilesController files, + String folderPath, + ) { + const columns = [FileSortField.name, FileSortField.size, FileSortField.dateModified]; + final field = files.sortFieldFor(folderPath); + final sortColumn = columns.indexOf(field); + return NooFileTableHeader( + col2Label: 'Size', + col3Label: 'Modified', + sortColumn: sortColumn < 0 ? null : sortColumn, + sortAscending: files.sortAscendingFor(folderPath), + onSort: (index) { + final tapped = columns[index]; + if (files.sortFieldFor(folderPath) == tapped) { + files.toggleSortOrderFor(folderPath); + } else { + files.setSortFieldFor(folderPath, tapped); + } + }, + ); + } + + /// Desktop 52px table row (`NooFileTableRow`) - no swipe actions or card + /// framing (the design's desktop rows sit directly on the pane). + Widget _buildDesktopRow(BuildContext context, NextcloudItem item) { + final pick = context.watch(); + final sync = context.watch(); + final session = context.watch(); + final isSelected = _selectedIds.contains(item.id); + final picking = !_offline && pick.isPicking; + final status = _offline ? SyncItemStatus.none : sync.syncStatusFor(item); + + return NooFileTableRow( + kind: NooFileKind.from( + name: item.name, + mimeType: item.mimeType, + isDirectory: item.isFolder, + ), + name: item.name, + col2: item.isFolder ? null : formatBytes(item.size), + col3: item.isFolder ? null : DateFormat.yMMMd().format(item.lastModified), + statuses: _nooStatuses(status), + favorite: item.isFavorite, + selected: isSelected, + thumbnail: _rowThumbnail(context, item, session, NooFileTileSize.desktop), + onTap: () => _handleItemTap(context, item, picking: picking), + onMore: !_isSelecting && !picking + ? () => _showItemActions(context, item) + : null, + ); + } + + Future _confirmAndDeleteViaSwipe(NextcloudItem item) async { + final confirmed = await showDialog( + context: context, + builder: (dialogContext) { + return AlertDialog( + title: const Text('Delete Item'), + content: Text( + 'Delete "${item.name}" from the server? This cannot be undone.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext, false), + child: const Text('Cancel'), + ), + FilledButton( + style: FilledButton.styleFrom( + backgroundColor: Theme.of(dialogContext).colorScheme.error, + ), + onPressed: () => Navigator.pop(dialogContext, true), + child: const Text('Delete'), + ), + ], + ); + }, + ); + if (confirmed != true || !mounted) return; + HapticFeedback.mediumImpact(); + final ops = context.read(); final success = await ops.deleteItem(item.path); if (!mounted) return; @@ -1072,128 +1445,60 @@ class _FilesViewState extends State ); } + /// `NooGridCard` (`DESIGN_SYSTEM.md`'s grid card): a full-bleed thumbnail + /// area (real preview, or the file-kind soft color + icon when there + /// isn't one) above a name/meta line. The card has no slot for a + /// favorite star or sync-status icon (only the row/table variants do) - + /// see the report. Widget _buildGridCard(BuildContext context, NextcloudItem item) { - final browser = _browserOf(context); + final colors = context.nooColors; final pick = context.watch(); final sync = context.watch(); + final session = context.watch(); final isSelected = _selectedIds.contains(item.id); final picking = !_offline && pick.isPicking; + final status = _offline ? SyncItemStatus.none : sync.syncStatusFor(item); + final kind = NooFileKind.from( + name: item.name, + mimeType: item.mimeType, + isDirectory: item.isFolder, + ); // Offline reads the image straight from the local mirror; videos have // no local thumbnail (that would need a frame-extraction plugin), so - // they fall through to the plain icon card there. + // they fall through to the plain kind-icon card there. final isMedia = _offline ? item.type == NextcloudItemType.image : (item.type == NextcloudItemType.image || item.type == NextcloudItemType.video) && item.previewUrl != null; + final meta = status == SyncItemStatus.conflict + ? "Couldn't sync · Tap to retry" + : (item.isFolder ? 'Folder' : formatBytes(item.size)); - return Material( - color: Theme.of(context).colorScheme.surfaceContainerLow, - borderRadius: BorderRadius.circular(20), - clipBehavior: Clip.antiAlias, - child: InkWell( - onTap: () { - if (picking) { - _handlePickTap(context, item); - } else if (_isSelecting) { - _toggleSelection(item); - } else if (item.isFolder) { - browser.navigateToFolder(item.path); - } else { - _openFile(context, item); - } - }, - onLongPress: _offline || (picking && !pick.pickRequest!.allowMultiple) - ? null - : () => _toggleSelection(item), - child: Stack( - children: [ - SelectableThumbnail( - isSelected: isSelected, - checkmarkSize: 32, - child: isMedia - ? _buildMediaGridContent(context, item) - : _buildPlainGridContent(context, item), - ), - if (!_offline) - Positioned( - right: 6, - bottom: 6, - child: SyncStatusBadge(status: sync.syncStatusFor(item)), - ), - ], - ), - ), - ); - } - - /// Grid content for images/videos - see [MediaGridTile]. - Widget _buildMediaGridContent(BuildContext context, NextcloudItem item) { - final session = context.watch(); - final localFile = _localFileFor(context, item); - - return MediaGridTile( - item: item, - imageBuilder: (cachePixels) => ResizeImage( - localFile != null - ? FileImage(localFile) - : NetworkImage( - item.previewUrl!, - headers: session.service?.authHeaders, - ), - width: cachePixels, - height: cachePixels, - ), - fallbackBuilder: (ctx) => _buildPlainGridContent(ctx, item), - ); - } - - /// Grid content for folders and non-previewable files: an icon badge - /// with name/size below, since there's no meaningful preview to show. - Widget _buildPlainGridContent(BuildContext context, NextcloudItem item) { - final theme = Theme.of(context); - final colorScheme = theme.colorScheme; - final iconColor = getIconColor(context, item.type); - - return Padding( - padding: const EdgeInsets.all(14), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Container( - width: 40, - height: 40, - decoration: BoxDecoration( - color: iconColor.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(12), - ), - child: Icon(getItemIcon(item.type), color: iconColor, size: 24), - ), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - item.name, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w600, - fontSize: 13, - ), - ), - const SizedBox(height: 2), - Text( - item.isFolder ? 'Folder' : formatBytes(item.size), - style: theme.textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - fontSize: 11, - ), - ), - ], - ), - ], - ), + return NooGridCard( + name: item.name, + meta: meta, + placeholderColor: kind.background(colors), + icon: kind.icon, + iconColor: kind.foreground(colors), + thumbnail: isMedia + ? _GridThumbnail( + item: item, + service: session.service, + localFile: _localFileFor(context, item), + fallback: Icon(kind.icon, color: kind.foreground(colors), size: 32), + ) + : null, + thumbnailHeight: NooLayout.isDesktop(context) ? 118 : 104, + selected: isSelected, + onTap: () => _handleItemTap(context, item, picking: picking), + onLongPress: _offline || (picking && !pick.pickRequest!.allowMultiple) + ? null + : () => _toggleSelection(item), + onMore: !_isSelecting && !picking + ? () => _showItemActions(context, item) + : null, + verticalOverflowIcon: !NooLayout.iosStyle(context), ); } @@ -1219,117 +1524,4 @@ class _FilesViewState extends State FileViewerScreen.route(item: item, siblings: files.items), ); } - - void _showCreateMenu(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - showModalBottomSheet( - context: context, - backgroundColor: colorScheme.surfaceContainerHigh, - showDragHandle: true, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(28)), - ), - builder: (sheetContext) { - return SafeArea( - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 12), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ListTile( - leading: const Icon(Icons.upload_file_rounded), - title: const Text('Upload File'), - onTap: () { - Navigator.pop(sheetContext); - _pickAndUploadFile(context); - }, - ), - ListTile( - leading: const Icon(Icons.create_new_folder_outlined), - title: const Text('New Folder'), - onTap: () { - Navigator.pop(sheetContext); - _showCreateFolderDialog(context); - }, - ), - ], - ), - ), - ); - }, - ); - } - - void _showCreateFolderDialog(BuildContext context) { - final files = context.read(); - final controller = TextEditingController(); - - showDialog( - context: context, - builder: (context) { - return AlertDialog( - title: const Text('Create New Folder'), - content: TextField( - controller: controller, - decoration: const InputDecoration( - labelText: 'Folder Name', - hintText: 'e.g. Finance', - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('Cancel'), - ), - FilledButton( - onPressed: () async { - final name = controller.text.trim(); - if (name.isNotEmpty) { - Navigator.pop(context); - final success = await files.createFolder(name); - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - success - ? 'Created folder $name' - : 'Failed to create folder', - ), - behavior: SnackBarBehavior.floating, - ), - ); - } - } - }, - child: const Text('Create'), - ), - ], - ); - }, - ); - } - - // Mirrors `main.dart`'s `_handleSharedFiles` exactly, so picking a file - // via "+" lands on the same destination-picker screen and background - // foreground-service upload as receiving one via Android's "Share - // to..." sheet does, rather than a separate in-app-only upload path. - Future _pickAndUploadFile(BuildContext context) async { - final picked = await FilePicker.pickFiles(); - if (picked.isEmpty) return; - final files = picked - .where((f) => f.path != null) - .map( - (f) => SharedFileRef( - uri: Uri.file(f.path!).toString(), - name: f.name, - size: f.lengthSync(), - ), - ) - .toList(); - if (files.isEmpty || !context.mounted) return; - - Navigator.of( - context, - ).push(MaterialPageRoute(builder: (_) => ShareUploadView(files: files))); - } } diff --git a/lib/views/lock_screen_view.dart b/lib/views/lock_screen_view.dart index 979cf06..081b568 100644 --- a/lib/views/lock_screen_view.dart +++ b/lib/views/lock_screen_view.dart @@ -1,6 +1,9 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../providers/session_controller.dart'; +import '../theme/design_tokens.dart'; +import '../widgets/noo/core/noo_button.dart'; /// Shown in place of the main shell whenever [SessionController.needsUnlock] is /// true - a fresh app launch with login lock configured, or returning to the @@ -48,10 +51,10 @@ class _LockScreenViewState extends State @override Widget build(BuildContext context) { - final theme = Theme.of(context); - final colorScheme = theme.colorScheme; + final colors = context.nooColors; return Scaffold( + backgroundColor: colors.bg, body: Center( child: Padding( padding: const EdgeInsets.all(32), @@ -63,10 +66,7 @@ class _LockScreenViewState extends State // silhouette on transparent, meant to be recolored rather // than shown as-is. 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: 80, @@ -77,35 +77,22 @@ class _LockScreenViewState extends State Text( 'Noo is locked', textAlign: TextAlign.center, - style: theme.textTheme.headlineSmall?.copyWith( - fontWeight: FontWeight.w700, - ), + style: NooText.title.copyWith(color: colors.fg1), ), const SizedBox(height: 8), Text( 'Unlock with your PIN or biometrics to continue', textAlign: TextAlign.center, - style: theme.textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - ), + style: NooText.body.copyWith(color: colors.fg3), ), const SizedBox(height: 32), - SizedBox( - height: 54, - child: FilledButton.icon( - onPressed: _authenticating ? null : _attemptUnlock, - icon: _authenticating - ? SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2.5, - color: colorScheme.onPrimary, - ), - ) - : const Icon(Icons.lock_open_rounded), - label: Text(_authenticating ? 'Unlocking…' : 'Unlock'), - ), + NooButton( + variant: NooButtonVariant.primary, + size: NooButtonSize.cta, + icon: LucideIcons.lockOpen, + disabled: _authenticating, + onTap: _attemptUnlock, + child: Text(_authenticating ? 'Unlocking…' : 'Unlock'), ), ], ), diff --git a/lib/views/login_view.dart b/lib/views/login_view.dart index fb06668..3e2177e 100644 --- a/lib/views/login_view.dart +++ b/lib/views/login_view.dart @@ -1,13 +1,22 @@ import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:provider/provider.dart'; import '../models/saved_account.dart'; import '../providers/session_controller.dart'; +import '../theme/design_tokens.dart'; +import '../widgets/noo/core/noo_avatar.dart'; +import '../widgets/noo/core/noo_button.dart'; +import '../widgets/noo/nav/noo_top_bar.dart'; +import '../widgets/noo/noo_layout.dart'; +import '../widgets/noo/overlays/noo_text_field.dart'; +import '../widgets/settings/settings_dialogs.dart'; +import '../widgets/shell/shell_common.dart'; import 'login_webview_view.dart'; class LoginView extends StatefulWidget { /// True when this is pushed from Settings ("Add account") on top of an /// already-logged-in session, rather than shown as the app's root screen - /// with no account yet. Adds an AppBar/back affordance and auto-pops once + /// with no account yet. Adds a top bar/back affordance and auto-pops once /// the new account becomes active. final bool isAddingAccount; @@ -18,9 +27,9 @@ class LoginView extends StatefulWidget { } class _LoginViewState extends State { - final _formKey = GlobalKey(); final _urlController = TextEditingController(); String? _originalActiveAccountId; + String? _localError; bool _popped = false; bool _webViewPushed = false; @@ -39,12 +48,14 @@ class _LoginViewState extends State { } void _handleContinue() { - if (!_formKey.currentState!.validate()) return; + final url = _urlController.text.trim(); + if (url.isEmpty) { + setState(() => _localError = 'Please enter your Nextcloud server address'); + return; + } + setState(() => _localError = null); FocusScope.of(context).unfocus(); - context.read().startLoginFlow( - _urlController.text.trim(), - addAccount: widget.isAddingAccount, - ); + context.read().startLoginFlow(url, addAccount: widget.isAddingAccount); } /// Resumes a saved account with one tap (see [SessionController.switchAccount]). @@ -68,47 +79,25 @@ class _LoginViewState extends State { ); } - /// Mirrors AccountView's `_AccountsCard._confirmRemove` - same - /// destructive-action gate, just reached from the login screen instead of - /// Settings. + /// Mirrors Settings' own account removal - same confirm/remove gate, just + /// reached from the login screen instead of the saved-accounts list. Future _confirmRemoveAccount(SavedAccount account) async { - final host = Uri.tryParse(account.serverUrl)?.host ?? account.serverUrl; - final confirmed = await showDialog( - context: context, - builder: (dialogContext) { - return AlertDialog( - title: const Text('Remove Account'), - content: Text( - 'Remove ${account.username} ($host)? You can add it again later.', - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(dialogContext, false), - child: const Text('Cancel'), - ), - FilledButton( - style: FilledButton.styleFrom( - backgroundColor: Theme.of(dialogContext).colorScheme.error, - ), - onPressed: () => Navigator.pop(dialogContext, true), - child: const Text('Remove'), - ), - ], - ); - }, + await confirmRemoveAccount( + context, + context.read(), + accountId: account.id, + username: account.username, + host: serverHost(account.serverUrl), + isActive: false, ); - if (confirmed != true || !mounted) return; - await context.read().removeAccount(account.id); } @override Widget build(BuildContext context) { - final theme = Theme.of(context); - final colorScheme = theme.colorScheme; + final colors = context.nooColors; final session = context.watch(); - final isAwaitingBrowser = - session.loginFlowStatus == LoginFlowStatus.awaitingBrowser; + final isAwaitingBrowser = session.loginFlowStatus == LoginFlowStatus.awaitingBrowser; final isInitiating = session.loginFlowStatus == LoginFlowStatus.initiating; // A new/refreshed account has just become active - pop back to @@ -128,9 +117,7 @@ class _LoginViewState extends State { // SessionController.startLoginFlow's doc comment for why. Push it the // moment there's a URL to show; _webViewPushed resets once that route // pops (cancelled or done) so a retry after cancelling pushes it again. - if (!_webViewPushed && - isAwaitingBrowser && - session.pendingLoginUrl != null) { + if (!_webViewPushed && isAwaitingBrowser && session.pendingLoginUrl != null) { _webViewPushed = true; final url = session.pendingLoginUrl!; WidgetsBinding.instance.addPostFrameCallback((_) async { @@ -154,9 +141,17 @@ class _LoginViewState extends State { }); } + final errorMessage = + _localError ?? (session.loginFlowStatus == LoginFlowStatus.error ? session.errorMessage : null); + final body = Scaffold( + backgroundColor: colors.bg, appBar: widget.isAddingAccount - ? AppBar(title: const Text('Add Account')) + ? NooTopBar( + style: NooLayout.navStyle(context), + title: 'Add account', + leading: const NooTopBarBack(), + ) : null, body: SafeArea( child: Center( @@ -172,8 +167,7 @@ class _LoginViewState extends State { // deleting it, specifically so it can be resumed // from here with one tap - no need to repeat // Login Flow v2. - if (!widget.isAddingAccount && - session.accounts.isNotEmpty) ...[ + if (!widget.isAddingAccount && session.accounts.isNotEmpty) ...[ _SavedAccountsSection( accounts: session.accounts, onSelect: (account) => _continueAsAccount(account), @@ -182,33 +176,21 @@ class _LoginViewState extends State { const SizedBox(height: 28), Row( children: [ - const Expanded(child: Divider()), + Expanded(child: Divider(color: colors.line)), Padding( padding: const EdgeInsets.symmetric(horizontal: 12), - child: Text( - 'or', - style: theme.textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), + child: Text('or', style: NooText.meta.copyWith(color: colors.fg3)), ), - const Expanded(child: Divider()), + Expanded(child: Divider(color: colors.line)), ], ), const SizedBox(height: 28), ], _ServerForm( - formKey: _formKey, urlController: _urlController, - isLoading: - isInitiating || session.isLoading || isAwaitingBrowser, - errorMessage: - session.loginFlowStatus == LoginFlowStatus.error - ? session.errorMessage - : null, + isLoading: isInitiating || session.isLoading || isAwaitingBrowser, + errorMessage: errorMessage, onContinue: _handleContinue, - theme: theme, - colorScheme: colorScheme, ), ], ), @@ -236,157 +218,94 @@ class _LoginViewState extends State { } class _ServerForm extends StatelessWidget { - final GlobalKey formKey; final TextEditingController urlController; final bool isLoading; final String? errorMessage; final VoidCallback onContinue; - final ThemeData theme; - final ColorScheme colorScheme; const _ServerForm({ - required this.formKey, required this.urlController, required this.isLoading, required this.errorMessage, required this.onContinue, - required this.theme, - required this.colorScheme, }); @override Widget build(BuildContext context) { - return Form( - key: formKey, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Center( - // Same monochrome-tinted treatment as the splash/lock screens - // (see main.dart's _SplashView) - the asset is a plain white - // silhouette on transparent, meant to be recolored rather than - // shown as-is. Used everywhere the app shows its own icon - // in-app, rather than the full-color launcher icon. - child: ColorFiltered( - colorFilter: ColorFilter.mode( - Theme.of(context).colorScheme.onSurface, - BlendMode.srcIn, - ), - child: Image.asset( - 'assets/icon/app_icon_monochrome.png', - width: 80, - height: 80, - ), - ), + final colors = context.nooColors; + + return Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Center( + // Same monochrome-tinted treatment as the splash/lock screens + // (see main.dart's _SplashView) - the asset is a plain white + // silhouette on transparent, meant to be recolored rather than + // shown as-is. Used everywhere the app shows its own icon + // in-app, rather than the full-color launcher icon. + child: ColorFiltered( + colorFilter: ColorFilter.mode(colors.fg1, BlendMode.srcIn), + child: Image.asset('assets/icon/app_icon_monochrome.png', width: 80, height: 80), ), - const SizedBox(height: 24), - Text( - 'Noo', - textAlign: TextAlign.center, - style: theme.textTheme.headlineMedium?.copyWith( - fontWeight: FontWeight.w800, - letterSpacing: -0.5, + ), + const SizedBox(height: 24), + Text('Noo', textAlign: TextAlign.center, style: NooText.largeTitle.copyWith(color: colors.fg1)), + const SizedBox(height: 6), + Text( + 'Connect to your self-hosted server', + textAlign: TextAlign.center, + style: NooText.body.copyWith(color: colors.fg3), + ), + const SizedBox(height: 36), + if (errorMessage != null) + Container( + margin: const EdgeInsets.only(bottom: 20), + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: colors.dangerSoft, + borderRadius: BorderRadius.circular(NooRadii.input), ), - ), - const SizedBox(height: 6), - Text( - 'Connect to your self-hosted server', - textAlign: TextAlign.center, - style: theme.textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 36), - if (errorMessage != null) - Container( - margin: const EdgeInsets.only(bottom: 20), - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - color: colorScheme.errorContainer, - borderRadius: BorderRadius.circular(16), - ), - child: Row( - children: [ - Icon( - Icons.error_outline_rounded, - color: colorScheme.onErrorContainer, - size: 20, - ), - const SizedBox(width: 12), - Expanded( - child: Text( - errorMessage!, - style: theme.textTheme.bodySmall?.copyWith( - color: colorScheme.onErrorContainer, - fontWeight: FontWeight.w500, - ), + child: Row( + children: [ + Icon(LucideIcons.circleAlert, color: colors.danger, size: 20), + const SizedBox(width: 12), + Expanded( + child: Text( + errorMessage!, + style: NooText.body.copyWith( + fontSize: 13, + fontWeight: FontWeight.w500, + color: colors.danger, ), ), - ], - ), - ), - TextFormField( - controller: urlController, - keyboardType: TextInputType.url, - autofillHints: const [AutofillHints.url], - decoration: InputDecoration( - labelText: 'Server Address', - hintText: 'cloud.example.com', - prefixIcon: const Icon(Icons.dns_outlined), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(16), - ), - filled: true, - fillColor: colorScheme.surfaceContainerLow, - ), - validator: (val) { - if (val == null || val.trim().isEmpty) { - return 'Please enter your Nextcloud server address'; - } - return null; - }, - onFieldSubmitted: (_) => onContinue(), - ), - const SizedBox(height: 28), - SizedBox( - height: 54, - child: FilledButton( - onPressed: isLoading ? null : onContinue, - style: FilledButton.styleFrom( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(27), ), - ), - child: isLoading - ? SizedBox( - width: 24, - height: 24, - child: CircularProgressIndicator( - strokeWidth: 2.5, - color: colorScheme.onPrimary, - ), - ) - : const Text( - 'Continue', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - ), - ), + ], ), ), - const SizedBox(height: 24), - Text( - "You'll finish signing in on the page that opens. This app never sees your password.", - textAlign: TextAlign.center, - style: theme.textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - fontSize: 11, - ), - ), - ], - ), + NooTextField( + controller: urlController, + placeholder: 'cloud.example.com', + leadingIcon: LucideIcons.server, + keyboardType: TextInputType.url, + onSubmitted: (_) => onContinue(), + ), + const SizedBox(height: 28), + NooButton( + variant: NooButtonVariant.primary, + size: NooButtonSize.cta, + fullWidth: true, + disabled: isLoading, + onTap: onContinue, + child: Text(isLoading ? 'Connecting…' : 'Continue'), + ), + const SizedBox(height: 24), + Text( + "You'll finish signing in on the page that opens. This app never sees your password.", + textAlign: TextAlign.center, + style: NooText.meta.copyWith(color: colors.fg3, fontSize: 11), + ), + ], ); } } @@ -398,44 +317,35 @@ class _SavedAccountsSection extends StatelessWidget { final ValueChanged onSelect; final ValueChanged onRemove; - const _SavedAccountsSection({ - required this.accounts, - required this.onSelect, - required this.onRemove, - }); + const _SavedAccountsSection({required this.accounts, required this.onSelect, required this.onRemove}); @override Widget build(BuildContext context) { - final theme = Theme.of(context); - final colorScheme = theme.colorScheme; + final colors = context.nooColors; return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Padding( padding: const EdgeInsets.only(left: 4, bottom: 8), - child: Text( - 'Continue as', - style: theme.textTheme.labelLarge?.copyWith( - color: colorScheme.onSurfaceVariant, - fontWeight: FontWeight.w600, - ), - ), + child: Text('Continue as', style: NooText.label.copyWith(color: colors.fg2)), ), - Card( - margin: EdgeInsets.zero, - child: Column( - children: [ - for (final account in accounts) ...[ - _SavedAccountRow( - account: account, - onTap: () => onSelect(account), - onRemove: () => onRemove(account), - ), - if (account != accounts.last) - const Divider(height: 1, indent: 16, endIndent: 16), + ClipRRect( + borderRadius: BorderRadius.circular(NooRadii.card), + child: DecoratedBox( + decoration: BoxDecoration(color: colors.line), + child: Column( + children: [ + for (var i = 0; i < accounts.length; i++) ...[ + if (i > 0) const SizedBox(height: 1), + _SavedAccountRow( + account: accounts[i], + onTap: () => onSelect(accounts[i]), + onRemove: () => onRemove(accounts[i]), + ), + ], ], - ], + ), ), ), ], @@ -448,40 +358,60 @@ class _SavedAccountRow extends StatelessWidget { final VoidCallback onTap; final VoidCallback onRemove; - const _SavedAccountRow({ - required this.account, - required this.onTap, - required this.onRemove, - }); + const _SavedAccountRow({required this.account, required this.onTap, required this.onRemove}); @override Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - final host = Uri.tryParse(account.serverUrl)?.host ?? account.serverUrl; - final initial = account.username.isNotEmpty - ? account.username[0].toUpperCase() - : '?'; + final colors = context.nooColors; - return ListTile( - leading: CircleAvatar( - backgroundColor: colorScheme.primary, - child: Text( - initial, - style: TextStyle( - color: colorScheme.onPrimary, - fontWeight: FontWeight.bold, + return Material( + color: colors.surface, + child: InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: NooSpace.md, vertical: 10), + child: Row( + spacing: 12, + children: [ + NooAvatar(initials: accountInitial(account.username), size: 36), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 2, + children: [ + Text( + account.username, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: NooText.bodyL.copyWith( + height: 1.1, + fontWeight: FontWeight.w500, + color: colors.fg1, + ), + ), + Text( + serverHost(account.serverUrl), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: NooText.meta.copyWith(color: colors.fg3), + ), + ], + ), + ), + GestureDetector( + onTap: onRemove, + child: Container( + width: 36, + height: 36, + alignment: Alignment.center, + decoration: BoxDecoration(color: colors.surface2, shape: BoxShape.circle), + child: Icon(LucideIcons.x, size: 16, color: colors.fg2), + ), + ), + ], ), ), ), - title: Text(account.username), - subtitle: Text(host), - trailing: IconButton( - icon: const Icon(Icons.close_rounded), - tooltip: 'Remove account', - visualDensity: VisualDensity.compact, - onPressed: onRemove, - ), - onTap: onTap, ); } } diff --git a/lib/views/login_webview_view.dart b/lib/views/login_webview_view.dart index 31ae35f..e514d35 100644 --- a/lib/views/login_webview_view.dart +++ b/lib/views/login_webview_view.dart @@ -1,7 +1,11 @@ import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:provider/provider.dart'; import 'package:webview_flutter/webview_flutter.dart'; import '../providers/session_controller.dart'; +import '../theme/design_tokens.dart'; +import '../widgets/noo/nav/noo_top_bar.dart'; +import '../widgets/noo/noo_layout.dart'; /// Every Login Flow v2 page - first login or an additional account - /// rendered in Flutter's own WebView rather than a Chrome Custom Tab or @@ -63,6 +67,7 @@ class _LoginWebViewViewState extends State { @override Widget build(BuildContext context) { final session = context.watch(); + final colors = context.nooColors; // The login flow finished (success or error) or was cancelled // elsewhere - close this screen automatically rather than leaving it @@ -81,18 +86,24 @@ class _LoginWebViewViewState extends State { if (!didPop) _cancel(); }, child: Scaffold( - appBar: AppBar( - title: const Text('Sign in'), - leading: IconButton( - icon: const Icon(Icons.close_rounded), + backgroundColor: colors.bg, + appBar: NooTopBar( + style: NooLayout.navStyle(context), + title: 'Log in', + leading: NooTopBarButton( + icon: LucideIcons.x, tooltip: 'Cancel', - onPressed: _cancel, + onTap: _cancel, ), ), body: Stack( children: [ WebViewWidget(controller: _controller), - if (_pageLoading) const LinearProgressIndicator(), + if (_pageLoading) + LinearProgressIndicator( + color: colors.accent, + backgroundColor: colors.surface3, + ), ], ), ), diff --git a/lib/views/move_copy_destination_picker.dart b/lib/views/move_copy_destination_picker.dart index 23bb7f3..fab4d22 100644 --- a/lib/views/move_copy_destination_picker.dart +++ b/lib/views/move_copy_destination_picker.dart @@ -1,21 +1,22 @@ import 'package:flutter/material.dart'; -import 'package:intl/intl.dart'; -import 'package:material_symbols_icons/symbols.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:provider/provider.dart'; import '../models/move_copy_result.dart'; import '../models/nextcloud_item.dart'; import '../providers/files_controller.dart'; import '../providers/item_operations.dart'; -import '../providers/session_controller.dart'; +import '../theme/design_tokens.dart'; import '../widgets/breadcrumbs.dart'; -import '../widgets/item_icon.dart'; import '../widgets/more_tabs_button.dart'; import '../widgets/move_copy_conflict_sheet.dart'; -import '../widgets/profile_avatar_button.dart'; -import '../widgets/segmented_icon_toggle.dart'; -import '../widgets/sort_menu_button.dart'; -import '../widgets/sticky_header_delegate.dart'; -import '../widgets/synced_header_scaffold.dart'; +import '../widgets/noo/core/noo_button.dart'; +import '../widgets/noo/files/noo_file_kind.dart'; +import '../widgets/noo/files/noo_file_row.dart'; +import '../widgets/noo/files/noo_file_table.dart'; +import '../widgets/noo/nav/noo_top_bar.dart'; +import '../widgets/noo/nav/noo_toolbar.dart'; +import '../widgets/noo/noo_layout.dart'; +import '../widgets/tabs/tab_state_slivers.dart'; /// Destination-folder browser for moving/copying [items] (a multi-select /// batch from Files or Photos). Visually mirrors `ShareUploadView`'s @@ -175,352 +176,194 @@ class _MoveCopyDestinationPickerState extends State { ).pop(MoveCopyResult(succeeded: succeeded, failed: failed)); } - Widget _buildControlsRow(FilesController files) { - return SizedBox( - height: 44, - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: [ - IconButton( - icon: Icon( - files.filesSortAscending - ? Icons.arrow_upward_rounded - : Icons.arrow_downward_rounded, - size: 20, - ), - visualDensity: VisualDensity.compact, - tooltip: files.filesSortAscending ? 'Ascending' : 'Descending', - onPressed: files.toggleFilesSortOrder, - ), - SizedBox( - width: 130, - child: SortMenuButton( - field: files.filesSortField, - onChanged: files.setFilesSortField, - ), - ), - ToggleIconButton( - icon: files.showHiddenFiles - ? Icons.visibility_rounded - : Icons.visibility_off_rounded, - isSelected: files.showHiddenFiles, - onTap: () => files.toggleShowHiddenFiles(), - tooltip: 'Show hidden files', - ), - const SizedBox(width: 4), - SegmentedIconGroup( - children: [ - ToggleIconButton( - icon: Symbols.circles_rounded, - isSelected: files.storageScope == StorageScope.cloud, - onTap: () => files.setStorageScope(StorageScope.cloud), - tooltip: 'Cloud storage', - ), - ToggleIconButton( - icon: Symbols.hard_drive_rounded, - isSelected: files.storageScope == StorageScope.external, - onTap: () => files.setStorageScope(StorageScope.external), - tooltip: 'External storage', - ), - ], - ), - const SizedBox(width: 8), - SegmentedIconGroup( - children: [ - ToggleIconButton( - icon: Icons.view_list_rounded, - isSelected: !files.isGridView, - onTap: () => files.setGridView(false), - tooltip: 'List view', - ), - ToggleIconButton( - icon: Icons.grid_view_rounded, - isSelected: files.isGridView, - onTap: () => files.setGridView(true), - tooltip: 'Grid view', - ), - ], - ), - ], + // Only folders are valid Move/Copy destinations, so the listing - unlike + // Files' own - never shows plain files at all. + Widget _buildRow(NextcloudItem item, int index, int count) { + final colors = context.nooColors; + final row = NooFileRow( + kind: NooFileKind.folder, + name: item.name, + meta: 'Folder', + iosStyle: NooLayout.iosStyle(context), + onTap: () => _navigateToFolder(item.path), + ); + + final isFirst = index == 0; + final isLast = index == count - 1; + return Column( + children: [ + ClipRRect( + borderRadius: BorderRadius.vertical( + top: isFirst ? const Radius.circular(NooRadii.card) : Radius.zero, + bottom: isLast ? const Radius.circular(NooRadii.card) : Radius.zero, + ), + child: row, ), - ), + if (!isLast) Container(height: 1, color: colors.line), + ], ); } - // Only folders are valid Move/Copy destinations - files still show (so - // the listing matches what the Files tab itself would show for this - // folder) but are visually dimmed and inert, same treatment as - // ShareUploadView's own destination browser. - Widget _buildListTile(NextcloudItem item) { - final theme = Theme.of(context); - final colorScheme = theme.colorScheme; - final session = context.watch(); - final isFolder = item.isFolder; - - return Padding( - padding: const EdgeInsets.only(bottom: 8), - child: ClipRRect( - borderRadius: BorderRadius.circular(16), - child: Opacity( - opacity: isFolder ? 1 : 0.5, - child: Material( - color: colorScheme.surfaceContainerLow, - child: InkWell( - onTap: isFolder ? () => _navigateToFolder(item.path) : null, - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 12, - ), - child: Row( - children: [ - ItemThumbnail( - item: item, - service: session.service, - size: 44, - borderRadius: 12, - iconSize: 22, - ), - const SizedBox(width: 14), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - item.name, - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w600, - fontSize: 14, - ), - ), - const SizedBox(height: 2), - Text( - isFolder - ? 'Folder' - : '${formatBytes(item.size)} • ${DateFormat.yMMMd().format(item.lastModified)}', - style: theme.textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ), - if (isFolder) const Icon(Icons.chevron_right_rounded), - ], - ), - ), - ), - ), - ), - ), - ); - } - - Widget _buildGridCard(NextcloudItem item) { - final theme = Theme.of(context); - final colorScheme = theme.colorScheme; - final isFolder = item.isFolder; - final iconColor = getIconColor(context, item.type); - - return Opacity( - opacity: isFolder ? 1 : 0.5, - child: Material( - color: colorScheme.surfaceContainerLow, - borderRadius: BorderRadius.circular(20), - clipBehavior: Clip.antiAlias, - child: InkWell( - onTap: isFolder ? () => _navigateToFolder(item.path) : null, - child: Padding( - padding: const EdgeInsets.all(14), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Container( - width: 40, - height: 40, - decoration: BoxDecoration( - color: iconColor.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(12), - ), - child: Icon( - getItemIcon(item.type), - color: iconColor, - size: 24, - ), - ), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - item.name, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w600, - fontSize: 13, - ), - ), - const SizedBox(height: 2), - Text( - isFolder ? 'Folder' : formatBytes(item.size), - style: theme.textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - fontSize: 11, - ), - ), - ], - ), - ], - ), - ), - ), - ), + Widget _buildDesktopRow(NextcloudItem item) { + return NooFileTableRow( + kind: NooFileKind.folder, + name: item.name, + onTap: () => _navigateToFolder(item.path), ); } @override Widget build(BuildContext context) { - final theme = Theme.of(context); - final colorScheme = theme.colorScheme; + final colors = context.nooColors; + final desktop = NooLayout.isDesktop(context); final files = context.watch(); final ops = context.read(); final hasBreadcrumbs = _pathStack.length > 1; - final currentLabel = _currentPath == '/' - ? 'Home' - : _currentPath.split('/').where((s) => s.isNotEmpty).last; - final items = files.applyFilesDisplayPrefs(_rawItems); + final folders = files + .applyFilesDisplayPrefs(_rawItems) + .where((item) => item.isFolder) + .toList(); final invalidDestination = _destinationIsInvalid(); + final gutter = NooLayout.gutter(context); + final verb = widget.copy ? 'Copying' : 'Moving'; + final itemCount = widget.items.length; - final controlsColumn = Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildControlsRow(files), - if (hasBreadcrumbs) ...[ - const SizedBox(height: 10), - SizedBox( - height: 32, - child: Breadcrumbs( - pathStack: _pathStack, - onTap: _navigateToPathIndex, - ), - ), - ], - ], - ), - ); - - final contentSlivers = [ - SliverPersistentHeader( - pinned: true, - delegate: StickyHeaderDelegate( - height: hasBreadcrumbs ? 114 : 72, - child: controlsColumn, + final slivers = [ + SliverToBoxAdapter( + child: Padding( + padding: EdgeInsets.fromLTRB(gutter, NooSpace.md, gutter, NooSpace.sm), + child: hasBreadcrumbs + ? SizedBox( + height: 32, + child: Breadcrumbs( + pathStack: _pathStack, + onTap: _navigateToPathIndex, + ), + ) + : const SizedBox.shrink(), ), ), if (_isLoading) - const SliverFillRemaining( - hasScrollBody: false, - child: Center(child: CircularProgressIndicator()), + tabLoadingSliver + else if (folders.isEmpty) + tabEmptySliver( + context, + icon: LucideIcons.folder, + message: 'No folders here', ) - else if (items.isEmpty) - SliverFillRemaining( - hasScrollBody: false, - child: Center( - child: Text( - 'Folder is empty', - style: TextStyle(color: colorScheme.onSurfaceVariant), - ), - ), - ) - else if (files.isGridView) + else if (desktop) SliverPadding( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), - sliver: SliverGrid( - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 2, - childAspectRatio: 1.1, - crossAxisSpacing: 12, - mainAxisSpacing: 12, + padding: EdgeInsets.symmetric(horizontal: gutter), + sliver: SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) => _buildDesktopRow(folders[index]), + childCount: folders.length, ), - delegate: SliverChildBuilderDelegate((context, index) { - return _buildGridCard(items[index]); - }, childCount: items.length), ), ) else SliverPadding( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), + padding: EdgeInsets.symmetric(horizontal: gutter), sliver: SliverList( - delegate: SliverChildBuilderDelegate((context, index) { - return _buildListTile(items[index]); - }, childCount: items.length), + delegate: SliverChildBuilderDelegate( + (context, index) => _buildRow(folders[index], index, folders.length), + childCount: folders.length, + ), ), ), - const SliverToBoxAdapter(child: SizedBox(height: 24)), + const SliverToBoxAdapter(child: SizedBox(height: NooSpace.xl)), ]; + final title = widget.copy ? 'Copy to...' : 'Move to...'; + return PopScope( canPop: _pathStack.length <= 1, onPopInvokedWithResult: (didPop, result) { if (!didPop) _navigateUp(); }, child: Scaffold( - body: SyncedHeaderScaffold( - scrollController: _scrollController, - actions: const [MoreTabsButton(), ProfileAvatarButton()], - contentSlivers: contentSlivers, + backgroundColor: colors.bg, + appBar: desktop + ? NooToolbar( + title: title, + actions: [ + const MoreTabsButton(), + const SizedBox(width: NooSpace.xs), + NooButton( + variant: NooButtonVariant.secondary, + size: NooButtonSize.toolbar, + onTap: () => Navigator.maybePop(context), + child: const Text('Cancel'), + ), + ], + ) + : NooTopBar( + style: NooLayout.navStyle(context), + title: title, + leading: const NooTopBarBack(label: 'Cancel'), + // Stacks a hidden tab's own screen on top rather than + // jumping the shell there, so it doesn't abandon this + // picker - safe to keep, unlike ShareUploadView's chrome. + actions: const [MoreTabsButton()], + ), + body: SafeArea( + top: false, + child: RefreshIndicator( + color: colors.accent, + backgroundColor: colors.surface, + onRefresh: () => _fetch(_currentPath), + child: CustomScrollView(controller: _scrollController, slivers: slivers), + ), ), - bottomNavigationBar: Material( - color: colorScheme.surfaceContainerHigh, - elevation: 8, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + bottomNavigationBar: DecoratedBox( + decoration: BoxDecoration( + color: colors.surface, + border: Border(top: BorderSide(color: colors.line)), ), - clipBehavior: Clip.antiAlias, child: SafeArea( top: false, child: Padding( - padding: const EdgeInsets.fromLTRB(20, 16, 20, 16), + padding: const EdgeInsets.fromLTRB( + NooSpace.lg, + NooSpace.md, + NooSpace.lg, + NooSpace.md, + ), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (invalidDestination) ...[ - Text( - "Can't ${widget.copy ? 'copy' : 'move'} a folder into " - "itself or one of its own subfolders", - style: theme.textTheme.bodySmall?.copyWith( - color: colorScheme.error, - ), + Text( + invalidDestination + ? "Can't ${widget.copy ? 'copy' : 'move'} a folder into " + "itself or one of its own subfolders" + : '$verb $itemCount item${itemCount == 1 ? '' : 's'}', + style: NooText.meta.copyWith( + color: invalidDestination ? colors.danger : colors.fg3, ), - const SizedBox(height: 8), - ], - SizedBox( - width: double.infinity, - child: FilledButton.icon( - onPressed: invalidDestination || _isSubmitting - ? null - : () => _confirm(ops), - icon: _isSubmitting - ? const SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : Icon( - widget.copy - ? Icons.copy_rounded - : Icons.drive_file_move_rounded, + ), + const SizedBox(height: NooSpace.sm), + NooButton( + variant: NooButtonVariant.primary, + size: NooButtonSize.cta, + fullWidth: true, + disabled: invalidDestination || _isSubmitting, + icon: _isSubmitting + ? null + : (widget.copy ? LucideIcons.copy : LucideIcons.folderInput), + onTap: invalidDestination || _isSubmitting + ? null + : () => _confirm(ops), + child: _isSubmitting + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, ), - label: Text( - '${widget.copy ? 'Copy' : 'Move'} ${widget.items.length} ' - 'item(s) to $currentLabel', - ), - ), + ) + : Text(widget.copy ? 'Copy here' : 'Move here'), ), ], ), diff --git a/lib/views/photos_view.dart b/lib/views/photos_view.dart index 7d53068..794923c 100644 --- a/lib/views/photos_view.dart +++ b/lib/views/photos_view.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:material_symbols_icons/symbols.dart'; +import 'package:intl/intl.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:provider/provider.dart'; import 'package:share_plus/share_plus.dart'; import '../models/nextcloud_item.dart'; @@ -11,18 +12,42 @@ import '../providers/photos_controller.dart'; import '../providers/pick_controller.dart'; import '../providers/session_controller.dart'; import '../services/download_service.dart'; +import '../theme/design_tokens.dart'; import '../widgets/details/details_sheet.dart'; -import '../widgets/more_tabs_button.dart'; -import '../widgets/profile_avatar_button.dart'; -import '../widgets/segmented_icon_toggle.dart'; -import '../widgets/selectable_thumbnail.dart'; +import '../widgets/noo/core/noo_button.dart'; +import '../widgets/noo/core/noo_chip.dart'; +import '../widgets/noo/core/noo_segmented_control.dart'; +import '../widgets/noo/core/noo_toggle.dart'; +import '../widgets/noo/files/noo_file_kind.dart'; +import '../widgets/noo/lists/noo_grouped_list.dart'; +import '../widgets/noo/lists/noo_settings_row.dart'; +import '../widgets/noo/media/noo_photo_group.dart'; +import '../widgets/noo/media/noo_photo_tile.dart'; +import '../widgets/noo/noo_layout.dart'; +import '../widgets/noo/overlays/noo_sheet.dart'; import '../widgets/share_sheet.dart'; -import '../widgets/sort_menu_button.dart'; +import '../widgets/sort_menu_button.dart' show sortFieldLabel; import '../widgets/sticky_header_delegate.dart'; -import '../widgets/synced_header_scaffold.dart'; import 'file_viewer_screen.dart'; import 'move_copy_destination_picker.dart'; +/// The type chips PhotosController actually has data for (DESIGN_SYSTEM.md +/// section 4 also lists "Camera", but there's no camera-upload/EXIF source +/// to distinguish that from any other photo - see the view's doc comment). +enum _PhotoTypeFilter { all, image, video } + +/// A contiguous run of [PhotosController.items] that shares a calendar +/// month, in whatever order the controller already sorted them - see +/// [_PhotosViewState._groupByMonth]. +class _MonthGroup { + final DateTime month; + final List items; + _MonthGroup(this.month, this.items); +} + +/// The Photos tab: every image/video across the account, grouped by month +/// (DESIGN_SYSTEM.md section 4). Renders content only - the shell +/// (`MainShellView`) owns the top bar, bottom bar, drawer and FAB. class PhotosView extends StatefulWidget { final ScrollController scrollController; @@ -39,6 +64,12 @@ class _PhotosViewState extends State final ScrollController _selectionActionsScrollController = ScrollController(); final List _scrollHintControllers = []; + // Session-local only (not one of PhotosController's persisted display + // prefs) - a pure narrowing of the already-fetched/filtered/sorted list, + // same way the type chips narrow Files' listing without touching the + // controller. + _PhotoTypeFilter _typeFilter = _PhotoTypeFilter.all; + bool get _isSelecting => _selectedIds.isNotEmpty; void _toggleSelection(NextcloudItem item) { @@ -64,7 +95,14 @@ class _PhotosViewState extends State /// starts. No-ops if there's nothing to scroll (row already fits). Future _playScrollHint(ScrollController scrollController) async { await Future.delayed(const Duration(milliseconds: 500)); - if (!mounted || !scrollController.hasClients) return; + // `hasClients` only means a position is attached, not that it's + // finished its first layout - reading maxScrollExtent before that + // throws (min/maxScrollExtent are still null internally). + if (!mounted || + !scrollController.hasClients || + !scrollController.position.hasContentDimensions) { + return; + } final maxExtent = scrollController.position.maxScrollExtent; if (maxExtent <= 0) return; final double peak = maxExtent < 36 ? maxExtent : 36; @@ -195,10 +233,39 @@ class _PhotosViewState extends State ]; } + /// Splits an already-filtered/sorted list into contiguous month runs + /// (`dateCreated`) in list order - deliberately not a global re-sort by + /// date, so a "Name"/"Size" sort chip still governs the order shown + /// (see the group header row it feeds). + List<_MonthGroup> _groupByMonth(List items) { + final groups = <_MonthGroup>[]; + for (final item in items) { + final d = item.dateCreated; + final month = DateTime(d.year, d.month); + if (groups.isNotEmpty && groups.last.month == month) { + groups.last.items.add(item); + } else { + groups.add(_MonthGroup(month, [item])); + } + } + return groups; + } + + bool _matchesTypeFilter(NextcloudItem item) { + switch (_typeFilter) { + case _PhotoTypeFilter.all: + return true; + case _PhotoTypeFilter.image: + return item.type == NextcloudItemType.image; + case _PhotoTypeFilter.video: + return item.type == NextcloudItemType.video; + } + } + @override Widget build(BuildContext context) { - final theme = Theme.of(context); - final colorScheme = theme.colorScheme; + final colors = context.nooColors; + final isDesktop = NooLayout.isDesktop(context); final photosController = context.watch(); final filesController = context.watch(); final pick = context.watch(); @@ -210,89 +277,44 @@ class _PhotosViewState extends State ); } - final photos = photosController.items; - final selectedItems = photos + final allPhotos = photosController.items; + final visiblePhotos = allPhotos.where(_matchesTypeFilter).toList(); + // Selection tracks the full (favorites/hidden-filtered) list, not just + // the type-filtered view, so switching the type chip mid-selection + // never silently drops a selected item from the bulk actions. + final selectedItems = allPhotos .where((i) => _selectedIds.contains(i.id)) .toList(); + final groups = _groupByMonth(visiblePhotos); - final controlsRow = Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 0), - child: SizedBox( - height: 44, - child: Row( - children: [ - IconButton( - icon: Icon( - photosController.sortAscending - ? Icons.arrow_upward_rounded - : Icons.arrow_downward_rounded, - size: 20, - ), - visualDensity: VisualDensity.compact, - tooltip: photosController.sortAscending - ? 'Ascending' - : 'Descending', - onPressed: photosController.toggleSortOrder, - ), - Expanded( - child: SortMenuButton( - field: photosController.sortField, - onChanged: photosController.setSortField, - ), - ), - ToggleIconButton( - icon: photosController.showFavoritesOnly - ? Icons.favorite_rounded - : Icons.favorite_border_rounded, - isSelected: photosController.showFavoritesOnly, - onTap: photosController.toggleFavoritesFilter, - tooltip: 'Favorites only', - ), - const SizedBox(width: 4), - ToggleIconButton( - icon: photosController.showHidden - ? Icons.visibility_rounded - : Icons.visibility_off_rounded, - isSelected: photosController.showHidden, - onTap: () => photosController.toggleShowHidden(), - tooltip: 'Show hidden files', - ), - const SizedBox(width: 4), - SegmentedIconGroup( - children: [ - ToggleIconButton( - icon: Symbols.circles_rounded, - isSelected: filesController.storageScope == StorageScope.cloud, - onTap: () => - filesController.setStorageScope(StorageScope.cloud), - tooltip: 'Cloud storage', - ), - ToggleIconButton( - icon: Symbols.hard_drive_rounded, - isSelected: - filesController.storageScope == StorageScope.external, - onTap: () => - filesController.setStorageScope(StorageScope.external), - tooltip: 'External storage', - ), - ], - ), - ], - ), - ), + final controlsRow = _buildControlsRow( + context, + photosController, + filesController, ); final List contentSlivers = [ // Sticky while browsing; once selecting, the selection bar takes over - // the very top of the screen instead (see `selectionBar` below), so - // this is free to scroll away rather than staying pinned under it. + // the same slot instead. SliverPersistentHeader( pinned: !_isSelecting, - delegate: StickyHeaderDelegate(height: 60, child: controlsRow), + delegate: StickyHeaderDelegate( + height: _isSelecting ? 56 : 64, + child: _isSelecting + ? _buildSelectionBar(context, pick, selectedItems) + : Padding( + padding: const EdgeInsets.fromLTRB( + NooSpace.sm, + NooSpace.sm, + NooSpace.sm, + NooSpace.xs, + ), + child: controlsRow, + ), + ), ), - const SliverToBoxAdapter(child: SizedBox(height: 8)), - if (photosController.isLoading && photos.isEmpty) + if (photosController.isLoading && allPhotos.isEmpty) const SliverFillRemaining( hasScrollBody: false, child: Center(child: CircularProgressIndicator()), @@ -306,85 +328,82 @@ class _PhotosViewState extends State child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon( - Icons.error_outline_rounded, - size: 64, - color: colorScheme.error, - ), + Icon(LucideIcons.circleAlert, size: 56, color: colors.danger), const SizedBox(height: 16), Text( 'Could not load photos', - style: theme.textTheme.titleLarge?.copyWith( - color: colorScheme.error, - fontWeight: FontWeight.bold, - ), + style: NooText.cardTitle.copyWith(color: colors.danger), ), const SizedBox(height: 8), Text( photosController.errorMessage!, textAlign: TextAlign.center, - style: theme.textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - ), + style: NooText.body.copyWith(color: colors.fg3), ), const SizedBox(height: 20), - FilledButton.icon( - onPressed: photosController.fetchAllMedia, - icon: const Icon(Icons.refresh_rounded), - label: const Text('Retry'), + NooButton( + variant: NooButtonVariant.secondary, + size: NooButtonSize.field, + icon: LucideIcons.refreshCw, + onTap: photosController.fetchAllMedia, + child: const Text('Retry'), ), ], ), ), ), ) - else if (photos.isEmpty) + else if (visiblePhotos.isEmpty) SliverFillRemaining( hasScrollBody: false, child: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon( - Icons.photo_library_outlined, - size: 64, - color: colorScheme.outlineVariant, - ), + Icon(LucideIcons.image, size: 56, color: colors.fg3), const SizedBox(height: 12), Text( - 'No photos found in Nextcloud library', - style: theme.textTheme.titleMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - ), + allPhotos.isEmpty + ? 'No photos found in Nextcloud library' + : 'No photos match this filter', + style: NooText.cardTitle.copyWith(color: colors.fg2), ), ], ), ), ) else - SliverPadding( - padding: const EdgeInsets.symmetric(horizontal: 16), - sliver: SliverGrid( - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 3, - crossAxisSpacing: 8, - mainAxisSpacing: 8, - childAspectRatio: 1.0, + for (final group in groups) ...[ + SliverToBoxAdapter( + child: NooPhotoGroupHeader( + title: DateFormat('MMMM yyyy').format(group.month), + count: + '${group.items.length} ${group.items.length == 1 ? 'item' : 'items'}', + padding: EdgeInsets.fromLTRB( + NooLayout.gutter(context), + NooSpace.lg, + NooLayout.gutter(context), + NooSpace.sm, + ), ), - delegate: SliverChildBuilderDelegate((context, index) { - final photo = photos[index]; - return _buildPhotoTile(context, photo); - }, childCount: photos.length), ), - ), + NooPhotoGrid( + itemCount: group.items.length, + columns: isDesktop + ? NooPhotoGrid.desktopColumns + : NooPhotoGrid.mobileColumns, + gap: isDesktop ? NooPhotoGrid.desktopGap : NooPhotoGrid.mobileGap, + padding: EdgeInsets.symmetric( + horizontal: isDesktop ? NooLayout.gutter(context) : 0, + ), + itemBuilder: (context, index) => + _buildPhotoTile(context, group.items[index], visiblePhotos), + ), + ], // Fixed clearance so the last row isn't hidden behind the floating // nav bar, regardless of grid length. const SliverToBoxAdapter(child: SizedBox(height: 100)), - // For a short grid this stretches the white card's background down to - // the screen edge (matching the empty state above); for a long grid - // that already fills the viewport it contributes nothing extra. - const SliverFillRemaining(hasScrollBody: false, child: SizedBox()), ]; return PopScope( @@ -392,29 +411,181 @@ class _PhotosViewState extends State onPopInvokedWithResult: (didPop, result) { if (!didPop && _isSelecting) _clearSelection(); }, - child: SyncedHeaderScaffold( - scrollController: widget.scrollController, - actions: const [MoreTabsButton(), ProfileAvatarButton()], - onRefresh: photosController.fetchAllMedia, - selectionBar: _isSelecting - ? _buildSelectionBar(context, theme, pick, selectedItems) - : null, - contentSlivers: contentSlivers, + child: ColoredBox( + color: colors.bg, + child: RefreshIndicator( + color: colors.accent, + backgroundColor: colors.surface, + onRefresh: photosController.fetchAllMedia, + child: CustomScrollView( + controller: widget.scrollController, + slivers: contentSlivers, + ), + ), ), ); } - /// Replaces the top bar entirely while selecting (see - /// `SyncedHeaderScaffold.selectionBar`) - a close button, the "N - /// selected" count, and the horizontally-scrollable bulk actions. + /// Type chips (All/Photos/Videos - "Camera" from the spec has no data + /// source, see the view's doc comment), a sort chip and a filter chip for + /// favorites-only/hidden/external-storage, mirroring `FilesControlsRow`'s + /// layout/pattern. + Widget _buildControlsRow( + BuildContext context, + PhotosController photos, + FilesController files, + ) { + final filtersActive = + photos.showFavoritesOnly || + photos.showHidden || + files.storageScope != StorageScope.cloud; + + return SizedBox( + height: 44, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + NooChip( + icon: photos.sortAscending + ? LucideIcons.arrowUp + : LucideIcons.arrowDown, + onTap: () => _showSortSheet(context, photos), + child: Text(sortFieldLabel(photos.sortField)), + ), + const SizedBox(width: 8), + NooChip( + selected: _typeFilter == _PhotoTypeFilter.all, + onTap: () => setState(() => _typeFilter = _PhotoTypeFilter.all), + child: const Text('All'), + ), + const SizedBox(width: 8), + NooChip( + selected: _typeFilter == _PhotoTypeFilter.image, + onTap: () => + setState(() => _typeFilter = _PhotoTypeFilter.image), + child: const Text('Photos'), + ), + const SizedBox(width: 8), + NooChip( + selected: _typeFilter == _PhotoTypeFilter.video, + onTap: () => + setState(() => _typeFilter = _PhotoTypeFilter.video), + child: const Text('Videos'), + ), + const SizedBox(width: 8), + NooChip( + icon: LucideIcons.filter, + trailing: NooChipTrailing.menu, + selected: filtersActive, + onTap: () => _showFilterSheet(context, photos, files), + child: const Text('Filter'), + ), + ], + ), + ), + ); + } + + void _showSortSheet(BuildContext context, PhotosController photos) { + showNooSheet( + context, + children: [ + NooSegmentedControl( + fill: true, + value: photos.sortAscending, + onChanged: (asc) { + if (asc != photos.sortAscending) photos.toggleSortOrder(); + }, + options: const [ + NooSegmentOption( + value: true, + icon: LucideIcons.arrowUp, + label: 'Ascending', + ), + NooSegmentOption( + value: false, + icon: LucideIcons.arrowDown, + label: 'Descending', + ), + ], + ), + NooGroupedList( + children: [ + for (final field in FileSortField.values) + NooSettingsRow( + label: Text(sortFieldLabel(field)), + trailing: field == photos.sortField + ? Icon( + LucideIcons.check, + size: 18, + color: context.nooColors.accentText, + ) + : null, + onTap: () { + photos.setSortField(field); + Navigator.pop(context); + }, + ), + ], + ), + ], + ); + } + + void _showFilterSheet( + BuildContext context, + PhotosController photos, + FilesController files, + ) { + showNooSheet( + context, + children: [ + NooGroupedList( + children: [ + NooSettingsRow( + icon: LucideIcons.heart, + label: const Text('Favorites only'), + trailing: NooToggle( + checked: photos.showFavoritesOnly, + onChanged: (_) => photos.toggleFavoritesFilter(), + ), + ), + NooSettingsRow( + icon: LucideIcons.eye, + label: const Text('Show hidden files'), + trailing: NooToggle( + checked: photos.showHidden, + onChanged: (_) => photos.toggleShowHidden(), + ), + ), + NooSettingsRow( + icon: LucideIcons.hardDrive, + label: const Text('External storage'), + trailing: NooToggle( + checked: files.storageScope == StorageScope.external, + onChanged: (external) => files.setStorageScope( + external ? StorageScope.external : StorageScope.cloud, + ), + ), + ), + ], + ), + ], + ); + } + + /// Replaces the controls row's own sticky slot while selecting - a close + /// button, the "N selected" count, and the horizontally-scrollable bulk + /// actions. Mirrors `FilesView`'s identical selection bar. Widget _buildSelectionBar( BuildContext context, - ThemeData theme, PickController pick, List selectedItems, ) { + final colors = context.nooColors; return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), + padding: const EdgeInsets.symmetric(horizontal: NooSpace.sm), child: Row( children: [ SizedBox( @@ -425,7 +596,7 @@ class _PhotosViewState extends State mainAxisSize: MainAxisSize.min, children: [ IconButton( - icon: const Icon(Icons.close_rounded, size: 20), + icon: Icon(LucideIcons.x, size: 20, color: colors.fg1), tooltip: 'Cancel selection', onPressed: _clearSelection, visualDensity: VisualDensity.compact, @@ -433,8 +604,9 @@ class _PhotosViewState extends State const SizedBox(width: 8), Text( '${selectedItems.length} selected', - style: theme.textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w700, + style: NooText.cardTitle.copyWith( + fontSize: 17, + color: colors.fg1, ), ), ], @@ -447,7 +619,7 @@ class _PhotosViewState extends State scrollDirection: Axis.horizontal, // Left-aligned (not anchored to the trailing edge) so the // first action's left edge sits at a fixed spot - lining up - // with the controls row's own first icon directly below it - + // with the controls row's own first chip directly below it - // regardless of how many actions there are. child: Row( mainAxisSize: MainAxisSize.min, @@ -457,7 +629,7 @@ class _PhotosViewState extends State selectedItems, )) IconButton( - icon: Icon(action.icon, size: 20), + icon: Icon(action.icon, size: 20, color: colors.fg1), tooltip: action.label, onPressed: action.onTap, visualDensity: VisualDensity.compact, @@ -471,117 +643,82 @@ class _PhotosViewState extends State ); } - Widget _buildPhotoTile(BuildContext context, NextcloudItem photo) { - final colorScheme = Theme.of(context).colorScheme; + Widget _buildPhotoTile( + BuildContext context, + NextcloudItem photo, + List siblings, + ) { final pick = context.watch(); final session = context.watch(); final isSelected = _selectedIds.contains(photo.id); + final isVideo = photo.type == NextcloudItemType.video; - // No border radius on the tile itself — the outer ClipRRect below is - // the only place that rounds it, so a swipe doesn't reveal a stray - // floating rounded corner mid-drag (see the same fix in files_view). - final tile = GestureDetector( + return NooPhotoTile( + selected: isSelected, + selectionMode: _isSelecting, + // No real duration source (no video-metadata extraction anywhere in + // the app) - an empty string still marks it as a video and shows the + // icon-only badge (DESIGN_SYSTEM.md: "else icon-only"). + videoDuration: isVideo ? '' : null, + placeholderColor: NooPhotoTile.paletteColor(photo.id.hashCode), onTap: () { if (pick.isPicking) { _handlePickTap(context, pick, photo); } else if (_isSelecting) { _toggleSelection(photo); } else { - _openLightbox(context, photo); + _openLightbox(context, photo, siblings); } }, onLongPress: pick.isPicking && !pick.pickRequest!.allowMultiple ? null : () => _toggleSelection(photo), - child: ColoredBox( - color: colorScheme.surfaceContainerHigh, - child: SelectableThumbnail( - isSelected: isSelected, - checkmarkSize: 32, - child: Stack( - fit: StackFit.expand, - children: [ - photo.previewUrl != null - ? LayoutBuilder( - builder: (context, constraints) { - // Decode at the tile's actual rendered size rather - // than the full 500x500 preview the server returns — - // cheaper to decode/cache while scrolling a grid. - final cachePixels = - (constraints.maxWidth * - MediaQuery.of(context).devicePixelRatio) - .round(); - return Image.network( - photo.previewUrl!, - headers: session.service?.authHeaders, - fit: BoxFit.cover, - cacheWidth: cachePixels, - cacheHeight: cachePixels, - filterQuality: FilterQuality.low, - gaplessPlayback: true, - errorBuilder: (ctx, err, stack) => - _buildFallbackTile(context, photo), - ); - }, - ) - : _buildFallbackTile(context, photo), - if (photo.type == NextcloudItemType.video) - Positioned( - bottom: 8, - right: 8, - child: Container( - padding: const EdgeInsets.all(4), - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.6), - shape: BoxShape.circle, - ), - child: const Icon( - Icons.play_arrow_rounded, - color: Colors.white, - size: 16, - ), - ), - ), - if (photo.isFavorite) - const Positioned( - top: 8, - right: 8, - child: Icon( - Icons.favorite_rounded, - color: Colors.red, - size: 18, - ), - ), - ], - ), - ), - ), + child: photo.previewUrl != null + ? LayoutBuilder( + builder: (context, constraints) { + // Decode at the tile's actual rendered size rather than the + // full 500x500 preview the server returns - cheaper to + // decode/cache while scrolling a grid. + final cachePixels = + (constraints.maxWidth * + MediaQuery.of(context).devicePixelRatio) + .round(); + return Image.network( + photo.previewUrl!, + headers: session.service?.authHeaders, + fit: BoxFit.cover, + cacheWidth: cachePixels, + cacheHeight: cachePixels, + filterQuality: FilterQuality.low, + gaplessPlayback: true, + errorBuilder: (ctx, err, stack) => + _buildFallbackTile(context, photo), + ); + }, + ) + : _buildFallbackTile(context, photo), ); - - return ClipRRect(borderRadius: BorderRadius.circular(16), child: tile); } Widget _buildFallbackTile(BuildContext context, NextcloudItem photo) { - final colorScheme = Theme.of(context).colorScheme; - return Container( - color: colorScheme.secondaryContainer, + final colors = context.nooColors; + final kind = NooFileKind.from(name: photo.name, mimeType: photo.mimeType); + return ColoredBox( + color: kind.background(colors), child: Center( - child: Icon( - photo.type == NextcloudItemType.video - ? Icons.movie_rounded - : Icons.image_rounded, - color: colorScheme.onSecondaryContainer, - size: 32, - ), + child: Icon(kind.icon, color: kind.foreground(colors), size: 32), ), ); } - void _openLightbox(BuildContext context, NextcloudItem photo) { - final photos = context.read().items; + void _openLightbox( + BuildContext context, + NextcloudItem photo, + List siblings, + ) { Navigator.push( context, - FileViewerScreen.route(item: photo, siblings: photos), + FileViewerScreen.route(item: photo, siblings: siblings), ); } diff --git a/lib/views/recent_view.dart b/lib/views/recent_view.dart index 79f225e..e8484f6 100644 --- a/lib/views/recent_view.dart +++ b/lib/views/recent_view.dart @@ -1,54 +1,29 @@ import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import 'package:provider/provider.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../models/nextcloud_item.dart'; import '../providers/recent_controller.dart'; -import '../widgets/more_tabs_button.dart'; -import '../widgets/profile_avatar_button.dart'; -import '../widgets/synced_header_scaffold.dart'; +import '../theme/design_tokens.dart'; +import '../widgets/details/details_sheet.dart'; +import '../widgets/noo/files/noo_file_kind.dart'; +import '../widgets/noo/files/noo_file_row.dart'; +import '../widgets/noo/files/noo_file_table.dart'; +import '../widgets/noo/lists/noo_grouped_list.dart'; +import '../widgets/noo/lists/noo_settings_row.dart'; +import '../widgets/noo/noo_layout.dart'; +import '../widgets/noo/overlays/noo_sheet.dart'; +import '../widgets/share_sheet.dart'; +import '../widgets/tabs/tab_day_groups.dart'; +import '../widgets/tabs/tab_location.dart'; +import '../widgets/tabs/tab_state_slivers.dart'; import 'file_viewer_screen.dart'; -IconData _recentItemIcon(NextcloudItemType type) { - switch (type) { - case NextcloudItemType.folder: - return Icons.folder_rounded; - case NextcloudItemType.image: - return Icons.image_rounded; - case NextcloudItemType.video: - return Icons.movie_rounded; - case NextcloudItemType.audio: - return Icons.audiotrack_rounded; - case NextcloudItemType.document: - return Icons.description_rounded; - case NextcloudItemType.archive: - return Icons.folder_zip_rounded; - case NextcloudItemType.file: - return Icons.insert_drive_file_rounded; - } -} - -Color _recentItemIconColor(BuildContext context, NextcloudItemType type) { - final colorScheme = Theme.of(context).colorScheme; - switch (type) { - case NextcloudItemType.folder: - return colorScheme.primary; - case NextcloudItemType.image: - return Colors.amber.shade700; - case NextcloudItemType.video: - return Colors.deepOrange.shade600; - case NextcloudItemType.audio: - return Colors.purple.shade600; - case NextcloudItemType.document: - return Colors.blue.shade700; - case NextcloudItemType.archive: - return Colors.teal.shade700; - case NextcloudItemType.file: - return colorScheme.outline; - } -} - /// Recently modified files across the whole account (not folders), newest -/// first — see [RecentController.fetchAll]. +/// first - see [RecentController.fetchAll]. Grouped into Today/Yesterday/ +/// This week/Earlier per `DESIGN_SYSTEM.md` §4; meta is "Modified {time} · +/// {location}" since the server only ever reports a modification, not a +/// distinct action per entry. class RecentView extends StatefulWidget { final ScrollController scrollController; @@ -63,8 +38,7 @@ class _RecentViewState extends State { @override Widget build(BuildContext context) { - final theme = Theme.of(context); - final colorScheme = theme.colorScheme; + final colors = context.nooColors; final recent = context.watch(); if (!_requested) { @@ -73,174 +47,164 @@ class _RecentViewState extends State { } final items = recent.items; + final isDesktop = NooLayout.isDesktop(context); + final groups = groupByRecentBucket( + items, + (item) => item.lastModified, + ); final List contentSlivers = [ - const SliverToBoxAdapter(child: SizedBox(height: 16)), - + const SliverToBoxAdapter(child: SizedBox(height: NooSpace.md)), if (recent.isLoading && items.isEmpty) - const SliverFillRemaining( - hasScrollBody: false, - child: Center(child: CircularProgressIndicator()), - ) + tabLoadingSliver else if (recent.errorMessage != null) - SliverFillRemaining( - hasScrollBody: false, - child: Center( - child: Padding( - padding: const EdgeInsets.all(24), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.error_outline_rounded, - size: 64, - color: colorScheme.error, - ), - const SizedBox(height: 16), - Text( - 'Could not load recent files', - style: theme.textTheme.titleLarge?.copyWith( - color: colorScheme.error, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 8), - Text( - recent.errorMessage!, - textAlign: TextAlign.center, - style: theme.textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 20), - FilledButton.icon( - onPressed: recent.fetchAll, - icon: const Icon(Icons.refresh_rounded), - label: const Text('Retry'), - ), - ], - ), - ), - ), + tabErrorSliver( + context, + title: 'Could not load recent files', + message: recent.errorMessage!, + onRetry: recent.fetchAll, ) else if (items.isEmpty) - SliverFillRemaining( - hasScrollBody: false, - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.history_rounded, - size: 64, - color: colorScheme.outlineVariant, - ), - const SizedBox(height: 12), - Text( - 'No recent files', - style: theme.textTheme.titleMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ), + tabEmptySliver( + context, + icon: LucideIcons.clock, + message: 'No recent files', ) + else if (isDesktop) + _buildDesktopTable(context, groups) else - SliverPadding( - padding: const EdgeInsets.symmetric(horizontal: 20), - sliver: SliverList( - delegate: SliverChildBuilderDelegate((context, index) { - final item = items[index]; - return _buildTile(context, item); - }, childCount: items.length), - ), - ), - - const SliverToBoxAdapter(child: SizedBox(height: 100)), - const SliverFillRemaining(hasScrollBody: false, child: SizedBox()), + _buildMobileGroups(context, groups), + ...tabBottomInsetSlivers, ]; - return SyncedHeaderScaffold( - scrollController: widget.scrollController, - actions: const [MoreTabsButton(), ProfileAvatarButton()], - onRefresh: recent.fetchAll, - contentSlivers: contentSlivers, - ); - } - - Widget _buildTile(BuildContext context, NextcloudItem item) { - final theme = Theme.of(context); - final colorScheme = theme.colorScheme; - final iconColor = _recentItemIconColor(context, item.type); - final folderPath = item.path.substring( - 0, - item.path.length - item.name.length, - ); - - return Padding( - padding: const EdgeInsets.only(bottom: 8), - child: Material( - color: colorScheme.surfaceContainerLow, - borderRadius: BorderRadius.circular(16), - child: InkWell( - borderRadius: BorderRadius.circular(16), - onTap: () => _openFile(context, item), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - child: Row( - children: [ - Container( - width: 44, - height: 44, - decoration: BoxDecoration( - color: iconColor.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(12), - ), - child: Icon( - _recentItemIcon(item.type), - color: iconColor, - size: 22, - ), - ), - const SizedBox(width: 14), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - item.name, - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w600, - fontSize: 14, - ), - ), - const SizedBox(height: 2), - Text( - folderPath.isEmpty ? '/' : folderPath, - style: theme.textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 2), - Text( - DateFormat.yMMMd().add_jm().format(item.lastModified), - style: theme.textTheme.bodySmall?.copyWith( - color: colorScheme.primary, - fontWeight: FontWeight.w500, - ), - ), - ], - ), - ), - ], - ), - ), + return ColoredBox( + color: colors.bg, + child: RefreshIndicator( + color: colors.accent, + backgroundColor: colors.surface, + onRefresh: recent.fetchAll, + child: CustomScrollView( + controller: widget.scrollController, + slivers: contentSlivers, ), ), ); } + Widget _buildMobileGroups( + BuildContext context, + List> groups, + ) { + return SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: NooSpace.sm), + sliver: SliverList( + delegate: SliverChildBuilderDelegate((context, index) { + final group = groups[index]; + return Padding( + padding: EdgeInsets.only(bottom: index == groups.length - 1 ? 0 : 18), + child: NooGroupedList( + label: group.label, + children: [ + for (final item in group.items) _buildRow(context, item), + ], + ), + ); + }, childCount: groups.length), + ), + ); + } + + Widget _buildDesktopTable( + BuildContext context, + List> groups, + ) { + return SliverPadding( + padding: const EdgeInsets.symmetric( + horizontal: NooSpace.xl, + vertical: NooSpace.xs, + ), + sliver: SliverList( + delegate: SliverChildBuilderDelegate((context, index) { + final group = groups[index]; + return Padding( + padding: const EdgeInsets.only(bottom: 18), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TabGroupLabel(group.label), + const NooFileTableHeader(col2Label: 'Modified', col3Label: 'Location'), + for (final item in group.items) _buildDesktopRow(context, item), + ], + ), + ); + }, childCount: groups.length), + ), + ); + } + + Widget _buildRow(BuildContext context, NextcloudItem item) { + return NooFileRow( + kind: NooFileKind.from( + name: item.name, + mimeType: item.mimeType, + isDirectory: item.isFolder, + ), + name: item.name, + meta: 'Modified ${_metaTime(item.lastModified)} · ${_location(item)}', + favorite: item.isFavorite, + iosStyle: NooLayout.iosStyle(context), + onTap: () => _openFile(context, item), + onMore: () => _showItemSheet(context, item), + ); + } + + Widget _buildDesktopRow(BuildContext context, NextcloudItem item) { + return NooFileTableRow( + kind: NooFileKind.from( + name: item.name, + mimeType: item.mimeType, + isDirectory: item.isFolder, + ), + name: item.name, + col2: _metaTime(item.lastModified), + col3: _location(item), + favorite: item.isFavorite, + onTap: () => _openFile(context, item), + onMore: () => _showItemSheet(context, item), + ); + } + + void _showItemSheet(BuildContext context, NextcloudItem item) { + showNooSheet( + context, + children: [ + NooGroupedList( + children: [ + NooSettingsRow( + icon: LucideIcons.info, + label: const Text('Details'), + onTap: () { + Navigator.pop(context); + DetailsSheet.show(context, item); + }, + ), + NooSettingsRow( + icon: LucideIcons.share2, + label: const Text('Share'), + onTap: () { + Navigator.pop(context); + ShareSheet.show(context, item); + }, + ), + ], + ), + ], + ); + } + + /// Preserves the original screen's exact tap behavior: always opens the + /// media viewer (the server's recent-files search only ever returns + /// files, never folders, so there's no folder-navigation case to handle). void _openFile(BuildContext context, NextcloudItem item) { final recent = context.read(); final siblings = recent.items.where((i) => i.isMedia).toList(); @@ -250,3 +214,14 @@ class _RecentViewState extends State { ); } } + +String _metaTime(DateTime dt) { + final now = DateTime.now(); + if (dt.year == now.year && dt.month == now.month && dt.day == now.day) { + return DateFormat.jm().format(dt); + } + if (dt.year == now.year) return DateFormat.MMMd().format(dt); + return DateFormat.yMMMd().format(dt); +} + +String _location(NextcloudItem item) => tabLocationLabel(item.path, item.name); diff --git a/lib/views/search_view.dart b/lib/views/search_view.dart index 28f1655..0ba0ed6 100644 --- a/lib/views/search_view.dart +++ b/lib/views/search_view.dart @@ -1,14 +1,29 @@ import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:path/path.dart' as p; import 'package:provider/provider.dart'; + import '../models/app_tab.dart'; import '../models/nextcloud_item.dart'; import '../providers/files_controller.dart'; import '../providers/settings_controller.dart'; +import '../theme/design_tokens.dart'; +import '../widgets/noo/files/noo_file_kind.dart'; +import '../widgets/noo/files/noo_file_row.dart'; +import '../widgets/noo/files/noo_file_table.dart'; +import '../widgets/noo/core/noo_search_field.dart'; +import '../widgets/noo/nav/noo_top_bar.dart'; +import '../widgets/noo/nav/noo_toolbar.dart'; +import '../widgets/noo/noo_layout.dart'; import 'file_viewer_screen.dart'; +/// Full-text file search, pushed from the shell's search entry point +/// (`openSearch` in `widgets/shell/shell_common.dart`). Debounces input, +/// then lists matches as file rows - tapping one jumps to the result on the +/// Files tab (see [_openResult]). Per DESIGN_SYSTEM.md §4 "Search". class SearchView extends StatefulWidget { const SearchView({super.key}); @@ -74,25 +89,6 @@ class _SearchViewState extends State { } } - IconData _iconFor(NextcloudItemType type) { - switch (type) { - case NextcloudItemType.folder: - return Icons.folder_rounded; - case NextcloudItemType.image: - return Icons.image_rounded; - case NextcloudItemType.video: - return Icons.movie_rounded; - case NextcloudItemType.audio: - return Icons.audiotrack_rounded; - case NextcloudItemType.document: - return Icons.description_rounded; - case NextcloudItemType.archive: - return Icons.folder_zip_rounded; - case NextcloudItemType.file: - return Icons.insert_drive_file_rounded; - } - } - /// Navigates the Files tab to [item] (its own folder if it's a folder, /// its parent folder otherwise - rebuilding the whole breadcrumb trail /// from root rather than assuming it's under wherever the user was @@ -113,98 +109,249 @@ class _SearchViewState extends State { } } + void _clear() { + _controller.clear(); + _focusNode.requestFocus(); + _onChanged(''); + } + @override Widget build(BuildContext context) { - final theme = Theme.of(context); - final colorScheme = theme.colorScheme; + final colors = context.nooColors; + final desktop = NooLayout.isDesktop(context); return Scaffold( - body: SafeArea( - child: Column( - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(12, 8, 12, 4), - child: SearchBar( - controller: _controller, - focusNode: _focusNode, - autoFocus: true, - hintText: 'Search your files...', - elevation: const WidgetStatePropertyAll(0), - onChanged: _onChanged, - leading: IconButton( - icon: const Icon(Icons.arrow_back_rounded), - onPressed: () => Navigator.pop(context), + backgroundColor: colors.bg, + appBar: desktop + ? NooToolbar( + titleWidget: _buildField(onSurface: true), + actions: [ + IconTheme.merge( + data: IconThemeData(color: colors.fg1, size: 24), + child: NooTopBarButton( + icon: LucideIcons.x, + tooltip: 'Close', + onTap: () => Navigator.pop(context), + ), ), - trailing: [ - if (_controller.text.isNotEmpty) - IconButton( - icon: const Icon(Icons.close_rounded), - onPressed: () { - _controller.clear(); - _onChanged(''); - }, + ], + ) + : null, + body: SafeArea( + top: !desktop, + child: desktop + ? _buildBody(context, desktop) + : Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB( + NooSpace.xs, + NooSpace.xs, + NooSpace.md, + NooSpace.xs, ), + child: Row( + children: [ + IconTheme.merge( + data: IconThemeData(color: colors.fg1, size: 24), + child: NooTopBarButton( + icon: LucideIcons.arrowLeft, + tooltip: 'Back', + onTap: () => Navigator.pop(context), + ), + ), + Expanded(child: _buildField(onSurface: false)), + ], + ), + ), + Expanded(child: _buildBody(context, desktop)), ], ), + ), + ); + } + + Widget _buildField({required bool onSurface}) { + // The pill `NooSearchField`, not the inset-panel `NooTextField` - this + // is a search field (DESIGN_SYSTEM.md's "Search field" component, + // radius 999 outside the one iOS-inline exception), not a form input. + // `ios: false` matches the Android shape we're building against now; + // revisit once the iOS large-title/inline-field entry point is built. + return NooSearchField( + controller: _controller, + focusNode: _focusNode, + autofocus: true, + placeholder: 'Search files', + onChanged: _onChanged, + onSurface: onSurface, + ios: false, + trailing: _controller.text.isEmpty + ? null + : _ClearFieldButton(onTap: _clear), + ); + } + + Widget _buildBody(BuildContext context, bool desktop) { + final colors = context.nooColors; + + if (_isSearching) { + return Center(child: CircularProgressIndicator(color: colors.accent)); + } + if (_error != null) { + return _buildMessage( + context, + icon: LucideIcons.circleAlert, + iconColor: colors.danger, + title: 'Couldn\'t search', + message: _error, + ); + } + if (!_hasSearched) { + return _buildMessage( + context, + icon: LucideIcons.search, + iconColor: colors.fg3, + title: 'Search your files', + message: 'Find files and folders by name.', + ); + } + if (_results.isEmpty) { + return _buildMessage( + context, + icon: LucideIcons.searchX, + iconColor: colors.fg3, + title: 'No results found', + ); + } + return desktop ? _buildDesktopResults(context) : _buildMobileResults(context); + } + + Widget _buildMessage( + BuildContext context, { + required IconData icon, + required Color iconColor, + required String title, + String? message, + }) { + final colors = context.nooColors; + return Center( + child: Padding( + padding: const EdgeInsets.all(NooSpace.xl), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 56, color: iconColor), + const SizedBox(height: NooSpace.sm), + Text( + title, + textAlign: TextAlign.center, + style: NooText.cardTitle.copyWith(color: colors.fg2), ), - Expanded(child: _buildBody(context, colorScheme, theme)), + if (message != null) ...[ + const SizedBox(height: NooSpace.xxs), + Text( + message, + textAlign: TextAlign.center, + style: NooText.body.copyWith(color: colors.fg3), + ), + ], ], ), ), ); } - Widget _buildBody( - BuildContext context, - ColorScheme colorScheme, - ThemeData theme, - ) { - if (_isSearching) { - return const Center(child: CircularProgressIndicator()); - } - if (_error != null) { - return Center( - child: Padding( - padding: const EdgeInsets.all(24), - child: Text( - _error!, - textAlign: TextAlign.center, - style: TextStyle(color: colorScheme.error), - ), - ), - ); - } - if (!_hasSearched) { - return const SizedBox.shrink(); - } - if (_results.isEmpty) { - return Center( - child: Text( - 'No results found', - style: theme.textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ); - } + Widget _buildMobileResults(BuildContext context) { return ListView.builder( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + padding: const EdgeInsets.fromLTRB( + NooSpace.sm, + NooSpace.xs, + NooSpace.sm, + NooSpace.xl, + ), + physics: const BouncingScrollPhysics(), itemCount: _results.length, itemBuilder: (context, index) { final item = _results[index]; - return ListTile( - leading: Icon(_iconFor(item.type), color: colorScheme.primary), - title: Text(item.name, maxLines: 1, overflow: TextOverflow.ellipsis), - subtitle: Text( - item.isFolder - ? item.path - : DateFormat.yMMMd().format(item.lastModified), - maxLines: 1, - overflow: TextOverflow.ellipsis, + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: ClipRRect( + borderRadius: BorderRadius.circular(NooRadii.card), + child: NooFileRow( + kind: NooFileKind.from( + name: item.name, + mimeType: item.mimeType, + isDirectory: item.isFolder, + ), + name: item.name, + meta: _mobileMeta(item), + favorite: item.isFavorite, + iosStyle: NooLayout.iosStyle(context), + onTap: () => _openResult(item), + ), ), - onTap: () => _openResult(item), ); }, ); } + + Widget _buildDesktopResults(BuildContext context) { + return SingleChildScrollView( + padding: const EdgeInsets.symmetric( + horizontal: NooSpace.xl, + vertical: NooSpace.sm, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const NooFileTableHeader(col2Label: 'Modified', col3Label: 'Location'), + for (final item in _results) + NooFileTableRow( + kind: NooFileKind.from( + name: item.name, + mimeType: item.mimeType, + isDirectory: item.isFolder, + ), + name: item.name, + col2: DateFormat.yMMMd().format(item.lastModified), + col3: item.isFolder ? item.path : p.dirname(item.path), + favorite: item.isFavorite, + onTap: () => _openResult(item), + ), + ], + ), + ); + } + + /// Mirrors the pre-rework screen's exact copy: a folder result shows its + /// own full path (there's no separate "location" field to fall back to), + /// a file result shows when it was last modified. + String _mobileMeta(NextcloudItem item) => + item.isFolder ? item.path : DateFormat.yMMMd().format(item.lastModified); +} + +/// Small round "clear" button for [NooTextField.trailing] - clears the +/// query and puts focus back in the field, same as the old `SearchBar`'s +/// trailing close icon. +class _ClearFieldButton extends StatelessWidget { + final VoidCallback onTap; + + const _ClearFieldButton({required this.onTap}); + + @override + Widget build(BuildContext context) { + final colors = context.nooColors; + return SizedBox.square( + dimension: 32, + child: Material( + type: MaterialType.transparency, + shape: const CircleBorder(), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + child: Icon(LucideIcons.x, size: 16, color: colors.fg3), + ), + ), + ); + } } diff --git a/lib/views/share_upload_view.dart b/lib/views/share_upload_view.dart index 6404379..57ee2af 100644 --- a/lib/views/share_upload_view.dart +++ b/lib/views/share_upload_view.dart @@ -1,6 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:intl/intl.dart'; -import 'package:material_symbols_icons/symbols.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:provider/provider.dart'; import '../models/app_tab.dart'; import '../models/nextcloud_item.dart'; @@ -9,24 +8,31 @@ import '../providers/session_controller.dart'; import '../providers/settings_controller.dart'; import '../services/share_intent_service.dart'; import '../services/upload_service.dart'; +import '../theme/design_tokens.dart'; import '../widgets/breadcrumbs.dart'; -import '../widgets/item_icon.dart'; import '../widgets/marquee_title.dart'; -import '../widgets/profile_avatar_button.dart'; -import '../widgets/segmented_icon_toggle.dart'; -import '../widgets/sort_menu_button.dart'; -import '../widgets/sticky_header_delegate.dart'; -import '../widgets/synced_header_scaffold.dart'; +import '../widgets/noo/core/noo_button.dart'; +import '../widgets/noo/files/noo_file_kind.dart'; +import '../widgets/noo/files/noo_file_row.dart'; +import '../widgets/noo/files/noo_file_table.dart'; +import '../widgets/noo/nav/noo_top_bar.dart'; +import '../widgets/noo/nav/noo_toolbar.dart'; +import '../widgets/noo/noo_layout.dart'; +import '../widgets/shell/shell_common.dart'; +import '../widgets/tabs/tab_state_slivers.dart'; /// Shown when another app shares one or more files to Noo (Android's /// "Share to..." sheet). Lets the user browse to a destination folder, -/// mirroring the Files tab's own controls/filters/listing so this feels -/// like the same browser rather than a stripped-down picker, then hands the -/// actual prepare+upload off to [UploadService] - a real Android foreground +/// reusing `FilesController`'s shared folder-browsing state so this feels +/// like the same browser Files itself uses, then hands the actual +/// prepare+upload off to [UploadService] - a real Android foreground /// service (see `ShareUploadService.kt`'s doc comment), not something this /// screen or even the app needs to stay open for. [files] only ever carry /// cheap Uri metadata (see [ShareIntentService]'s doc comment); that -/// service is the only thing that ever reads their actual bytes. +/// service is the only thing that ever reads their actual bytes. Pushed +/// via `Navigator` from outside `MainShellView` (a cold share-intent +/// launch, or Files' own "+" -> "Upload file"), so - unlike a tab - it +/// builds its own complete top chrome rather than relying on the shell's. class ShareUploadView extends StatefulWidget { final List files; @@ -86,243 +92,48 @@ class _ShareUploadViewState extends State { ); } - Widget _buildControlsRow(FilesController files) { - return SizedBox( - height: 44, - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: [ - IconButton( - icon: Icon( - files.filesSortAscending - ? Icons.arrow_upward_rounded - : Icons.arrow_downward_rounded, - size: 20, - ), - visualDensity: VisualDensity.compact, - tooltip: files.filesSortAscending ? 'Ascending' : 'Descending', - onPressed: files.toggleFilesSortOrder, - ), - SizedBox( - width: 130, - child: SortMenuButton( - field: files.filesSortField, - onChanged: files.setFilesSortField, - ), - ), - ToggleIconButton( - icon: files.showHiddenFiles - ? Icons.visibility_rounded - : Icons.visibility_off_rounded, - isSelected: files.showHiddenFiles, - onTap: () => files.toggleShowHiddenFiles(), - tooltip: 'Show hidden files', - ), - const SizedBox(width: 4), - SegmentedIconGroup( - children: [ - ToggleIconButton( - icon: Symbols.circles_rounded, - isSelected: files.storageScope == StorageScope.cloud, - onTap: () => files.setStorageScope(StorageScope.cloud), - tooltip: 'Cloud storage', - ), - ToggleIconButton( - icon: Symbols.hard_drive_rounded, - isSelected: files.storageScope == StorageScope.external, - onTap: () => files.setStorageScope(StorageScope.external), - tooltip: 'External storage', - ), - ], - ), - const SizedBox(width: 8), - SegmentedIconGroup( - children: [ - ToggleIconButton( - icon: Icons.select_all_rounded, - isSelected: files.filesTypeFilter == FilesTypeFilter.all, - onTap: () => files.setFilesTypeFilter(FilesTypeFilter.all), - tooltip: 'Files & folders', - ), - ToggleIconButton( - icon: Icons.insert_drive_file_outlined, - isSelected: - files.filesTypeFilter == FilesTypeFilter.filesOnly, - onTap: () => - files.setFilesTypeFilter(FilesTypeFilter.filesOnly), - tooltip: 'Files only', - ), - ToggleIconButton( - icon: Icons.folder_outlined, - isSelected: - files.filesTypeFilter == FilesTypeFilter.foldersOnly, - onTap: () => - files.setFilesTypeFilter(FilesTypeFilter.foldersOnly), - tooltip: 'Folders only', - ), - ], - ), - const SizedBox(width: 8), - SegmentedIconGroup( - children: [ - ToggleIconButton( - icon: Icons.view_list_rounded, - isSelected: !files.isGridView, - onTap: () => files.setGridView(false), - tooltip: 'List view', - ), - ToggleIconButton( - icon: Icons.grid_view_rounded, - isSelected: files.isGridView, - onTap: () => files.setGridView(true), - tooltip: 'Grid view', - ), - ], - ), - ], + // Only folders are valid upload destinations, so the listing - unlike + // Files' own - never shows plain files at all. + Widget _buildRow(BuildContext context, NextcloudItem item, int index, int count) { + final colors = context.nooColors; + final files = context.read(); + final row = NooFileRow( + kind: NooFileKind.folder, + name: item.name, + meta: 'Folder', + iosStyle: NooLayout.iosStyle(context), + onTap: () => files.navigateToFolder(item.path), + ); + + final isFirst = index == 0; + final isLast = index == count - 1; + return Column( + children: [ + ClipRRect( + borderRadius: BorderRadius.vertical( + top: isFirst ? const Radius.circular(NooRadii.card) : Radius.zero, + bottom: isLast ? const Radius.circular(NooRadii.card) : Radius.zero, + ), + child: row, ), - ), + if (!isLast) Container(height: 1, color: colors.line), + ], ); } - // Only folders are valid upload destinations - files still show (so the - // listing matches what the Files tab itself would show for this folder) - // but are visually dimmed and inert rather than hidden outright. - Widget _buildListTile(BuildContext context, NextcloudItem item) { - final theme = Theme.of(context); - final colorScheme = theme.colorScheme; + Widget _buildDesktopRow(BuildContext context, NextcloudItem item) { final files = context.read(); - final session = context.watch(); - final isFolder = item.isFolder; - - return Padding( - padding: const EdgeInsets.only(bottom: 8), - child: ClipRRect( - borderRadius: BorderRadius.circular(16), - child: Opacity( - opacity: isFolder ? 1 : 0.5, - child: Material( - color: colorScheme.surfaceContainerLow, - child: InkWell( - onTap: isFolder ? () => files.navigateToFolder(item.path) : null, - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 12, - ), - child: Row( - children: [ - ItemThumbnail( - item: item, - service: session.service, - size: 44, - borderRadius: 12, - iconSize: 22, - ), - const SizedBox(width: 14), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - item.name, - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w600, - fontSize: 14, - ), - ), - const SizedBox(height: 2), - Text( - isFolder - ? 'Folder' - : '${formatBytes(item.size)} • ${DateFormat.yMMMd().format(item.lastModified)}', - style: theme.textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ), - if (isFolder) const Icon(Icons.chevron_right_rounded), - ], - ), - ), - ), - ), - ), - ), - ); - } - - Widget _buildGridCard(BuildContext context, NextcloudItem item) { - final theme = Theme.of(context); - final colorScheme = theme.colorScheme; - final files = context.read(); - final isFolder = item.isFolder; - final iconColor = getIconColor(context, item.type); - - return Opacity( - opacity: isFolder ? 1 : 0.5, - child: Material( - color: colorScheme.surfaceContainerLow, - borderRadius: BorderRadius.circular(20), - clipBehavior: Clip.antiAlias, - child: InkWell( - onTap: isFolder ? () => files.navigateToFolder(item.path) : null, - child: Padding( - padding: const EdgeInsets.all(14), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Container( - width: 40, - height: 40, - decoration: BoxDecoration( - color: iconColor.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(12), - ), - child: Icon( - getItemIcon(item.type), - color: iconColor, - size: 24, - ), - ), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - item.name, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w600, - fontSize: 13, - ), - ), - const SizedBox(height: 2), - Text( - isFolder ? 'Folder' : formatBytes(item.size), - style: theme.textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - fontSize: 11, - ), - ), - ], - ), - ], - ), - ), - ), - ), + return NooFileTableRow( + kind: NooFileKind.folder, + name: item.name, + onTap: () => files.navigateToFolder(item.path), ); } @override Widget build(BuildContext context) { - final theme = Theme.of(context); - final colorScheme = theme.colorScheme; + final colors = context.nooColors; + final desktop = NooLayout.isDesktop(context); final files = context.watch(); final session = context.watch(); final hasBreadcrumbs = files.pathStack.length > 1; @@ -330,131 +141,124 @@ class _ShareUploadViewState extends State { final currentLabel = currentPath == '/' ? 'Home' : currentPath.split('/').where((s) => s.isNotEmpty).last; - final items = files.items; + final folders = files.items.where((item) => item.isFolder).toList(); + final gutter = NooLayout.gutter(context); - // Mirrors FilesView's own controls-row + breadcrumbs sticky header - // exactly (padding, heights) so this reads as the same browser, just - // reached from a share intent instead of the Files tab. - final controlsColumn = Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildControlsRow(files), - if (hasBreadcrumbs) ...[ - const SizedBox(height: 10), - SizedBox( - height: 32, - child: Breadcrumbs( - pathStack: files.pathStack, - onTap: (index) => files.navigateToPathIndex(index), - ), - ), - ], - ], - ), - ); - - final contentSlivers = [ - SliverPersistentHeader( - pinned: true, - delegate: StickyHeaderDelegate( - height: hasBreadcrumbs ? 114 : 72, - child: controlsColumn, + final slivers = [ + SliverToBoxAdapter( + child: Padding( + padding: EdgeInsets.fromLTRB(gutter, NooSpace.md, gutter, NooSpace.sm), + child: hasBreadcrumbs + ? SizedBox( + height: 32, + child: Breadcrumbs( + pathStack: files.pathStack, + onTap: (index) => files.navigateToPathIndex(index), + ), + ) + : const SizedBox.shrink(), ), ), if (files.isLoading) - const SliverFillRemaining( - hasScrollBody: false, - child: Center(child: CircularProgressIndicator()), + tabLoadingSliver + else if (folders.isEmpty) + tabEmptySliver( + context, + icon: LucideIcons.folder, + message: 'No folders here', ) - else if (items.isEmpty) - SliverFillRemaining( - hasScrollBody: false, - child: Center( - child: Text( - 'Folder is empty', - style: TextStyle(color: colorScheme.onSurfaceVariant), - ), - ), - ) - else if (files.isGridView) + else if (desktop) SliverPadding( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), - sliver: SliverGrid( - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 2, - childAspectRatio: 1.1, - crossAxisSpacing: 12, - mainAxisSpacing: 12, + padding: EdgeInsets.symmetric(horizontal: gutter), + sliver: SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) => _buildDesktopRow(context, folders[index]), + childCount: folders.length, ), - delegate: SliverChildBuilderDelegate((context, index) { - return _buildGridCard(context, items[index]); - }, childCount: items.length), ), ) else SliverPadding( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), + padding: EdgeInsets.symmetric(horizontal: gutter), sliver: SliverList( - delegate: SliverChildBuilderDelegate((context, index) { - return _buildListTile(context, items[index]); - }, childCount: items.length), + delegate: SliverChildBuilderDelegate( + (context, index) => + _buildRow(context, folders[index], index, folders.length), + childCount: folders.length, + ), ), ), - // So the last row isn't hidden behind the bottom "Upload to..." bar. - const SliverToBoxAdapter(child: SizedBox(height: 24)), + const SliverToBoxAdapter(child: SizedBox(height: NooSpace.xl)), ]; + final title = 'Upload to...'; + return Scaffold( - body: SyncedHeaderScaffold( - scrollController: _scrollController, - // Same trailing actions as every other tab except "More tabs" - - // there's nowhere useful for it to go while mid-upload (jumping to - // Trash/Shares/etc. would abandon this destination picker), so the - // top chrome is identical to every other tab minus that one entry. - // Backing out is still the system back gesture/button, same as any - // other pushed screen. - actions: const [ProfileAvatarButton()], - contentSlivers: contentSlivers, + backgroundColor: colors.bg, + appBar: desktop + ? NooToolbar( + title: title, + actions: [ + NooButton( + variant: NooButtonVariant.secondary, + size: NooButtonSize.toolbar, + onTap: () => Navigator.of(context).maybePop(), + child: const Text('Cancel'), + ), + ], + ) + : NooTopBar( + style: NooLayout.navStyle(context), + title: title, + leading: const NooTopBarBack(label: 'Cancel'), + // Same top-bar access as every tab (avatar -> Settings, swipe + // to switch accounts) - nothing else belongs here: jumping to + // another tab mid-upload would abandon this destination + // picker, so there's no "more tabs" entry. + actions: const [ShellAvatarButton()], + ), + body: SafeArea( + top: false, + child: RefreshIndicator( + color: colors.accent, + backgroundColor: colors.surface, + onRefresh: files.refreshData, + child: CustomScrollView(controller: _scrollController, slivers: slivers), + ), ), - // A rounded-top, elevated bar "peeking" up from the bottom edge - the - // uploading-file summary (marqueed if it doesn't fit on one line) - // sits directly above the destination button, both inside the one - // sheet, rather than the summary living up in the scrolling content - // far away from the action it describes. - bottomNavigationBar: Material( - color: colorScheme.surfaceContainerHigh, - elevation: 8, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + // A flat surface bar with a top line, peeking up from the bottom edge - + // the uploading-file summary (marqueed if it doesn't fit on one line) + // sits directly above the destination button. + bottomNavigationBar: DecoratedBox( + decoration: BoxDecoration( + color: colors.surface, + border: Border(top: BorderSide(color: colors.line)), ), - clipBehavior: Clip.antiAlias, child: SafeArea( top: false, child: Padding( - padding: const EdgeInsets.fromLTRB(20, 16, 20, 16), + padding: const EdgeInsets.fromLTRB( + NooSpace.lg, + NooSpace.md, + NooSpace.lg, + NooSpace.md, + ), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Same pill/chip look and exact same duller background - // color as the filter toggles' shared background - // (SegmentedIconGroup's `surfaceContainerHigh`), so it reads - // as part of the same visual language rather than a new - // accent color. Sized to the text itself (like a real chip) - // up to the row's available width - MarqueeTitle needs a - // concrete (not just loose) width to know whether/how far - // to scroll, so this measures the text once up front rather - // than leaving the chip unconstrained. + // Sized to the text itself (like a real chip) up to the + // row's available width - MarqueeTitle needs a concrete + // (not just loose) width to know whether/how far to scroll, + // so this measures the text once up front rather than + // leaving the chip unconstrained. LayoutBuilder( builder: (context, constraints) { final uploadingText = widget.files.length == 1 ? 'Uploading ${widget.files.first.name}' : 'Uploading ${widget.files.length} files'; - final chipTextStyle = theme.textTheme.titleSmall?.copyWith( - color: colorScheme.onSurfaceVariant, - fontWeight: FontWeight.w700, + final chipTextStyle = NooText.label.copyWith( + color: colors.fg2, ); const horizontalPadding = 28.0; final painter = TextPainter( @@ -470,31 +274,28 @@ class _ShareUploadViewState extends State { return Container( width: chipWidth, padding: const EdgeInsets.symmetric( - horizontal: 14, - vertical: 8, + horizontal: NooSpace.smd, + vertical: NooSpace.xs, ), decoration: BoxDecoration( - color: colorScheme.surfaceContainerHigh, - borderRadius: BorderRadius.circular(20), + color: colors.surface2, + borderRadius: BorderRadius.circular(NooRadii.pill), ), child: SizedBox( height: 20, - child: MarqueeTitle( - text: uploadingText, - style: chipTextStyle, - ), + child: MarqueeTitle(text: uploadingText, style: chipTextStyle), ), ); }, ), - const SizedBox(height: 12), - SizedBox( - width: double.infinity, - child: FilledButton.icon( - onPressed: () => _uploadHere(session), - icon: const Icon(Icons.upload_rounded), - label: Text('Upload to $currentLabel'), - ), + const SizedBox(height: NooSpace.sm), + NooButton( + variant: NooButtonVariant.primary, + size: NooButtonSize.cta, + fullWidth: true, + icon: LucideIcons.upload, + onTap: () => _uploadHere(session), + child: Text('Upload to $currentLabel'), ), ], ), diff --git a/lib/views/shares_view.dart b/lib/views/shares_view.dart index 0a2922b..f7d4e60 100644 --- a/lib/views/shares_view.dart +++ b/lib/views/shares_view.dart @@ -1,49 +1,55 @@ import 'package:flutter/material.dart'; -import 'package:intl/intl.dart'; import 'package:provider/provider.dart'; -import '../models/nextcloud_item.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import '../models/app_tab.dart'; import '../models/nextcloud_share.dart'; +import '../providers/files_controller.dart'; +import '../providers/settings_controller.dart'; import '../providers/shares_controller.dart'; -import '../widgets/more_tabs_button.dart'; -import '../widgets/profile_avatar_button.dart'; -import '../widgets/synced_header_scaffold.dart'; +import '../theme/design_tokens.dart'; +import '../widgets/noo/core/noo_segmented_control.dart'; +import '../widgets/noo/files/noo_file_kind.dart'; +import '../widgets/noo/files/noo_file_row.dart'; +import '../widgets/noo/files/noo_file_table.dart'; +import '../widgets/noo/lists/noo_grouped_list.dart'; +import '../widgets/noo/noo_layout.dart'; +import '../widgets/tabs/tab_state_slivers.dart'; -IconData _shareItemIcon(NextcloudItemType type) { - switch (type) { - case NextcloudItemType.folder: - return Icons.folder_rounded; - case NextcloudItemType.image: - return Icons.image_rounded; - case NextcloudItemType.video: - return Icons.movie_rounded; - case NextcloudItemType.audio: - return Icons.audiotrack_rounded; - case NextcloudItemType.document: - return Icons.description_rounded; - case NextcloudItemType.archive: - return Icons.folder_zip_rounded; - case NextcloudItemType.file: - return Icons.insert_drive_file_rounded; - } -} +/// Which slice of [SharesController.shares] is on screen - `With you` maps +/// straight onto the controller's own `sharedWithMe: true` fetch, but the +/// controller only ever exposes one boolean scope, so `By you` and `Links` +/// both use its `sharedWithMe: false` fetch and are told apart client-side +/// by [NextcloudShare.shareType] (see `_visibleShares`) - `Links` and +/// `By you` never trigger a re-fetch when switching between each other, +/// only when crossing to/from `With you`. +enum _ShareScope { withYou, byYou, links } IconData _shareTypeIcon(ShareType type) { switch (type) { case ShareType.user: - return Icons.person_rounded; + return LucideIcons.user; case ShareType.group: - return Icons.groups_rounded; + return LucideIcons.users; case ShareType.publicLink: - return Icons.link_rounded; + return LucideIcons.link; case ShareType.email: - return Icons.email_rounded; + return LucideIcons.mail; case ShareType.federated: - return Icons.public_rounded; + return LucideIcons.globe; case ShareType.other: - return Icons.share_rounded; + return LucideIcons.share2; } } +/// "Can edit" when any write bit (update/create/delete) is set on the OCS +/// share permissions bitmask, "Can view" otherwise - kept to the two +/// broad buckets the row's one-line meta has room for. +String _permissionLabel(int permissions) { + const update = 2, create = 4, delete = 8; + final canWrite = permissions & (update | create | delete) != 0; + return canWrite ? 'Can edit' : 'Can view'; +} + class SharesView extends StatefulWidget { final ScrollController scrollController; @@ -55,11 +61,13 @@ class SharesView extends StatefulWidget { class _SharesViewState extends State { bool _requested = false; + // Mirrors `SharesController.sharedWithMe`'s own default (false, "shared + // by me") so the first fetch this triggers matches what's shown. + _ShareScope _scope = _ShareScope.byYou; @override Widget build(BuildContext context) { - final theme = Theme.of(context); - final colorScheme = theme.colorScheme; + final colors = context.nooColors; final sharesController = context.watch(); if (!_requested) { @@ -69,202 +77,147 @@ class _SharesViewState extends State { ); } - final shares = sharesController.shares; + final shares = _visibleShares(sharesController); + final isDesktop = NooLayout.isDesktop(context); final List contentSlivers = [ - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(20, 16, 20, 8), - child: SegmentedButton( - segments: const [ - ButtonSegment(value: false, label: Text('Shared by me')), - ButtonSegment(value: true, label: Text('Shared with me')), + SliverPadding( + padding: EdgeInsets.fromLTRB( + NooLayout.gutter(context), + NooSpace.md, + NooLayout.gutter(context), + 0, + ), + sliver: SliverToBoxAdapter( + child: NooSegmentedControl<_ShareScope>( + value: _scope, + options: const [ + NooSegmentOption(value: _ShareScope.withYou, label: 'With you'), + NooSegmentOption(value: _ShareScope.byYou, label: 'By you'), + NooSegmentOption(value: _ShareScope.links, label: 'Links'), ], - selected: {sharesController.sharedWithMe}, - onSelectionChanged: (set) => - sharesController.setSharedWithMe(set.first), + onChanged: (scope) => _selectScope(sharesController, scope), ), ), ), - const SliverToBoxAdapter(child: SizedBox(height: 8)), - + const SliverToBoxAdapter(child: SizedBox(height: NooSpace.md)), if (sharesController.isLoading && shares.isEmpty) - const SliverFillRemaining( - hasScrollBody: false, - child: Center(child: CircularProgressIndicator()), - ) + tabLoadingSliver else if (sharesController.errorMessage != null) - SliverFillRemaining( - hasScrollBody: false, - child: Center( - child: Padding( - padding: const EdgeInsets.all(24), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.error_outline_rounded, - size: 64, - color: colorScheme.error, - ), - const SizedBox(height: 16), - Text( - 'Could not load shares', - style: theme.textTheme.titleLarge?.copyWith( - color: colorScheme.error, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 8), - Text( - sharesController.errorMessage!, - textAlign: TextAlign.center, - style: theme.textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 20), - FilledButton.icon( - onPressed: sharesController.fetchAll, - icon: const Icon(Icons.refresh_rounded), - label: const Text('Retry'), - ), - ], - ), - ), - ), + tabErrorSliver( + context, + title: 'Could not load shares', + message: sharesController.errorMessage!, + onRetry: sharesController.fetchAll, ) else if (shares.isEmpty) - SliverFillRemaining( - hasScrollBody: false, - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.groups_rounded, - size: 64, - color: colorScheme.outlineVariant, - ), - const SizedBox(height: 12), - Text( - sharesController.sharedWithMe - ? 'Nothing has been shared with you' - : 'You haven\'t shared anything yet', - style: theme.textTheme.titleMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ), - ) + tabEmptySliver(context, icon: LucideIcons.share2, message: _emptyMessage()) + else if (isDesktop) + _buildDesktopTable(context, shares) else - SliverPadding( - padding: const EdgeInsets.symmetric(horizontal: 20), - sliver: SliverList( - delegate: SliverChildBuilderDelegate((context, index) { - final share = shares[index]; - return _buildShareTile(context, share); - }, childCount: shares.length), - ), - ), - - const SliverToBoxAdapter(child: SizedBox(height: 100)), - const SliverFillRemaining(hasScrollBody: false, child: SizedBox()), + _buildMobileList(context, shares), + ...tabBottomInsetSlivers, ]; - return SyncedHeaderScaffold( - scrollController: widget.scrollController, - actions: const [MoreTabsButton(), ProfileAvatarButton()], - onRefresh: sharesController.fetchAll, - contentSlivers: contentSlivers, + return ColoredBox( + color: colors.bg, + child: RefreshIndicator( + color: colors.accent, + backgroundColor: colors.surface, + onRefresh: sharesController.fetchAll, + child: CustomScrollView( + controller: widget.scrollController, + slivers: contentSlivers, + ), + ), ); } - Widget _buildShareTile(BuildContext context, NextcloudShare share) { - final theme = Theme.of(context); - final colorScheme = theme.colorScheme; - final iconColor = colorScheme.primary; + List _visibleShares(SharesController c) { + switch (_scope) { + case _ShareScope.withYou: + return c.shares; + case _ShareScope.byYou: + return c.shares.where((s) => s.shareType != ShareType.publicLink).toList(); + case _ShareScope.links: + return c.shares.where((s) => s.shareType == ShareType.publicLink).toList(); + } + } - final withWhom = share.sharedWithMe - ? 'From ${share.ownerDisplayName}' - : (share.sharedWithDisplayName != null - ? 'With ${share.sharedWithDisplayName}' - : 'Shared link'); + String _emptyMessage() { + switch (_scope) { + case _ShareScope.withYou: + return 'Nothing has been shared with you'; + case _ShareScope.byYou: + return "You haven't shared anything yet"; + case _ShareScope.links: + return 'No share links yet'; + } + } - return Padding( - padding: const EdgeInsets.only(bottom: 8), - child: Material( - color: colorScheme.surfaceContainerLow, - borderRadius: BorderRadius.circular(16), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - child: Row( - children: [ - Container( - width: 44, - height: 44, - decoration: BoxDecoration( - color: iconColor.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(12), - ), - child: Icon( - _shareItemIcon(share.itemType), - color: iconColor, - size: 22, - ), + void _selectScope(SharesController c, _ShareScope scope) { + setState(() => _scope = scope); + // No-ops (see `SharesController.setSharedWithMe`) when moving between + // `byYou`/`links`, which share the same `sharedWithMe: false` fetch. + c.setSharedWithMe(scope == _ShareScope.withYou); + } + + Widget _buildMobileList(BuildContext context, List shares) { + return SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: NooSpace.sm), + sliver: SliverToBoxAdapter( + child: NooGroupedList( + children: [ + for (final share in shares) + NooFileRow( + kind: NooFileKind.from(name: share.name, isDirectory: share.isFolder), + name: share.name, + meta: '${share.ownerDisplayName} · ${_permissionLabel(share.permissions)}', + iosStyle: NooLayout.iosStyle(context), + onTap: share.isFolder ? () => _openFolder(context, share) : null, + trailing: Icon(_shareTypeIcon(share.shareType), size: 16), + onMore: () => _confirmUnshare(context, share), ), - const SizedBox(width: 14), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - share.name, - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w600, - fontSize: 14, - ), - ), - const SizedBox(height: 2), - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.only(top: 2), - child: Icon( - _shareTypeIcon(share.shareType), - size: 13, - color: colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(width: 4), - Expanded( - child: Text( - '$withWhom • ${DateFormat.yMMMd().format(share.sharedAt)}', - style: theme.textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ), - ], - ), - ], - ), - ), - IconButton( - icon: Icon(Icons.link_off_rounded, color: colorScheme.error), - tooltip: share.sharedWithMe ? 'Remove' : 'Unshare', - onPressed: () => _confirmUnshare(context, share), - ), - ], - ), + ], ), ), ); } + Widget _buildDesktopTable(BuildContext context, List shares) { + return SliverPadding( + padding: const EdgeInsets.symmetric( + horizontal: NooSpace.xl, + vertical: NooSpace.xs, + ), + sliver: SliverList( + delegate: SliverChildBuilderDelegate((context, index) { + if (index == 0) { + return const NooFileTableHeader(col2Label: 'Owner', col3Label: 'Permission'); + } + final share = shares[index - 1]; + return NooFileTableRow( + kind: NooFileKind.from(name: share.name, isDirectory: share.isFolder), + name: share.name, + col2: share.ownerDisplayName, + col3: _permissionLabel(share.permissions), + onTap: share.isFolder ? () => _openFolder(context, share) : null, + onMore: () => _confirmUnshare(context, share), + ); + }, childCount: shares.length + 1), + ), + ); + } + + /// Only folders are safely tappable - unlike Recent/Favorites, a share + /// doesn't carry the size/mime-type `FileViewerScreen` needs, so opening + /// a file share here would have to synthesize that data instead of + /// reading it, same as the original screen (no tap action at all). + void _openFolder(BuildContext context, NextcloudShare share) { + context.read().navigateToAbsoluteFolder(share.path); + context.read().requestTab(AppTab.files); + } + Future _confirmUnshare( BuildContext context, NextcloudShare share, @@ -273,7 +226,7 @@ class _SharesViewState extends State { context: context, builder: (dialogContext) { return AlertDialog( - title: Text(share.sharedWithMe ? 'Remove Share' : 'Unshare'), + title: Text(share.sharedWithMe ? 'Remove share' : 'Unshare'), content: Text( share.sharedWithMe ? 'Remove "${share.name}" shared with you by ${share.ownerDisplayName}?' @@ -298,9 +251,7 @@ class _SharesViewState extends State { if (confirmed != true || !context.mounted) return; final messenger = ScaffoldMessenger.of(context); - final success = await context.read().deleteShare( - share, - ); + final success = await context.read().deleteShare(share); messenger.showSnackBar( SnackBar( content: Text( diff --git a/lib/views/trash_view.dart b/lib/views/trash_view.dart index 4190504..339f01a 100644 --- a/lib/views/trash_view.dart +++ b/lib/views/trash_view.dart @@ -1,31 +1,28 @@ import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import 'package:provider/provider.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../models/nextcloud_item.dart'; import '../providers/trash_controller.dart'; -import '../widgets/more_tabs_button.dart'; -import '../widgets/profile_avatar_button.dart'; -import '../widgets/synced_header_scaffold.dart'; - -IconData _trashItemIcon(NextcloudItemType type) { - switch (type) { - case NextcloudItemType.folder: - return Icons.folder_rounded; - case NextcloudItemType.image: - return Icons.image_rounded; - case NextcloudItemType.video: - return Icons.movie_rounded; - case NextcloudItemType.audio: - return Icons.audiotrack_rounded; - case NextcloudItemType.document: - return Icons.description_rounded; - case NextcloudItemType.archive: - return Icons.folder_zip_rounded; - case NextcloudItemType.file: - return Icons.insert_drive_file_rounded; - } -} +import '../theme/design_tokens.dart'; +import '../widgets/noo/core/noo_button.dart'; +import '../widgets/noo/files/noo_file_kind.dart'; +import '../widgets/noo/files/noo_file_row.dart'; +import '../widgets/noo/files/noo_file_table.dart'; +import '../widgets/noo/files/noo_file_tile.dart'; +import '../widgets/noo/lists/noo_banner.dart'; +import '../widgets/noo/lists/noo_grouped_list.dart'; +import '../widgets/noo/noo_layout.dart'; +import '../widgets/tabs/tab_location.dart'; +import '../widgets/tabs/tab_state_slivers.dart'; +/// The Trash tab: a retention banner (`NooBanner`, with "Empty trash" as +/// its danger action) then the trashed-items list, per `DESIGN_SYSTEM.md` +/// §4. There's no dedicated "empty trash" endpoint anywhere in +/// [TrashController]/`NextcloudService` - [_emptyTrash] composes it from +/// the existing per-item [TrashController.deleteForever], the same way +/// `FavoritesView._confirmDeleteSelected` builds its bulk delete from a +/// per-item call. class TrashView extends StatefulWidget { final ScrollController scrollController; @@ -40,8 +37,7 @@ class _TrashViewState extends State { @override Widget build(BuildContext context) { - final theme = Theme.of(context); - final colorScheme = theme.colorScheme; + final colors = context.nooColors; final trashController = context.watch(); if (!_requested) { @@ -52,176 +48,135 @@ class _TrashViewState extends State { } final trash = trashController.items; + final isDesktop = NooLayout.isDesktop(context); final List contentSlivers = [ - const SliverToBoxAdapter(child: SizedBox(height: 16)), - - if (trashController.isLoading && trash.isEmpty) - const SliverFillRemaining( - hasScrollBody: false, - child: Center(child: CircularProgressIndicator()), - ) - else if (trashController.errorMessage != null) - SliverFillRemaining( - hasScrollBody: false, - child: Center( - child: Padding( - padding: const EdgeInsets.all(24), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.error_outline_rounded, - size: 64, - color: colorScheme.error, - ), - const SizedBox(height: 16), - Text( - 'Could not load trash', - style: theme.textTheme.titleLarge?.copyWith( - color: colorScheme.error, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 8), - Text( - trashController.errorMessage!, - textAlign: TextAlign.center, - style: theme.textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 20), - FilledButton.icon( - onPressed: trashController.fetchAll, - icon: const Icon(Icons.refresh_rounded), - label: const Text('Retry'), - ), - ], - ), - ), - ), - ) - else if (trash.isEmpty) - SliverFillRemaining( - hasScrollBody: false, - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.delete_outline_rounded, - size: 64, - color: colorScheme.outlineVariant, - ), - const SizedBox(height: 12), - Text( - 'Trash is empty', - style: theme.textTheme.titleMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ), - ) - else - SliverPadding( - padding: const EdgeInsets.symmetric(horizontal: 20), - sliver: SliverList( - delegate: SliverChildBuilderDelegate((context, index) { - final item = trash[index]; - return _buildTrashTile(context, item); - }, childCount: trash.length), + SliverPadding( + padding: EdgeInsets.fromLTRB( + NooLayout.gutter(context), + NooSpace.md, + NooLayout.gutter(context), + 0, + ), + sliver: SliverToBoxAdapter( + child: NooBanner( + actionLabel: trash.isEmpty ? null : 'Empty trash', + onAction: trash.isEmpty ? null : () => _confirmEmptyTrash(context, trash), + child: const Text('Deleted items are kept for 30 days, then removed automatically.'), ), ), - - const SliverToBoxAdapter(child: SizedBox(height: 100)), - const SliverFillRemaining(hasScrollBody: false, child: SizedBox()), + ), + const SliverToBoxAdapter(child: SizedBox(height: NooSpace.md)), + if (trashController.isLoading && trash.isEmpty) + tabLoadingSliver + else if (trashController.errorMessage != null) + tabErrorSliver( + context, + title: 'Could not load trash', + message: trashController.errorMessage!, + onRetry: trashController.fetchAll, + ) + else if (trash.isEmpty) + tabEmptySliver( + context, + icon: LucideIcons.trash2, + message: 'Trash is empty', + ) + else if (isDesktop) + _buildDesktopTable(context, trash) + else + _buildMobileList(context, trash), + ...tabBottomInsetSlivers, ]; - return SyncedHeaderScaffold( - scrollController: widget.scrollController, - actions: const [MoreTabsButton(), ProfileAvatarButton()], - onRefresh: trashController.fetchAll, - contentSlivers: contentSlivers, - ); - } - - Widget _buildTrashTile(BuildContext context, NextcloudItem item) { - final theme = Theme.of(context); - final colorScheme = theme.colorScheme; - final iconColor = colorScheme.outline; - - return Padding( - padding: const EdgeInsets.only(bottom: 8), - child: Material( - color: colorScheme.surfaceContainerLow, - borderRadius: BorderRadius.circular(16), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - child: Row( - children: [ - Container( - width: 44, - height: 44, - decoration: BoxDecoration( - color: iconColor.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(12), - ), - child: Icon( - _trashItemIcon(item.type), - color: iconColor, - size: 22, - ), - ), - const SizedBox(width: 14), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - item.name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w600, - fontSize: 14, - ), - ), - const SizedBox(height: 2), - Text( - item.deletedAt != null - ? 'Deleted ${DateFormat.yMMMd().format(item.deletedAt!)}' - : 'Deleted', - style: theme.textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ), - IconButton( - icon: const Icon(Icons.restore_rounded), - tooltip: 'Restore', - color: colorScheme.primary, - onPressed: () => _restore(context, item), - ), - IconButton( - icon: Icon( - Icons.delete_forever_rounded, - color: colorScheme.error, - ), - tooltip: 'Delete forever', - onPressed: () => _confirmDeleteForever(context, item), - ), - ], - ), + return ColoredBox( + color: colors.bg, + child: RefreshIndicator( + color: colors.accent, + backgroundColor: colors.surface, + onRefresh: trashController.fetchAll, + child: CustomScrollView( + controller: widget.scrollController, + slivers: contentSlivers, ), ), ); } + Widget _buildMobileList(BuildContext context, List trash) { + return SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: NooSpace.sm), + sliver: SliverToBoxAdapter( + child: NooGroupedList( + children: [ + for (final item in trash) + NooFileRow( + kind: NooFileKind.from( + name: item.name, + mimeType: item.mimeType, + isDirectory: item.isFolder, + ), + name: item.name, + meta: _meta(item), + iosStyle: NooLayout.iosStyle(context), + trailing: SizedBox.square( + dimension: 40, + child: Semantics( + button: true, + label: 'Restore', + child: InkResponse( + onTap: () => _restore(context, item), + radius: 20, + child: const Icon(LucideIcons.rotateCcw), + ), + ), + ), + onMore: () => _confirmDeleteForever(context, item), + ), + ], + ), + ), + ); + } + + Widget _buildDesktopTable(BuildContext context, List trash) { + return SliverPadding( + padding: const EdgeInsets.symmetric( + horizontal: NooSpace.xl, + vertical: NooSpace.xs, + ), + sliver: SliverList( + delegate: SliverChildBuilderDelegate((context, index) { + if (index == 0) { + return const NooFileTableHeader( + col2Label: 'Deleted', + col3Label: 'Original location', + ); + } + final item = trash[index - 1]; + return _TrashDesktopRow( + kind: NooFileKind.from( + name: item.name, + mimeType: item.mimeType, + isDirectory: item.isFolder, + ), + name: item.name, + col2: _deletedLabel(item), + col3: item.originalLocation ?? 'Unknown location', + onRestore: () => _restore(context, item), + onMore: () => _confirmDeleteForever(context, item), + ); + }, childCount: trash.length + 1), + ), + ); + } + + String _meta(NextcloudItem item) => + 'Deleted ${_deletedLabel(item)} · ${tabLocationLabel(item.originalLocation ?? item.path, item.name)}'; + + String _deletedLabel(NextcloudItem item) => + item.deletedAt != null ? DateFormat.yMMMd().format(item.deletedAt!) : 'recently'; + Future _restore(BuildContext context, NextcloudItem item) async { final messenger = ScaffoldMessenger.of(context); final success = await context.read().restore(item); @@ -243,7 +198,7 @@ class _TrashViewState extends State { context: context, builder: (dialogContext) { return AlertDialog( - title: const Text('Delete Forever'), + title: const Text('Delete forever'), content: Text( 'Permanently delete "${item.name}"? This cannot be undone.', ), @@ -257,7 +212,7 @@ class _TrashViewState extends State { backgroundColor: Theme.of(dialogContext).colorScheme.error, ), onPressed: () => Navigator.pop(dialogContext, true), - child: const Text('Delete Forever'), + child: const Text('Delete forever'), ), ], ); @@ -276,4 +231,140 @@ class _TrashViewState extends State { ), ); } + + Future _confirmEmptyTrash( + BuildContext context, + List trash, + ) async { + final confirmed = await showDialog( + context: context, + builder: (dialogContext) { + return AlertDialog( + title: const Text('Empty trash'), + content: Text( + 'Permanently delete all ${trash.length} item(s) in trash? This cannot be undone.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext, false), + child: const Text('Cancel'), + ), + FilledButton( + style: FilledButton.styleFrom( + backgroundColor: Theme.of(dialogContext).colorScheme.error, + ), + onPressed: () => Navigator.pop(dialogContext, true), + child: const Text('Empty trash'), + ), + ], + ); + }, + ); + if (confirmed != true || !context.mounted) return; + + final controller = context.read(); + final messenger = ScaffoldMessenger.of(context); + var succeeded = 0; + for (final item in trash) { + if (await controller.deleteForever(item)) succeeded++; + } + if (!context.mounted) return; + messenger.showSnackBar( + SnackBar( + content: Text('Deleted $succeeded of ${trash.length} item(s)'), + behavior: SnackBarBehavior.floating, + ), + ); + } +} + +/// Desktop trash row: like `NooFileTableRow`, but its 120px last column +/// holds a tonal "Restore" button plus the overflow button (permanent +/// delete) instead of status icons - `NooFileTableRow` has no slot for +/// screen-specific actions there. Column widths (180/160/120) are matched +/// by hand to `NooFileTableHeader`'s (private in `noo_file_table.dart`) so +/// this still lines up under it; promoting an optional `actions` slot onto +/// `NooFileTableRow` would let this fold back into the shared component. +class _TrashDesktopRow extends StatelessWidget { + final NooFileKind kind; + final String name; + final String col2; + final String col3; + final VoidCallback onRestore; + final VoidCallback onMore; + + const _TrashDesktopRow({ + required this.kind, + required this.name, + required this.col2, + required this.col3, + required this.onRestore, + required this.onMore, + }); + + @override + Widget build(BuildContext context) { + final colors = context.nooColors; + final metaStyle = NooText.meta.copyWith(color: colors.fg3); + + return SizedBox( + height: NooSizes.rowDesktop, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: NooSpace.sm), + child: Row( + children: [ + Expanded( + child: Row( + children: [ + NooFileTile(kind: kind, size: NooFileTileSize.desktop), + const SizedBox(width: NooSpace.sm), + Expanded( + child: Text( + name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: NooText.body.copyWith( + fontSize: 14, + fontWeight: FontWeight.w500, + color: colors.fg1, + ), + ), + ), + const SizedBox(width: NooSpace.md), + ], + ), + ), + SizedBox( + width: 180, + child: Text(col2, maxLines: 1, overflow: TextOverflow.ellipsis, style: metaStyle), + ), + SizedBox( + width: 160, + child: Text(col3, maxLines: 1, overflow: TextOverflow.ellipsis, style: metaStyle), + ), + SizedBox( + width: 120, + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerRight, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + NooButton( + variant: NooButtonVariant.tonal, + size: NooButtonSize.xs, + onTap: onRestore, + child: const Text('Restore'), + ), + const SizedBox(width: 4), + NooOverflowButton(size: 24, onTap: onMore), + ], + ), + ), + ), + ], + ), + ), + ); + } } diff --git a/lib/widgets/app_drawer.dart b/lib/widgets/app_drawer.dart new file mode 100644 index 0000000..7cc22df --- /dev/null +++ b/lib/widgets/app_drawer.dart @@ -0,0 +1,83 @@ +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:provider/provider.dart'; +import '../models/app_tab.dart'; +import '../providers/files_controller.dart'; +import '../providers/session_controller.dart'; +import '../providers/settings_controller.dart'; +import '../providers/trash_controller.dart'; +import 'noo/core/noo_avatar.dart'; +import 'noo/nav/noo_drawer.dart'; +import 'shell/shell_common.dart'; + +/// The mobile drawer (DESIGN_SYSTEM.md 2 "Drawer" / 3; `drawer` in +/// `Mobile Screen.dc.html`): account block, storage bar, the tabs not +/// pinned to the bottom nav bar ("More"), Settings, and an "Edit tabs" +/// link. Opened the same way as before (menu icon in [AppTopBar]), but +/// built on [NooDrawer] instead of the old Material [Drawer] + ad hoc rows. +/// +/// Tapping a "More" tab switches the shell straight to it (rather than +/// pushing it as a standalone screen, like the pre-rework `MoreTabsButton` +/// did) via `SettingsController.requestTab` - the same one-shot mechanism +/// `SearchView`/`FavoritesView`/`ShareUploadView` already use to jump the +/// shell to a tab from outside it, so `MainShellView` needs no extra +/// plumbing to support it. +class AppDrawer extends StatelessWidget { + const AppDrawer({super.key}); + + @override + Widget build(BuildContext context) { + final session = context.watch(); + final settings = context.watch(); + final quota = context.watch().quota; + // Cheap and already loaded when the Trash tab has been visited this + // session - not worth a fetch just to populate a drawer badge. + final trashCount = context.watch().items.length; + final hiddenTabs = settings.tabOrder + .where((t) => settings.hiddenTabs.contains(t)) + .toList(); + + void closeAndOpenSettings() { + Navigator.pop(context); + openSettings(context); + } + + return NooDrawer( + account: NooDrawerAccount( + avatar: NooAvatar( + initials: accountInitial(session.username), + current: true, + size: 48, + ), + name: session.username, + subtitle: serverHost(session.serverUrl), + onTap: closeAndOpenSettings, + onSwitchAccount: () => showAccountSwitcher(context), + ), + storage: NooDrawerStorage( + value: quotaFraction(quota), + label: quotaUsedLabel(quota), + ), + moreItems: [ + for (final tab in hiddenTabs) + NooDrawerItem( + icon: tab.icon, + label: tab.label, + count: tab == AppTab.trash && trashCount > 0 + ? '$trashCount' + : null, + onTap: () { + Navigator.pop(context); + settings.requestTab(tab); + }, + ), + ], + settings: NooDrawerItem( + icon: LucideIcons.settings, + label: 'Settings', + onTap: closeAndOpenSettings, + ), + footer: NooDrawerLink(onTap: closeAndOpenSettings), + ); + } +} diff --git a/lib/widgets/app_top_bar.dart b/lib/widgets/app_top_bar.dart new file mode 100644 index 0000000..70d329c --- /dev/null +++ b/lib/widgets/app_top_bar.dart @@ -0,0 +1,64 @@ +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import '../models/app_tab.dart'; +import 'create_menu.dart'; +import 'noo/nav/noo_top_bar.dart'; +import 'shell/shell_common.dart'; + +/// The shell's mobile top bar (DESIGN_SYSTEM.md 3, "Mobile"): every regular +/// tab now gets the same chrome (this used to only wrap Files - the other +/// tabs built their own via `SyncedHeaderScaffold`, which the rework drops). +/// Built on [NooTopBar]: +/// +/// - iOS: `menu` leading, `plus` (Files only - no tab besides Files has a +/// create/upload flow) and the account avatar trailing, then the large +/// title, then an inline search field that opens [SearchView]. +/// - Android: `menu`, title, `search` and avatar actions. Upload has no +/// top-bar icon here - it's the [NooFab] `MainShellView` shows instead. +/// +/// [style] is resolved by the caller (`NooLayout.navStyle`) rather than +/// read from context here, since [preferredSize] is a plain getter with no +/// BuildContext - the same reason [NooTopBar] itself takes it as a field. +class AppTopBar extends StatelessWidget implements PreferredSizeWidget { + final NooNavStyle style; + final AppTab tab; + + const AppTopBar({super.key, required this.style, required this.tab}); + + bool get _ios => style == NooNavStyle.ios; + + @override + Size get preferredSize => NooTopBar( + style: style, + title: tab.label, + // A throwaway placeholder purely to make preferredSize's "is there a + // search slot" check match what build() actually shows - never + // inserted into the tree, so its content doesn't matter. + search: _ios ? const SizedBox.shrink() : null, + ).preferredSize; + + @override + Widget build(BuildContext context) { + return NooTopBar( + style: style, + title: tab.label, + onMenu: () => Scaffold.of(context).openDrawer(), + actions: [ + if (!_ios) + NooTopBarButton( + icon: LucideIcons.search, + tooltip: 'Search', + onTap: () => openSearch(context), + ), + if (_ios && tab == AppTab.files) + NooTopBarButton( + icon: LucideIcons.plus, + tooltip: 'Create', + onTap: () => showCreateMenu(context), + ), + ShellAvatarButton(hitBox: _ios ? 44 : 48), + ], + search: _ios ? const ShellSearchLauncher(ios: true) : null, + ); + } +} diff --git a/lib/widgets/bottom_nav_bar.dart b/lib/widgets/bottom_nav_bar.dart new file mode 100644 index 0000000..9407d53 --- /dev/null +++ b/lib/widgets/bottom_nav_bar.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; +import '../models/app_tab.dart'; +import 'noo/nav/noo_bottom_bar.dart'; + +/// Adapts the app's [AppTab] model onto [NooBottomBar]'s generic +/// [NooNavDestination] list, so `MainShellView` only deals in tabs and +/// doesn't need to import `noo/nav` itself. [selectedIndex] may be -1 (no +/// destination highlighted) when the shell is showing a tab opened from the +/// drawer's "More" list that isn't one of the pinned [tabs]. +class BottomNavBar extends StatelessWidget { + final NooNavStyle style; + final List tabs; + final int selectedIndex; + final ValueChanged onDestinationSelected; + + const BottomNavBar({ + super.key, + required this.style, + required this.tabs, + required this.selectedIndex, + required this.onDestinationSelected, + }); + + @override + Widget build(BuildContext context) { + return NooBottomBar( + style: style, + destinations: [ + for (final tab in tabs) + NooNavDestination(icon: tab.icon, label: tab.label), + ], + selectedIndex: selectedIndex, + onSelected: onDestinationSelected, + ); + } +} diff --git a/lib/widgets/breadcrumbs.dart b/lib/widgets/breadcrumbs.dart index 23c6843..ed486e7 100644 --- a/lib/widgets/breadcrumbs.dart +++ b/lib/widgets/breadcrumbs.dart @@ -1,7 +1,13 @@ import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import '../theme/design_tokens.dart'; /// Tappable "Home / Folder / Sub-folder" trail shown whenever the user has -/// navigated below the root, in both the Files and Photos tabs. +/// navigated below the root - now only used by `ShareUploadView` and +/// `MoveCopyDestinationPicker` (Files/Photos moved to their own noo-styled +/// `FileBreadcrumbRow`), so this is free to carry noo tokens directly rather +/// than staying on the old Material look. Same behaviour: every crumb but +/// the last is tappable, jumping back to that depth. class Breadcrumbs extends StatelessWidget { final List pathStack; final ValueChanged onTap; @@ -19,8 +25,7 @@ class Breadcrumbs extends StatelessWidget { @override Widget build(BuildContext context) { - final theme = Theme.of(context); - final colorScheme = theme.colorScheme; + final colors = context.nooColors; final lastIndex = pathStack.length - 1; return SingleChildScrollView( @@ -32,37 +37,42 @@ class Breadcrumbs extends StatelessWidget { Padding( padding: const EdgeInsets.symmetric(horizontal: 2), child: Icon( - Icons.chevron_right_rounded, - size: 16, - color: colorScheme.outlineVariant, + LucideIcons.chevronRight, + size: 14, + color: colors.fg3, ), ), if (i == lastIndex) Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), + padding: const EdgeInsets.symmetric(horizontal: 6), child: Text( _labelFor(i), - style: theme.textTheme.bodySmall?.copyWith( - fontWeight: FontWeight.w700, - color: colorScheme.onSurface, + style: NooText.body.copyWith( + fontSize: 13, + fontWeight: FontWeight.w600, + color: colors.fg1, ), ), ) else - // A chip's whole pill is the tap target (not just the text), - // so the previous plain-text crumbs were fiddly to hit. - ActionChip( - label: Text(_labelFor(i)), - labelStyle: theme.textTheme.bodySmall?.copyWith( - fontWeight: FontWeight.w600, - color: colorScheme.primary, + // The whole pill is the tap target, not just the text. + InkWell( + onTap: () => onTap(i), + borderRadius: BorderRadius.circular(NooRadii.pill), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + child: Text( + _labelFor(i), + style: NooText.body.copyWith( + fontSize: 13, + fontWeight: FontWeight.w600, + color: colors.accentText, + ), + ), ), - onPressed: () => onTap(i), - backgroundColor: colorScheme.surfaceContainerHigh, - side: BorderSide.none, - visualDensity: VisualDensity.compact, - materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, - padding: const EdgeInsets.symmetric(horizontal: 4), ), ], ], diff --git a/lib/widgets/create_menu.dart b/lib/widgets/create_menu.dart new file mode 100644 index 0000000..f2ef550 --- /dev/null +++ b/lib/widgets/create_menu.dart @@ -0,0 +1,119 @@ +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:provider/provider.dart'; +import '../providers/files_controller.dart'; +import '../services/share_intent_service.dart'; +import '../views/share_upload_view.dart'; +import 'noo/core/noo_button.dart'; +import 'noo/lists/noo_grouped_list.dart'; +import 'noo/lists/noo_settings_row.dart'; +import 'noo/overlays/noo_dialog.dart'; +import 'noo/overlays/noo_sheet.dart'; +import 'noo/overlays/noo_text_field.dart'; + +/// The Files tab's "+"/FAB action: upload a file or create a folder into +/// the current folder. Shared by `FilesView`'s own create button and +/// `MainShellView`'s FAB (the Noo Design System project's FAB component), +/// since both need to trigger the exact same flow. +void showCreateMenu(BuildContext context) { + showNooSheet( + context, + children: [ + NooGroupedList( + children: [ + NooSettingsRow( + icon: LucideIcons.upload, + label: const Text('Upload file'), + onTap: () { + Navigator.pop(context); + _pickAndUploadFile(context); + }, + ), + NooSettingsRow( + icon: LucideIcons.folder, + label: const Text('New folder'), + onTap: () { + Navigator.pop(context); + _showCreateFolderDialog(context); + }, + ), + ], + ), + ], + ); +} + +void _showCreateFolderDialog(BuildContext context) { + final files = context.read(); + final controller = TextEditingController(); + + Future submit(BuildContext ctx) async { + final name = controller.text.trim(); + if (name.isEmpty) return; + Navigator.pop(ctx); + final success = await files.createFolder(name); + if (ctx.mounted) { + ScaffoldMessenger.of(ctx).showSnackBar( + SnackBar( + content: Text(success ? 'Created folder $name' : 'Failed to create folder'), + behavior: SnackBarBehavior.floating, + ), + ); + } + } + + showNooDialog( + context, + title: 'New folder', + children: [ + NooTextField( + controller: controller, + placeholder: 'e.g. Finance', + autofocus: true, + onSurface: false, + onSubmitted: (_) => submit(context), + ), + Row( + mainAxisAlignment: MainAxisAlignment.end, + spacing: 10, + children: [ + NooButton( + variant: NooButtonVariant.secondary, + onTap: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + NooButton( + variant: NooButtonVariant.primary, + onTap: () => submit(context), + child: const Text('Create'), + ), + ], + ), + ], + ); +} + +// Mirrors `main.dart`'s `_handleSharedFiles` exactly, so picking a file via +// "+"/the FAB lands on the same destination-picker screen and background +// foreground-service upload as receiving one via Android's "Share to..." +// sheet does, rather than a separate in-app-only upload path. +Future _pickAndUploadFile(BuildContext context) async { + final picked = await FilePicker.pickFiles(); + if (picked.isEmpty) return; + final files = picked + .where((f) => f.path != null) + .map( + (f) => SharedFileRef( + uri: Uri.file(f.path!).toString(), + name: f.name, + size: f.lengthSync(), + ), + ) + .toList(); + if (files.isEmpty || !context.mounted) return; + + Navigator.of( + context, + ).push(MaterialPageRoute(builder: (_) => ShareUploadView(files: files))); +} diff --git a/lib/widgets/details/details_activity_tab.dart b/lib/widgets/details/details_activity_tab.dart index 1a9f70e..4b772b9 100644 --- a/lib/widgets/details/details_activity_tab.dart +++ b/lib/widgets/details/details_activity_tab.dart @@ -1,11 +1,18 @@ import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:provider/provider.dart'; import '../../models/nextcloud_item.dart'; import '../../providers/item_operations.dart'; +import '../../providers/session_controller.dart'; +import '../../theme/design_tokens.dart'; +import '../noo/lists/noo_grouped_list.dart'; +import '../noo/media/noo_activity_item.dart'; +import '../noo/media/noo_photo_tile.dart'; -/// Per-file activity feed — same list-item look as the account-wide -/// Activity tab (activity_view.dart), scoped to a single item. +/// Per-file activity feed - the same actor/verb/tail mapping and +/// avatar-palette convention as the account-wide Activity tab +/// (`activity_view.dart`), scoped to a single item. class DetailsActivityTab extends StatefulWidget { final NextcloudItem item; @@ -36,107 +43,52 @@ class _DetailsActivityTabState extends State { _requested = true; WidgetsBinding.instance.addPostFrameCallback((_) => _load()); } - final theme = Theme.of(context); - final colorScheme = theme.colorScheme; + final colors = context.nooColors; + final session = context.watch(); if (_isLoading) { - return const Center(child: CircularProgressIndicator()); + return const Padding( + padding: EdgeInsets.symmetric(vertical: 32), + child: Center(child: CircularProgressIndicator()), + ); } if (_activities.isEmpty) { - return Center( - child: Text( - 'No recent activity', - style: theme.textTheme.titleMedium?.copyWith( - color: colorScheme.onSurfaceVariant, + return Padding( + padding: const EdgeInsets.symmetric(vertical: 32), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(LucideIcons.activity, size: 40, color: colors.fg3), + const SizedBox(height: NooSpace.sm), + Text( + 'No recent activity', + style: NooText.cardTitle.copyWith(fontSize: 16, color: colors.fg2), + ), + ], ), ), ); } - return ListView.builder( - padding: const EdgeInsets.fromLTRB(20, 12, 20, 24), - itemCount: _activities.length, - itemBuilder: (context, index) { - final act = _activities[index]; - return Padding( - padding: const EdgeInsets.only(bottom: 12), - child: Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: colorScheme.surfaceContainerLow, - borderRadius: BorderRadius.circular(20), - border: Border.all( - color: colorScheme.outlineVariant.withValues(alpha: 0.2), - ), - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: colorScheme.primaryContainer, - shape: BoxShape.circle, - ), - child: Icon( - act.icon, - color: colorScheme.onPrimaryContainer, - size: 20, - ), - ), - const SizedBox(width: 14), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Text( - act.title, - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w600, - fontSize: 14, - ), - ), - ), - const SizedBox(width: 8), - Text( - DateFormat.jm().format(act.timestamp), - style: theme.textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - fontSize: 11, - ), - ), - ], - ), - const SizedBox(height: 4), - Text( - act.subject, - style: theme.textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - fontSize: 13, - ), - ), - const SizedBox(height: 6), - Text( - 'By ${act.author} • ${DateFormat.MMMd().format(act.timestamp)}', - style: theme.textTheme.bodySmall?.copyWith( - color: colorScheme.primary, - fontWeight: FontWeight.w500, - fontSize: 11, - ), - ), - ], - ), - ), - ], + return NooGroupedList( + children: [ + for (final act in _activities) + ColoredBox( + color: colors.surface, + child: NooActivityItem( + actor: act.author, + verb: act.title, + tail: act.subject.isNotEmpty && act.subject != act.title + ? act.subject + : null, + time: DateFormat.yMMMd().add_jm().format(act.timestamp), + avatarColor: NooPhotoTile.paletteColor(act.author.hashCode), + currentUser: + act.author.toLowerCase() == session.username.toLowerCase(), ), ), - ); - }, + ], ); } } diff --git a/lib/widgets/details/details_info_tab.dart b/lib/widgets/details/details_info_tab.dart index 821441c..313d32b 100644 --- a/lib/widgets/details/details_info_tab.dart +++ b/lib/widgets/details/details_info_tab.dart @@ -1,21 +1,19 @@ import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import '../../models/nextcloud_item.dart'; +import '../../theme/design_tokens.dart'; +import '../noo/lists/noo_grouped_list.dart'; import '../synced_header_scaffold.dart' show formatBytes; -/// Plain metadata tab of the Details sheet — name, type, size, location, -/// and dates. No network calls, so no loading state needed. +/// Plain metadata list of the Details sheet/dialog - name, type, size, +/// location, and dates. No network calls, so no loading state needed. class DetailsInfoTab extends StatelessWidget { final NextcloudItem item; - final ScrollController? scrollController; - const DetailsInfoTab({super.key, required this.item, this.scrollController}); + const DetailsInfoTab({super.key, required this.item}); @override Widget build(BuildContext context) { - final theme = Theme.of(context); - final colorScheme = theme.colorScheme; - final rows = <(String, String)>[ ('Name', item.name), ('Type', item.isFolder ? 'Folder' : (item.mimeType ?? 'File')), @@ -26,39 +24,51 @@ class DetailsInfoTab extends StatelessWidget { ('Favorite', item.isFavorite ? 'Yes' : 'No'), ]; - return ListView.separated( - controller: scrollController, - padding: const EdgeInsets.fromLTRB(20, 12, 20, 24), - itemCount: rows.length, - separatorBuilder: (context, index) => const Divider(height: 1), - itemBuilder: (context, index) { - final (label, value) = rows[index]; - return Padding( - padding: const EdgeInsets.symmetric(vertical: 12), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox( - width: 96, - child: Text( - label, - style: theme.textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), + return NooGroupedList( + children: [for (final row in rows) _InfoRow(label: row.$1, value: row.$2)], + ); + } +} + +class _InfoRow extends StatelessWidget { + final String label; + final String value; + + const _InfoRow({required this.label, required this.value}); + + @override + Widget build(BuildContext context) { + final colors = context.nooColors; + return ColoredBox( + color: colors.surface, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: NooSpace.md, + vertical: NooSpace.sm, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 96, + child: Text( + label, + style: NooText.body.copyWith(fontSize: 14, color: colors.fg3), ), - Expanded( - child: Text( - value, - style: theme.textTheme.bodyMedium?.copyWith( - fontWeight: FontWeight.w500, - ), + ), + Expanded( + child: Text( + value, + style: NooText.body.copyWith( + fontSize: 14, + fontWeight: FontWeight.w500, + color: colors.fg1, ), ), - ], - ), - ); - }, + ), + ], + ), + ), ); } } diff --git a/lib/widgets/details/details_sheet.dart b/lib/widgets/details/details_sheet.dart index 8d39827..7ffa9e9 100644 --- a/lib/widgets/details/details_sheet.dart +++ b/lib/widgets/details/details_sheet.dart @@ -1,160 +1,149 @@ import 'package:flutter/material.dart'; -import 'package:intl/intl.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:path/path.dart' as p; import '../../models/nextcloud_item.dart'; -import '../gradual_bottom_sheet.dart'; +import '../../theme/design_tokens.dart'; +import '../noo/core/noo_segmented_control.dart'; +import '../noo/files/noo_file_kind.dart'; +import '../noo/files/noo_file_tile.dart'; +import '../noo/noo_layout.dart'; +import '../noo/overlays/noo_dialog.dart'; +import '../noo/overlays/noo_overlay_header.dart'; +import '../noo/overlays/noo_sheet.dart'; import '../synced_header_scaffold.dart' show formatBytes; import 'details_activity_tab.dart'; import 'details_info_tab.dart'; import 'details_versions_tab.dart'; -IconData _detailsItemIcon(NextcloudItemType type) { - switch (type) { - case NextcloudItemType.folder: - return Icons.folder_rounded; - case NextcloudItemType.image: - return Icons.image_rounded; - case NextcloudItemType.video: - return Icons.movie_rounded; - case NextcloudItemType.audio: - return Icons.audiotrack_rounded; - case NextcloudItemType.document: - return Icons.description_rounded; - case NextcloudItemType.archive: - return Icons.folder_zip_rounded; - case NextcloudItemType.file: - return Icons.insert_drive_file_rounded; - } +/// The file-type tile every per-item sheet/dialog opens on +/// (`DESIGN_SYSTEM.md` §1.2/§4) - shared by [DetailsSheet] and [ShareSheet] +/// so both overlays for the same item use the same header tile. +Widget detailsFileTile( + NextcloudItem item, { + NooFileTileSize size = NooFileTileSize.header, +}) { + return NooFileTile( + kind: NooFileKind.from( + name: item.name, + mimeType: item.mimeType, + isDirectory: item.isFolder, + ), + size: size, + ); } -/// Reusable "Details" bottom sheet for a single file/folder — Info, -/// Versions, and Activity tabs. Sharing lives in its own sheet -/// ([ShareSheet]), triggered separately from wherever a "Share" action -/// appears. Triggered from long-press selection (exactly one item), the -/// media viewer's action bar, and the "open externally" flow for -/// unsupported file types. +/// The header's meta subtitle - "12.3 KB · Documents" for a file, "Folder" +/// for a folder (`DESIGN_SYSTEM.md` §4: "size · folder"). +String detailsMetaLine(NextcloudItem item) { + if (item.isFolder) return 'Folder'; + final parent = p.basename(p.dirname(item.path)); + final location = parent.isEmpty || parent == '.' || parent == '/' + ? 'Home' + : parent; + return '${formatBytes(item.size)} · $location'; +} + +enum _DetailsTab { info, versions, activity } + +/// Reusable "Details" bottom sheet/dialog for a single file/folder - Info, +/// Versions and Activity switched by a segmented control (there's no +/// tab-strip component in the noo kit, so this is the closest fit - see the +/// rebuild report). Sharing lives in its own sheet ([ShareSheet]), triggered +/// separately from wherever a "Share" action appears. Triggered from +/// long-press selection (exactly one item), the media viewer's action bar, +/// and the "open externally" flow for unsupported file types. +/// +/// Only used directly on mobile (bundles its own [NooOverlayHeader]); the +/// desktop path in [show] passes [_DetailsBody] straight to [showNooDialog], +/// which renders the header itself via `leading`/`title`/`subtitle`. class DetailsSheet extends StatelessWidget { final NextcloudItem item; const DetailsSheet({super.key, required this.item}); static Future show(BuildContext context, NextcloudItem item) { - return showGradualBottomSheet( - context, - builder: (context, scrollController) => DetailsSheet(item: item), - ); + if (NooLayout.isDesktop(context)) { + return showNooDialog( + context, + leading: detailsFileTile(item), + title: item.name, + subtitle: detailsMetaLine(item), + children: [_DetailsBody(item: item)], + ); + } + return showNooSheet(context, children: [DetailsSheet(item: item)]); } @override Widget build(BuildContext context) { - return DefaultTabController( - length: 3, - child: Column( - children: [ - DetailsHeader(item: item), - // Icon size matches the bottom nav bar's own tab icons - // (FloatingBottomNavBar) rather than TabBar's oversized default, - // so a "tabs" affordance reads the same size everywhere in the - // app. - const TabBar( - tabs: [ - Tab( - icon: Icon(Icons.info_outline_rounded, size: 22), - text: 'Info', - ), - Tab( - icon: Icon(Icons.history_rounded, size: 22), - text: 'Versions', - ), - Tab( - icon: Icon(Icons.electric_bolt_rounded, size: 22), - text: 'Activity', - ), - ], - ), - Expanded( - child: TabBarView( - children: [ - DetailsInfoTab(item: item), - DetailsVersionsTab(item: item), - DetailsActivityTab(item: item), - ], - ), - ), - ], - ), + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + NooOverlayHeader( + leading: detailsFileTile(item), + title: item.name, + subtitle: detailsMetaLine(item), + onClose: () => Navigator.pop(context), + ), + const SizedBox(height: NooSpace.lg), + _DetailsBody(item: item), + ], ); } } -/// Icon/name/size/date row shared by [DetailsSheet], the media viewer's -/// collapsed "peek" state (which shows just this, before the user drags the -/// sheet open far enough to reveal the tabs), and [ShareSheet]'s own title -/// (so every per-item bottom sheet opens on the same header instead of -/// [ShareSheet] alone using a bare title `Text`). -class DetailsHeader extends StatelessWidget { +/// The segmented control plus whichever tab's content it selects. A private +/// [StatefulWidget] rather than three fields on [DetailsSheet] so the +/// selection survives independently of how the header/container around it +/// is built (mobile embeds this once, desktop hands it to [showNooDialog] +/// directly). +class _DetailsBody extends StatefulWidget { final NextcloudItem item; - /// Defaults to this widget's own standalone inset ([DetailsSheet]); a - /// caller whose surrounding scroll view already applies horizontal - /// padding (e.g. [ShareSheet]'s `ListView`) should pass a padding with - /// zero left/right to avoid doubling it up. - final EdgeInsetsGeometry padding; + const _DetailsBody({required this.item}); - const DetailsHeader({ - super.key, - required this.item, - this.padding = const EdgeInsets.fromLTRB(20, 12, 20, 8), - }); + @override + State<_DetailsBody> createState() => _DetailsBodyState(); +} + +class _DetailsBodyState extends State<_DetailsBody> { + _DetailsTab _tab = _DetailsTab.info; @override Widget build(BuildContext context) { - final theme = Theme.of(context); - final colorScheme = theme.colorScheme; - - return Padding( - padding: padding, - child: Row( - children: [ - Container( - width: 44, - height: 44, - decoration: BoxDecoration( - color: colorScheme.primaryContainer, - borderRadius: BorderRadius.circular(12), + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + NooSegmentedControl<_DetailsTab>( + onSurface: true, + fill: true, + value: _tab, + onChanged: (tab) => setState(() => _tab = tab), + options: const [ + NooSegmentOption( + value: _DetailsTab.info, + icon: LucideIcons.info, + label: 'Info', ), - child: Icon( - _detailsItemIcon(item.type), - color: colorScheme.onPrimaryContainer, - size: 22, + NooSegmentOption( + value: _DetailsTab.versions, + icon: LucideIcons.history, + label: 'Versions', ), - ), - const SizedBox(width: 14), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - item.name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: 2), - Text( - item.isFolder - ? DateFormat.yMMMd().format(item.lastModified) - : '${formatBytes(item.size)} • ${DateFormat.yMMMd().format(item.lastModified)}', - style: theme.textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ], + NooSegmentOption( + value: _DetailsTab.activity, + icon: LucideIcons.activity, + label: 'Activity', ), - ), - ], - ), + ], + ), + const SizedBox(height: NooSpace.lg), + switch (_tab) { + _DetailsTab.info => DetailsInfoTab(item: widget.item), + _DetailsTab.versions => DetailsVersionsTab(item: widget.item), + _DetailsTab.activity => DetailsActivityTab(item: widget.item), + }, + ], ); } } diff --git a/lib/widgets/details/details_versions_tab.dart b/lib/widgets/details/details_versions_tab.dart index 2881ec6..f83d105 100644 --- a/lib/widgets/details/details_versions_tab.dart +++ b/lib/widgets/details/details_versions_tab.dart @@ -1,15 +1,20 @@ import 'package:file_saver/file_saver.dart'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; import 'package:provider/provider.dart'; import '../../models/nextcloud_file_version.dart'; import '../../models/nextcloud_item.dart'; import '../../providers/item_operations.dart'; +import '../../theme/design_tokens.dart'; +import '../noo/files/noo_file_kind.dart'; +import '../noo/files/noo_file_tile.dart'; +import '../noo/lists/noo_grouped_list.dart'; import '../synced_header_scaffold.dart' show formatBytes; -/// File version history — a synthetic "Current version" row (from the +/// File version history - a synthetic "Current version" row (from the /// item's own metadata) followed by whatever the DAV versions endpoint /// returns, each with Restore/Download actions. class DetailsVersionsTab extends StatefulWidget { @@ -75,7 +80,7 @@ class _DetailsVersionsTabState extends State { await FileSaver.instance.saveFile( name: baseName, filePath: tempPath, - ext: ext, + fileExtension: ext, ); if (!mounted) return; messenger.showSnackBar( @@ -101,11 +106,12 @@ class _DetailsVersionsTabState extends State { _requested = true; WidgetsBinding.instance.addPostFrameCallback((_) => _load()); } - final theme = Theme.of(context); - final colorScheme = theme.colorScheme; if (_isLoading) { - return const Center(child: CircularProgressIndicator()); + return const Padding( + padding: EdgeInsets.symmetric(vertical: 32), + child: Center(child: CircularProgressIndicator()), + ); } final items = [ @@ -117,44 +123,126 @@ class _DetailsVersionsTabState extends State { ), ..._versions, ]; + final kind = NooFileKind.from( + name: widget.item.name, + mimeType: widget.item.mimeType, + isDirectory: false, + ); - return ListView.separated( - padding: const EdgeInsets.fromLTRB(20, 12, 20, 24), - itemCount: items.length, - separatorBuilder: (context, index) => const SizedBox(height: 4), - itemBuilder: (context, index) { - final version = items[index]; - return ListTile( - contentPadding: EdgeInsets.zero, - leading: CircleAvatar( - backgroundColor: colorScheme.primaryContainer, - child: Icon( - Icons.history_rounded, - color: colorScheme.onPrimaryContainer, - size: 20, - ), + return NooGroupedList( + children: [ + for (final version in items) + _VersionRow( + kind: kind, + version: version, + onRestore: () => _restore(version), + onDownload: () => _download(version), ), - title: Text( - version.isCurrent - ? 'Current version' - : DateFormat.yMMMd().add_jm().format(version.timestamp), - ), - subtitle: Text( - '${DateFormat.yMMMd().format(version.timestamp)} • ${formatBytes(version.size)}', - ), - trailing: version.isCurrent - ? null - : PopupMenuButton( - onSelected: (value) => value == 'restore' - ? _restore(version) - : _download(version), - itemBuilder: (context) => const [ - PopupMenuItem(value: 'restore', child: Text('Restore')), - PopupMenuItem(value: 'download', child: Text('Download')), - ], - ), - ); - }, + ], + ); + } +} + +class _VersionRow extends StatelessWidget { + final NooFileKind kind; + final NextcloudFileVersion version; + final VoidCallback onRestore; + final VoidCallback onDownload; + + const _VersionRow({ + required this.kind, + required this.version, + required this.onRestore, + required this.onDownload, + }); + + @override + Widget build(BuildContext context) { + final colors = context.nooColors; + return ColoredBox( + color: colors.surface, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: NooSpace.md, + vertical: NooSpace.sm, + ), + child: Row( + children: [ + NooFileTile(kind: kind, size: NooFileTileSize.activity), + const SizedBox(width: NooSpace.sm), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + version.isCurrent + ? 'Current version' + : DateFormat.yMMMd().add_jm().format(version.timestamp), + style: NooText.bodyL.copyWith( + fontWeight: FontWeight.w500, + color: colors.fg1, + ), + ), + const SizedBox(height: 2), + Text( + '${formatBytes(version.size)} · ${DateFormat.yMMMd().format(version.timestamp)}', + style: NooText.meta.copyWith(color: colors.fg3), + ), + ], + ), + ), + if (!version.isCurrent) ...[ + const SizedBox(width: NooSpace.xs), + _RowIconButton( + icon: LucideIcons.rotateCcw, + tooltip: 'Restore', + onTap: onRestore, + ), + const SizedBox(width: NooSpace.xxs), + _RowIconButton( + icon: LucideIcons.download, + tooltip: 'Download', + onTap: onDownload, + ), + ], + ], + ), + ), + ); + } +} + +/// A small round icon button matching [NooCloseButton]'s visual language +/// (surface-2 circle) but for an arbitrary action - promote this to `noo/` +/// if another row-level icon action needs the same treatment. +class _RowIconButton extends StatelessWidget { + final IconData icon; + final String tooltip; + final VoidCallback onTap; + + const _RowIconButton({ + required this.icon, + required this.tooltip, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final colors = context.nooColors; + return Tooltip( + message: tooltip, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onTap, + child: Container( + width: 32, + height: 32, + alignment: Alignment.center, + decoration: BoxDecoration(color: colors.surface2, shape: BoxShape.circle), + child: Icon(icon, size: 16, color: colors.fg2), + ), + ), ); } } diff --git a/lib/widgets/files/file_breadcrumb_row.dart b/lib/widgets/files/file_breadcrumb_row.dart new file mode 100644 index 0000000..20f4088 --- /dev/null +++ b/lib/widgets/files/file_breadcrumb_row.dart @@ -0,0 +1,85 @@ +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import '../../theme/design_tokens.dart'; + +/// A noo-styled "Home / Folder / Sub-folder" trail for the Files/Offline +/// tabs - a private restyle of the shared `Breadcrumbs` widget (which other +/// screens still use in its old Material look), so this screen's crumbs use +/// noo tokens without touching that shared file. Same behaviour: every +/// crumb but the last is tappable, jumping back to that depth. +class FileBreadcrumbRow extends StatelessWidget { + final List pathStack; + final ValueChanged onTap; + + const FileBreadcrumbRow({ + super.key, + required this.pathStack, + required this.onTap, + }); + + String _labelFor(int index) { + if (index == 0) return 'Home'; + final segments = pathStack[index] + .split('/') + .where((s) => s.isNotEmpty) + .toList(); + return segments.isEmpty ? 'Home' : segments.last; + } + + @override + Widget build(BuildContext context) { + final colors = context.nooColors; + final lastIndex = pathStack.length - 1; + + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + for (var i = 0; i < pathStack.length; i++) ...[ + if (i > 0) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 2), + child: Icon( + LucideIcons.chevronRight, + size: 14, + color: colors.fg3, + ), + ), + if (i == lastIndex) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 6), + child: Text( + _labelFor(i), + style: NooText.body.copyWith( + fontSize: 13, + fontWeight: FontWeight.w600, + color: colors.fg1, + ), + ), + ) + else + // The whole pill is the tap target, not just the text. + InkWell( + onTap: () => onTap(i), + borderRadius: BorderRadius.circular(NooRadii.pill), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + child: Text( + _labelFor(i), + style: NooText.body.copyWith( + fontSize: 13, + fontWeight: FontWeight.w600, + color: colors.accentText, + ), + ), + ), + ), + ], + ], + ), + ); + } +} diff --git a/lib/widgets/files_controls_row.dart b/lib/widgets/files_controls_row.dart index 9597fea..553724c 100644 --- a/lib/widgets/files_controls_row.dart +++ b/lib/widgets/files_controls_row.dart @@ -1,16 +1,37 @@ import 'package:flutter/material.dart'; -import 'package:material_symbols_icons/symbols.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:provider/provider.dart'; import '../providers/files_controller.dart'; -import 'segmented_icon_toggle.dart'; -import 'sort_menu_button.dart'; +import '../theme/design_tokens.dart'; +import 'noo/core/noo_chip.dart'; +import 'noo/core/noo_segmented_control.dart'; +import 'noo/core/noo_toggle.dart'; +import 'noo/lists/noo_grouped_list.dart'; +import 'noo/lists/noo_settings_row.dart'; +import 'noo/overlays/noo_sheet.dart'; +import 'sort_menu_button.dart' show sortFieldLabel; -/// The Files tab's sort/hidden/scope/type-filter/view-mode controls row, -/// shared with the Offline tab so both read and write the exact same -/// [FilesController] prefs. [folderPath] picks which folder's sort applies -/// (the Files tab's current folder, or the folder being browsed offline). -/// [showStorageScope] is off for Offline - a local mirror has no -/// cloud/external distinction. +String _typeFilterLabel(FilesTypeFilter filter) { + switch (filter) { + case FilesTypeFilter.all: + return 'All types'; + case FilesTypeFilter.filesOnly: + return 'Files only'; + case FilesTypeFilter.foldersOnly: + return 'Folders only'; + } +} + +/// The Files tab's sort/filter/view-mode row (Noo Design System project's +/// Files screen: a sort chip, a filter chip, a spacer, then the list/grid +/// segmented toggle) - shared with the Offline tab, which reads and writes +/// the exact same [FilesController] prefs. [folderPath] picks which +/// folder's sort applies. [showStorageScope] is off for Offline - a local +/// mirror has no cloud/external distinction. +/// +/// Hidden-files and storage-scope, which have no home in the mockup's +/// simple two-chip row, live inside the filter chip's sheet instead of as +/// their own row controls. class FilesControlsRow extends StatelessWidget { final String folderPath; final bool showStorageScope; @@ -26,6 +47,9 @@ class FilesControlsRow extends StatelessWidget { @override Widget build(BuildContext context) { final files = context.watch(); + final filtersActive = files.filesTypeFilter != FilesTypeFilter.all || + files.showHiddenFiles || + (showStorageScope && files.storageScope != StorageScope.cloud); return SizedBox( height: 44, @@ -34,98 +58,29 @@ class FilesControlsRow extends StatelessWidget { scrollDirection: Axis.horizontal, child: Row( children: [ - IconButton( - icon: Icon( - files.sortAscendingFor(folderPath) - ? Icons.arrow_upward_rounded - : Icons.arrow_downward_rounded, - size: 20, - ), - visualDensity: VisualDensity.compact, - tooltip: files.sortAscendingFor(folderPath) - ? 'Ascending' - : 'Descending', - onPressed: () => files.toggleSortOrderFor(folderPath), - ), - // A plain width, not Expanded - this row scrolls horizontally, - // which gives every control unbounded width to lay out in, so a - // flex child would throw. - SizedBox( - width: 130, - child: SortMenuButton( - field: files.sortFieldFor(folderPath), - onChanged: (field) => files.setSortFieldFor(folderPath, field), - ), - ), - ToggleIconButton( - icon: files.showHiddenFiles - ? Icons.visibility_rounded - : Icons.visibility_off_rounded, - isSelected: files.showHiddenFiles, - onTap: () => files.toggleShowHiddenFiles(), - tooltip: 'Show hidden files', - ), - const SizedBox(width: 4), - if (showStorageScope) ...[ - SegmentedIconGroup( - children: [ - ToggleIconButton( - icon: Symbols.circles_rounded, - isSelected: files.storageScope == StorageScope.cloud, - onTap: () => files.setStorageScope(StorageScope.cloud), - tooltip: 'Cloud storage', - ), - ToggleIconButton( - icon: Symbols.hard_drive_rounded, - isSelected: files.storageScope == StorageScope.external, - onTap: () => files.setStorageScope(StorageScope.external), - tooltip: 'External storage', - ), - ], - ), - const SizedBox(width: 8), - ], - SegmentedIconGroup( - children: [ - ToggleIconButton( - icon: Icons.select_all_rounded, - isSelected: files.filesTypeFilter == FilesTypeFilter.all, - onTap: () => files.setFilesTypeFilter(FilesTypeFilter.all), - tooltip: 'Files & folders', - ), - ToggleIconButton( - icon: Icons.insert_drive_file_outlined, - isSelected: - files.filesTypeFilter == FilesTypeFilter.filesOnly, - onTap: () => - files.setFilesTypeFilter(FilesTypeFilter.filesOnly), - tooltip: 'Files only', - ), - ToggleIconButton( - icon: Icons.folder_outlined, - isSelected: - files.filesTypeFilter == FilesTypeFilter.foldersOnly, - onTap: () => - files.setFilesTypeFilter(FilesTypeFilter.foldersOnly), - tooltip: 'Folders only', - ), - ], + NooChip( + icon: files.sortAscendingFor(folderPath) + ? LucideIcons.arrowUp + : LucideIcons.arrowDown, + onTap: () => _showSortSheet(context, files), + child: Text(sortFieldLabel(files.sortFieldFor(folderPath))), ), const SizedBox(width: 8), - SegmentedIconGroup( - children: [ - ToggleIconButton( - icon: Icons.view_list_rounded, - isSelected: !files.isGridView, - onTap: () => files.setGridView(false), - tooltip: 'List view', - ), - ToggleIconButton( - icon: Icons.grid_view_rounded, - isSelected: files.isGridView, - onTap: () => files.setGridView(true), - tooltip: 'Grid view', - ), + NooChip( + icon: LucideIcons.filter, + trailing: NooChipTrailing.menu, + selected: filtersActive, + onTap: () => _showFilterSheet(context, files), + child: Text(_typeFilterLabel(files.filesTypeFilter)), + ), + const Spacer(), + NooSegmentedControl( + iconOnly: true, + value: files.isGridView, + onChanged: files.setGridView, + options: const [ + NooSegmentOption(value: false, icon: LucideIcons.list), + NooSegmentOption(value: true, icon: LucideIcons.grid), ], ), ], @@ -133,4 +88,97 @@ class FilesControlsRow extends StatelessWidget { ), ); } + + void _showSortSheet(BuildContext context, FilesController files) { + showNooSheet( + context, + children: [ + NooSegmentedControl( + fill: true, + value: files.sortAscendingFor(folderPath), + onChanged: (asc) { + if (asc != files.sortAscendingFor(folderPath)) { + files.toggleSortOrderFor(folderPath); + } + }, + options: const [ + NooSegmentOption(value: true, icon: LucideIcons.arrowUp, label: 'Ascending'), + NooSegmentOption(value: false, icon: LucideIcons.arrowDown, label: 'Descending'), + ], + ), + NooGroupedList( + children: [ + for (final field in FileSortField.values) + NooSettingsRow( + label: Text(sortFieldLabel(field)), + trailing: field == files.sortFieldFor(folderPath) + ? Icon(LucideIcons.check, size: 18, color: context.nooColors.accentText) + : null, + onTap: () { + files.setSortFieldFor(folderPath, field); + Navigator.pop(context); + }, + ), + ], + ), + ], + ); + } + + void _showFilterSheet(BuildContext context, FilesController files) { + showNooSheet( + context, + children: [ + NooGroupedList( + label: 'Show', + children: [ + NooSettingsRow( + label: const Text('Files and folders'), + trailing: files.filesTypeFilter == FilesTypeFilter.all + ? Icon(LucideIcons.check, size: 18, color: context.nooColors.accentText) + : null, + onTap: () => files.setFilesTypeFilter(FilesTypeFilter.all), + ), + NooSettingsRow( + label: const Text('Files only'), + trailing: files.filesTypeFilter == FilesTypeFilter.filesOnly + ? Icon(LucideIcons.check, size: 18, color: context.nooColors.accentText) + : null, + onTap: () => files.setFilesTypeFilter(FilesTypeFilter.filesOnly), + ), + NooSettingsRow( + label: const Text('Folders only'), + trailing: files.filesTypeFilter == FilesTypeFilter.foldersOnly + ? Icon(LucideIcons.check, size: 18, color: context.nooColors.accentText) + : null, + onTap: () => files.setFilesTypeFilter(FilesTypeFilter.foldersOnly), + ), + ], + ), + NooGroupedList( + children: [ + NooSettingsRow( + icon: LucideIcons.eye, + label: const Text('Show hidden files'), + trailing: NooToggle( + checked: files.showHiddenFiles, + onChanged: (_) => files.toggleShowHiddenFiles(), + ), + ), + if (showStorageScope) + NooSettingsRow( + icon: LucideIcons.hardDrive, + label: const Text('External storage'), + trailing: NooToggle( + checked: files.storageScope == StorageScope.external, + onChanged: (external) => files.setStorageScope( + external ? StorageScope.external : StorageScope.cloud, + ), + ), + ), + ], + ), + ], + ); + } } diff --git a/lib/widgets/floating_bottom_bar.dart b/lib/widgets/floating_bottom_bar.dart deleted file mode 100644 index b8d8298..0000000 --- a/lib/widgets/floating_bottom_bar.dart +++ /dev/null @@ -1,151 +0,0 @@ -import 'package:flutter/material.dart'; -import 'frosted_glass_container.dart'; - -class FloatingNavItem { - final String label; - final IconData icon; - - const FloatingNavItem({required this.label, required this.icon}); -} - -/// A floating pill-shaped bottom navigation bar in the style of Google -/// Photos' 2026 redesign: unselected destinations collapse to just their -/// icon, the selected destination expands into an icon+label capsule, and -/// a detached circular action (search) sits just outside the pill. -class FloatingBottomNavBar extends StatelessWidget { - final int selectedIndex; - final ValueChanged onDestinationSelected; - final List items; - final VoidCallback? onSearchTap; - final double opacity; - final double blurSigma; - - const FloatingBottomNavBar({ - super.key, - required this.selectedIndex, - required this.onDestinationSelected, - required this.items, - this.onSearchTap, - this.opacity = 0.55, - this.blurSigma = 28, - }); - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - - return SafeArea( - child: Align( - alignment: Alignment.bottomCenter, - child: Padding( - padding: const EdgeInsets.only(bottom: 20, left: 20, right: 20), - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 480), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - _buildPill(context, colorScheme), - const SizedBox(width: 10), - _buildSearchButton(context, colorScheme), - ], - ), - ), - ), - ), - ); - } - - Widget _buildPill(BuildContext context, ColorScheme colorScheme) { - return FrostedGlassContainer( - opacity: opacity, - blurSigma: blurSigma, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 6), - // The selected item's label expands the pill, and with enough - // tabs visible that can outgrow the available width (the - // ConstrainedBox above caps it at 480, but the screen itself may - // be narrower) - wrapped in a scroll view rather than a plain Row - // so it degrades to a swipe instead of overflowing/clipping. - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - physics: const BouncingScrollPhysics(), - child: Row( - mainAxisSize: MainAxisSize.min, - children: List.generate(items.length, (index) { - final item = items[index]; - final isSelected = selectedIndex == index; - - return InkWell( - onTap: () => onDestinationSelected(index), - borderRadius: BorderRadius.circular(26), - splashColor: colorScheme.primary.withValues(alpha: 0.12), - highlightColor: Colors.transparent, - child: AnimatedContainer( - duration: const Duration(milliseconds: 250), - curve: Curves.easeInOutCubic, - padding: EdgeInsets.symmetric( - horizontal: isSelected ? 16 : 12, - vertical: 12, - ), - decoration: BoxDecoration( - color: isSelected - ? colorScheme.primary - : Colors.transparent, - borderRadius: BorderRadius.circular(26), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - item.icon, - size: 22, - color: isSelected - ? colorScheme.onPrimary - : colorScheme.onSurfaceVariant, - ), - if (isSelected) ...[ - const SizedBox(width: 8), - Text( - item.label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w700, - color: colorScheme.onPrimary, - letterSpacing: 0.1, - ), - ), - ], - ], - ), - ), - ); - }), - ), - ), - ), - ); - } - - Widget _buildSearchButton(BuildContext context, ColorScheme colorScheme) { - return FrostedGlassContainer( - opacity: opacity, - blurSigma: blurSigma, - child: SizedBox( - width: 52, - height: 52, - child: InkWell( - onTap: onSearchTap, - borderRadius: BorderRadius.circular(32), - splashColor: colorScheme.primary.withValues(alpha: 0.12), - child: Icon( - Icons.search_rounded, - color: colorScheme.onSurfaceVariant, - size: 24, - ), - ), - ), - ); - } -} diff --git a/lib/widgets/item_icon.dart b/lib/widgets/item_icon.dart index 42c1f16..19c9c93 100644 --- a/lib/widgets/item_icon.dart +++ b/lib/widgets/item_icon.dart @@ -1,48 +1,73 @@ import 'dart:io'; import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../models/nextcloud_item.dart'; import '../services/nextcloud_service.dart'; +import '../theme/design_tokens.dart'; /// The icon/color/thumbnail treatment for a file or folder, shared by any /// screen that lists [NextcloudItem]s the same way the Files tab does (see /// `files_view.dart`, and `share_upload_view.dart`'s destination picker). +/// Matches DESIGN_SYSTEM.md 1.2's file-type tile table (`KIND`) - a pdf is +/// its own kind there (distinct from a generic document), which is why +/// these take the whole [NextcloudItem] rather than just its +/// [NextcloudItemType]: the type alone can't tell a pdf from any other +/// document. +bool _isPdf(NextcloudItem item) => item.name.toLowerCase().endsWith('.pdf'); -IconData getItemIcon(NextcloudItemType type) { - switch (type) { +IconData getItemIcon(NextcloudItem item) { + switch (item.type) { case NextcloudItemType.folder: - return Icons.folder_rounded; + return LucideIcons.folder; case NextcloudItemType.image: - return Icons.image_rounded; + return LucideIcons.image; case NextcloudItemType.video: - return Icons.movie_rounded; - case NextcloudItemType.audio: - return Icons.audiotrack_rounded; + return LucideIcons.film; case NextcloudItemType.document: - return Icons.description_rounded; + return LucideIcons.fileText; case NextcloudItemType.archive: - return Icons.folder_zip_rounded; + return LucideIcons.fileArchive; + case NextcloudItemType.audio: case NextcloudItemType.file: - return Icons.insert_drive_file_rounded; + return LucideIcons.file; } } -Color getIconColor(BuildContext context, NextcloudItemType type) { - final colorScheme = Theme.of(context).colorScheme; - switch (type) { +Color getIconColor(BuildContext context, NextcloudItem item) { + final colors = context.nooColors; + switch (item.type) { case NextcloudItemType.folder: - return colorScheme.primary; - case NextcloudItemType.image: - return Colors.amber.shade700; - case NextcloudItemType.video: - return Colors.deepOrange.shade600; - case NextcloudItemType.audio: - return Colors.purple.shade600; + return colors.accentText; case NextcloudItemType.document: - return Colors.blue.shade700; + return _isPdf(item) ? colors.danger : colors.info; + case NextcloudItemType.image: + return colors.info; + case NextcloudItemType.video: + return colors.warning; case NextcloudItemType.archive: - return Colors.teal.shade700; + case NextcloudItemType.audio: case NextcloudItemType.file: - return colorScheme.outline; + return colors.fg2; + } +} + +/// The tile's background (the soft-tinted square/circle behind the icon) - +/// the other half of the [getIconColor] pair every call site pairs it with. +Color getIconBackground(BuildContext context, NextcloudItem item) { + final colors = context.nooColors; + switch (item.type) { + case NextcloudItemType.folder: + return colors.accentSoft; + case NextcloudItemType.document: + return _isPdf(item) ? colors.dangerSoft : colors.infoSoft; + case NextcloudItemType.image: + return colors.infoSoft; + case NextcloudItemType.video: + return colors.warningSoft; + case NextcloudItemType.archive: + case NextcloudItemType.audio: + case NextcloudItemType.file: + return colors.surface3; } } @@ -74,7 +99,7 @@ class ItemThumbnail extends StatelessWidget { @override Widget build(BuildContext context) { - final iconColor = getIconColor(context, item.type); + final iconColor = getIconColor(context, item); final isMedia = item.type == NextcloudItemType.image || item.type == NextcloudItemType.video; @@ -91,7 +116,7 @@ class ItemThumbnail extends StatelessWidget { height: size, clipBehavior: Clip.antiAlias, decoration: BoxDecoration( - color: iconColor.withValues(alpha: 0.12), + color: getIconBackground(context, item), borderRadius: BorderRadius.circular(borderRadius), ), child: useLocal || (isMedia && item.previewUrl != null) @@ -116,7 +141,7 @@ class ItemThumbnail extends StatelessWidget { if (progress == null) return child; return Center( child: Icon( - getItemIcon(item.type), + getItemIcon(item), color: iconColor, size: iconSize, ), @@ -124,7 +149,7 @@ class ItemThumbnail extends StatelessWidget { }, errorBuilder: (context, error, stack) => Center( child: Icon( - getItemIcon(item.type), + getItemIcon(item), color: iconColor, size: iconSize, ), @@ -142,7 +167,7 @@ class ItemThumbnail extends StatelessWidget { ) : Center( child: Icon( - getItemIcon(item.type), + getItemIcon(item), color: iconColor, size: iconSize, ), diff --git a/lib/widgets/manage_synced_folders_sheet.dart b/lib/widgets/manage_synced_folders_sheet.dart index 83db338..53506ee 100644 --- a/lib/widgets/manage_synced_folders_sheet.dart +++ b/lib/widgets/manage_synced_folders_sheet.dart @@ -1,6 +1,11 @@ import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:provider/provider.dart'; import '../providers/sync_status_controller.dart'; +import '../theme/design_tokens.dart'; +import 'noo/lists/noo_grouped_list.dart'; +import 'noo/lists/noo_settings_row.dart'; +import 'noo/overlays/noo_sheet.dart'; /// The configured sync targets (folders/files) with a "stop syncing" /// action per target - what used to be Settings' Device Sync card's own @@ -14,16 +19,7 @@ class ManageSyncedFoldersSheet extends StatelessWidget { static void show(BuildContext context) { final sync = context.read(); - showModalBottomSheet( - context: context, - isScrollControlled: true, - backgroundColor: Theme.of(context).colorScheme.surfaceContainerHigh, - showDragHandle: true, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(28)), - ), - builder: (sheetContext) => ManageSyncedFoldersSheet(sync: sync), - ); + showNooSheet(context, children: [ManageSyncedFoldersSheet(sync: sync)]); } void _remove(BuildContext context, String path) { @@ -38,69 +34,52 @@ class ManageSyncedFoldersSheet extends StatelessWidget { @override Widget build(BuildContext context) { + final colors = context.nooColors; + // AnimatedBuilder, not a one-shot read: removing a target should update + // this list in place without closing the sheet, and showNooSheet's + // `children` is built once by the caller - this widget owns its own + // rebuild instead. return AnimatedBuilder( animation: sync, builder: (context, _) { - final theme = Theme.of(context); - final colorScheme = theme.colorScheme; final everything = sync.syncEverything; final targets = sync.syncedPaths; - return SafeArea( - child: Padding( - padding: const EdgeInsets.fromLTRB(20, 4, 20, 20), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Synced to this device', - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: 12), - if (everything) - Text( - 'Every folder in this account is being synced to this ' - 'device.', - style: theme.textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ) - else if (targets.isEmpty) - Text( - 'Nothing synced yet - select a folder or file in Files ' - 'and use "Sync to device" to mirror it here for offline ' - 'access.', - style: theme.textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ) - else - Flexible( - child: ListView.separated( - shrinkWrap: true, - itemCount: targets.length, - separatorBuilder: (_, _) => const Divider(height: 1), - itemBuilder: (context, index) { - final target = targets[index]; - return ListTile( - contentPadding: EdgeInsets.zero, - leading: const Icon(Icons.sync_rounded), - title: Text(target), - trailing: IconButton( - icon: const Icon(Icons.close_rounded), - tooltip: 'Stop syncing', - onPressed: () => _remove(context, target), - ), - ); - }, - ), - ), - ], + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Synced to this device', + style: NooText.cardTitle.copyWith(color: colors.fg1), ), - ), + const SizedBox(height: NooSpace.sm), + if (everything) + Text( + 'Every folder in this account is being synced to this device.', + style: NooText.body.copyWith(color: colors.fg3), + ) + else if (targets.isEmpty) + Text( + 'Nothing synced yet. Select a folder or file in Files and ' + 'use "Sync to device" to mirror it here for offline access.', + style: NooText.body.copyWith(color: colors.fg3), + ) + else + NooGroupedList( + children: [ + for (final target in targets) + NooSettingsRow( + icon: LucideIcons.folderSync, + label: Text(target), + trailing: IconButton( + icon: Icon(LucideIcons.x, size: 18, color: colors.fg3), + tooltip: 'Stop syncing', + onPressed: () => _remove(context, target), + ), + ), + ], + ), + ], ); }, ); diff --git a/lib/widgets/media_grid_tile.dart b/lib/widgets/media_grid_tile.dart deleted file mode 100644 index 2347ea9..0000000 --- a/lib/widgets/media_grid_tile.dart +++ /dev/null @@ -1,98 +0,0 @@ -import 'package:flutter/material.dart'; -import '../models/nextcloud_item.dart'; -import 'synced_header_scaffold.dart' show formatBytes; - -/// Grid content for an image/video: the actual preview fills the whole card -/// as a background, with the name/size legible over a bottom scrim - -/// matching a Google Photos-style grid instead of a small icon badge. -/// Shared by the Files and Offline tabs; they differ only in where the -/// pixels come from ([imageBuilder] - a server preview vs. the local -/// mirror file) and what to show if that fails ([fallbackBuilder]). -class MediaGridTile extends StatelessWidget { - final NextcloudItem item; - - /// Given the pixel width to decode at, returns the image to paint. - /// Decoding straight to the painted size (rather than the full 500x500 - /// server preview / full-resolution local file) avoids a real source of - /// scrolling jank. - final ImageProvider Function(int cachePixels) imageBuilder; - final WidgetBuilder fallbackBuilder; - - const MediaGridTile({ - super.key, - required this.item, - required this.imageBuilder, - required this.fallbackBuilder, - }); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - - return LayoutBuilder( - builder: (context, constraints) { - final cachePixels = - (constraints.maxWidth * MediaQuery.of(context).devicePixelRatio) - .round(); - return Stack( - fit: StackFit.expand, - children: [ - Image( - image: imageBuilder(cachePixels), - fit: BoxFit.cover, - filterQuality: FilterQuality.low, - gaplessPlayback: true, - errorBuilder: (ctx, err, stack) => fallbackBuilder(ctx), - ), - if (item.type == NextcloudItemType.video) - const Center( - child: Icon( - Icons.play_circle_fill_rounded, - color: Colors.white, - size: 36, - ), - ), - Positioned( - left: 0, - right: 0, - bottom: 0, - child: Container( - padding: const EdgeInsets.fromLTRB(10, 20, 10, 8), - decoration: const BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [Colors.transparent, Colors.black87], - ), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - item.name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.titleSmall?.copyWith( - color: Colors.white, - fontWeight: FontWeight.w600, - fontSize: 12, - ), - ), - Text( - formatBytes(item.size), - style: theme.textTheme.bodySmall?.copyWith( - color: Colors.white70, - fontSize: 10, - ), - ), - ], - ), - ), - ), - ], - ); - }, - ); - } -} diff --git a/lib/widgets/move_copy_conflict_sheet.dart b/lib/widgets/move_copy_conflict_sheet.dart index 4ff5a23..2549b59 100644 --- a/lib/widgets/move_copy_conflict_sheet.dart +++ b/lib/widgets/move_copy_conflict_sheet.dart @@ -1,6 +1,15 @@ import 'package:flutter/material.dart'; import '../models/move_copy_result.dart'; -import 'gradual_bottom_sheet.dart'; +import '../theme/design_tokens.dart'; +import 'noo/core/noo_button.dart'; +import 'noo/core/noo_segmented_control.dart'; +import 'noo/files/noo_file_kind.dart'; +import 'noo/files/noo_file_tile.dart'; +import 'noo/lists/noo_grouped_list.dart'; +import 'noo/noo_layout.dart'; +import 'noo/overlays/noo_dialog.dart'; +import 'noo/overlays/noo_overlay_header.dart'; +import 'noo/overlays/noo_sheet.dart'; /// Shown after a Move/Copy batch comes back with `MoveCopyResult.conflicts` /// non-empty (an item with the same name already exists at the @@ -8,27 +17,36 @@ import 'gradual_bottom_sheet.dart'; /// per item as they're hit, and lets the user resolve them all the same /// way (overwrite/keep both) or open a per-item breakdown. Returns the /// `ConflictChoice` map `ItemOperations.resolveConflicts` expects, or null -/// if dismissed without choosing. +/// if dismissed without choosing. A `showNooDialog` (desktop) / `showNooSheet` +/// (mobile) instead of the old `showGradualBottomSheet` - the content never +/// grows past a couple of screens, so the drag-to-resize sheet that needed +/// isn't worth it here. class MoveCopyConflictSheet extends StatefulWidget { final List conflicts; - final ScrollController? scrollController; - const MoveCopyConflictSheet({ - super.key, - required this.conflicts, - this.scrollController, - }); + const MoveCopyConflictSheet({super.key, required this.conflicts}); static Future?> show( BuildContext context, List conflicts, ) { - return showGradualBottomSheet>( + final content = MoveCopyConflictSheet(conflicts: conflicts); + final count = conflicts.length; + final title = count == 1 + ? '1 item already exists' + : '$count items already exist'; + + if (NooLayout.isDesktop(context)) { + return showNooDialog>( + context, + title: title, + subtitle: 'Choose how to handle the conflicts', + children: [content], + ); + } + return showNooSheet>( context, - builder: (context, scrollController) => MoveCopyConflictSheet( - conflicts: conflicts, - scrollController: scrollController, - ), + children: [content], ); } @@ -55,118 +73,151 @@ class _MoveCopyConflictSheetState extends State { @override Widget build(BuildContext context) { - final theme = Theme.of(context); - final colorScheme = theme.colorScheme; + final isDesktop = NooLayout.isDesktop(context); final count = widget.conflicts.length; - return ListView( - controller: widget.scrollController, - padding: const EdgeInsets.fromLTRB(20, 12, 20, 24), + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Text( - count == 1 - ? '1 item already exists at the destination' - : '$count items already exist at the destination', - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w700, + // The dialog already renders this title/subtitle in its own header + // (see `show` above); the sheet has no header of its own, so it + // needs one here. + if (!isDesktop) ...[ + NooOverlayHeader( + title: count == 1 + ? '1 item already exists' + : '$count items already exist', + subtitle: 'Choose how to handle the conflicts', + onClose: () => Navigator.pop(context), ), - ), - const SizedBox(height: 4), - Text( - 'Choose how to handle the conflicts.', - style: theme.textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 16), + const SizedBox(height: NooSpace.lg), + ], if (!_decidingPerItem) ...[ - FilledButton( - onPressed: () => _resolveAll(ConflictChoice.overwrite), + NooButton( + variant: NooButtonVariant.primary, + size: NooButtonSize.card, + fullWidth: true, + onTap: () => _resolveAll(ConflictChoice.overwrite), child: const Text('Overwrite all'), ), - const SizedBox(height: 8), - FilledButton.tonal( - onPressed: () => _resolveAll(ConflictChoice.keepBoth), + const SizedBox(height: NooSpace.xs), + NooButton( + variant: NooButtonVariant.tonal, + size: NooButtonSize.card, + fullWidth: true, + onTap: () => _resolveAll(ConflictChoice.keepBoth), child: const Text('Keep both (rename)'), ), - const SizedBox(height: 8), - OutlinedButton( - onPressed: () => setState(() => _decidingPerItem = true), + const SizedBox(height: NooSpace.xs), + NooButton( + variant: NooButtonVariant.outline, + size: NooButtonSize.card, + fullWidth: true, + onTap: () => setState(() => _decidingPerItem = true), child: const Text('Decide per item'), ), - const SizedBox(height: 16), - const Divider(), - const SizedBox(height: 8), + const SizedBox(height: NooSpace.lg), ], - for (final conflict in widget.conflicts) - Padding( - padding: const EdgeInsets.symmetric(vertical: 8), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon( - conflict.item.isFolder - ? Icons.folder_rounded - : Icons.insert_drive_file_rounded, - color: colorScheme.onSurfaceVariant, - size: 20, - ), - const SizedBox(width: 10), - Expanded( - child: Text( - conflict.item.name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.bodyMedium?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - ), - ], - ), - if (_decidingPerItem) ...[ - const SizedBox(height: 6), - SizedBox( - width: double.infinity, - child: SegmentedButton( - segments: const [ - ButtonSegment( - value: ConflictChoice.overwrite, - label: Text('Overwrite'), - ), - ButtonSegment( - value: ConflictChoice.keepBoth, - label: Text('Keep both'), - ), - ButtonSegment( - value: ConflictChoice.skip, - label: Text('Skip'), - ), - ], - selected: {_perItemChoices[conflict.item.id]!}, - showSelectedIcon: false, - onSelectionChanged: (selection) => setState(() { - _perItemChoices[conflict.item.id] = selection.first; - }), - ), - ), - ], - ], - ), - ), + NooGroupedList( + label: _decidingPerItem ? 'Items' : null, + children: [ + for (final conflict in widget.conflicts) + _ConflictRow( + conflict: conflict, + decidingPerItem: _decidingPerItem, + choice: _perItemChoices[conflict.item.id]!, + onChoiceChanged: (choice) => setState(() { + _perItemChoices[conflict.item.id] = choice; + }), + ), + ], + ), if (_decidingPerItem) ...[ - const SizedBox(height: 8), - SizedBox( - width: double.infinity, - child: FilledButton( - onPressed: _confirmPerItem, - child: const Text('Confirm'), - ), + const SizedBox(height: NooSpace.lg), + NooButton( + variant: NooButtonVariant.primary, + size: NooButtonSize.card, + fullWidth: true, + onTap: _confirmPerItem, + child: const Text('Confirm'), ), ], ], ); } } + +/// One conflicting item: its file-type tile and name, plus (once "Decide +/// per item" is active) a segmented overwrite/keep both/skip choice. +class _ConflictRow extends StatelessWidget { + final MoveCopyConflict conflict; + final bool decidingPerItem; + final ConflictChoice choice; + final ValueChanged onChoiceChanged; + + const _ConflictRow({ + required this.conflict, + required this.decidingPerItem, + required this.choice, + required this.onChoiceChanged, + }); + + @override + Widget build(BuildContext context) { + final colors = context.nooColors; + final item = conflict.item; + final kind = NooFileKind.from(name: item.name, isDirectory: item.isFolder); + + return Material( + color: colors.surface, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: NooSpace.md, + vertical: NooSpace.sm, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + NooFileTile(kind: kind), + const SizedBox(width: NooSpace.sm), + Expanded( + child: Text( + item.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: NooText.bodyL.copyWith( + fontWeight: FontWeight.w500, + color: colors.fg1, + ), + ), + ), + ], + ), + if (decidingPerItem) ...[ + const SizedBox(height: NooSpace.xs), + NooSegmentedControl( + fill: true, + onSurface: true, + value: choice, + onChanged: onChoiceChanged, + options: const [ + NooSegmentOption( + value: ConflictChoice.overwrite, + label: 'Overwrite', + ), + NooSegmentOption( + value: ConflictChoice.keepBoth, + label: 'Keep both', + ), + NooSegmentOption(value: ConflictChoice.skip, label: 'Skip'), + ], + ), + ], + ], + ), + ), + ); + } +} diff --git a/lib/widgets/noo/core/noo_avatar.dart b/lib/widgets/noo/core/noo_avatar.dart new file mode 100644 index 0000000..2d6fc5c --- /dev/null +++ b/lib/widgets/noo/core/noo_avatar.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; +import '../../../theme/design_tokens.dart'; + +/// Initials circle on a secondary-palette colour; the current user uses +/// accent-soft (Noo Design System project, `components/core/Avatar.jsx`). +/// Sizes: 30-32 in bars, 36 in lists, 48 in the drawer, 52-56 on the +/// account card. +class NooAvatar extends StatelessWidget { + final String? initials; + final double size; + /// One of [NooColors.avatarPalette]; ignored when [current] or [icon] is set. + final Color? color; + final bool current; + /// Show an icon instead of initials (e.g. a group share). + final IconData? icon; + + const NooAvatar({ + super.key, + this.initials, + this.size = 36, + this.color, + this.current = false, + this.icon, + }); + + @override + Widget build(BuildContext context) { + final colors = context.nooColors; + final Color bg; + final Color fg; + if (current) { + bg = colors.accentSoft; + fg = colors.accentText; + } else if (icon != null) { + bg = colors.surface2; + fg = colors.fg2; + } else { + bg = color ?? NooColors.avatarPalette.first; + fg = NooColors.avatarTextColor; + } + + return Container( + width: size, + height: size, + alignment: Alignment.center, + decoration: BoxDecoration(color: bg, shape: BoxShape.circle), + child: icon != null + ? Icon(icon, size: (size * 0.5).round().toDouble(), color: fg) + : Text( + initials ?? '', + style: GoogleFonts.instrumentSans( + fontWeight: FontWeight.w600, + fontSize: (size * 0.36).round().toDouble(), + height: 1, + color: fg, + ), + ), + ); + } +} diff --git a/lib/widgets/noo/core/noo_badge.dart b/lib/widgets/noo/core/noo_badge.dart new file mode 100644 index 0000000..622e0b8 --- /dev/null +++ b/lib/widgets/noo/core/noo_badge.dart @@ -0,0 +1,47 @@ +import 'package:flutter/material.dart'; +import '../../../theme/design_tokens.dart'; + +enum NooBadgeTone { success, danger, accent, warning, info } + +/// Small status pill, e.g. "Connected" in settings (Noo Design System +/// project, `components/core/Badge.jsx`). +class NooBadge extends StatelessWidget { + final NooBadgeTone tone; + final Widget child; + + const NooBadge({super.key, this.tone = NooBadgeTone.success, required this.child}); + + @override + Widget build(BuildContext context) { + final colors = context.nooColors; + final (bg, fg) = switch (tone) { + NooBadgeTone.success => (colors.successSoft, colors.success), + NooBadgeTone.danger => (colors.dangerSoft, colors.danger), + NooBadgeTone.accent => (colors.accentSoft, colors.accentText), + NooBadgeTone.warning => (colors.warningSoft, colors.warning), + NooBadgeTone.info => (colors.infoSoft, colors.info), + }; + return Container( + height: 24, + // Centering the text vertically within the fixed 24px height needs + // *some* alignment mechanism, but Container's own `alignment:` (with + // a height but no width) would expand to fill all available + // *bounded* width instead of shrink-wrapping (see NooFab's doc + // comment) - a Row with mainAxisSize.min sidesteps that entirely: + // it shrink-wraps horizontally regardless of incoming constraints, + // while its default crossAxisAlignment.center still centers the + // text within the Container's tight 24px height. + padding: const EdgeInsets.symmetric(horizontal: 10), + decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(NooRadii.pill)), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + DefaultTextStyle( + style: NooText.label.copyWith(fontSize: 12, height: 1, color: fg), + child: child, + ), + ], + ), + ); + } +} diff --git a/lib/widgets/noo/core/noo_button.dart b/lib/widgets/noo/core/noo_button.dart new file mode 100644 index 0000000..53a19d2 --- /dev/null +++ b/lib/widgets/noo/core/noo_button.dart @@ -0,0 +1,143 @@ +import 'package:flutter/material.dart'; +import '../../../theme/design_tokens.dart'; + +enum NooButtonVariant { primary, tonal, secondary, danger, outline, text, textDanger } + +enum NooButtonSize { cta, card, field, toolbar, compact, xs } + +class _SizeSpec { + final double height; + final double px; + final double font; + final double icon; + final double gap; + const _SizeSpec(this.height, this.px, this.font, this.icon, this.gap); +} + +const _sizes = { + NooButtonSize.cta: _SizeSpec(52, 20, 16, 20, 10), + NooButtonSize.card: _SizeSpec(44, 18, 15, 16, 8), + NooButtonSize.field: _SizeSpec(40, 16, 14, 16, 6), + NooButtonSize.toolbar: _SizeSpec(36, 16, 14, 16, 8), + NooButtonSize.compact: _SizeSpec(32, 14, 13, 14, 6), + NooButtonSize.xs: _SizeSpec(28, 12, 12, 14, 6), +}; + +/// The pill button for every action in the design system +/// (Noo Design System project, `components/core/Button.jsx`). Pick +/// [NooButtonVariant] by emphasis and [NooButtonSize] by context - never +/// build a bespoke `ElevatedButton`/`FilledButton` for app chrome; extend +/// this instead. +class NooButton extends StatefulWidget { + final NooButtonVariant variant; + final NooButtonSize size; + final IconData? icon; + final Widget? child; + final bool fullWidth; + final bool disabled; + final VoidCallback? onTap; + + const NooButton({ + super.key, + this.variant = NooButtonVariant.primary, + this.size = NooButtonSize.toolbar, + this.icon, + this.child, + this.fullWidth = false, + this.disabled = false, + this.onTap, + }); + + @override + State createState() => _NooButtonState(); +} + +class _NooButtonState extends State { + bool _down = false; + + (Color, Color, BoxBorder?) _colors(NooColors colors) { + switch (widget.variant) { + case NooButtonVariant.primary: + return (colors.accent, Colors.white, null); + case NooButtonVariant.tonal: + return (colors.accentSoft, colors.accentText, null); + case NooButtonVariant.secondary: + return (colors.surface2, colors.fg1, null); + case NooButtonVariant.danger: + return (colors.dangerSoft, colors.danger, null); + case NooButtonVariant.outline: + return ( + Colors.transparent, + colors.fg1, + Border.all(color: colors.surface3, width: 1.5), + ); + case NooButtonVariant.text: + return (Colors.transparent, colors.accentText, null); + case NooButtonVariant.textDanger: + return (Colors.transparent, colors.danger, null); + } + } + + bool get _isTextVariant => + widget.variant == NooButtonVariant.text || + widget.variant == NooButtonVariant.textDanger; + + @override + Widget build(BuildContext context) { + final colors = context.nooColors; + final s = _sizes[widget.size]!; + final (bg, fg, border) = _colors(colors); + final hasLeadingIcon = widget.icon != null && widget.child != null && !_isTextVariant; + + final content = Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (widget.icon != null) Icon(widget.icon, size: s.icon, color: fg), + if (widget.icon != null && widget.child != null) SizedBox(width: s.gap), + if (widget.child != null) + DefaultTextStyle( + style: NooText.buttonSm.copyWith(fontSize: s.font, color: fg), + child: widget.child!, + ), + ], + ); + + return Opacity( + opacity: widget.disabled ? 0.45 : 1, + child: GestureDetector( + onTapDown: widget.disabled ? null : (_) => setState(() => _down = true), + onTapUp: widget.disabled ? null : (_) => setState(() => _down = false), + onTapCancel: widget.disabled ? null : () => setState(() => _down = false), + onTap: widget.disabled ? null : widget.onTap, + child: AnimatedScale( + scale: _down && !widget.disabled ? NooMotion.pressScale : 1, + duration: NooMotion.fast, + curve: NooMotion.ease, + child: Container( + height: s.height, + width: widget.fullWidth ? double.infinity : null, + padding: _isTextVariant + ? EdgeInsets.zero + : EdgeInsets.only( + left: hasLeadingIcon ? s.px - 4 : s.px, + right: s.px, + ), + // Only align when fullWidth actually expands this Container - + // otherwise (no width, alignment set) Container wraps the child + // in an Align that fills all available *bounded* space instead + // of shrink-wrapping to it, which is what stretched this button + // edge-to-edge instead of sizing to its content (see NooFab's + // doc comment for the same bug). + alignment: widget.fullWidth ? Alignment.center : null, + decoration: BoxDecoration( + color: bg, + borderRadius: BorderRadius.circular(NooRadii.pill), + border: border, + ), + child: content, + ), + ), + ), + ); + } +} diff --git a/lib/widgets/noo/core/noo_chip.dart b/lib/widgets/noo/core/noo_chip.dart new file mode 100644 index 0000000..42c804e --- /dev/null +++ b/lib/widgets/noo/core/noo_chip.dart @@ -0,0 +1,92 @@ +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import '../../../theme/design_tokens.dart'; + +enum NooChipTrailing { none, menu, remove } + +/// A pill filter/sort chip; selected = accent-soft (Noo Design System +/// project, `components/core/Chip.jsx`). +class NooChip extends StatelessWidget { + final Widget child; + final IconData? icon; + final bool selected; + final NooChipTrailing trailing; + /// filled = mobile (surface fill, 34px); outline = desktop (1px line, 32px) + final bool outline; + final VoidCallback? onTap; + + const NooChip({ + super.key, + required this.child, + this.icon, + this.selected = false, + this.trailing = NooChipTrailing.none, + this.outline = false, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + final colors = context.nooColors; + final height = outline ? 32.0 : 34.0; + final hasSideContent = icon != null || trailing != NooChipTrailing.none; + + Color bg; + Color fg; + FontWeight weight; + Border? border; + if (selected) { + bg = colors.accentSoft; + fg = colors.accentText; + weight = FontWeight.w600; + } else if (outline) { + bg = Colors.transparent; + fg = colors.fg1; + weight = FontWeight.w500; + border = Border.all(color: colors.line, width: 1); + } else { + bg = colors.surface; + fg = colors.fg1; + weight = FontWeight.w500; + } + + return GestureDetector( + onTap: onTap, + child: Container( + height: height, + padding: EdgeInsets.symmetric(horizontal: hasSideContent ? 12 : 14), + decoration: BoxDecoration( + color: bg, + border: border, + borderRadius: BorderRadius.circular(NooRadii.pill), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + Icon(icon, size: outline ? 14 : 16, color: fg), + const SizedBox(width: 6), + ], + DefaultTextStyle( + style: NooText.body.copyWith( + fontSize: outline ? 13 : 14, + height: 1, + fontWeight: weight, + color: fg, + ), + child: child, + ), + if (trailing == NooChipTrailing.menu) ...[ + const SizedBox(width: 6), + Icon(LucideIcons.chevronDown, size: 14, color: fg), + ], + if (trailing == NooChipTrailing.remove) ...[ + const SizedBox(width: 6), + Icon(LucideIcons.x, size: 14, color: fg), + ], + ], + ), + ), + ); + } +} diff --git a/lib/widgets/noo/core/noo_fab.dart b/lib/widgets/noo/core/noo_fab.dart new file mode 100644 index 0000000..4d79175 --- /dev/null +++ b/lib/widgets/noo/core/noo_fab.dart @@ -0,0 +1,47 @@ +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import '../../../theme/design_tokens.dart'; + +/// Android-only extended Upload pill, bottom-right of Files and Photos (Noo +/// Design System project, `components/core/FAB.jsx`). 56px tall, accent +/// fill - iOS uses a `plus` in the nav bar instead (not yet built). +class NooFab extends StatelessWidget { + final String label; + final IconData icon; + final VoidCallback? onTap; + + const NooFab({super.key, this.label = 'Upload', this.icon = LucideIcons.plus, this.onTap}); + + @override + Widget build(BuildContext context) { + final colors = context.nooColors; + return Material( + color: colors.accent, + borderRadius: BorderRadius.circular(NooRadii.pill), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(NooRadii.pill), + child: Container( + height: 56, + padding: const EdgeInsets.fromLTRB(16, 0, 20, 0), + // No `alignment:` here - Container without an explicit width + // wraps its child in an Align when alignment is set, and Align + // EXPANDS to fill all available bounded space (not just the + // child's size) unless the incoming constraints are unbounded. + // The Scaffold floating-action-button slot gives bounded + // constraints, so this shipped as a full-width bar instead of a + // compact pill. Row+mainAxisSize.min already sizes correctly on + // its own; the fix is just not adding alignment back here. + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 24, color: Colors.white), + const SizedBox(width: 10), + Text(label, style: NooText.button.copyWith(color: Colors.white)), + ], + ), + ), + ), + ); + } +} diff --git a/lib/widgets/noo/core/noo_progress_bar.dart b/lib/widgets/noo/core/noo_progress_bar.dart new file mode 100644 index 0000000..fd43443 --- /dev/null +++ b/lib/widgets/noo/core/noo_progress_bar.dart @@ -0,0 +1,27 @@ +import 'package:flutter/material.dart'; +import '../../../theme/design_tokens.dart'; + +/// 6px pill meter for storage, cache and offline limits (Noo Design System +/// project, `components/core/ProgressBar.jsx`). +class NooProgressBar extends StatelessWidget { + /// 0-1 + final double value; + + const NooProgressBar({super.key, required this.value}); + + @override + Widget build(BuildContext context) { + final colors = context.nooColors; + return ClipRRect( + borderRadius: BorderRadius.circular(NooRadii.pill), + child: SizedBox( + height: 6, + child: LinearProgressIndicator( + value: value.clamp(0.0, 1.0), + backgroundColor: colors.surface3, + valueColor: AlwaysStoppedAnimation(colors.accent), + ), + ), + ); + } +} diff --git a/lib/widgets/noo/core/noo_search_field.dart b/lib/widgets/noo/core/noo_search_field.dart new file mode 100644 index 0000000..f29f39b --- /dev/null +++ b/lib/widgets/noo/core/noo_search_field.dart @@ -0,0 +1,84 @@ +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import '../../../theme/design_tokens.dart'; + +/// Pill search input with a leading icon (Noo Design System project, +/// `components/core/SearchField.jsx`). +class NooSearchField extends StatelessWidget { + final String placeholder; + final TextEditingController? controller; + final FocusNode? focusNode; + final ValueChanged? onChanged; + final ValueChanged? onSubmitted; + final bool autofocus; + /// true = iOS inline field (radius 12, 38px, 16px text, below the large + /// title); false = the generic 36px pill used everywhere else (also the + /// Android "expands from a top-bar icon" search field once expanded). + final bool ios; + /// True when placed directly on a `surface` background (uses surface-2 + /// fill instead of surface, so it doesn't disappear against it). + final bool onSurface; + final double? width; + /// A trailing widget (e.g. a clear button), shown after the input. + final Widget? trailing; + + const NooSearchField({ + super.key, + this.placeholder = 'Search', + this.controller, + this.focusNode, + this.onChanged, + this.onSubmitted, + this.autofocus = false, + this.ios = false, + this.onSurface = false, + this.width, + this.trailing, + }); + + @override + Widget build(BuildContext context) { + final colors = context.nooColors; + return Container( + width: width, + height: ios ? 38 : 36, + padding: EdgeInsets.symmetric(horizontal: ios ? 10 : 14), + decoration: BoxDecoration( + color: onSurface ? colors.surface2 : colors.surface, + borderRadius: BorderRadius.circular(ios ? 12 : NooRadii.pill), + ), + child: Row( + children: [ + Icon(LucideIcons.search, size: ios ? 18 : 16, color: colors.fg3), + const SizedBox(width: 8), + Expanded( + child: TextField( + controller: controller, + focusNode: focusNode, + onChanged: onChanged, + onSubmitted: onSubmitted, + autofocus: autofocus, + style: NooText.body.copyWith( + fontSize: ios ? 16 : 14, + height: 1, + color: colors.fg1, + ), + decoration: InputDecoration( + isDense: true, + border: InputBorder.none, + contentPadding: EdgeInsets.zero, + hintText: placeholder, + hintStyle: NooText.body.copyWith( + fontSize: ios ? 16 : 14, + height: 1, + color: colors.fg3, + ), + ), + ), + ), + ?trailing, + ], + ), + ); + } +} diff --git a/lib/widgets/noo/core/noo_segmented_control.dart b/lib/widgets/noo/core/noo_segmented_control.dart new file mode 100644 index 0000000..5158abb --- /dev/null +++ b/lib/widgets/noo/core/noo_segmented_control.dart @@ -0,0 +1,95 @@ +import 'package:flutter/material.dart'; +import '../../../theme/design_tokens.dart'; + +class NooSegmentOption { + final T value; + final String? label; + final IconData? icon; + + const NooSegmentOption({required this.value, this.label, this.icon}); +} + +enum NooSegmentedSize { md, sm, xs } + +const _heights = {NooSegmentedSize.md: 34.0, NooSegmentedSize.sm: 30.0, NooSegmentedSize.xs: 28.0}; + +/// Pill track with an accent-soft active segment - List/Grid, Shares scope, +/// Theme (Noo Design System project, `components/core/SegmentedControl.jsx`). +/// Replaces the older `SegmentedIconGroup`/`ToggleIconButton` pair; migrate +/// call sites to this as they're touched rather than adding new ones there. +class NooSegmentedControl extends StatelessWidget { + final List> options; + final T value; + final ValueChanged? onChanged; + /// Use surface on bg, surface-2 on surface. + final bool onSurface; + final NooSegmentedSize size; + final bool fill; + final bool iconOnly; + + const NooSegmentedControl({ + super.key, + required this.options, + required this.value, + this.onChanged, + this.onSurface = false, + this.size = NooSegmentedSize.md, + this.fill = false, + this.iconOnly = false, + }); + + @override + Widget build(BuildContext context) { + final colors = context.nooColors; + final h = iconOnly ? 28.0 : (_heights[size] ?? 34.0); + final segments = options.map((o) { + final on = o.value == value; + final child = Container( + height: h, + width: iconOnly ? 36 : null, + padding: iconOnly || fill ? EdgeInsets.zero : const EdgeInsets.symmetric(horizontal: 12), + alignment: Alignment.center, + decoration: BoxDecoration( + color: on ? colors.accentSoft : Colors.transparent, + borderRadius: BorderRadius.circular(NooRadii.pill), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (o.icon != null) + Icon( + o.icon, + size: iconOnly ? (size == NooSegmentedSize.md ? 18 : 16) : 14, + color: on ? colors.accentText : colors.fg2, + ), + if (o.icon != null && !iconOnly && o.label != null) const SizedBox(width: 6), + if (!iconOnly && o.label != null) + Text( + o.label!, + style: NooText.body.copyWith( + fontSize: size == NooSegmentedSize.md ? 14 : 13, + height: 1, + fontWeight: on ? FontWeight.w600 : FontWeight.w500, + color: on ? colors.accentText : colors.fg2, + ), + ), + ], + ), + ); + final tappable = GestureDetector( + onTap: onChanged == null ? null : () => onChanged!(o.value), + child: child, + ); + return fill ? Expanded(child: tappable) : tappable; + }).toList(); + + return Container( + padding: const EdgeInsets.all(3), + decoration: BoxDecoration( + color: onSurface ? colors.surface2 : colors.surface, + borderRadius: BorderRadius.circular(NooRadii.pill), + ), + child: Row(mainAxisSize: fill ? MainAxisSize.max : MainAxisSize.min, children: segments), + ); + } +} diff --git a/lib/widgets/noo/core/noo_toggle.dart b/lib/widgets/noo/core/noo_toggle.dart new file mode 100644 index 0000000..d5ab30b --- /dev/null +++ b/lib/widgets/noo/core/noo_toggle.dart @@ -0,0 +1,54 @@ +import 'package:flutter/material.dart'; +import '../../../theme/design_tokens.dart'; + +/// On/off switch; accent track on, surface-3 track off, white knob - +/// identical on every platform, never a native iOS/Android switch (Noo +/// Design System project, `components/core/Toggle.jsx`). +class NooToggle extends StatelessWidget { + final bool checked; + final ValueChanged? onChanged; + final bool desktop; + + const NooToggle({ + super.key, + required this.checked, + this.onChanged, + this.desktop = false, + }); + + @override + Widget build(BuildContext context) { + final colors = context.nooColors; + final w = desktop ? 44.0 : 48.0; + final h = desktop ? 26.0 : 28.0; + final knob = h - 6; + + return GestureDetector( + onTap: onChanged == null ? null : () => onChanged!(!checked), + child: AnimatedContainer( + duration: NooMotion.fast, + curve: NooMotion.ease, + width: w, + height: h, + padding: const EdgeInsets.all(3), + decoration: BoxDecoration( + color: checked ? colors.accent : colors.surface3, + borderRadius: BorderRadius.circular(NooRadii.pill), + ), + child: AnimatedAlign( + duration: NooMotion.fast, + curve: NooMotion.ease, + alignment: checked ? Alignment.centerRight : Alignment.centerLeft, + child: Container( + width: knob, + height: knob, + decoration: const BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + ), + ), + ), + ), + ); + } +} diff --git a/lib/widgets/noo/files/noo_file_kind.dart b/lib/widgets/noo/files/noo_file_kind.dart new file mode 100644 index 0000000..1054224 --- /dev/null +++ b/lib/widgets/noo/files/noo_file_kind.dart @@ -0,0 +1,188 @@ +import 'package:flutter/widgets.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import '../../../theme/design_tokens.dart'; + +/// File-type buckets from `DESIGN_SYSTEM.md` 1.2 (`KIND` in `noo-kit.js`). +/// Drives the tile tint + icon in `NooFileTile`, `NooFileRow` and +/// `NooFileTableRow`. [other] isn't in the spec table - it's the neutral +/// fallback (archive colors, plain `file` icon) for anything the table +/// doesn't cover (audio, binaries, unknown extensions) so those don't +/// masquerade as an archive or a document. +enum NooFileKind { + folder, + pdf, + doc, + sheet, + video, + image, + archive, + other; + + /// Lucide icon for this kind. + IconData get icon { + switch (this) { + case NooFileKind.folder: + return LucideIcons.folder; + case NooFileKind.pdf: + case NooFileKind.doc: + return LucideIcons.fileText; + case NooFileKind.sheet: + return LucideIcons.sheet; + case NooFileKind.video: + return LucideIcons.film; + case NooFileKind.image: + return LucideIcons.image; + case NooFileKind.archive: + return LucideIcons.fileArchive; + case NooFileKind.other: + return LucideIcons.file; + } + } + + /// Tile background - the soft half of the pair. + Color background(NooColors colors) { + switch (this) { + case NooFileKind.folder: + return colors.accentSoft; + case NooFileKind.pdf: + return colors.dangerSoft; + case NooFileKind.doc: + case NooFileKind.image: + return colors.infoSoft; + case NooFileKind.sheet: + return colors.successSoft; + case NooFileKind.video: + return colors.warningSoft; + case NooFileKind.archive: + case NooFileKind.other: + return colors.surface3; + } + } + + /// Icon color - always the strong half of [background]'s pair. + Color foreground(NooColors colors) { + switch (this) { + case NooFileKind.folder: + return colors.accentText; + case NooFileKind.pdf: + return colors.danger; + case NooFileKind.doc: + case NooFileKind.image: + return colors.info; + case NooFileKind.sheet: + return colors.success; + case NooFileKind.video: + return colors.warning; + case NooFileKind.archive: + case NooFileKind.other: + return colors.fg2; + } + } + + /// Derives a kind from whatever the caller has: [isDirectory] wins, then + /// [mimeType] (e.g. WebDAV `getcontenttype`), then [name]'s extension. + static NooFileKind from({ + String? name, + String? mimeType, + bool isDirectory = false, + }) { + final mime = mimeType?.toLowerCase().trim(); + if (isDirectory || mime == 'httpd/unix-directory') { + return NooFileKind.folder; + } + if (mime != null && mime.isNotEmpty) { + final byMime = _fromMime(mime); + if (byMime != null) return byMime; + } + if (name != null) { + final dot = name.lastIndexOf('.'); + if (dot >= 0 && dot < name.length - 1) { + final byExt = _extensions[name.substring(dot + 1).toLowerCase()]; + if (byExt != null) return byExt; + } + } + return NooFileKind.other; + } + + static NooFileKind? _fromMime(String mime) { + if (mime == 'application/pdf') return NooFileKind.pdf; + if (mime.startsWith('image/')) return NooFileKind.image; + if (mime.startsWith('video/')) return NooFileKind.video; + if (mime.contains('spreadsheet') || + mime.contains('ms-excel') || + mime == 'text/csv') { + return NooFileKind.sheet; + } + if (mime.contains('zip') || + mime.contains('compressed') || + mime.contains('x-tar') || + mime.contains('x-7z') || + mime.contains('x-rar') || + mime.contains('gzip')) { + return NooFileKind.archive; + } + if (mime.startsWith('text/') || + mime.contains('wordprocessing') || + mime.contains('msword') || + mime.contains('opendocument.text') || + mime.contains('presentation') || + mime.contains('powerpoint') || + mime == 'application/rtf') { + return NooFileKind.doc; + } + // Generic types (application/octet-stream etc.) fall through to the + // extension check. + return null; + } + + static const _extensions = { + 'pdf': NooFileKind.pdf, + // doc / text + 'doc': NooFileKind.doc, + 'docx': NooFileKind.doc, + 'odt': NooFileKind.doc, + 'rtf': NooFileKind.doc, + 'txt': NooFileKind.doc, + 'md': NooFileKind.doc, + 'ppt': NooFileKind.doc, + 'pptx': NooFileKind.doc, + 'odp': NooFileKind.doc, + 'pages': NooFileKind.doc, + // sheet + 'xls': NooFileKind.sheet, + 'xlsx': NooFileKind.sheet, + 'ods': NooFileKind.sheet, + 'csv': NooFileKind.sheet, + 'numbers': NooFileKind.sheet, + // video + 'mp4': NooFileKind.video, + 'mov': NooFileKind.video, + 'mkv': NooFileKind.video, + 'webm': NooFileKind.video, + 'avi': NooFileKind.video, + 'm4v': NooFileKind.video, + '3gp': NooFileKind.video, + // image + 'jpg': NooFileKind.image, + 'jpeg': NooFileKind.image, + 'png': NooFileKind.image, + 'gif': NooFileKind.image, + 'webp': NooFileKind.image, + 'heic': NooFileKind.image, + 'heif': NooFileKind.image, + 'bmp': NooFileKind.image, + 'svg': NooFileKind.image, + 'tif': NooFileKind.image, + 'tiff': NooFileKind.image, + 'dng': NooFileKind.image, + // archive + 'zip': NooFileKind.archive, + 'tar': NooFileKind.archive, + 'gz': NooFileKind.archive, + 'tgz': NooFileKind.archive, + 'bz2': NooFileKind.archive, + 'xz': NooFileKind.archive, + '7z': NooFileKind.archive, + 'rar': NooFileKind.archive, + }; +} diff --git a/lib/widgets/noo/files/noo_file_row.dart b/lib/widgets/noo/files/noo_file_row.dart new file mode 100644 index 0000000..88461ad --- /dev/null +++ b/lib/widgets/noo/files/noo_file_row.dart @@ -0,0 +1,193 @@ +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import '../../../theme/design_tokens.dart'; +import 'noo_file_kind.dart'; +import 'noo_file_tile.dart'; +import 'noo_status_icon.dart'; + +/// Mobile 64px file row (`DESIGN_SYSTEM.md` 2, "File row"): 40px tile, name +/// (16/500, one line) over a meta line (`[status][shared] size · modified`, +/// 13 fg-3), an optional favorite star / [trailing] widget, then the +/// overflow button. Paints its own `surface` background (accent-soft when +/// [selected]), so it can sit directly in a list or inside a +/// `NooSwipeAction`. +class NooFileRow extends StatelessWidget { + final NooFileKind kind; + final String name; + + /// Formatted size, e.g. "2.4 MB". Joined to [modified] with " · ". + final String? size; + + /// Formatted modified time, e.g. "Yesterday". + final String? modified; + + /// Replaces the "size · modified" text entirely - use it for the error + /// hint ("Couldn't sync · Tap to retry") or screen-specific meta (Trash's + /// "Deleted 3 days ago"). + final String? meta; + + /// Leading meta-line icons, drawn in order (spec order is sync status, + /// then shared). When this contains [NooSyncStatus.error] the meta text + /// turns `danger`. + final List statuses; + + /// Shows the accent-text star before the overflow button. + final bool favorite; + + /// Screen-specific trailing icon (e.g. `rotate-ccw` in Trash), placed + /// after the favorite star and before the overflow button. + final Widget? trailing; + + /// `ellipsis` on iOS, `ellipsis-vertical` everywhere else. + final bool iosStyle; + final bool selected; + + /// Real image/video thumbnail for the tile; see [NooFileTile.thumbnail]. + final Widget? thumbnail; + final VoidCallback? onTap; + final VoidCallback? onLongPress; + + /// Overflow menu tap. The button is hidden when null. + final VoidCallback? onMore; + + const NooFileRow({ + super.key, + required this.kind, + required this.name, + this.size, + this.modified, + this.meta, + this.statuses = const [], + this.favorite = false, + this.trailing, + this.iosStyle = false, + this.selected = false, + this.thumbnail, + this.onTap, + this.onLongPress, + this.onMore, + }); + + @override + Widget build(BuildContext context) { + final colors = context.nooColors; + final metaText = + meta ?? + [ + size, + modified, + ].whereType().where((s) => s.isNotEmpty).join(' · '); + final hasError = statuses.contains(NooSyncStatus.error); + + return Material( + color: selected ? colors.accentSoft : colors.surface, + child: InkWell( + onTap: onTap, + onLongPress: onLongPress, + child: SizedBox( + height: NooSizes.rowMobile, + child: Padding( + padding: EdgeInsets.only( + left: NooSpace.md, + right: onMore != null ? NooSpace.xxs : NooSpace.md, + ), + child: Row( + children: [ + NooFileTile(kind: kind, thumbnail: thumbnail), + const SizedBox(width: NooSpace.sm), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: NooText.bodyL.copyWith( + fontWeight: FontWeight.w500, + color: colors.fg1, + ), + ), + if (statuses.isNotEmpty || metaText.isNotEmpty) ...[ + const SizedBox(height: NooSpace.xxs), + Row( + children: [ + for (final s in statuses) ...[ + NooStatusIcon(s), + const SizedBox(width: NooSpace.xxs), + ], + Expanded( + child: Text( + metaText, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: NooText.meta.copyWith( + color: hasError ? colors.danger : colors.fg3, + ), + ), + ), + ], + ), + ], + ], + ), + ), + if (favorite) ...[ + const SizedBox(width: NooSpace.xs), + Icon(LucideIcons.star, size: 18, color: colors.accentText), + ], + if (trailing != null) ...[ + const SizedBox(width: NooSpace.xs), + IconTheme.merge( + data: IconThemeData(size: 18, color: colors.fg3), + child: trailing!, + ), + ], + if (onMore != null) + NooOverflowButton(iosStyle: iosStyle, onTap: onMore), + ], + ), + ), + ), + ), + ); + } +} + +/// The 40px round overflow ("more") hit target used at the end of file +/// rows - `ellipsis` when [iosStyle], `ellipsis-vertical` otherwise, 20px +/// in fg-3. +class NooOverflowButton extends StatelessWidget { + final bool iosStyle; + final double size; + final VoidCallback? onTap; + + const NooOverflowButton({ + super.key, + this.iosStyle = false, + this.size = 40, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + final colors = context.nooColors; + return SizedBox.square( + dimension: size, + child: Material( + type: MaterialType.transparency, + shape: const CircleBorder(), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + child: Icon( + iosStyle ? LucideIcons.ellipsis : LucideIcons.ellipsisVertical, + size: size >= 40 ? 20 : 18, + color: colors.fg3, + ), + ), + ), + ); + } +} diff --git a/lib/widgets/noo/files/noo_file_table.dart b/lib/widgets/noo/files/noo_file_table.dart new file mode 100644 index 0000000..5a13d97 --- /dev/null +++ b/lib/widgets/noo/files/noo_file_table.dart @@ -0,0 +1,243 @@ +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import '../../../theme/design_tokens.dart'; +import 'noo_file_kind.dart'; +import 'noo_file_row.dart'; +import 'noo_file_tile.dart'; +import 'noo_status_icon.dart'; + +/// Fixed widths of the desktop file table's grid +/// (`minmax(0,1fr) 180px 160px 120px`, `DESIGN_SYSTEM.md` 2 "File row"). +/// The first column takes the remaining width. +const double _col2Width = 180; +const double _col3Width = 160; +const double _col4Width = 120; + +/// Inset shared by header and rows so their columns line up; also the +/// padding inside a selected row's r12 fill. +const double _tableInset = NooSpace.sm; + +/// Lays out the four table columns. Shared by [NooFileTableHeader] and +/// [NooFileTableRow] so the grid can't drift between them. +class _TableColumns extends StatelessWidget { + final Widget name; + final Widget col2; + final Widget col3; + final Widget col4; + + const _TableColumns({ + required this.name, + required this.col2, + required this.col3, + required this.col4, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: _tableInset), + child: Row( + children: [ + Expanded(child: name), + SizedBox(width: _col2Width, child: col2), + SizedBox(width: _col3Width, child: col3), + SizedBox(width: _col4Width, child: col4), + ], + ), + ); + } +} + +/// Desktop file table header: 36px, 12/600 fg-3 labels, and a sort arrow +/// on the active column. The fourth (status/overflow) column is unlabelled. +class NooFileTableHeader extends StatelessWidget { + /// Label for the flexible Name column. + final String nameLabel; + + /// Labels for the 180px and 160px data columns, e.g. "Modified", "Size". + final String col2Label; + final String col3Label; + + /// Active sort column - 0 = name, 1 = col2, 2 = col3 - or null for none. + final int? sortColumn; + final bool sortAscending; + + /// Called with the tapped column's index; header cells aren't tappable + /// when null. + final ValueChanged? onSort; + + const NooFileTableHeader({ + super.key, + this.nameLabel = 'Name', + required this.col2Label, + required this.col3Label, + this.sortColumn, + this.sortAscending = true, + this.onSort, + }); + + Widget _cell(BuildContext context, int index) { + final colors = context.nooColors; + final style = NooText.label.copyWith( + fontSize: 12, + height: 1, + color: colors.fg3, + ); + final active = sortColumn == index; + final content = Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: Text( + [nameLabel, col2Label, col3Label][index], + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: style, + ), + ), + if (active) ...[ + const SizedBox(width: NooSpace.xxs), + Icon( + sortAscending ? LucideIcons.arrowUp : LucideIcons.arrowDown, + size: 12, + color: colors.fg3, + ), + ], + ], + ); + return Align( + alignment: Alignment.centerLeft, + child: onSort == null + ? content + : GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => onSort!(index), + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: content, + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 36, + child: _TableColumns( + name: _cell(context, 0), + col2: _cell(context, 1), + col3: _cell(context, 2), + col4: const SizedBox.shrink(), + ), + ); + } +} + +/// Desktop file table row: 52px, 30px tile + name (14/500) in the flexible +/// column, two meta-style data columns, then status icons and the overflow +/// button right-aligned in the 120px column. Selected rows get an +/// accent-soft r12 fill; otherwise the row is transparent over the pane. +class NooFileTableRow extends StatelessWidget { + final NooFileKind kind; + final String name; + + /// Second and third column contents, e.g. modified time and size. + final String? col2; + final String? col3; + + /// Status icons in the last column, drawn before the favorite star. + final List statuses; + final bool favorite; + final bool selected; + final Widget? thumbnail; + final VoidCallback? onTap; + final VoidCallback? onDoubleTap; + final VoidCallback? onSecondaryTap; + + /// Overflow menu tap. The button is hidden when null. + final VoidCallback? onMore; + + const NooFileTableRow({ + super.key, + required this.kind, + required this.name, + this.col2, + this.col3, + this.statuses = const [], + this.favorite = false, + this.selected = false, + this.thumbnail, + this.onTap, + this.onDoubleTap, + this.onSecondaryTap, + this.onMore, + }); + + @override + Widget build(BuildContext context) { + final colors = context.nooColors; + final metaStyle = NooText.meta.copyWith(color: colors.fg3); + Widget dataCell(String? text) => Text( + text ?? '', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: metaStyle, + ); + + return Material( + color: selected ? colors.accentSoft : Colors.transparent, + borderRadius: BorderRadius.circular(NooRadii.sidebarItem), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + onDoubleTap: onDoubleTap, + onSecondaryTap: onSecondaryTap, + child: SizedBox( + height: NooSizes.rowDesktop, + child: _TableColumns( + name: Row( + children: [ + NooFileTile( + kind: kind, + size: NooFileTileSize.desktop, + thumbnail: thumbnail, + ), + const SizedBox(width: NooSpace.sm), + Expanded( + child: Text( + name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: NooText.body.copyWith( + fontSize: 14, + fontWeight: FontWeight.w500, + color: colors.fg1, + ), + ), + ), + const SizedBox(width: NooSpace.md), + ], + ), + col2: dataCell(col2), + col3: dataCell(col3), + col4: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + for (final s in statuses) ...[ + NooStatusIcon(s), + const SizedBox(width: 6), + ], + if (favorite) ...[ + Icon(LucideIcons.star, size: 14, color: colors.accentText), + const SizedBox(width: 6), + ], + if (onMore != null) NooOverflowButton(size: 32, onTap: onMore), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/widgets/noo/files/noo_file_tile.dart b/lib/widgets/noo/files/noo_file_tile.dart new file mode 100644 index 0000000..1e0f8d7 --- /dev/null +++ b/lib/widgets/noo/files/noo_file_tile.dart @@ -0,0 +1,74 @@ +import 'package:flutter/material.dart'; +import '../../../theme/design_tokens.dart'; +import 'noo_file_kind.dart'; + +/// The four tile sizes from `DESIGN_SYSTEM.md` 1.2 - pick by context; the +/// icon is always half the tile. +enum NooFileTileSize { + /// 40px / r12 - mobile file rows. + row(40, 12), + + /// 30px / r9 - desktop table rows. + desktop(30, 9), + + /// 44px / r12 - share sheet/dialog header. + header(44, 12), + + /// 32px / r10 - activity items. + activity(32, 10); + + final double extent; + final double radius; + const NooFileTileSize(this.extent, this.radius); + + double get iconSize => extent / 2; +} + +/// Rounded-square file-type tile: the kind's soft tint with its Lucide icon +/// in the strong tint (`DESIGN_SYSTEM.md` 1.2). Pass [thumbnail] (e.g. an +/// `Image`) to show real content instead - it's clipped to the same shape +/// and cover-fit over the kind's tint, which shows while it loads. +class NooFileTile extends StatelessWidget { + final NooFileKind kind; + final NooFileTileSize size; + final Widget? thumbnail; + + const NooFileTile({ + super.key, + required this.kind, + this.size = NooFileTileSize.row, + this.thumbnail, + }); + + @override + Widget build(BuildContext context) { + final colors = context.nooColors; + final radius = BorderRadius.circular(size.radius); + return SizedBox.square( + dimension: size.extent, + child: thumbnail != null + ? ClipRRect( + borderRadius: radius, + child: ColoredBox( + color: kind.background(colors), + child: SizedBox.expand( + child: FittedBox(fit: BoxFit.cover, child: thumbnail), + ), + ), + ) + : DecoratedBox( + decoration: BoxDecoration( + color: kind.background(colors), + borderRadius: radius, + ), + child: Center( + child: Icon( + kind.icon, + size: size.iconSize, + color: kind.foreground(colors), + ), + ), + ), + ); + } +} diff --git a/lib/widgets/noo/files/noo_status_icon.dart b/lib/widgets/noo/files/noo_status_icon.dart new file mode 100644 index 0000000..806be8a --- /dev/null +++ b/lib/widgets/noo/files/noo_status_icon.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import '../../../theme/design_tokens.dart'; + +/// Per-file status markers for a file row's meta line or a table row's +/// status column (`DESIGN_SYSTEM.md` 2, "Status icons"). +enum NooSyncStatus { + synced, + syncing, + error, + shared; + + IconData get icon { + switch (this) { + case NooSyncStatus.synced: + return LucideIcons.circleCheck; + case NooSyncStatus.syncing: + return LucideIcons.refreshCw; + case NooSyncStatus.error: + return LucideIcons.circleAlert; + case NooSyncStatus.shared: + return LucideIcons.users; + } + } + + Color color(NooColors colors) { + switch (this) { + case NooSyncStatus.synced: + return colors.success; + case NooSyncStatus.syncing: + return colors.accentText; + case NooSyncStatus.error: + return colors.danger; + case NooSyncStatus.shared: + return colors.fg3; + } + } +} + +/// A single 14px status icon. For [NooSyncStatus.error] the row's meta +/// text should say what to do ("Couldn't sync · Tap to retry") - this only +/// draws the icon. +class NooStatusIcon extends StatelessWidget { + final NooSyncStatus status; + final double size; + + const NooStatusIcon(this.status, {super.key, this.size = 14}); + + @override + Widget build(BuildContext context) { + return Icon( + status.icon, + size: size, + color: status.color(context.nooColors), + ); + } +} diff --git a/lib/widgets/noo/files/noo_swipe_action.dart b/lib/widgets/noo/files/noo_swipe_action.dart new file mode 100644 index 0000000..dcc2f9a --- /dev/null +++ b/lib/widgets/noo/files/noo_swipe_action.dart @@ -0,0 +1,242 @@ +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import '../../../theme/design_tokens.dart'; + +/// The two swipe actions the design system defines (`DESIGN_SYSTEM.md` 2, +/// "Swipe action"); which one sits on which side is a user setting +/// (Settings -> Swipe on a file). +enum NooSwipeActionKind { delete, favorite } + +/// One side's action for [NooSwipeAction]. +class NooSwipeActionSpec { + final NooSwipeActionKind kind; + final VoidCallback onTriggered; + + /// Overrides the default label ("Delete" / "Favorite"), e.g. + /// "Unfavorite" for an already-starred file. + final String? label; + + /// Overrides the default icon (`trash-2` / `star`). + final IconData? icon; + + const NooSwipeActionSpec({ + required this.kind, + required this.onTriggered, + this.label, + this.icon, + }); +} + +/// Wraps a row (normally a `NooFileRow`) so dragging it horizontally slides +/// it aside to uncover a 96px action block: delete is white on +/// `danger-fill`, favorite is white on `accent`, each a 20px icon over a +/// 12/600 label. Releasing past half the block snaps it open, otherwise it +/// springs shut - both on [NooMotion.base] / [NooMotion.ease], no bounce. +/// Tapping the block fires the action and closes it; tapping the row while +/// open just closes it. +class NooSwipeAction extends StatefulWidget { + final Widget child; + + /// Revealed on the leading edge (swipe toward the trailing edge). + final NooSwipeActionSpec? startAction; + + /// Revealed on the trailing edge (swipe toward the leading edge). + final NooSwipeActionSpec? endAction; + + const NooSwipeAction({ + super.key, + required this.child, + this.startAction, + this.endAction, + }); + + /// Width of the uncovered action block. + static const double actionWidth = 96; + + @override + State createState() => _NooSwipeActionState(); +} + +class _NooSwipeActionState extends State + with SingleTickerProviderStateMixin { + /// -1 = end action fully revealed, 0 = closed, 1 = start action revealed + /// (in visual left/right terms after [_dir] is applied). + late final AnimationController _ctrl = AnimationController( + vsync: this, + lowerBound: -1, + upperBound: 1, + value: 0, + ); + + double get _dir => Directionality.of(context) == TextDirection.rtl ? -1 : 1; + + @override + void dispose() { + _ctrl.dispose(); + super.dispose(); + } + + void _animateTo(double target) { + _ctrl.animateTo(target, duration: NooMotion.base, curve: NooMotion.ease); + } + + void _close() => _animateTo(0); + + void _onDragUpdate(DragUpdateDetails d) { + // Positive = revealing the start action. + final delta = d.primaryDelta! * _dir / NooSwipeAction.actionWidth; + final min = widget.endAction != null ? -1.0 : 0.0; + final max = widget.startAction != null ? 1.0 : 0.0; + _ctrl.value = (_ctrl.value + delta).clamp(min, max); + } + + void _onDragEnd(DragEndDetails d) { + final v = _ctrl.value; + final velocity = d.primaryVelocity! * _dir; + double target; + if (velocity.abs() > 700) { + final opening = velocity.sign == v.sign || v == 0; + target = opening ? velocity.sign : 0; + } else { + target = v.abs() >= 0.5 ? v.sign : 0; + } + if (target > 0 && widget.startAction == null) target = 0; + if (target < 0 && widget.endAction == null) target = 0; + _animateTo(target); + } + + void _trigger(NooSwipeActionSpec spec) { + _close(); + spec.onTriggered(); + } + + @override + Widget build(BuildContext context) { + final colors = context.nooColors; + if (widget.startAction == null && widget.endAction == null) { + return widget.child; + } + + return GestureDetector( + onHorizontalDragUpdate: _onDragUpdate, + onHorizontalDragEnd: _onDragEnd, + child: ClipRect( + child: AnimatedBuilder( + animation: _ctrl, + builder: (context, child) { + final v = _ctrl.value; + // Only the side being uncovered is painted, so the opposite + // block never peeks out from behind a translucent row. + final showing = v > 0 + ? widget.startAction + : (v < 0 ? widget.endAction : null); + return Stack( + children: [ + if (showing != null) + Positioned.fill( + child: Align( + alignment: v > 0 + ? AlignmentDirectional.centerStart + : AlignmentDirectional.centerEnd, + child: _ActionBlock( + spec: showing, + colors: colors, + onTap: () => _trigger(showing), + ), + ), + ), + Transform.translate( + offset: Offset(v * NooSwipeAction.actionWidth * _dir, 0), + child: child, + ), + ], + ); + }, + child: _OpenAwareChild( + controller: _ctrl, + onCloseTap: _close, + child: widget.child, + ), + ), + ), + ); + } +} + +/// Swallows taps on the row while it's swiped open (closing it instead), +/// so an open row doesn't also navigate. +class _OpenAwareChild extends AnimatedWidget { + final Widget child; + final VoidCallback onCloseTap; + + const _OpenAwareChild({ + required AnimationController controller, + required this.onCloseTap, + required this.child, + }) : super(listenable: controller); + + @override + Widget build(BuildContext context) { + final open = (listenable as AnimationController).value != 0; + return Stack( + children: [ + child, + if (open) + Positioned.fill( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onCloseTap, + ), + ), + ], + ); + } +} + +class _ActionBlock extends StatelessWidget { + final NooSwipeActionSpec spec; + final NooColors colors; + final VoidCallback onTap; + + const _ActionBlock({ + required this.spec, + required this.colors, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final isDelete = spec.kind == NooSwipeActionKind.delete; + final bg = isDelete ? colors.dangerFill : colors.accent; + final icon = + spec.icon ?? (isDelete ? LucideIcons.trash2 : LucideIcons.star); + final label = spec.label ?? (isDelete ? 'Delete' : 'Favorite'); + + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onTap, + child: Container( + width: NooSwipeAction.actionWidth, + color: bg, + alignment: Alignment.center, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 20, color: Colors.white), + const SizedBox(height: 6), + Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: NooText.label.copyWith( + fontSize: 12, + height: 1, + color: Colors.white, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/noo/lists/noo_banner.dart b/lib/widgets/noo/lists/noo_banner.dart new file mode 100644 index 0000000..06365f1 --- /dev/null +++ b/lib/widgets/noo/lists/noo_banner.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import '../../../theme/design_tokens.dart'; + +/// Inline info card with an optional text action, e.g. Trash's retention +/// notice + "Empty trash" (Noo Design System project, +/// `components/lists/Banner.jsx`, mobile variant). +class NooBanner extends StatelessWidget { + final IconData icon; + final Widget child; + final String? actionLabel; + final VoidCallback? onAction; + final bool actionIsDanger; + + const NooBanner({ + super.key, + this.icon = LucideIcons.info, + required this.child, + this.actionLabel, + this.onAction, + this.actionIsDanger = true, + }); + + @override + Widget build(BuildContext context) { + final colors = context.nooColors; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + decoration: BoxDecoration(color: colors.surface, borderRadius: BorderRadius.circular(NooRadii.card)), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Icon(icon, size: 20, color: colors.fg2), + const SizedBox(width: 12), + Expanded( + child: DefaultTextStyle( + style: NooText.body.copyWith(color: colors.fg2), + child: child, + ), + ), + if (actionLabel != null) + GestureDetector( + onTap: onAction, + child: Text( + actionLabel!, + style: NooText.buttonSm.copyWith( + fontSize: 14, + color: actionIsDanger ? colors.danger : colors.accentText, + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/noo/lists/noo_grouped_list.dart b/lib/widgets/noo/lists/noo_grouped_list.dart new file mode 100644 index 0000000..99c949f --- /dev/null +++ b/lib/widgets/noo/lists/noo_grouped_list.dart @@ -0,0 +1,79 @@ +import 'package:flutter/material.dart'; +import '../../../theme/design_tokens.dart'; + +/// Radius-20 surface card of rows with 1px line dividers and an optional +/// label above it (Noo Design System project, +/// `components/lists/GroupedList.jsx`, mobile variant - desktop's bordered/ +/// titled card isn't built yet). Each child (`SettingsRow`, `FileRow`, ...) +/// paints its own `surface` background; this only supplies the 1px `line` +/// gaps between them and the rounded clip. Stack groups 18-20px apart. +class NooGroupedList extends StatelessWidget { + final String? label; + /// Right-aligned secondary text next to [label]. + final String? aside; + final List children; + final Widget? footer; + + const NooGroupedList({ + super.key, + this.label, + this.aside, + required this.children, + this.footer, + }); + + @override + Widget build(BuildContext context) { + final colors = context.nooColors; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (label != null) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label!, style: NooText.label.copyWith(color: colors.fg2)), + if (aside != null) + Text( + aside!, + style: NooText.body.copyWith( + fontSize: 13, + height: 1, + fontWeight: FontWeight.w500, + color: colors.fg3, + ), + ), + ], + ), + ), + if (label != null) const SizedBox(height: 8), + ClipRRect( + borderRadius: BorderRadius.circular(NooRadii.card), + child: DecoratedBox( + decoration: BoxDecoration(color: colors.line), + child: Column( + children: [ + for (var i = 0; i < children.length; i++) ...[ + if (i > 0) const SizedBox(height: 1), + children[i], + ], + ], + ), + ), + ), + if (footer != null) ...[ + const SizedBox(height: 8), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: DefaultTextStyle( + style: NooText.meta.copyWith(color: colors.fg3), + child: footer!, + ), + ), + ], + ], + ); + } +} diff --git a/lib/widgets/noo/lists/noo_settings_row.dart b/lib/widgets/noo/lists/noo_settings_row.dart new file mode 100644 index 0000000..b5e3fd6 --- /dev/null +++ b/lib/widgets/noo/lists/noo_settings_row.dart @@ -0,0 +1,106 @@ +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import '../../../theme/design_tokens.dart'; + +/// 52/60px settings row: icon, label, subtitle, trailing control (Noo +/// Design System project, `components/lists/SettingsRow.jsx`, mobile +/// variant). Goes inside a [NooGroupedList] - it paints its own `surface` +/// background, the list only supplies the line gaps. +class NooSettingsRow extends StatelessWidget { + final IconData? icon; + final Widget label; + final Widget? subtitle; + /// Renders as `value` text + a trailing chevron - mutually exclusive with + /// [trailing] (a `NooToggle`, button, badge, etc.). + final String? value; + final Widget? trailing; + final bool danger; + /// Accent-text action row, e.g. "Add account". + final bool accent; + final VoidCallback? onTap; + + const NooSettingsRow({ + super.key, + this.icon, + required this.label, + this.subtitle, + this.value, + this.trailing, + this.danger = false, + this.accent = false, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + final colors = context.nooColors; + final color = danger ? colors.danger : (accent ? colors.accentText : colors.fg1); + final minHeight = subtitle != null ? 60.0 : 52.0; + + Widget? trail = trailing; + if (trail == null && value != null) { + trail = Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + value!, + style: NooText.body.copyWith(fontSize: 15, height: 1, color: colors.fg3), + ), + const SizedBox(width: 4), + Icon(LucideIcons.chevronRight, size: 18, color: colors.fg3), + ], + ); + } + + return Material( + color: colors.surface, + child: InkWell( + onTap: onTap, + child: ConstrainedBox( + constraints: BoxConstraints(minHeight: minHeight), + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: NooSpace.md, + vertical: subtitle != null ? 11 : 0, + ), + child: Row( + children: [ + if (icon != null) ...[ + Icon(icon, size: 20, color: danger || accent ? color : colors.fg2), + const SizedBox(width: 14), + ], + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + DefaultTextStyle( + style: NooText.bodyL.copyWith( + height: 1.1, + fontWeight: danger || accent + ? (accent ? FontWeight.w600 : FontWeight.w500) + : FontWeight.w400, + color: color, + ), + child: label, + ), + if (subtitle != null) ...[ + const SizedBox(height: 4), + DefaultTextStyle( + style: NooText.meta.copyWith(color: colors.fg3), + child: subtitle!, + ), + ], + ], + ), + ), + ?trail, + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/widgets/noo/lists/noo_summary_card.dart b/lib/widgets/noo/lists/noo_summary_card.dart new file mode 100644 index 0000000..27ab47e --- /dev/null +++ b/lib/widgets/noo/lists/noo_summary_card.dart @@ -0,0 +1,79 @@ +import 'package:flutter/material.dart'; +import '../../../theme/design_tokens.dart'; +import '../core/noo_progress_bar.dart'; + +enum NooSummaryCardTone { normal, danger, accent } + +/// Stat card with optional progress, meta and action (Offline summary, +/// desktop stats) - Noo Design System project, +/// `components/lists/SummaryCard.jsx`, mobile variant. +class NooSummaryCard extends StatelessWidget { + final String stat; + final String? caption; + /// 0-1; renders a [NooProgressBar]. + final double? progress; + final String? meta; + final Widget? action; + final NooSummaryCardTone tone; + + const NooSummaryCard({ + super.key, + required this.stat, + this.caption, + this.progress, + this.meta, + this.action, + this.tone = NooSummaryCardTone.normal, + }); + + @override + Widget build(BuildContext context) { + final colors = context.nooColors; + final bg = tone == NooSummaryCardTone.danger ? colors.dangerSoft : colors.surface; + final statColor = switch (tone) { + NooSummaryCardTone.danger => colors.danger, + NooSummaryCardTone.accent => colors.accentText, + NooSummaryCardTone.normal => colors.fg1, + }; + final capColor = tone == NooSummaryCardTone.danger ? colors.danger : colors.fg2; + + return Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(NooRadii.card)), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(stat, style: NooText.stat.copyWith(color: statColor)), + if (caption != null) ...[ + const SizedBox(height: 6), + Text( + caption!, + style: NooText.body.copyWith(fontSize: 14, height: 1.2, color: capColor), + ), + ], + ], + ), + ), + ?action, + ], + ), + if (progress != null) ...[ + const SizedBox(height: 14), + NooProgressBar(value: progress!), + ], + if (meta != null) ...[ + const SizedBox(height: 14), + Text(meta!, style: NooText.meta.copyWith(color: colors.fg3)), + ], + ], + ), + ); + } +} diff --git a/lib/widgets/noo/lists/noo_tab_order_row.dart b/lib/widgets/noo/lists/noo_tab_order_row.dart new file mode 100644 index 0000000..0c41c6a --- /dev/null +++ b/lib/widgets/noo/lists/noo_tab_order_row.dart @@ -0,0 +1,62 @@ +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import '../../../theme/design_tokens.dart'; + +/// Reorderable tab row with a grip and pin toggle - max 5 pinned (Noo +/// Design System project, `components/lists/TabOrderRow.jsx`, mobile +/// variant). +class NooTabOrderRow extends StatelessWidget { + final IconData icon; + final String label; + final bool pinned; + final VoidCallback? onTogglePin; + + const NooTabOrderRow({ + super.key, + required this.icon, + required this.label, + this.pinned = false, + this.onTogglePin, + }); + + @override + Widget build(BuildContext context) { + final colors = context.nooColors; + return Container( + height: 52, + padding: const EdgeInsets.symmetric(horizontal: 10), + color: colors.surface, + child: Row( + children: [ + Icon(LucideIcons.gripVertical, size: 18, color: colors.fg3), + const SizedBox(width: 12), + Icon(icon, size: 20, color: colors.fg2), + const SizedBox(width: 12), + Expanded( + child: Text( + label, + style: NooText.bodyL.copyWith(height: 1, color: colors.fg1), + ), + ), + GestureDetector( + onTap: onTogglePin, + child: Container( + width: 36, + height: 36, + alignment: Alignment.center, + decoration: BoxDecoration( + color: pinned ? colors.accentSoft : colors.surface2, + shape: BoxShape.circle, + ), + child: Icon( + pinned ? LucideIcons.pin : LucideIcons.pinOff, + size: 16, + color: pinned ? colors.accentText : colors.fg3, + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/noo/media/noo_activity_item.dart b/lib/widgets/noo/media/noo_activity_item.dart new file mode 100644 index 0000000..75485a0 --- /dev/null +++ b/lib/widgets/noo/media/noo_activity_item.dart @@ -0,0 +1,116 @@ +import 'package:flutter/material.dart'; +import '../../../theme/design_tokens.dart'; +import '../core/noo_avatar.dart'; + +/// Activity feed entry (`DESIGN_SYSTEM.md` section 2, "Activity item"): +/// a 36px [NooAvatar], then the sentence **actor** verb **object** tail +/// with the time below in meta style, and an optional [trailing] slot on +/// the right for the 32px/r10 file tile (built by the caller, so this +/// doesn't depend on the file-kind mapping). +class NooActivityItem extends StatelessWidget { + final String actor; + + /// Plain text between actor and object, e.g. `"shared"`. + final String verb; + + /// Bold subject of the action, e.g. a file name. + final String? object; + + /// Plain text after the object, e.g. `"with you"`. + final String? tail; + + final String? time; + + /// Avatar initials; defaults to the first letter of [actor]. + final String? initials; + + /// One of [NooColors.avatarPalette]. + final Color? avatarColor; + + /// Uses the accent-soft current-user avatar. + final bool currentUser; + + /// Right-aligned slot, typically a 32px file tile. + final Widget? trailing; + + final VoidCallback? onTap; + + const NooActivityItem({ + super.key, + required this.actor, + required this.verb, + this.object, + this.tail, + this.time, + this.initials, + this.avatarColor, + this.currentUser = false, + this.trailing, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + final colors = context.nooColors; + final base = NooText.body.copyWith(color: colors.fg1); + const bold = TextStyle(fontWeight: FontWeight.w600); + + final fallbackInitials = actor.trim().isEmpty + ? '' + : actor.trim().characters.first.toUpperCase(); + + return Material( + type: MaterialType.transparency, + child: InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: NooSpace.md, + vertical: NooSpace.sm, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + NooAvatar( + initials: initials ?? fallbackInitials, + size: 36, + color: avatarColor, + current: currentUser, + ), + const SizedBox(width: NooSpace.sm), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text.rich( + TextSpan( + style: base, + children: [ + TextSpan(text: actor, style: bold), + TextSpan(text: ' $verb'), + if (object != null) ...[ + const TextSpan(text: ' '), + TextSpan(text: object, style: bold), + ], + if (tail != null) TextSpan(text: ' $tail'), + ], + ), + ), + if (time != null) ...[ + const SizedBox(height: NooSpace.xxs), + Text(time!, style: NooText.meta.copyWith(color: colors.fg3)), + ], + ], + ), + ), + if (trailing != null) ...[ + const SizedBox(width: NooSpace.sm), + trailing!, + ], + ], + ), + ), + ), + ); + } +} diff --git a/lib/widgets/noo/media/noo_grid_card.dart b/lib/widgets/noo/media/noo_grid_card.dart new file mode 100644 index 0000000..50a132b --- /dev/null +++ b/lib/widgets/noo/media/noo_grid_card.dart @@ -0,0 +1,179 @@ +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import '../../../theme/design_tokens.dart'; + +/// Grid-view file card: a thumbnail area (file-type soft colour + 32px +/// icon, or a real [thumbnail]) above the name, an overflow button and a +/// meta line (`DESIGN_SYSTEM.md` section 2, "Grid card"). Mobile lays these +/// out 2-up with a 10px gap, desktop 5-up with a 16px gap - the parent grid +/// owns that, this card just fills its cell. +/// +/// The placeholder colours/icon are plain parameters rather than a file +/// kind so this stays independent of the file-kind mapping; callers pass +/// e.g. `accentSoft`/`accentText`/`LucideIcons.folder` for a folder. +class NooGridCard extends StatelessWidget { + final String name; + final String? meta; + + /// Soft fill behind [icon] when there's no [thumbnail] (e.g. `infoSoft`). + final Color? placeholderColor; + final IconData? icon; + + /// Icon colour on the placeholder (e.g. `info`). + final Color? iconColor; + + /// Real thumbnail (typically an `Image` with `BoxFit.cover`); replaces + /// the placeholder and is clipped to the card's top corners. + final Widget? thumbnail; + + /// 104 (mobile) - 118 (desktop). + final double thumbnailHeight; + + final bool selected; + final VoidCallback? onTap; + final VoidCallback? onLongPress; + + /// Shows the overflow button when non-null. + final VoidCallback? onMore; + + /// `ellipsis` on iOS/desktop, `ellipsis-vertical` on Android. + final bool verticalOverflowIcon; + + const NooGridCard({ + super.key, + required this.name, + this.meta, + this.placeholderColor, + this.icon, + this.iconColor, + this.thumbnail, + this.thumbnailHeight = 104, + this.selected = false, + this.onTap, + this.onLongPress, + this.onMore, + this.verticalOverflowIcon = false, + }); + + @override + Widget build(BuildContext context) { + final colors = context.nooColors; + final radius = BorderRadius.circular(NooRadii.gridCard); + + final thumb = SizedBox( + height: thumbnailHeight, + width: double.infinity, + child: + thumbnail ?? + ColoredBox( + color: placeholderColor ?? colors.surface2, + child: Center( + child: Icon( + icon ?? LucideIcons.file, + size: 32, + color: iconColor ?? colors.fg2, + ), + ), + ), + ); + + return Material( + color: selected ? colors.accentSoft : colors.surface, + borderRadius: radius, + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + onLongPress: onLongPress, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + thumb, + Padding( + padding: EdgeInsets.fromLTRB( + NooSpace.sm, + 10, + onMore != null ? NooSpace.xxs : NooSpace.sm, + NooSpace.sm, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: NooText.body.copyWith( + fontSize: 14, + height: 1.2, + fontWeight: FontWeight.w500, + color: selected ? colors.accentText : colors.fg1, + ), + ), + if (meta != null) ...[ + const SizedBox(height: NooSpace.xxs), + Text( + meta!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: NooText.meta.copyWith( + fontSize: 12, + color: colors.fg3, + ), + ), + ], + ], + ), + ), + if (onMore != null) + _OverflowButton( + icon: verticalOverflowIcon + ? LucideIcons.ellipsisVertical + : LucideIcons.ellipsis, + color: colors.fg3, + onTap: onMore!, + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +/// Compact 28px circular hit target so the overflow button doesn't push +/// the name/meta column taller than the text itself. +class _OverflowButton extends StatelessWidget { + final IconData icon; + final Color color; + final VoidCallback onTap; + + const _OverflowButton({ + required this.icon, + required this.color, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return SizedBox( + width: 28, + height: 28, + child: Material( + type: MaterialType.transparency, + shape: const CircleBorder(), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + child: Icon(icon, size: 16, color: color), + ), + ), + ); + } +} diff --git a/lib/widgets/noo/media/noo_photo_group.dart b/lib/widgets/noo/media/noo_photo_group.dart new file mode 100644 index 0000000..a1f4804 --- /dev/null +++ b/lib/widgets/noo/media/noo_photo_group.dart @@ -0,0 +1,130 @@ +import 'package:flutter/material.dart'; +import '../../../theme/design_tokens.dart'; + +/// Month group heading for the Photos grid (`DESIGN_SYSTEM.md` section 2, +/// "Photo grid"): group heading (Schibsted 18/500, fg-1) on the left and +/// the item count in meta style (fg-3) on the right. +class NooPhotoGroupHeader extends StatelessWidget { + final String title; + + /// e.g. `"128 photos"`; hidden when null. + final String? count; + + /// Horizontal inset - 16 on mobile (the grid itself is edge to edge), + /// 24 on desktop. + final EdgeInsetsGeometry padding; + + const NooPhotoGroupHeader({ + super.key, + required this.title, + this.count, + this.padding = const EdgeInsets.fromLTRB( + NooSpace.md, + NooSpace.lg, + NooSpace.md, + NooSpace.sm, + ), + }); + + @override + Widget build(BuildContext context) { + final colors = context.nooColors; + return Padding( + padding: padding, + child: Row( + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + Expanded( + child: Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: NooText.groupHeading.copyWith(height: 1, color: colors.fg1), + ), + ), + if (count != null) ...[ + const SizedBox(width: NooSpace.sm), + Text(count!, style: NooText.meta.copyWith(color: colors.fg3)), + ], + ], + ), + ); + } +} + +/// Square-tile photo grid (`DESIGN_SYSTEM.md` section 2, "Photo grid"): +/// mobile is 3 columns with a 2px gap edge to edge ([NooPhotoGrid.mobile] +/// defaults), desktop 8 columns with 4px ([NooPhotoGrid.desktop]). +/// +/// Builds lazily from [itemBuilder] (typically returning a `NooPhotoTile`). +/// Use the default constructor inside a `CustomScrollView` as a sliver +/// (one per month group, after a `SliverToBoxAdapter(NooPhotoGroupHeader)`), +/// or [NooPhotoGrid.box] for a non-scrolling box that sizes to its content +/// (e.g. inside a `Column`/`ListView` item). +class NooPhotoGrid extends StatelessWidget { + final int itemCount; + final IndexedWidgetBuilder itemBuilder; + final int columns; + final double gap; + final EdgeInsetsGeometry padding; + final bool _sliver; + + /// Sliver form. Defaults are the mobile layout. + const NooPhotoGrid({ + super.key, + required this.itemCount, + required this.itemBuilder, + this.columns = 3, + this.gap = 2, + this.padding = EdgeInsets.zero, + }) : _sliver = true; + + /// Box form: a shrink-wrapped, non-scrolling grid. + const NooPhotoGrid.box({ + super.key, + required this.itemCount, + required this.itemBuilder, + this.columns = 3, + this.gap = 2, + this.padding = EdgeInsets.zero, + }) : _sliver = false; + + /// Mobile/desktop column + gap presets from the spec. + static const mobileColumns = 3; + static const mobileGap = 2.0; + static const desktopColumns = 8; + static const desktopGap = 4.0; + + SliverGridDelegate get _delegate => + SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: columns, + mainAxisSpacing: gap, + crossAxisSpacing: gap, + ); + + @override + Widget build(BuildContext context) { + if (_sliver) { + return SliverPadding( + padding: padding, + sliver: SliverGrid( + gridDelegate: _delegate, + delegate: SliverChildBuilderDelegate( + itemBuilder, + childCount: itemCount, + ), + ), + ); + } + return GridView.builder( + shrinkWrap: true, + primary: false, + physics: const NeverScrollableScrollPhysics(), + padding: padding, + gridDelegate: _delegate, + itemCount: itemCount, + itemBuilder: itemBuilder, + ); + } +} diff --git a/lib/widgets/noo/media/noo_photo_tile.dart b/lib/widgets/noo/media/noo_photo_tile.dart new file mode 100644 index 0000000..29eee2b --- /dev/null +++ b/lib/widgets/noo/media/noo_photo_tile.dart @@ -0,0 +1,164 @@ +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import '../../../theme/design_tokens.dart'; + +/// Video badge background from `DESIGN_SYSTEM.md` section 2 ("Photo +/// grid"): `rgba(14,13,11,.7)` - not a [NooColors] token, it's a fixed +/// overlay that sits on photo content in both themes. +const _videoBadgeColor = Color(0xB30E0D0B); + +/// Square photo tile for [NooPhotoGrid] (`DESIGN_SYSTEM.md` section 2, +/// "Photo grid"): an image [child] (usually `Image` with `BoxFit.cover`) +/// or, while loading/absent, a flat [placeholderColor] from +/// [NooColors.avatarPalette]. Videos get a bottom-right badge with a `play` +/// icon and [videoDuration]. +class NooPhotoTile extends StatelessWidget { + final Widget? child; + + /// Fill shown behind/instead of [child]; defaults to the first + /// [NooColors.avatarPalette] entry. Use [NooPhotoTile.paletteColor] to + /// pick a stable one per item. + final Color? placeholderColor; + + /// Non-null marks the tile as a video and shows the badge, e.g. `0:42`. + /// Pass an empty string for a play-icon-only badge. + final String? videoDuration; + + final bool selected; + + /// Shows the selection check circle even when not [selected] (i.e. the + /// grid is in multi-select mode). + final bool selectionMode; + + final VoidCallback? onTap; + final VoidCallback? onLongPress; + + const NooPhotoTile({ + super.key, + this.child, + this.placeholderColor, + this.videoDuration, + this.selected = false, + this.selectionMode = false, + this.onTap, + this.onLongPress, + }); + + /// Stable secondary-palette colour for an item (e.g. from its id/path + /// hash), so placeholders don't reshuffle between rebuilds. + static Color paletteColor(int seed) => + NooColors.avatarPalette[seed.abs() % NooColors.avatarPalette.length]; + + @override + Widget build(BuildContext context) { + final colors = context.nooColors; + + return AspectRatio( + aspectRatio: 1, + child: GestureDetector( + onTap: onTap, + onLongPress: onLongPress, + child: Stack( + fit: StackFit.expand, + children: [ + // Selected tiles inset so an accent-soft frame shows around + // the photo, instead of covering it with a tint. + ColoredBox(color: selected ? colors.accentSoft : Colors.transparent), + AnimatedPadding( + duration: NooMotion.fast, + curve: NooMotion.ease, + padding: EdgeInsets.all(selected ? 8 : 0), + child: ClipRRect( + borderRadius: BorderRadius.circular(selected ? 8 : 0), + child: Stack( + fit: StackFit.expand, + children: [ + ColoredBox( + color: placeholderColor ?? NooColors.avatarPalette.first, + ), + ?child, + ], + ), + ), + ), + if (videoDuration != null) + Positioned( + right: 6, + bottom: 6, + child: _VideoBadge(duration: videoDuration!), + ), + if (selected || selectionMode) + Positioned( + left: 6, + top: 6, + child: _SelectCheck(selected: selected), + ), + ], + ), + ), + ); + } +} + +class _VideoBadge extends StatelessWidget { + final String duration; + + const _VideoBadge({required this.duration}); + + @override + Widget build(BuildContext context) { + return Container( + height: 20, + padding: EdgeInsets.symmetric(horizontal: duration.isEmpty ? 4 : 6), + decoration: BoxDecoration( + color: _videoBadgeColor, + borderRadius: BorderRadius.circular(NooRadii.pill), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(LucideIcons.play, size: 12, color: Colors.white), + if (duration.isNotEmpty) ...[ + const SizedBox(width: 3), + Text( + duration, + style: NooText.meta.copyWith( + fontSize: 11, + height: 1, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ), + ], + ], + ), + ); + } +} + +/// 22px selection circle: accent fill + white check when selected, a +/// white-outlined empty circle on the video-badge scrim otherwise. +class _SelectCheck extends StatelessWidget { + final bool selected; + + const _SelectCheck({required this.selected}); + + @override + Widget build(BuildContext context) { + final colors = context.nooColors; + return Container( + width: 22, + height: 22, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: selected ? colors.accent : _videoBadgeColor, + border: selected + ? null + : Border.all(color: Colors.white, width: 1.5), + ), + child: selected + ? const Icon(LucideIcons.check, size: 14, color: Colors.white) + : null, + ); + } +} diff --git a/lib/widgets/noo/media/noo_stat_card.dart b/lib/widgets/noo/media/noo_stat_card.dart new file mode 100644 index 0000000..af999a6 --- /dev/null +++ b/lib/widgets/noo/media/noo_stat_card.dart @@ -0,0 +1,98 @@ +import 'package:flutter/material.dart'; +import '../../../theme/design_tokens.dart'; +import '../core/noo_progress_bar.dart'; + +/// Desktop stat card (`DESIGN_SYSTEM.md` sections 1.3/1.4): radius 18, +/// Stat text (Schibsted 600, 28 on desktop), a label below and an optional +/// 6px [NooProgressBar]. The mobile/Offline equivalent with an action +/// button is `NooSummaryCard`. +class NooStatCard extends StatelessWidget { + final String stat; + final String label; + + /// 0-1; renders a [NooProgressBar] under the label. + final double? progress; + + /// Optional line under the progress bar, e.g. `"of 50 GB"`. + final String? meta; + + final IconData? icon; + + /// Defaults to `surface`; pass `surface2` when the card sits on a + /// `surface` pane. + final Color? color; + + final VoidCallback? onTap; + + const NooStatCard({ + super.key, + required this.stat, + required this.label, + this.progress, + this.meta, + this.icon, + this.color, + this.onTap, + }); + + /// Desktop Stat size (spec 1.3: 32 mobile / 28 desktop); [NooText.stat] + /// only carries the mobile size. + static const _desktopStatSize = 28.0; + + @override + Widget build(BuildContext context) { + final colors = context.nooColors; + + return Material( + color: color ?? colors.surface, + borderRadius: BorderRadius.circular(NooRadii.gridCard), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.all(18), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + Icon(icon, size: 18, color: colors.fg2), + const SizedBox(height: NooSpace.sm), + ], + Text( + stat, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: NooText.stat.copyWith( + fontSize: _desktopStatSize, + letterSpacing: -_desktopStatSize * 0.03, + color: colors.fg1, + ), + ), + const SizedBox(height: NooSpace.xs), + Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: NooText.meta.copyWith(color: colors.fg2), + ), + if (progress != null) ...[ + const SizedBox(height: NooSpace.smd), + NooProgressBar(value: progress!), + ], + if (meta != null) ...[ + const SizedBox(height: NooSpace.xs), + Text( + meta!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: NooText.meta.copyWith(fontSize: 12, color: colors.fg3), + ), + ], + ], + ), + ), + ), + ); + } +} diff --git a/lib/widgets/noo/nav/noo_bottom_bar.dart b/lib/widgets/noo/nav/noo_bottom_bar.dart new file mode 100644 index 0000000..44b98bb --- /dev/null +++ b/lib/widgets/noo/nav/noo_bottom_bar.dart @@ -0,0 +1,178 @@ +import 'package:flutter/material.dart'; +import '../../../theme/design_tokens.dart'; +import 'noo_nav_style.dart'; + +export 'noo_nav_style.dart'; + +/// The mobile bottom bar (DESIGN_SYSTEM.md 3, "Mobile"; `iNav`/`aNav` in +/// `Mobile Screen.dc.html`). Shows the 5 pinned tabs - which ones, and in +/// what order, is the caller's business; this only draws [destinations]. +/// +/// - [NooNavStyle.ios]: surface fill, 1px top `line`, a 50px row (8px top +/// padding) of icon 24 over a 10px label. Active: accent-text, 600. +/// Idle: fg-3, 500. No ripple. +/// - [NooNavStyle.android]: surface fill, 80px. The active icon sits in a +/// 56x32 accent-soft pill (accent-text icon); label is 12px, fg-1/600 +/// active and fg-2/500 idle (idle icon fg-2). +/// +/// The home indicator / gesture bar area below the row comes from the +/// bottom safe-area inset rather than a fixed 34/20px spacer, so it's +/// right on every device. Meant for `Scaffold.bottomNavigationBar`. +class NooBottomBar extends StatelessWidget { + final NooNavStyle style; + final List destinations; + final int selectedIndex; + final ValueChanged onSelected; + + const NooBottomBar({ + super.key, + required this.style, + required this.destinations, + required this.selectedIndex, + required this.onSelected, + }); + + @override + Widget build(BuildContext context) { + final colors = context.nooColors; + final ios = style == NooNavStyle.ios; + + final row = Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (var i = 0; i < destinations.length; i++) + Expanded( + child: ios + ? _IosItem( + destination: destinations[i], + selected: i == selectedIndex, + onTap: () => onSelected(i), + ) + : _AndroidItem( + destination: destinations[i], + selected: i == selectedIndex, + onTap: () => onSelected(i), + ), + ), + ], + ); + + return Container( + decoration: BoxDecoration( + color: colors.surface, + border: ios ? Border(top: BorderSide(color: colors.line)) : null, + ), + child: SafeArea( + top: false, + child: SizedBox(height: ios ? 50 : 80, child: row), + ), + ); + } +} + +class _IosItem extends StatelessWidget { + final NooNavDestination destination; + final bool selected; + final VoidCallback onTap; + + const _IosItem({ + required this.destination, + required this.selected, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final colors = context.nooColors; + final fg = selected ? colors.accentText : colors.fg3; + return Semantics( + button: true, + selected: selected, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onTap, + child: Padding( + padding: const EdgeInsets.only(top: 8), + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Icon(destination.icon, size: 24, color: fg), + const SizedBox(height: 4), + Text( + destination.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: NooText.navLabel.copyWith( + fontSize: 10, + height: 1, + fontWeight: selected ? FontWeight.w600 : FontWeight.w500, + color: fg, + ), + ), + ], + ), + ), + ), + ); + } +} + +class _AndroidItem extends StatelessWidget { + final NooNavDestination destination; + final bool selected; + final VoidCallback onTap; + + const _AndroidItem({ + required this.destination, + required this.selected, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final colors = context.nooColors; + return Semantics( + button: true, + selected: selected, + // No ripple: the animated pill is the press/selection feedback. + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onTap, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + AnimatedContainer( + duration: NooMotion.fast, + curve: NooMotion.ease, + width: 56, + height: 32, + alignment: Alignment.center, + decoration: BoxDecoration( + color: selected + ? colors.accentSoft + : colors.accentSoft.withValues(alpha: 0), + borderRadius: BorderRadius.circular(NooRadii.pill), + ), + child: Icon( + destination.icon, + size: 24, + color: selected ? colors.accentText : colors.fg2, + ), + ), + const SizedBox(height: 4), + Text( + destination.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: (selected ? NooText.navLabelActive : NooText.navLabel) + .copyWith( + height: 1, + color: selected ? colors.fg1 : colors.fg2, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/noo/nav/noo_drawer.dart b/lib/widgets/noo/nav/noo_drawer.dart new file mode 100644 index 0000000..89b57c0 --- /dev/null +++ b/lib/widgets/noo/nav/noo_drawer.dart @@ -0,0 +1,365 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import '../../../theme/design_tokens.dart'; +import '../core/noo_progress_bar.dart'; + +/// The mobile drawer shell (DESIGN_SYSTEM.md 2 "Drawer" / 3; `drawer` in +/// `Mobile Screen.dc.html`): 316px wide, `surface` fill, radius 28 on the +/// trailing edge, flat (no elevation). Slots top to bottom: +/// +/// 1. [account] - usually a [NooDrawerAccount] +/// 2. [storage] - usually a [NooDrawerStorage] +/// 3. a divider, then - only when [moreItems] isn't empty - the [moreLabel] +/// heading and [moreItems] (the unpinned tabs, as [NooDrawerItem]s) +/// 4. a divider, then [settings] (a [NooDrawerItem]) +/// 5. a spacer, then [footer] - the "Edit tabs" [NooDrawerLink] +/// +/// Wraps Flutter's [Drawer], so pass it to `Scaffold.drawer` and the open/ +/// close gesture and semantics come for free. The scrim is the Scaffold's +/// job, not the drawer's: set `Scaffold.drawerScrimColor` to +/// `context.nooColors.scrim`. The content scrolls if it doesn't fit (e.g. +/// landscape), keeping [footer] pinned to the bottom when it does. +class NooDrawer extends StatelessWidget { + final Widget? account; + final Widget? storage; + final String moreLabel; + final List moreItems; + final Widget? settings; + final Widget? footer; + + const NooDrawer({ + super.key, + this.account, + this.storage, + this.moreLabel = 'More', + this.moreItems = const [], + this.settings, + this.footer, + }); + + @override + Widget build(BuildContext context) { + final colors = context.nooColors; + final insets = MediaQuery.paddingOf(context); + + Widget divider(EdgeInsets margin) => + Container(height: 1, margin: margin, color: colors.line); + + final children = [ + ?account, + ?storage, + if (account != null || storage != null) + divider(const EdgeInsets.fromLTRB(12, 0, 12, 8)), + if (moreItems.isNotEmpty) ...[ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Text( + moreLabel, + style: NooText.label.copyWith(height: 1, color: colors.fg2), + ), + ), + ...moreItems, + if (settings != null) + divider(const EdgeInsets.symmetric(horizontal: 12, vertical: 8)), + ], + ?settings, + const Spacer(), + ?footer, + ]; + + return Drawer( + width: 316, + backgroundColor: colors.surface, + surfaceTintColor: Colors.transparent, + elevation: 0, + shadowColor: Colors.transparent, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadiusDirectional.horizontal( + end: Radius.circular(NooRadii.sheetTop), + ), + ), + child: LayoutBuilder( + builder: (context, constraints) => SingleChildScrollView( + // Reference: 64px from the screen top (= status bar + 10-24px), + // 12px sides, 28px bottom. + padding: EdgeInsets.fromLTRB( + NooSpace.sm, + insets.top + NooSpace.md, + NooSpace.sm, + math.max(28, insets.bottom + NooSpace.sm), + ), + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: math.max( + 0, + constraints.maxHeight - + insets.top - + NooSpace.md - + math.max(28, insets.bottom + NooSpace.sm), + ), + ), + child: IntrinsicHeight( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + spacing: 4, + children: children, + ), + ), + ), + ), + ), + ); + } +} + +/// The drawer's account block: 48px [avatar], the account [name] (18 +/// Schibsted) over a [subtitle] (usually the server host, 13 fg-3), and a +/// 36px surface-2 circle with `chevron-down` to switch accounts. +class NooDrawerAccount extends StatelessWidget { + /// Usually `NooAvatar(size: 48, current: true, ...)`. + final Widget avatar; + final String name; + final String? subtitle; + + /// Tapping the block itself (e.g. open account settings). + final VoidCallback? onTap; + + /// The chevron button; hidden when null. + final VoidCallback? onSwitchAccount; + + const NooDrawerAccount({ + super.key, + required this.avatar, + required this.name, + this.subtitle, + this.onTap, + this.onSwitchAccount, + }); + + @override + Widget build(BuildContext context) { + final colors = context.nooColors; + return Padding( + padding: const EdgeInsets.only(bottom: 14), + child: Material( + type: MaterialType.transparency, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(NooRadii.input), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Row( + spacing: 12, + children: [ + avatar, + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + spacing: 5, + children: [ + Text( + name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: NooText.groupHeading.copyWith( + height: 1, + color: colors.fg1, + ), + ), + if (subtitle != null) + Text( + subtitle!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: NooText.meta.copyWith( + height: 1, + color: colors.fg3, + ), + ), + ], + ), + ), + if (onSwitchAccount != null) + Tooltip( + message: 'Switch account', + child: Material( + color: colors.surface2, + shape: const CircleBorder(), + child: InkWell( + customBorder: const CircleBorder(), + onTap: onSwitchAccount, + child: SizedBox.square( + dimension: 36, + child: Icon( + LucideIcons.chevronDown, + size: 18, + color: colors.fg1, + ), + ), + ), + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +/// The drawer's storage meter: a 6px [NooProgressBar] over a meta line +/// ("38.2 GB of 100 GB used"). +class NooDrawerStorage extends StatelessWidget { + /// 0-1. + final double value; + final String label; + + const NooDrawerStorage({super.key, required this.value, required this.label}); + + @override + Widget build(BuildContext context) { + final colors = context.nooColors; + return Padding( + padding: const EdgeInsets.fromLTRB(12, 0, 12, 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + spacing: 8, + children: [ + NooProgressBar(value: value), + Text( + label, + style: NooText.meta.copyWith(height: 1, color: colors.fg3), + ), + ], + ), + ); + } +} + +/// A 52px pill row in [NooDrawer]: 24px icon, 16/500 label, optional +/// trailing [count] (14/500 fg-3). [selected] gives it an accent-soft fill +/// with accent-text content (label at 600, matching the sidebar's active +/// item). +class NooDrawerItem extends StatelessWidget { + final IconData icon; + final String label; + final String? count; + final bool selected; + final VoidCallback? onTap; + + const NooDrawerItem({ + super.key, + required this.icon, + required this.label, + this.count, + this.selected = false, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + final colors = context.nooColors; + final fg = selected ? colors.accentText : colors.fg1; + return Semantics( + button: true, + selected: selected, + child: Material( + color: selected ? colors.accentSoft : Colors.transparent, + shape: const StadiumBorder(), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + child: SizedBox( + height: 52, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + spacing: 14, + children: [ + Icon(icon, size: 24, color: fg), + Expanded( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: NooText.bodyL.copyWith( + height: 1, + fontWeight: selected + ? FontWeight.w600 + : FontWeight.w500, + color: fg, + ), + ), + ), + if (count != null && count!.isNotEmpty) + Text( + count!, + style: NooText.body.copyWith( + fontSize: 14, + height: 1, + fontWeight: FontWeight.w500, + color: selected ? colors.accentText : colors.fg3, + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} + +/// The accent-text link at the bottom of [NooDrawer] ("Edit tabs"): 16px +/// icon, 14/600 label. +class NooDrawerLink extends StatelessWidget { + final String label; + final IconData icon; + final VoidCallback? onTap; + + const NooDrawerLink({ + super.key, + this.label = 'Edit tabs', + this.icon = LucideIcons.pencil, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + final colors = context.nooColors; + return Align( + alignment: AlignmentDirectional.centerStart, + child: Material( + type: MaterialType.transparency, + child: InkWell( + onTap: onTap, + customBorder: const StadiumBorder(), + child: Padding( + // Vertical padding only grows the hit target (to 44px); the + // 16px sides match the reference inset. + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + child: Row( + mainAxisSize: MainAxisSize.min, + spacing: 8, + children: [ + Icon(icon, size: 16, color: colors.accentText), + Text( + label, + style: NooText.buttonSm.copyWith( + height: 1, + color: colors.accentText, + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/widgets/noo/nav/noo_nav_style.dart b/lib/widgets/noo/nav/noo_nav_style.dart new file mode 100644 index 0000000..fae76e8 --- /dev/null +++ b/lib/widgets/noo/nav/noo_nav_style.dart @@ -0,0 +1,28 @@ +import 'package:flutter/widgets.dart'; + +/// Which platform's navigation chrome a mobile nav component draws +/// (DESIGN_SYSTEM.md 3, "Mobile"). Only the chrome differs per platform - +/// content components look the same everywhere, so this enum is only taken +/// by the `nav/` widgets ([NooBottomBar], [NooTopBar]). +enum NooNavStyle { + ios, + android; + + /// iOS (and macOS, when a mobile layout is shown there) get the iOS + /// chrome; everything else gets the Android chrome. + static NooNavStyle fromPlatform(TargetPlatform platform) => + switch (platform) { + TargetPlatform.iOS || TargetPlatform.macOS => NooNavStyle.ios, + _ => NooNavStyle.android, + }; +} + +/// One destination in a [NooBottomBar]. Data-agnostic on purpose - the app +/// maps its own tab model (e.g. `AppTab`) onto these. +@immutable +class NooNavDestination { + final IconData icon; + final String label; + + const NooNavDestination({required this.icon, required this.label}); +} diff --git a/lib/widgets/noo/nav/noo_sidebar.dart b/lib/widgets/noo/nav/noo_sidebar.dart new file mode 100644 index 0000000..9e08b8c --- /dev/null +++ b/lib/widgets/noo/nav/noo_sidebar.dart @@ -0,0 +1,320 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import '../../../theme/design_tokens.dart'; +import '../core/noo_progress_bar.dart'; + +/// The desktop sidebar (DESIGN_SYSTEM.md 3, "Desktop"; the 256px column in +/// `Desktop Screen.dc.html`): `bg` fill, 12/12/16 padding, 2px between +/// rows, and a 1px `line` on its trailing edge separating it from the +/// `surface` main pane. Slots top to bottom: +/// +/// 1. [windowControls] - e.g. macOS traffic lights (not built here) +/// 2. [account] - usually a [NooSidebarAccount] (14px gap below it) +/// 3. [items] - the pinned tabs, a [NooSidebarDivider], then the rest, as +/// [NooSidebarItem]s +/// 4. a spacer, then [storage] (usually a [NooSidebarStorage]) and +/// [settings] (a [NooSidebarItem]) +/// +/// Scrolls when the window is too short, keeping the bottom slots pinned +/// down when it isn't. +class NooSidebar extends StatelessWidget { + final Widget? windowControls; + final Widget? account; + final List items; + final Widget? storage; + final Widget? settings; + + /// Draw the 1px trailing-edge `line`. Turn off if the main pane draws + /// its own leading border. + final bool showDivider; + + const NooSidebar({ + super.key, + this.windowControls, + this.account, + this.items = const [], + this.storage, + this.settings, + this.showDivider = true, + }); + + @override + Widget build(BuildContext context) { + final colors = context.nooColors; + const padding = EdgeInsets.fromLTRB(12, 12, 12, 16); + + return Container( + width: NooSizes.sidebarWidth, + decoration: BoxDecoration( + color: colors.bg, + border: showDivider + ? BorderDirectional(end: BorderSide(color: colors.line)) + : null, + ), + child: Material( + type: MaterialType.transparency, + child: LayoutBuilder( + builder: (context, constraints) => SingleChildScrollView( + padding: padding, + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: math.max( + 0, + constraints.maxHeight - padding.vertical, + ), + ), + child: IntrinsicHeight( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + spacing: 2, + children: [ + ?windowControls, + if (account != null) + Padding( + padding: const EdgeInsets.only(bottom: 14), + child: account, + ), + ...items, + const Spacer(), + ?storage, + ?settings, + ], + ), + ), + ), + ), + ), + ), + ); + } +} + +/// A 38px sidebar row: 18px icon, 14/500 label, optional trailing [count] +/// (12/500). Idle: fg-1 label, fg-2 icon, fg-3 count, `surface` on hover. +/// Active ([selected]): accent-soft fill, everything accent-text, label at +/// 600. Radius 12. +class NooSidebarItem extends StatelessWidget { + final IconData icon; + final String label; + final String? count; + final bool selected; + final VoidCallback? onTap; + + const NooSidebarItem({ + super.key, + required this.icon, + required this.label, + this.count, + this.selected = false, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + final colors = context.nooColors; + final radius = BorderRadius.circular(NooRadii.sidebarItem); + return Semantics( + button: true, + selected: selected, + child: Material( + color: selected ? colors.accentSoft : Colors.transparent, + borderRadius: radius, + child: InkWell( + onTap: onTap, + borderRadius: radius, + hoverColor: selected ? Colors.transparent : colors.surface, + splashFactory: NoSplash.splashFactory, + child: SizedBox( + height: NooSizes.sidebarItem, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Row( + spacing: 12, + children: [ + Icon( + icon, + size: 18, + color: selected ? colors.accentText : colors.fg2, + ), + Expanded( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: NooText.body.copyWith( + fontSize: 14, + height: 1, + fontWeight: selected + ? FontWeight.w600 + : FontWeight.w500, + color: selected ? colors.accentText : colors.fg1, + ), + ), + ), + if (count != null && count!.isNotEmpty) + Text( + count!, + style: NooText.body.copyWith( + fontSize: 12, + height: 1, + fontWeight: FontWeight.w500, + color: selected ? colors.accentText : colors.fg3, + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} + +/// The 1px `line` between the pinned and remaining tabs, inset 12px with +/// 8px above and below. +class NooSidebarDivider extends StatelessWidget { + const NooSidebarDivider({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + height: 1, + margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + color: context.nooColors.line, + ); + } +} + +/// The account switcher card at the top of [NooSidebar]: a `surface` card +/// (radius 14, 8/10 padding) with a 32px [avatar], [name] (14/600) over +/// [subtitle] (12, fg-3), and a `chevron-right` in fg-3. +class NooSidebarAccount extends StatelessWidget { + /// Usually `NooAvatar(size: 32, current: true, ...)`. + final Widget avatar; + final String name; + final String? subtitle; + final VoidCallback? onTap; + + const NooSidebarAccount({ + super.key, + required this.avatar, + required this.name, + this.subtitle, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + final colors = context.nooColors; + final radius = BorderRadius.circular(NooRadii.input); + return Material( + color: colors.surface, + borderRadius: radius, + child: InkWell( + onTap: onTap, + borderRadius: radius, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + child: Row( + spacing: 10, + children: [ + avatar, + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + spacing: 4, + children: [ + Text( + name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: NooText.body.copyWith( + fontSize: 14, + height: 1, + fontWeight: FontWeight.w600, + color: colors.fg1, + ), + ), + if (subtitle != null) + Text( + subtitle!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: NooText.meta.copyWith( + fontSize: 12, + height: 1, + color: colors.fg3, + ), + ), + ], + ), + ), + Icon(LucideIcons.chevronRight, size: 16, color: colors.fg3), + ], + ), + ), + ), + ); + } +} + +/// The sidebar storage meter: "Storage" (12/500 fg-1) and a [detail] +/// ("38.2 of 100 GB", 12 fg-2) on one line, over a 6px [NooProgressBar]. +class NooSidebarStorage extends StatelessWidget { + /// 0-1. + final double value; + final String detail; + final String label; + + const NooSidebarStorage({ + super.key, + required this.value, + required this.detail, + this.label = 'Storage', + }); + + @override + Widget build(BuildContext context) { + final colors = context.nooColors; + final meta = NooText.meta.copyWith( + fontSize: 12, + height: 1, + color: colors.fg2, + ); + return Padding( + padding: const EdgeInsets.fromLTRB(12, 12, 12, 14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + spacing: 8, + children: [ + Row( + children: [ + Text( + label, + style: meta.copyWith( + fontWeight: FontWeight.w500, + color: colors.fg1, + ), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + detail, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.end, + style: meta, + ), + ), + ], + ), + NooProgressBar(value: value), + ], + ), + ); + } +} diff --git a/lib/widgets/noo/nav/noo_toolbar.dart b/lib/widgets/noo/nav/noo_toolbar.dart new file mode 100644 index 0000000..18e85d6 --- /dev/null +++ b/lib/widgets/noo/nav/noo_toolbar.dart @@ -0,0 +1,142 @@ +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import '../../../theme/design_tokens.dart'; + +/// The desktop main-pane toolbar (DESIGN_SYSTEM.md 3, "Desktop"; the 64px +/// header in `Desktop Screen.dc.html`): 24px leading / 20px trailing +/// padding, 12px gaps, a 1px `line` along the bottom. Left to right: +/// +/// - optional back/forward chevrons ([showHistoryNav] - macOS only; Windows +/// doesn't show them), 20px, fg-3 +/// - the [title] (22 Schibsted) - or [titleWidget] instead, e.g. a +/// breadcrumb in Files; it inherits the title [DefaultTextStyle] +/// - flexible space +/// - the [search] slot, 260px wide (pass a `NooSearchField(onSurface: true)`) +/// - [actions] - `NooSegmentedControl`, `NooButton(size: toolbar)`, etc. +/// +/// A [PreferredSizeWidget], so it can be the main pane's `Scaffold.appBar`. +/// No [SafeArea]: it never sits under a status bar. +class NooToolbar extends StatelessWidget implements PreferredSizeWidget { + final String? title; + final Widget? titleWidget; + final Widget? search; + final List actions; + + final bool showHistoryNav; + + /// Back/forward handlers for [showHistoryNav]; a null one draws that + /// chevron disabled. + final VoidCallback? onBack; + final VoidCallback? onForward; + + /// Defaults to `surface` - the toolbar belongs to the main pane. + final Color? backgroundColor; + + const NooToolbar({ + super.key, + this.title, + this.titleWidget, + this.search, + this.actions = const [], + this.showHistoryNav = false, + this.onBack, + this.onForward, + this.backgroundColor, + }) : assert( + title == null || titleWidget == null, + 'Pass either title or titleWidget, not both.', + ); + + @override + Size get preferredSize => const Size.fromHeight(NooSizes.toolbar); + + @override + Widget build(BuildContext context) { + final colors = context.nooColors; + final titleStyle = NooText.title.copyWith(height: 1, color: colors.fg1); + + return Material( + color: backgroundColor ?? colors.surface, + child: Container( + height: NooSizes.toolbar, + padding: const EdgeInsetsDirectional.only(start: 24, end: 20), + decoration: BoxDecoration( + border: Border(bottom: BorderSide(color: colors.line)), + ), + child: Row( + spacing: 12, + children: [ + if (showHistoryNav) + Padding( + padding: const EdgeInsetsDirectional.only(end: 4), + child: Row( + mainAxisSize: MainAxisSize.min, + spacing: 4, + children: [ + _HistoryButton( + icon: LucideIcons.chevronLeft, + tooltip: 'Back', + onTap: onBack, + ), + _HistoryButton( + icon: LucideIcons.chevronRight, + tooltip: 'Forward', + onTap: onForward, + ), + ], + ), + ), + Expanded( + child: Align( + alignment: AlignmentDirectional.centerStart, + child: DefaultTextStyle( + style: titleStyle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + child: titleWidget ?? Text(title ?? ''), + ), + ), + ), + if (search != null) SizedBox(width: 260, child: search), + ...actions, + ], + ), + ), + ); + } +} + +/// A 20px fg-3 chevron with a 28px hover/press target. Dimmed when +/// [onTap] is null (nothing to go back/forward to). +class _HistoryButton extends StatelessWidget { + final IconData icon; + final String tooltip; + final VoidCallback? onTap; + + const _HistoryButton({ + required this.icon, + required this.tooltip, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final colors = context.nooColors; + return Tooltip( + message: tooltip, + child: Opacity( + opacity: onTap == null ? 0.45 : 1, + child: InkResponse( + onTap: onTap, + radius: 16, + splashFactory: NoSplash.splashFactory, + hoverColor: colors.surface2, + child: SizedBox.square( + dimension: 28, + child: Icon(icon, size: 20, color: colors.fg3), + ), + ), + ), + ); + } +} diff --git a/lib/widgets/noo/nav/noo_top_bar.dart b/lib/widgets/noo/nav/noo_top_bar.dart new file mode 100644 index 0000000..f62bd37 --- /dev/null +++ b/lib/widgets/noo/nav/noo_top_bar.dart @@ -0,0 +1,296 @@ +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import '../../../theme/design_tokens.dart'; +import 'noo_nav_style.dart'; + +export 'noo_nav_style.dart'; + +// iOS large-title block: 2px above, a 34/0.9 line box (30.6 -> 31), then +// 12px below (14 when there's no search field under it, per the `iSet` +// Settings header in `Mobile Screen.dc.html`). +const double _iosRow = 44; +const double _iosTitleTop = 2; +const double _iosTitleBox = 31; +const double _iosSearch = 38; +const double _iosSearchBottom = 12; +const double _androidRow = 64; + +/// Mobile top bar (DESIGN_SYSTEM.md 3, "Mobile"; `iBar`/`aBar`/`iSet`/ +/// `aSet` in `Mobile Screen.dc.html`). +/// +/// - [NooNavStyle.ios]: a 44px row with [leading] on the left and +/// [actions] on the right, all in accent-text; then the 34px large +/// [title]; then the optional [search] slot (laid out at 38px - pass a +/// `NooSearchField(ios: true)`). +/// - [NooNavStyle.android]: one 64px row: [leading], the 22px [title], then +/// [actions] (fg-1). Android has no [search] slot - search is an action +/// icon there, so [search] is ignored. +/// +/// **Why a [PreferredSizeWidget] and not a sliver:** every part of it has a +/// fixed height, so it drops straight into `Scaffold.appBar` (which adds +/// the status-bar inset on top of [preferredSize]; the bar applies that +/// inset itself via [SafeArea]). It's still a plain box widget, so a screen +/// that wants the iOS large title to scroll away with content can put it in +/// a `SliverToBoxAdapter` (or a `SliverPersistentHeader` with a fixed +/// extent) instead - no sliver-specific variant needed. A collapsing +/// large-title-to-inline-title animation isn't in the spec, so it isn't +/// built here. +/// +/// Use [NooTopBarButton] for icon actions and [NooTopBarBack] for a pushed +/// screen's back button; both size themselves for the current [style]. +class NooTopBar extends StatelessWidget implements PreferredSizeWidget { + final NooNavStyle style; + final String title; + + /// Replaces the default leading button - e.g. a [NooTopBarBack] on a + /// pushed screen. When null, a `menu` [NooTopBarButton] calling [onMenu] + /// is shown if [onMenu] is set; otherwise nothing. + final Widget? leading; + final VoidCallback? onMenu; + + /// Trailing widgets, left to right: [NooTopBarButton]s, an avatar, etc. + /// Icons inherit the bar's icon color (accent-text on iOS, fg-1 on + /// Android) through [IconTheme]. + final List actions; + + /// iOS only: the search field shown below the large title. + final Widget? search; + + /// Defaults to `bg` - the top bar sits on the screen background. + final Color? backgroundColor; + + const NooTopBar({ + super.key, + required this.style, + required this.title, + this.leading, + this.onMenu, + this.actions = const [], + this.search, + this.backgroundColor, + }); + + bool get _ios => style == NooNavStyle.ios; + + @override + Size get preferredSize { + if (!_ios) return const Size.fromHeight(_androidRow); + return Size.fromHeight( + _iosRow + + _iosTitleTop + + _iosTitleBox + + (search != null ? 12 + _iosSearch + _iosSearchBottom : 14), + ); + } + + @override + Widget build(BuildContext context) { + final colors = context.nooColors; + final lead = + leading ?? + (onMenu != null + ? NooTopBarButton( + icon: LucideIcons.menu, + tooltip: 'Menu', + onTap: onMenu, + ) + : null); + + final Widget body = _ios + ? _buildIos(colors, lead) + : _buildAndroid(colors, lead); + + return _NooTopBarScope( + style: style, + child: IconTheme.merge( + data: IconThemeData( + size: 24, + color: _ios ? colors.accentText : colors.fg1, + ), + child: Material( + color: backgroundColor ?? colors.bg, + child: SafeArea(bottom: false, child: body), + ), + ), + ); + } + + Widget _buildIos(NooColors colors, Widget? lead) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SizedBox( + height: _iosRow, + // 44px hit boxes around 24px icons add 10px each side, so 6px of + // row padding puts the icons 16px from the screen edge. + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 6), + child: Row(children: [?lead, const Spacer(), ...actions]), + ), + ), + Padding( + padding: EdgeInsets.fromLTRB( + NooSpace.md, + _iosTitleTop, + NooSpace.md, + search != null ? 12 : 14, + ), + child: SizedBox( + height: _iosTitleBox, + child: Align( + alignment: AlignmentDirectional.centerStart, + child: Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: NooText.largeTitle.copyWith( + leadingDistribution: TextLeadingDistribution.even, + color: colors.fg1, + ), + ), + ), + ), + ), + if (search != null) + Padding( + padding: const EdgeInsets.fromLTRB( + NooSpace.md, + 0, + NooSpace.md, + _iosSearchBottom, + ), + child: SizedBox(height: _iosSearch, child: search), + ), + ], + ); + } + + Widget _buildAndroid(NooColors colors, Widget? lead) { + return SizedBox( + height: _androidRow, + child: Padding( + padding: EdgeInsetsDirectional.only( + start: lead != null ? 4 : 16, + end: 8, + ), + child: Row( + spacing: 4, + children: [ + ?lead, + Expanded( + child: Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: NooText.title.copyWith(height: 1, color: colors.fg1), + ), + ), + ...actions, + ], + ), + ), + ); + } +} + +/// Lets [NooTopBarButton]/[NooTopBarBack] size themselves for the bar +/// they're in without the caller repeating the style. +class _NooTopBarScope extends InheritedWidget { + final NooNavStyle style; + + const _NooTopBarScope({required this.style, required super.child}); + + static NooNavStyle of(BuildContext context) => + context.dependOnInheritedWidgetOfExactType<_NooTopBarScope>()?.style ?? + NooNavStyle.android; + + @override + bool updateShouldNotify(_NooTopBarScope oldWidget) => + style != oldWidget.style; +} + +/// A 24px icon action for [NooTopBar] - a 44px (iOS) or 48px (Android) +/// round hit box, colored by the bar's [IconTheme]. +class NooTopBarButton extends StatelessWidget { + final IconData icon; + final VoidCallback? onTap; + final String? tooltip; + + const NooTopBarButton({ + super.key, + required this.icon, + this.onTap, + this.tooltip, + }); + + @override + Widget build(BuildContext context) { + final box = _NooTopBarScope.of(context) == NooNavStyle.ios ? 44.0 : 48.0; + Widget button = SizedBox.square( + dimension: box, + child: InkResponse( + onTap: onTap, + radius: box / 2, + child: Center(child: Icon(icon)), + ), + ); + if (tooltip != null) button = Tooltip(message: tooltip, child: button); + return Semantics(button: true, label: tooltip, child: button); + } +} + +/// Back button for a pushed screen's [NooTopBar.leading]. iOS: a +/// `chevron-left` plus the previous screen's [label] in 17px accent-text +/// ("‹ Files"). Android: an `arrow-left` icon button ([label] becomes its +/// tooltip). +class NooTopBarBack extends StatelessWidget { + final String label; + final VoidCallback? onTap; + + const NooTopBarBack({super.key, this.label = 'Back', this.onTap}); + + @override + Widget build(BuildContext context) { + final onPressed = onTap ?? () => Navigator.maybePop(context); + if (_NooTopBarScope.of(context) == NooNavStyle.android) { + return NooTopBarButton( + icon: LucideIcons.arrowLeft, + tooltip: label, + onTap: onPressed, + ); + } + final colors = context.nooColors; + return Semantics( + button: true, + child: InkResponse( + onTap: onPressed, + highlightShape: BoxShape.rectangle, + borderRadius: BorderRadius.circular(NooRadii.pill), + child: SizedBox( + height: 44, + child: Padding( + // The row already has 6px padding; 4 more lands the chevron at + // the reference's 10px inset. + padding: const EdgeInsetsDirectional.only(start: 4, end: 8), + child: Row( + mainAxisSize: MainAxisSize.min, + spacing: 2, + children: [ + Icon(LucideIcons.chevronLeft, color: colors.accentText), + Text( + label, + style: NooText.bodyL.copyWith( + fontSize: 17, + height: 1, + color: colors.accentText, + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/widgets/noo/noo_layout.dart b/lib/widgets/noo/noo_layout.dart new file mode 100644 index 0000000..34c3fb8 --- /dev/null +++ b/lib/widgets/noo/noo_layout.dart @@ -0,0 +1,27 @@ +import 'package:flutter/material.dart'; +import 'nav/noo_nav_style.dart'; + +/// The one place screens decide mobile vs desktop layout and iOS vs Android +/// chrome (DESIGN_SYSTEM.md 3), so every screen switches at the same point. +class NooLayout { + const NooLayout._(); + + /// Window width at which the shell swaps the bottom bar + drawer for the + /// desktop sidebar + toolbar, and screens switch to desktop recipes + /// (table rows, 5-column grids, 8-column photos, 24px gutter). + static const double desktopBreakpoint = 900; + + static bool isDesktop(BuildContext context) => + MediaQuery.sizeOf(context).width >= desktopBreakpoint; + + static NooNavStyle navStyle(BuildContext context) => + NooNavStyle.fromPlatform(Theme.of(context).platform); + + /// True where the spec calls for the iOS variant of a content detail + /// (`ellipsis` rather than `ellipsis-vertical` overflow, etc.). + static bool iosStyle(BuildContext context) => + navStyle(context) == NooNavStyle.ios; + + /// Horizontal content gutter: 12 on mobile (cards), 24 on desktop. + static double gutter(BuildContext context) => isDesktop(context) ? 24 : 12; +} diff --git a/lib/widgets/noo/overlays/noo_dialog.dart b/lib/widgets/noo/overlays/noo_dialog.dart new file mode 100644 index 0000000..9a64981 --- /dev/null +++ b/lib/widgets/noo/overlays/noo_dialog.dart @@ -0,0 +1,214 @@ +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import '../../../theme/design_tokens.dart'; + +/// Desktop counterpart of `showNooSheet`: a 540px, radius-24 card on the +/// scrim, carrying the design system's one allowed shadow +/// ([nooDialogShadow]). `showGeneralDialog` is used instead of +/// `showDialog` so the barrier takes the `scrim` token and the transition +/// follows [NooMotion] (fade + 0.98 scale, no bounce) rather than +/// Material's defaults. +Future showNooDialog( + BuildContext context, { + Widget? leading, + required String title, + String? subtitle, + required List children, + bool barrierDismissible = true, +}) { + final colors = context.nooColors; + return showGeneralDialog( + context: context, + barrierDismissible: barrierDismissible, + barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel, + barrierColor: colors.scrim, + transitionDuration: NooMotion.base, + pageBuilder: (dialogContext, _, _) => NooDialog( + leading: leading, + title: title, + subtitle: subtitle, + onClose: () => Navigator.of(dialogContext).pop(), + children: children, + ), + transitionBuilder: (_, animation, _, child) { + final curved = CurvedAnimation(parent: animation, curve: NooMotion.ease); + return FadeTransition( + opacity: curved, + child: ScaleTransition( + scale: Tween( + begin: NooMotion.pressScale, + end: 1, + ).animate(curved), + child: child, + ), + ); + }, + ); +} + +/// The dialog card itself, centered in whatever space it's given. Exposed +/// separately from [showNooDialog] so it can be embedded/tested without a +/// route. Sections are spaced 22px apart to match `showNooSheet`, so the +/// same share-sheet parts lay out identically in both. +class NooDialog extends StatelessWidget { + final Widget? leading; + final String title; + final String? subtitle; + final VoidCallback? onClose; + final List children; + final double width; + + const NooDialog({ + super.key, + this.leading, + required this.title, + this.subtitle, + this.onClose, + required this.children, + this.width = 540, + }); + + @override + Widget build(BuildContext context) { + final colors = context.nooColors; + // Padding (not MediaQuery maths) keeps a 24px margin on every side, so + // the card shrinks to fit narrow windows and short content-heavy + // dialogs scroll rather than overflow. + return Padding( + padding: const EdgeInsets.all(NooSpace.xl), + child: Center( + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: width), + child: Container( + decoration: BoxDecoration( + color: colors.surface, + borderRadius: BorderRadius.circular(NooRadii.dialog), + boxShadow: const [nooDialogShadow], + ), + // Material (not just a DecoratedBox) so TextFields/InkWells inside + // the dialog have an ancestor to paint on. + child: Material( + type: MaterialType.transparency, + child: Padding( + padding: const EdgeInsets.all(NooSpace.xl), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + NooDialogHeader( + leading: leading, + title: title, + subtitle: subtitle, + onClose: onClose, + ), + Flexible( + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (final child in children) ...[ + const SizedBox(height: 22), + child, + ], + ], + ), + ), + ), + ], + ), + ), + ), + ), + ), + ), + ); + } +} + +/// Desktop dialog header: leading slot (a 44px file tile for Share), +/// title, optional meta subtitle, and a 32px surface-2 close circle. +/// `NooOverlayHeader` is the mobile sheet variant (36px close). +class NooDialogHeader extends StatelessWidget { + final Widget? leading; + final String title; + final String? subtitle; + final VoidCallback? onClose; + + const NooDialogHeader({ + super.key, + this.leading, + required this.title, + this.subtitle, + this.onClose, + }); + + @override + Widget build(BuildContext context) { + final colors = context.nooColors; + return Row( + children: [ + if (leading != null) ...[leading!, const SizedBox(width: NooSpace.sm)], + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: NooText.sectionTitle.copyWith( + fontSize: 19, + color: colors.fg1, + ), + ), + if (subtitle != null) ...[ + const SizedBox(height: 5), + Text( + subtitle!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: NooText.meta.copyWith(color: colors.fg3), + ), + ], + ], + ), + ), + if (onClose != null) ...[ + const SizedBox(width: NooSpace.sm), + NooCloseButton(onTap: onClose!), + ], + ], + ); + } +} + +/// 32px surface-2 circle with an `x` - the dialog close control. +class NooCloseButton extends StatelessWidget { + final VoidCallback onTap; + final double size; + + const NooCloseButton({super.key, required this.onTap, this.size = 32}); + + @override + Widget build(BuildContext context) { + final colors = context.nooColors; + return Semantics( + button: true, + label: MaterialLocalizations.of(context).closeButtonTooltip, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onTap, + child: Container( + width: size, + height: size, + alignment: Alignment.center, + decoration: BoxDecoration( + color: colors.surface2, + shape: BoxShape.circle, + ), + child: Icon(LucideIcons.x, size: 16, color: colors.fg1), + ), + ), + ); + } +} diff --git a/lib/widgets/noo/overlays/noo_overlay_header.dart b/lib/widgets/noo/overlays/noo_overlay_header.dart new file mode 100644 index 0000000..c8392c2 --- /dev/null +++ b/lib/widgets/noo/overlays/noo_overlay_header.dart @@ -0,0 +1,66 @@ +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import '../../../theme/design_tokens.dart'; + +/// Sheet/dialog header: an optional leading tile, title, subtitle, close +/// circle (Noo Design System project, +/// `components/overlays/OverlayHeader.jsx`, mobile variant). [leading] is a +/// plain [Widget] rather than a file kind so this stays decoupled from the +/// file model - pass an `ItemThumbnail`/file-type tile for a file header. +class NooOverlayHeader extends StatelessWidget { + final Widget? leading; + final String title; + final String? subtitle; + final VoidCallback? onClose; + + const NooOverlayHeader({ + super.key, + this.leading, + required this.title, + this.subtitle, + this.onClose, + }); + + @override + Widget build(BuildContext context) { + final colors = context.nooColors; + return Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + if (leading != null) ...[leading!, const SizedBox(width: 12)], + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: NooText.sectionTitle.copyWith(fontSize: 19, color: colors.fg1), + ), + if (subtitle != null) ...[ + const SizedBox(height: 5), + Text(subtitle!, style: NooText.meta.copyWith(color: colors.fg3)), + ], + ], + ), + ), + if (onClose != null) + Semantics( + button: true, + label: 'Close', + child: GestureDetector( + onTap: onClose, + child: Container( + width: 36, + height: 36, + alignment: Alignment.center, + decoration: BoxDecoration(color: colors.surface2, shape: BoxShape.circle), + child: Icon(LucideIcons.x, size: 18, color: colors.fg1), + ), + ), + ), + ], + ); + } +} diff --git a/lib/widgets/noo/overlays/noo_share_parts.dart b/lib/widgets/noo/overlays/noo_share_parts.dart new file mode 100644 index 0000000..5125d6a --- /dev/null +++ b/lib/widgets/noo/overlays/noo_share_parts.dart @@ -0,0 +1,158 @@ +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import '../../../theme/design_tokens.dart'; + +// Building blocks for the share sheet (mobile, via `showNooSheet`) and the +// share dialog (desktop, via `showNooDialog`) - DESIGN_SYSTEM.md section 4, +// "Share sheet / dialog". Both containers already space their children +// 22px apart, so each [NooShareSection] is one of those children. + +/// A titled share-sheet section ("Share with people", "Share link", "Send +/// file directly"). [trailing] sits on the title row - the link section's +/// `NooToggle`. [caption] is the meta line under the content (e.g. "Link +/// settings don't apply"). +class NooShareSection extends StatelessWidget { + final String title; + final Widget? trailing; + final Widget child; + final String? caption; + + const NooShareSection({ + super.key, + required this.title, + this.trailing, + required this.child, + this.caption, + }); + + @override + Widget build(BuildContext context) { + final colors = context.nooColors; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Expanded( + child: Text(title, style: NooText.sectionTitle.copyWith(color: colors.fg1)), + ), + ?trailing, + ], + ), + const SizedBox(height: NooSpace.sm), + child, + if (caption != null) ...[ + const SizedBox(height: NooSpace.xs), + Text(caption!, style: NooText.meta.copyWith(color: colors.fg3)), + ], + ], + ); + } +} + +/// One person/group with access: avatar, name, subtitle, then either the +/// owner label or a tappable permission pill ("Can edit ▾"). Owner rows +/// aren't interactive, so they get plain fg-3 text instead of a pill - the +/// pill shape is reserved for things that open a menu. +class NooPersonAccessRow extends StatelessWidget { + /// Typically a 36px `NooAvatar`. + final Widget avatar; + final String name; + final String? subtitle; + final bool owner; + /// Pill label for non-owners, e.g. "Can edit" / "Can view". + final String permission; + final VoidCallback? onPermissionTap; + final String ownerLabel; + + const NooPersonAccessRow({ + super.key, + required this.avatar, + required this.name, + this.subtitle, + this.owner = false, + this.permission = 'Can edit', + this.onPermissionTap, + this.ownerLabel = 'Owner', + }); + + @override + Widget build(BuildContext context) { + final colors = context.nooColors; + return ConstrainedBox( + constraints: const BoxConstraints(minHeight: 52), + child: Row( + children: [ + avatar, + const SizedBox(width: NooSpace.sm), + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: NooText.body.copyWith( + height: 1.2, + fontWeight: FontWeight.w500, + color: colors.fg1, + ), + ), + if (subtitle != null) ...[ + const SizedBox(height: 3), + Text( + subtitle!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: NooText.meta.copyWith(color: colors.fg3), + ), + ], + ], + ), + ), + const SizedBox(width: NooSpace.sm), + if (owner) + Text(ownerLabel, style: NooText.label.copyWith(color: colors.fg3)) + else + NooPermissionPill(label: permission, onTap: onPermissionTap), + ], + ), + ); + } +} + +/// Surface-2 pill with a trailing chevron that opens a permission menu. +/// Also used for the link section's permission option. +class NooPermissionPill extends StatelessWidget { + final String label; + final VoidCallback? onTap; + + const NooPermissionPill({super.key, required this.label, this.onTap}); + + @override + Widget build(BuildContext context) { + final colors = context.nooColors; + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onTap, + child: Container( + height: NooSizes.buttonCompact, + padding: const EdgeInsets.only(left: NooSpace.sm, right: 10), + decoration: BoxDecoration( + color: colors.surface2, + borderRadius: BorderRadius.circular(NooRadii.pill), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(label, style: NooText.label.copyWith(height: 1, color: colors.fg1)), + const SizedBox(width: NooSpace.xxs), + Icon(LucideIcons.chevronDown, size: 14, color: colors.fg2), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/noo/overlays/noo_sheet.dart b/lib/widgets/noo/overlays/noo_sheet.dart new file mode 100644 index 0000000..78fbfb1 --- /dev/null +++ b/lib/widgets/noo/overlays/noo_sheet.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart'; +import '../../../theme/design_tokens.dart'; + +/// Mobile bottom sheet on a scrim: radius-28 top, grabber, 22px gap between +/// sections (Noo Design System project, `components/overlays/Sheet.jsx`). +/// `showModalBottomSheet` already supplies the scrim/backdrop-dismiss +/// machinery, so this only standardizes the shape/padding - use it instead +/// of a bare `showModalBottomSheet` for any new sheet. Scroll-controlled so a +/// tall sheet (e.g. share) can grow past half the screen; content scrolls +/// once it hits the top. +Future showNooSheet( + BuildContext context, { + required List children, +}) { + final colors = context.nooColors; + return showModalBottomSheet( + context: context, + backgroundColor: colors.surface, + barrierColor: colors.scrim, + isScrollControlled: true, + useSafeArea: true, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(NooRadii.sheetTop)), + ), + builder: (context) { + return SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(20, 10, 20, 20), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 36, + height: 5, + decoration: BoxDecoration( + color: colors.surface3, + borderRadius: BorderRadius.circular(NooRadii.pill), + ), + ), + for (final child in children) ...[ + const SizedBox(height: 22), + child, + ], + ], + ), + ), + ); + }, + ); +} diff --git a/lib/widgets/noo/overlays/noo_text_field.dart b/lib/widgets/noo/overlays/noo_text_field.dart new file mode 100644 index 0000000..524f21b --- /dev/null +++ b/lib/widgets/noo/overlays/noo_text_field.dart @@ -0,0 +1,87 @@ +import 'package:flutter/material.dart'; +import '../../../theme/design_tokens.dart'; + +/// Radius-14 inset input (share "Name, email or group", link URL). Unlike +/// the pill `NooSearchField`, this is the inset-panel shape from the radius +/// scale. [trailing] hosts an inline 40px button (`NooButtonSize.field`), +/// e.g. "Copy link" - the field then tightens its right padding to 4 so the +/// button sits inset inside the rounded box. [mono] switches to the mono +/// style for URLs and local paths. +class NooTextField extends StatelessWidget { + final TextEditingController? controller; + final FocusNode? focusNode; + final String? placeholder; + final ValueChanged? onChanged; + final ValueChanged? onSubmitted; + final IconData? leadingIcon; + final Widget? trailing; + final bool mono; + final bool readOnly; + final bool autofocus; + final TextInputType? keyboardType; + /// True (default) on a `surface` background - uses surface-2 fill. Pass + /// false when placed on `bg`, which uses surface instead. + final bool onSurface; + + const NooTextField({ + super.key, + this.controller, + this.focusNode, + this.placeholder, + this.onChanged, + this.onSubmitted, + this.leadingIcon, + this.trailing, + this.mono = false, + this.readOnly = false, + this.autofocus = false, + this.keyboardType, + this.onSurface = true, + }); + + @override + Widget build(BuildContext context) { + final colors = context.nooColors; + final base = mono + ? NooText.mono.copyWith(fontSize: 13) + : NooText.body.copyWith(fontSize: 15, height: 1.2); + + return Container( + constraints: const BoxConstraints(minHeight: 48), + padding: EdgeInsets.only(left: NooSpace.smd, right: trailing != null ? 4 : NooSpace.smd), + decoration: BoxDecoration( + color: onSurface ? colors.surface2 : colors.surface, + borderRadius: BorderRadius.circular(NooRadii.input), + ), + child: Row( + children: [ + if (leadingIcon != null) ...[ + Icon(leadingIcon, size: 18, color: colors.fg3), + const SizedBox(width: NooSpace.xs), + ], + Expanded( + child: TextField( + controller: controller, + focusNode: focusNode, + onChanged: onChanged, + onSubmitted: onSubmitted, + readOnly: readOnly, + autofocus: autofocus, + keyboardType: keyboardType, + style: base.copyWith(color: colors.fg1), + cursorColor: colors.accentText, + decoration: InputDecoration( + isDense: true, + border: InputBorder.none, + contentPadding: const EdgeInsets.symmetric(vertical: NooSpace.smd), + hintText: placeholder, + hintStyle: base.copyWith(color: colors.fg3), + ), + ), + ), + if (trailing != null) ...[const SizedBox(width: NooSpace.xs), trailing!], + ], + ), + ); + } +} diff --git a/lib/widgets/profile_avatar_button.dart b/lib/widgets/profile_avatar_button.dart index f5e44c9..cbf5ef9 100644 --- a/lib/widgets/profile_avatar_button.dart +++ b/lib/widgets/profile_avatar_button.dart @@ -3,6 +3,7 @@ import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import '../providers/session_controller.dart'; import '../views/account_view.dart'; +import 'noo/core/noo_avatar.dart'; /// Top-bar avatar button that opens the account screen. Replaces the old /// "Account" bottom-nav destination, matching how Google Drive surfaces @@ -38,7 +39,6 @@ class ProfileAvatarButton extends StatelessWidget { @override Widget build(BuildContext context) { final provider = context.watch(); - final colorScheme = Theme.of(context).colorScheme; final initial = provider.username.isNotEmpty ? provider.username[0].toUpperCase() : '?'; @@ -55,18 +55,7 @@ class ProfileAvatarButton extends StatelessWidget { MaterialPageRoute(builder: (_) => const AccountView()), ); }, - icon: CircleAvatar( - radius: 17, - backgroundColor: colorScheme.primary, - child: Text( - initial, - style: TextStyle( - color: colorScheme.onPrimary, - fontWeight: FontWeight.bold, - fontSize: 14, - ), - ), - ), + icon: NooAvatar(initials: initial, current: true, size: 32), ), ), ); diff --git a/lib/widgets/segmented_icon_toggle.dart b/lib/widgets/segmented_icon_toggle.dart index ae78b10..47c0780 100644 --- a/lib/widgets/segmented_icon_toggle.dart +++ b/lib/widgets/segmented_icon_toggle.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import '../theme/design_tokens.dart'; /// A single icon button that fills with a solid circle when active — /// the building block for Drive-style toggle controls. Thin wrapper around @@ -21,7 +22,7 @@ class ToggleIconButton extends StatelessWidget { @override Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; + final colors = context.nooColors; return IconButton( isSelected: isSelected, @@ -33,12 +34,12 @@ class ToggleIconButton extends StatelessWidget { shape: const WidgetStatePropertyAll(CircleBorder()), foregroundColor: WidgetStateProperty.resolveWith( (states) => states.contains(WidgetState.selected) - ? colorScheme.onPrimaryContainer - : colorScheme.onSurfaceVariant, + ? colors.accentText + : colors.fg2, ), backgroundColor: WidgetStateProperty.resolveWith( (states) => states.contains(WidgetState.selected) - ? colorScheme.primaryContainer + ? colors.accentSoft : Colors.transparent, ), ), @@ -55,12 +56,12 @@ class SegmentedIconGroup extends StatelessWidget { @override Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; + final colors = context.nooColors; return Container( - padding: const EdgeInsets.all(2), + padding: const EdgeInsets.all(3), decoration: BoxDecoration( - color: colorScheme.surfaceContainerHigh, - borderRadius: BorderRadius.circular(20), + color: colors.surface, + borderRadius: BorderRadius.circular(NooRadii.pill), ), child: Row(mainAxisSize: MainAxisSize.min, children: children), ); diff --git a/lib/widgets/selectable_thumbnail.dart b/lib/widgets/selectable_thumbnail.dart deleted file mode 100644 index 2a6d71e..0000000 --- a/lib/widgets/selectable_thumbnail.dart +++ /dev/null @@ -1,53 +0,0 @@ -import 'package:flutter/material.dart'; - -/// Wraps a thumbnail so it can show the multi-select checkmark state: -/// dimmed with a checkmark overlaid once selected, matching the Google -/// Photos-style long-press-to-select pattern. Shared by the Files and -/// Photos tabs. -class SelectableThumbnail extends StatelessWidget { - final bool isSelected; - final Widget child; - - /// Fixed width/height for a small square thumbnail (Files list/grid - /// tiles). Leave null to fill whatever space the parent already gives it - /// instead (e.g. a Photos grid cell, whose size comes from the grid - /// layout rather than a fixed constant). - final double? size; - final double checkmarkSize; - - const SelectableThumbnail({ - super.key, - required this.isSelected, - required this.child, - this.size, - this.checkmarkSize = 28, - }); - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - final stack = Stack( - fit: StackFit.expand, - children: [ - Positioned.fill( - child: AnimatedOpacity( - opacity: isSelected ? 0.35 : 1, - duration: const Duration(milliseconds: 150), - child: child, - ), - ), - if (isSelected) - Center( - child: Icon( - Icons.check_circle_rounded, - color: colorScheme.primary, - size: checkmarkSize, - ), - ), - ], - ); - - if (size == null) return stack; - return SizedBox(width: size, height: size, child: stack); - } -} diff --git a/lib/widgets/settings/settings_account_card.dart b/lib/widgets/settings/settings_account_card.dart new file mode 100644 index 0000000..7f6cb6a --- /dev/null +++ b/lib/widgets/settings/settings_account_card.dart @@ -0,0 +1,154 @@ +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:provider/provider.dart'; +import '../../providers/files_controller.dart'; +import '../../providers/session_controller.dart'; +import '../../theme/design_tokens.dart'; +import '../noo/core/noo_avatar.dart'; +import '../noo/core/noo_button.dart'; +import '../noo/core/noo_chip.dart'; +import '../noo/core/noo_progress_bar.dart'; +import '../noo/noo_layout.dart'; +import '../shell/shell_common.dart'; +import 'settings_dialogs.dart'; + +/// Settings section 1: the account card (DESIGN_SYSTEM.md 4 - "NooAvatar +/// 52-56, name, server, storage NooProgressBar"). The quota/quick-actions +/// (refresh/log out/remove) aren't in the spec's short recipe but were on +/// the pre-rework card and have no other home, so they stay here as a +/// button row under the storage meter. +class SettingsAccountCard extends StatelessWidget { + const SettingsAccountCard({super.key}); + + Future _refresh(BuildContext context, FilesController files) async { + await files.refreshData(); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Refreshed WebDAV data'), + behavior: SnackBarBehavior.floating, + ), + ); + } + } + + /// Logout keeps the account saved (see [SessionController.logout]) so + /// it's harmless/reversible from a one-tap resume on the login screen - + /// doesn't need a confirmation dialog the way removing an account does. + Future _logout(BuildContext context, SessionController session) async { + final navigator = Navigator.of(context); + await session.logout(); + navigator.popUntil((route) => route.isFirst); + } + + Future _remove(BuildContext context, SessionController session) async { + final id = session.activeAccountId; + if (id == null) return; + await confirmRemoveAccount( + context, + session, + accountId: id, + username: session.username, + host: serverHost(session.serverUrl), + isActive: true, + ); + } + + @override + Widget build(BuildContext context) { + final colors = context.nooColors; + final session = context.watch(); + final files = context.watch(); + final quota = files.quota; + final name = quota?.userName ?? session.username; + + return Container( + padding: const EdgeInsets.all(NooSpace.lg), + decoration: BoxDecoration( + color: colors.surface, + borderRadius: BorderRadius.circular(NooRadii.card), + border: NooLayout.isDesktop(context) ? Border.all(color: colors.line) : null, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + NooAvatar(initials: accountInitial(name), current: true, size: 56), + const SizedBox(width: NooSpace.sm), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: NooText.cardTitle.copyWith(color: colors.fg1), + ), + const SizedBox(height: 2), + Text( + serverHost(session.serverUrl), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: NooText.meta.copyWith(color: colors.fg3), + ), + ], + ), + ), + ], + ), + const SizedBox(height: NooSpace.lg), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('Storage', style: NooText.label.copyWith(color: colors.fg2)), + Text( + quota != null ? quotaDetail(quota) : 'Loading…', + style: NooText.meta.copyWith(color: colors.fg3), + ), + ], + ), + const SizedBox(height: 8), + NooProgressBar(value: quotaFraction(quota)), + if (quota != null && quota.groups.isNotEmpty) ...[ + const SizedBox(height: NooSpace.md), + Wrap( + spacing: 8, + runSpacing: 8, + children: [for (final group in quota.groups) NooChip(child: Text(group))], + ), + ], + const SizedBox(height: NooSpace.md), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + NooButton( + variant: NooButtonVariant.secondary, + size: NooButtonSize.compact, + icon: LucideIcons.refreshCw, + onTap: () => _refresh(context, files), + child: const Text('Refresh'), + ), + NooButton( + variant: NooButtonVariant.secondary, + size: NooButtonSize.compact, + icon: LucideIcons.logOut, + onTap: () => _logout(context, session), + child: const Text('Log out'), + ), + NooButton( + variant: NooButtonVariant.danger, + size: NooButtonSize.compact, + icon: LucideIcons.trash2, + onTap: () => _remove(context, session), + child: const Text('Remove account'), + ), + ], + ), + ], + ), + ); + } +} diff --git a/lib/widgets/settings/settings_accounts.dart b/lib/widgets/settings/settings_accounts.dart new file mode 100644 index 0000000..6095ce3 --- /dev/null +++ b/lib/widgets/settings/settings_accounts.dart @@ -0,0 +1,128 @@ +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:provider/provider.dart'; +import '../../models/saved_account.dart'; +import '../../providers/session_controller.dart'; +import '../../theme/design_tokens.dart'; +import '../../views/login_view.dart'; +import '../noo/core/noo_avatar.dart'; +import '../noo/lists/noo_settings_row.dart'; +import '../shell/shell_common.dart'; +import 'settings_dialogs.dart'; +import 'settings_section.dart'; + +/// Settings section 2: every saved account (switch/remove), then "Add +/// account" (DESIGN_SYSTEM.md 4). +class SettingsAccountsSection extends StatelessWidget { + const SettingsAccountsSection({super.key}); + + @override + Widget build(BuildContext context) { + final session = context.watch(); + + return SettingsSection( + title: 'Accounts', + children: [ + for (final account in session.accounts) + _SavedAccountRow( + account: account, + isActive: account.id == session.activeAccountId, + onTap: account.id == session.activeAccountId + ? null + : () => session.switchAccount(account.id), + onRemove: () => confirmRemoveAccount( + context, + session, + accountId: account.id, + username: account.username, + host: serverHost(account.serverUrl), + isActive: account.id == session.activeAccountId, + ), + ), + NooSettingsRow( + icon: LucideIcons.userPlus, + accent: true, + label: const Text('Add account'), + onTap: () => Navigator.push( + context, + MaterialPageRoute(builder: (_) => const LoginView(isAddingAccount: true)), + ), + ), + ], + ); + } +} + +/// A saved-account row: avatar, name over host, a check on the active one, +/// and a remove button - [NooSettingsRow] can't show an avatar leading +/// widget (only an [IconData]), so this is its own row built to the same +/// proportions (mirrors `shell_common.dart`'s private `_AccountSwitchRow`, +/// which the account switcher sheet/dialog uses for the same data). +class _SavedAccountRow extends StatelessWidget { + final SavedAccount account; + final bool isActive; + final VoidCallback? onTap; + final VoidCallback onRemove; + + const _SavedAccountRow({ + required this.account, + required this.isActive, + required this.onTap, + required this.onRemove, + }); + + @override + Widget build(BuildContext context) { + final colors = context.nooColors; + return Material( + color: colors.surface, + child: InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: NooSpace.md, vertical: 10), + child: Row( + spacing: 12, + children: [ + NooAvatar(initials: accountInitial(account.username), current: isActive, size: 36), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 2, + children: [ + Text( + account.username, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: NooText.bodyL.copyWith( + height: 1.1, + fontWeight: FontWeight.w500, + color: colors.fg1, + ), + ), + Text( + serverHost(account.serverUrl), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: NooText.meta.copyWith(color: colors.fg3), + ), + ], + ), + ), + if (isActive) Icon(LucideIcons.checkCircle, size: 20, color: colors.accentText), + GestureDetector( + onTap: onRemove, + child: Container( + width: 36, + height: 36, + alignment: Alignment.center, + decoration: BoxDecoration(color: colors.surface2, shape: BoxShape.circle), + child: Icon(LucideIcons.trash2, size: 16, color: colors.danger), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/widgets/settings/settings_appearance.dart b/lib/widgets/settings/settings_appearance.dart new file mode 100644 index 0000000..8c24209 --- /dev/null +++ b/lib/widgets/settings/settings_appearance.dart @@ -0,0 +1,266 @@ +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:provider/provider.dart'; +import '../../providers/settings_controller.dart'; +import '../../theme/app_theme.dart'; +import '../../theme/design_tokens.dart'; +import '../noo/core/noo_segmented_control.dart'; +import '../noo/core/noo_toggle.dart'; +import '../noo/lists/noo_settings_row.dart'; +import '../noo/noo_layout.dart'; +import '../noo/overlays/noo_dialog.dart'; +import '../noo/overlays/noo_sheet.dart'; +import '../seek_bar_painter.dart'; +import 'settings_section.dart'; + +String _seekBarStyleLabel(MediaProgressBarStyle style) { + switch (style) { + case MediaProgressBarStyle.classic: + return 'Default'; + case MediaProgressBarStyle.wavy: + return 'Wavy'; + case MediaProgressBarStyle.slim: + return 'Slim'; + case MediaProgressBarStyle.squiggly: + return 'Squiggly'; + } +} + +/// Settings section 6: appearance. Only the theme mode (System/Light/Dark) +/// is in the design system's own recipe - the accent-color picker, dynamic +/// color, AMOLED and the video seek-bar style are all settings that predate +/// the design system and have no place in it (a single fixed accent, no +/// per-user color choice). They still work, just demoted into a separate +/// "Advanced appearance" group beneath the main card per the rebuild brief, +/// rather than dropped - see the handoff report for whether to remove them +/// outright. +class SettingsAppearanceSection extends StatelessWidget { + const SettingsAppearanceSection({super.key}); + + @override + Widget build(BuildContext context) { + final settings = context.watch(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SettingsSection( + title: 'Appearance', + children: [ + NooSettingsRow( + icon: LucideIcons.sunMoon, + label: const Text('Theme'), + trailing: NooSegmentedControl( + size: NooSegmentedSize.sm, + value: settings.themeMode, + onChanged: settings.setThemeMode, + options: const [ + NooSegmentOption(value: ThemeMode.system, icon: LucideIcons.monitor, label: 'System'), + NooSegmentOption(value: ThemeMode.light, icon: LucideIcons.sun, label: 'Light'), + NooSegmentOption(value: ThemeMode.dark, icon: LucideIcons.moon, label: 'Dark'), + ], + ), + ), + ], + ), + const SizedBox(height: NooSpace.xl), + SettingsSection( + title: 'Advanced appearance', + children: [ + NooSettingsRow( + icon: LucideIcons.palette, + label: const Text('Accent color'), + subtitle: Text(settings.useDynamicColor ? 'Matching your wallpaper' : 'Custom color'), + trailing: _AccentSwatchDot(color: settings.useDynamicColor ? null : settings.seedColor), + onTap: () => _openAccentPicker(context, settings), + ), + NooSettingsRow( + icon: LucideIcons.sparkles, + label: const Text('AMOLED black'), + subtitle: const Text('Use pure black backgrounds in dark mode'), + trailing: NooToggle(checked: settings.amoledDark, onChanged: settings.setAmoledDark), + ), + NooSettingsRow( + icon: LucideIcons.waves, + label: const Text('Seek bar style'), + subtitle: const Text('The progress bar style used when playing videos'), + value: _seekBarStyleLabel(settings.mediaProgressBarStyle), + onTap: () => _openSeekBarPicker(context, settings), + ), + ], + ), + ], + ); + } +} + +/// The current accent-color choice, shown as a small filled circle in the +/// "Accent color" row - a wallpaper icon when following the OS's dynamic +/// color instead of a fixed swatch. +class _AccentSwatchDot extends StatelessWidget { + final Color? color; + const _AccentSwatchDot({this.color}); + + @override + Widget build(BuildContext context) { + final colors = context.nooColors; + return Container( + width: 22, + height: 22, + alignment: Alignment.center, + decoration: BoxDecoration( + color: color ?? colors.surface3, + shape: BoxShape.circle, + ), + child: color == null ? Icon(LucideIcons.wallpaper, size: 12, color: colors.fg2) : null, + ); + } +} + +void _openAccentPicker(BuildContext context, SettingsController settings) { + Widget grid(NooColors colors) => Wrap( + spacing: 14, + runSpacing: 14, + children: [ + _AccentSwatch( + isSelected: settings.useDynamicColor, + onTap: () { + settings.setUseDynamicColor(true); + Navigator.pop(context); + }, + background: colors.surface3, + child: Icon(LucideIcons.wallpaper, color: colors.fg2, size: 20), + ), + for (final color in AppTheme.seedColors) + _AccentSwatch( + isSelected: !settings.useDynamicColor && settings.seedColor == color, + onTap: () { + settings.setSeedColor(color); + Navigator.pop(context); + }, + background: color, + ), + ], + ); + + final colors = context.nooColors; + if (NooLayout.isDesktop(context)) { + showNooDialog(context, title: 'Accent color', children: [grid(colors)]); + } else { + showNooSheet(context, children: [grid(colors)]); + } +} + +class _AccentSwatch extends StatelessWidget { + final bool isSelected; + final VoidCallback onTap; + final Color background; + final Widget? child; + + const _AccentSwatch({ + required this.isSelected, + required this.onTap, + required this.background, + this.child, + }); + + @override + Widget build(BuildContext context) { + final colors = context.nooColors; + return GestureDetector( + onTap: onTap, + child: Container( + width: 44, + height: 44, + alignment: Alignment.center, + decoration: BoxDecoration( + color: background, + shape: BoxShape.circle, + border: isSelected ? Border.all(color: colors.fg1, width: 3) : null, + ), + child: isSelected && child == null + ? const Icon(LucideIcons.check, color: Colors.white, size: 20) + : child, + ), + ); + } +} + +void _openSeekBarPicker(BuildContext context, SettingsController settings) { + Widget grid() => GridView.count( + crossAxisCount: 2, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + mainAxisSpacing: 12, + crossAxisSpacing: 12, + childAspectRatio: 1.35, + children: [ + for (final style in MediaProgressBarStyle.values) + _SeekBarStyleOption( + style: style, + isSelected: style == settings.mediaProgressBarStyle, + onTap: () { + settings.setMediaProgressBarStyle(style); + Navigator.pop(context); + }, + ), + ], + ); + + if (NooLayout.isDesktop(context)) { + showNooDialog(context, title: 'Seek bar style', children: [grid()]); + } else { + showNooSheet(context, children: [grid()]); + } +} + +class _SeekBarStyleOption extends StatelessWidget { + final MediaProgressBarStyle style; + final bool isSelected; + final VoidCallback onTap; + + const _SeekBarStyleOption({required this.style, required this.isSelected, required this.onTap}); + + @override + Widget build(BuildContext context) { + final colors = context.nooColors; + return Material( + color: isSelected ? colors.accentSoft : colors.surface2, + borderRadius: BorderRadius.circular(NooRadii.input), + child: InkWell( + borderRadius: BorderRadius.circular(NooRadii.input), + onTap: onTap, + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Expanded( + child: Center( + child: SizedBox( + width: 100, + height: 28, + child: SeekBarPreview( + style: style, + playedColor: isSelected ? colors.accentText : colors.fg2, + trackColor: colors.surface3, + ), + ), + ), + ), + const SizedBox(height: 8), + Text( + _seekBarStyleLabel(style), + style: NooText.body.copyWith( + fontSize: 13, + fontWeight: FontWeight.w600, + color: isSelected ? colors.accentText : colors.fg1, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/widgets/settings/settings_dialogs.dart b/lib/widgets/settings/settings_dialogs.dart new file mode 100644 index 0000000..7ea67b1 --- /dev/null +++ b/lib/widgets/settings/settings_dialogs.dart @@ -0,0 +1,92 @@ +import 'package:flutter/material.dart'; +import '../../providers/session_controller.dart'; +import '../../theme/design_tokens.dart'; +import '../noo/core/noo_button.dart'; +import '../noo/noo_layout.dart'; +import '../noo/overlays/noo_dialog.dart'; +import '../noo/overlays/noo_overlay_header.dart'; +import '../noo/overlays/noo_sheet.dart'; + +/// Shared by the account card's own quick "Remove account" action and each +/// row in the saved-accounts list - same confirm/remove/pop-to-root gate +/// either way (mirrors the pre-rework `_confirmRemoveActive`/ +/// `_AccountsCard._confirmRemove`), just restyled as [showNooDialog] +/// (desktop) / [showNooSheet] (mobile) instead of an [AlertDialog]. +Future confirmRemoveAccount( + BuildContext context, + SessionController session, { + required String accountId, + required String username, + required String host, + required bool isActive, +}) async { + final hasOtherAccounts = session.accounts.length > 1; + final message = isActive && hasOtherAccounts + ? 'Remove $username ($host)? Another saved account will become active.' + : 'Remove $username ($host)? You can add it again later.'; + + var confirmed = false; + void cancel() => Navigator.pop(context); + void confirm() { + confirmed = true; + Navigator.pop(context); + } + + Widget buttons(NooColors colors) => Row( + children: [ + Expanded( + child: NooButton( + variant: NooButtonVariant.secondary, + size: NooButtonSize.card, + fullWidth: true, + onTap: cancel, + child: const Text('Cancel'), + ), + ), + const SizedBox(width: NooSpace.sm), + Expanded( + child: NooButton( + variant: NooButtonVariant.danger, + size: NooButtonSize.card, + fullWidth: true, + onTap: confirm, + child: const Text('Remove'), + ), + ), + ], + ); + + final colors = context.nooColors; + if (NooLayout.isDesktop(context)) { + await showNooDialog( + context, + title: 'Remove account', + children: [ + Text(message, style: NooText.body.copyWith(color: colors.fg2)), + buttons(colors), + ], + ); + } else { + await showNooSheet( + context, + children: [ + NooOverlayHeader(title: 'Remove account', onClose: cancel), + Text(message, style: NooText.body.copyWith(color: colors.fg2)), + buttons(colors), + ], + ); + } + + if (!confirmed) return; + if (!context.mounted) return; + final navigator = Navigator.of(context); + await session.removeAccount(accountId); + // Only when this was the last saved account does isLoggedIn drop to false + // and main.dart swap the root route to LoginView underneath - pop back to + // it then, rather than leaving Settings stranded on top. Removing a + // non-active account, or falling back to another one, both keep the user + // logged in, so Settings should just stay put. + if (!session.isLoggedIn) { + navigator.popUntil((route) => route.isFirst); + } +} diff --git a/lib/widgets/settings/settings_file_sync.dart b/lib/widgets/settings/settings_file_sync.dart new file mode 100644 index 0000000..7c0e2b6 --- /dev/null +++ b/lib/widgets/settings/settings_file_sync.dart @@ -0,0 +1,132 @@ +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:provider/provider.dart'; +import '../../models/app_tab.dart'; +import '../../providers/session_controller.dart'; +import '../../providers/sync_status_controller.dart'; +import '../../services/sync_service.dart'; +import '../../theme/design_tokens.dart'; +import '../app_tab_view_builder.dart'; +import '../noo/core/noo_button.dart'; +import '../noo/core/noo_toggle.dart'; +import '../noo/lists/noo_settings_row.dart'; +import '../noo/nav/noo_top_bar.dart'; +import '../noo/nav/noo_toolbar.dart'; +import '../noo/noo_layout.dart'; +import 'settings_section.dart'; + +/// Settings section 4: device sync (which folders/files mirror locally, +/// notifications, cellular use, and a manual "Sync now"). +class SettingsFileSyncSection extends StatefulWidget { + const SettingsFileSyncSection({super.key}); + + @override + State createState() => _SettingsFileSyncSectionState(); +} + +class _SettingsFileSyncSectionState extends State { + bool _syncingNow = false; + + Future _syncNow(SessionController session, SyncStatusController sync) async { + setState(() => _syncingNow = true); + try { + await SyncService.syncNow(session, sync); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Could not start sync: $e'), behavior: SnackBarBehavior.floating), + ); + } + } finally { + if (mounted) setState(() => _syncingNow = false); + } + } + + /// The Offline tab has its own chrome-free content (see the shared + /// rebuild contract's "division of chrome" - only the shell draws top + /// bars for a tab), so opening it as a pushed screen from here needs a + /// minimal top bar of its own to stay navigable. + void _openOfflineFiles(BuildContext context) { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => Scaffold( + appBar: NooLayout.isDesktop(context) + ? const NooToolbar(title: 'Offline') + : NooTopBar( + style: NooLayout.navStyle(context), + title: 'Offline', + leading: const NooTopBarBack(), + ), + body: buildAppTabView(AppTab.offline, ScrollController()), + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + final session = context.watch(); + final sync = context.watch(); + final colors = context.nooColors; + final folders = sync.syncedPaths; + final everything = sync.syncEverything; + final hasSyncScope = everything || folders.isNotEmpty; + + return SettingsSection( + title: 'File sync', + children: [ + NooSettingsRow( + icon: LucideIcons.cloud, + label: const Text('Sync everything'), + subtitle: const Text('Mirror the whole account instead of picking folders'), + trailing: NooToggle(checked: everything, onChanged: sync.setSyncEverything), + ), + NooSettingsRow( + icon: LucideIcons.hardDriveDownload, + label: const Text('View offline files'), + subtitle: Text( + everything + ? 'Every folder in this account is synced' + : folders.isEmpty + ? 'Nothing synced yet' + : '${folders.length} synced folder${folders.length == 1 ? '' : 's'}/file${folders.length == 1 ? '' : 's'}', + ), + onTap: () => _openOfflineFiles(context), + ), + NooSettingsRow( + icon: LucideIcons.bellRing, + label: const Text('Background sync notifications'), + subtitle: const Text( + 'Show a notification when a background sync updates files. ' + 'Conflicts and "Sync now" always notify.', + ), + trailing: NooToggle(checked: sync.syncNotifications, onChanged: sync.setSyncNotifications), + ), + NooSettingsRow( + icon: LucideIcons.signal, + label: const Text('Sync on cellular'), + subtitle: const Text('Off = background sync only runs on Wi-Fi'), + trailing: NooToggle(checked: sync.syncOnCellular, onChanged: sync.setSyncOnCellular), + ), + NooSettingsRow( + icon: LucideIcons.refreshCw, + label: const Text('Sync now'), + trailing: _syncingNow + ? SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2, color: colors.accentText), + ) + : NooButton( + variant: NooButtonVariant.tonal, + size: NooButtonSize.compact, + disabled: !hasSyncScope, + onTap: () => _syncNow(session, sync), + child: const Text('Sync now'), + ), + ), + ], + ); + } +} diff --git a/lib/widgets/settings/settings_files_cache.dart b/lib/widgets/settings/settings_files_cache.dart new file mode 100644 index 0000000..41c40e9 --- /dev/null +++ b/lib/widgets/settings/settings_files_cache.dart @@ -0,0 +1,142 @@ +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:provider/provider.dart'; +import '../../providers/files_controller.dart'; +import '../../theme/design_tokens.dart'; +import '../noo/lists/noo_settings_row.dart'; +import 'settings_section.dart'; + +String _cachePolicyLabel(CachePolicy policy) { + switch (policy) { + case CachePolicy.never: + return 'Never cache'; + case CachePolicy.interval: + return 'Refresh periodically'; + case CachePolicy.manual: + return 'Refresh manually only'; + } +} + +String _cachePolicyDescription(CachePolicy policy) { + switch (policy) { + case CachePolicy.never: + return 'Every visit to a folder fetches it fresh; synced files are ' + 'checked each time you open the app'; + case CachePolicy.interval: + return "Reuse a folder's listing until it's a few minutes old; synced " + 'files update in the background on the same schedule'; + case CachePolicy.manual: + return "Reuse a folder's listing until you pull to refresh; synced " + 'files only update when you pull down or tap Sync now'; + } +} + +/// Settings section 5: how often folder listings and synced files refresh +/// from the server. +class SettingsFilesCacheSection extends StatelessWidget { + const SettingsFilesCacheSection({super.key}); + + void _openPolicyPicker(BuildContext context, FilesController files) { + showSettingsPicker( + context, + title: 'Cache policy', + options: [ + for (final policy in CachePolicy.values) + NooSettingsRow( + label: Text(_cachePolicyLabel(policy)), + subtitle: Text(_cachePolicyDescription(policy)), + trailing: policy == files.cachePolicy + ? Icon(LucideIcons.check, size: 18, color: context.nooColors.accentText) + : null, + onTap: () { + files.setCachePolicy(policy); + Navigator.pop(context); + }, + ), + ], + ); + } + + @override + Widget build(BuildContext context) { + final files = context.watch(); + + return SettingsSection( + title: 'Files cache', + subtitle: 'How often folder listings and synced files refresh from the server', + children: [ + NooSettingsRow( + icon: LucideIcons.database, + label: const Text('Cache policy'), + value: _cachePolicyLabel(files.cachePolicy), + onTap: () => _openPolicyPicker(context, files), + ), + if (files.cachePolicy == CachePolicy.interval) + _CacheIntervalRow( + minutes: files.cacheIntervalMinutes, + onChanged: files.setCacheIntervalMinutes, + ), + ], + ); + } +} + +/// The refresh-interval slider shown under "Cache policy" while +/// [CachePolicy.interval] is selected - not a plain [NooSettingsRow] since +/// it needs a full-width [Slider], but paints its own `surface` background +/// the same way so it sits correctly inside the group's line dividers (see +/// `NooGroupedList`'s doc comment). +class _CacheIntervalRow extends StatelessWidget { + final int minutes; + final ValueChanged onChanged; + + const _CacheIntervalRow({required this.minutes, required this.onChanged}); + + @override + Widget build(BuildContext context) { + final colors = context.nooColors; + return Material( + color: colors.surface, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: NooSpace.md, vertical: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('Refresh interval', style: NooText.bodyL.copyWith(color: colors.fg1)), + Text('$minutes min', style: NooText.meta.copyWith(color: colors.fg3)), + ], + ), + SliderTheme( + data: SliderTheme.of(context).copyWith( + activeTrackColor: colors.accent, + inactiveTrackColor: colors.surface3, + thumbColor: colors.accent, + overlayColor: colors.accent.withValues(alpha: 0.12), + ), + child: Slider( + value: minutes.toDouble(), + min: 1, + max: 60, + divisions: 59, + label: '$minutes min', + onChanged: (value) => onChanged(value.round()), + ), + ), + if (minutes < 15) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Text( + 'While the app is closed, synced files refresh at most every ' + '15 min (an Android limit).', + style: NooText.meta.copyWith(color: colors.fg3), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/settings/settings_section.dart b/lib/widgets/settings/settings_section.dart new file mode 100644 index 0000000..5c086e0 --- /dev/null +++ b/lib/widgets/settings/settings_section.dart @@ -0,0 +1,106 @@ +import 'package:flutter/material.dart'; +import '../../theme/design_tokens.dart'; +import '../noo/lists/noo_grouped_list.dart'; +import '../noo/noo_layout.dart'; +import '../noo/overlays/noo_dialog.dart'; +import '../noo/overlays/noo_sheet.dart'; + +/// One block of Settings (DESIGN_SYSTEM.md 4's 8-part order): a +/// [NooGroupedList] on mobile (label above a radius-20 card), or a titled, +/// bordered radius-20 card holding a flat row group on desktop +/// ("Mobile uses one column of grouped lists. Desktop uses a 2-column grid +/// of cards with a 1px line and radius 20."). Shared by every +/// `lib/widgets/settings/*.dart` section so both layouts stay in sync from +/// one place. +class SettingsSection extends StatelessWidget { + final String title; + final String? subtitle; + final List children; + + const SettingsSection({ + super.key, + required this.title, + this.subtitle, + required this.children, + }); + + @override + Widget build(BuildContext context) { + final colors = context.nooColors; + if (!NooLayout.isDesktop(context)) { + return NooGroupedList( + label: title, + footer: subtitle != null ? Text(subtitle!) : null, + children: children, + ); + } + return Container( + decoration: BoxDecoration( + color: colors.surface, + border: Border.all(color: colors.line), + borderRadius: BorderRadius.circular(NooRadii.card), + ), + padding: const EdgeInsets.all(NooSpace.lg), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: NooText.cardTitle.copyWith(color: colors.fg1)), + if (subtitle != null) ...[ + const SizedBox(height: 4), + Text(subtitle!, style: NooText.meta.copyWith(color: colors.fg3)), + ], + const SizedBox(height: NooSpace.md), + FlatRowGroup(children: children), + ], + ), + ); + } +} + +/// The line-gapped row stack [NooGroupedList] draws internally, without its +/// own outer card/label - used for [SettingsSection]'s desktop card body, +/// which already supplies the surrounding card chrome, so nesting a second +/// radius-20 card there would double up the framing. Also reused directly +/// by `settings_tabs.dart`, whose desktop card mixes plain rows with a +/// reorderable list under one shared title/border instead of going through +/// [SettingsSection] itself. +class FlatRowGroup extends StatelessWidget { + final List children; + const FlatRowGroup({super.key, required this.children}); + + @override + Widget build(BuildContext context) { + final colors = context.nooColors; + return ClipRRect( + borderRadius: BorderRadius.circular(NooRadii.input), + child: DecoratedBox( + decoration: BoxDecoration(color: colors.line), + child: Column( + children: [ + for (var i = 0; i < children.length; i++) ...[ + if (i > 0) const SizedBox(height: 1), + children[i], + ], + ], + ), + ), + ); + } +} + +/// The picker pattern for every "value + chevron" settings row (cache +/// policy, swipe actions, default tab...): a titled option list, a +/// [NooGroupedList] sheet on mobile or a [NooDialog] on desktop (mirrors +/// `files_controls_row.dart`'s sort/filter sheets). Each option row should +/// call `Navigator.pop(context)` itself after applying its choice. +void showSettingsPicker( + BuildContext context, { + required String title, + required List options, +}) { + if (NooLayout.isDesktop(context)) { + showNooDialog(context, title: title, children: [Column(children: options)]); + } else { + showNooSheet(context, children: [NooGroupedList(label: title, children: options)]); + } +} diff --git a/lib/widgets/settings/settings_security.dart b/lib/widgets/settings/settings_security.dart new file mode 100644 index 0000000..341ab93 --- /dev/null +++ b/lib/widgets/settings/settings_security.dart @@ -0,0 +1,94 @@ +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:provider/provider.dart'; +import '../../providers/session_controller.dart'; +import '../noo/core/noo_toggle.dart'; +import '../noo/lists/noo_settings_row.dart'; +import 'settings_section.dart'; + +/// Per-platform copy for the login-lock row's label (DESIGN_SYSTEM.md 3's +/// "Biometric label" row, both the mobile table and the macOS/Windows +/// desktop table). Falls back to generic copy on platforms the spec +/// doesn't name (Linux, tests). +String biometricLabel(TargetPlatform platform) { + switch (platform) { + case TargetPlatform.iOS: + return 'Lock with Face ID'; + case TargetPlatform.android: + return 'Lock with fingerprint'; + case TargetPlatform.macOS: + return 'Touch ID'; + case TargetPlatform.windows: + return 'Windows Hello'; + case TargetPlatform.linux: + case TargetPlatform.fuchsia: + return 'Lock with device credential'; + } +} + +/// Settings section 3: login lock, which gates opening the app, switching +/// accounts, and revealing hidden files behind the device's own PIN/ +/// biometric credential (see `AppLockService` - this app never stores or +/// handles a PIN itself). +class SettingsSecuritySection extends StatelessWidget { + const SettingsSecuritySection({super.key}); + + Future _handleLoginLockChanged( + BuildContext context, + SessionController session, + bool value, + ) async { + final success = value ? await session.setupLoginLock() : await session.disableLoginLock(); + if (!success && context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + value + ? "Could not set up login lock - make sure this device has a PIN, pattern, password, or biometric configured" + : 'Could not turn off login lock', + ), + behavior: SnackBarBehavior.floating, + ), + ); + } + } + + @override + Widget build(BuildContext context) { + final session = context.watch(); + final platform = Theme.of(context).platform; + + return SettingsSection( + title: 'Security', + children: [ + NooSettingsRow( + icon: LucideIcons.lock, + label: Text(biometricLabel(platform)), + subtitle: const Text("Require this device's PIN or biometric to open Noo"), + trailing: NooToggle( + checked: session.loginLockEnabled, + onChanged: (value) => _handleLoginLockChanged(context, session, value), + ), + ), + NooSettingsRow( + icon: LucideIcons.arrowLeftRight, + label: const Text('Lock account switching'), + subtitle: const Text('Unlock to switch between saved accounts'), + trailing: NooToggle( + checked: session.lockAccountSwitching, + onChanged: session.loginLockEnabled ? session.setLockAccountSwitching : null, + ), + ), + NooSettingsRow( + icon: LucideIcons.eyeOff, + label: const Text('Lock hidden files'), + subtitle: const Text('Unlock to reveal hidden files and folders'), + trailing: NooToggle( + checked: session.lockHiddenFiles, + onChanged: session.loginLockEnabled ? session.setLockHiddenFiles : null, + ), + ), + ], + ); + } +} diff --git a/lib/widgets/settings/settings_swipe.dart b/lib/widgets/settings/settings_swipe.dart new file mode 100644 index 0000000..d4f7613 --- /dev/null +++ b/lib/widgets/settings/settings_swipe.dart @@ -0,0 +1,84 @@ +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:provider/provider.dart'; +import '../../providers/settings_controller.dart'; +import '../../theme/design_tokens.dart'; +import '../noo/lists/noo_settings_row.dart'; +import 'settings_section.dart'; + +String _swipeActionLabel(SwipeAction action) { + switch (action) { + case SwipeAction.none: + return 'None'; + case SwipeAction.favorite: + return 'Favorite'; + case SwipeAction.delete: + return 'Delete'; + case SwipeAction.share: + return 'Share'; + } +} + +/// Settings section 8: what swiping a file left/right does in list view. +class SettingsSwipeSection extends StatelessWidget { + const SettingsSwipeSection({super.key}); + + void _openPicker( + BuildContext context, { + required String title, + required SwipeAction current, + required ValueChanged onChanged, + }) { + showSettingsPicker( + context, + title: title, + options: [ + for (final action in SwipeAction.values) + NooSettingsRow( + label: Text(_swipeActionLabel(action)), + trailing: action == current + ? Icon(LucideIcons.check, size: 18, color: context.nooColors.accentText) + : null, + onTap: () { + onChanged(action); + Navigator.pop(context); + }, + ), + ], + ); + } + + @override + Widget build(BuildContext context) { + final settings = context.watch(); + + return SettingsSection( + title: 'Swipe on a file', + subtitle: 'Choose what swiping a file left or right does in list view', + children: [ + NooSettingsRow( + icon: LucideIcons.chevronsRight, + label: const Text('Swipe right'), + value: _swipeActionLabel(settings.swipeRightAction), + onTap: () => _openPicker( + context, + title: 'Swipe right', + current: settings.swipeRightAction, + onChanged: settings.setSwipeRightAction, + ), + ), + NooSettingsRow( + icon: LucideIcons.chevronsLeft, + label: const Text('Swipe left'), + value: _swipeActionLabel(settings.swipeLeftAction), + onTap: () => _openPicker( + context, + title: 'Swipe left', + current: settings.swipeLeftAction, + onChanged: settings.setSwipeLeftAction, + ), + ), + ], + ); + } +} diff --git a/lib/widgets/settings/settings_tabs.dart b/lib/widgets/settings/settings_tabs.dart new file mode 100644 index 0000000..f95ea42 --- /dev/null +++ b/lib/widgets/settings/settings_tabs.dart @@ -0,0 +1,184 @@ +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:provider/provider.dart'; +import '../../models/app_tab.dart'; +import '../../providers/settings_controller.dart'; +import '../../theme/design_tokens.dart'; +import '../noo/core/noo_toggle.dart'; +import '../noo/lists/noo_settings_row.dart'; +import '../noo/lists/noo_tab_order_row.dart'; +import '../noo/noo_layout.dart'; +import 'settings_section.dart'; + +/// Settings section 7: bottom-nav tab order, visibility (pinned = shown, up +/// to [maxVisibleTabs]) and default tab. +class SettingsTabsSection extends StatelessWidget { + const SettingsTabsSection({super.key}); + + void _openDefaultTabPicker(BuildContext context, SettingsController settings) { + final visible = settings.tabOrder.where((t) => !settings.hiddenTabs.contains(t)).toList(); + showSettingsPicker( + context, + title: 'Default tab', + options: [ + for (final tab in visible) + NooSettingsRow( + icon: tab.icon, + label: Text(tab.label), + trailing: tab == settings.defaultTab + ? Icon(LucideIcons.check, size: 18, color: context.nooColors.accentText) + : null, + onTap: () { + settings.setDefaultTab(tab); + Navigator.pop(context); + }, + ), + ], + ); + } + + void _handleTogglePin(BuildContext context, SettingsController settings, AppTab tab, bool pinned) { + // setTabHidden's `hidden` argument is the *new* hidden state, which is + // just the tab's current pinned (= visible) state flipped. + final error = settings.setTabHidden(tab, pinned); + if (error != null && context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(error), behavior: SnackBarBehavior.floating), + ); + } + } + + void _handleReorder(SettingsController settings, int oldIndex, int newIndex) { + final order = List.from(settings.tabOrder); + final tab = order.removeAt(oldIndex); + order.insert(newIndex, tab); + settings.setTabOrder(order); + } + + static const _subtitle = 'Drag to reorder, pin up to 5 to the bottom bar'; + + @override + Widget build(BuildContext context) { + final settings = context.watch(); + final desktop = NooLayout.isDesktop(context); + + final optionRows = [ + NooSettingsRow( + icon: LucideIcons.arrowUpDown, + label: const Text('Tap tab to scroll to top'), + subtitle: const Text('Tapping the current bottom bar tab scrolls its list back to the top'), + trailing: NooToggle( + checked: settings.tapTabToScrollTop, + onChanged: settings.setTapTabToScrollTop, + ), + ), + NooSettingsRow( + icon: LucideIcons.star, + label: const Text('Default tab'), + value: settings.defaultTab.label, + onTap: () => _openDefaultTabPicker(context, settings), + ), + ]; + final orderList = _TabOrderList( + tabs: settings.tabOrder, + hiddenTabs: settings.hiddenTabs, + flat: desktop, + onReorder: (oldIndex, newIndex) => _handleReorder(settings, oldIndex, newIndex), + onTogglePin: (tab, pinned) => _handleTogglePin(context, settings, tab, pinned), + ); + + if (!desktop) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SettingsSection(title: 'Tabs', subtitle: _subtitle, children: optionRows), + const SizedBox(height: NooSpace.md), + orderList, + ], + ); + } + + // Desktop: one bordered card holding both the plain option rows and + // the reorderable list, so Tabs still reads as a single grid cell + // ("a 2-column grid of cards with a 1px line and radius 20") instead of + // splitting into two stacked cards the way the mobile layout does. + final colors = context.nooColors; + return Container( + decoration: BoxDecoration( + color: colors.surface, + border: Border.all(color: colors.line), + borderRadius: BorderRadius.circular(NooRadii.card), + ), + padding: const EdgeInsets.all(NooSpace.lg), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Tabs', style: NooText.cardTitle.copyWith(color: colors.fg1)), + const SizedBox(height: 4), + Text(_subtitle, style: NooText.meta.copyWith(color: colors.fg3)), + const SizedBox(height: NooSpace.md), + FlatRowGroup(children: optionRows), + const SizedBox(height: NooSpace.sm), + orderList, + ], + ), + ); + } +} + +/// The reorderable [NooTabOrderRow] stack - a radius-20, line-divided card +/// like [NooGroupedList] draws, but [NooGroupedList] itself has no +/// reordering support, so this rebuilds that same look around a +/// [ReorderableListView.builder] instead of wrapping it. +class _TabOrderList extends StatelessWidget { + final List tabs; + final Set hiddenTabs; + final void Function(int oldIndex, int newIndex) onReorder; + final void Function(AppTab tab, bool pinned) onTogglePin; + + /// True when an ancestor (the desktop card) already supplies the + /// surrounding radius-20 border, so this should render just the divided + /// rows without its own outer clip/line background. + final bool flat; + + const _TabOrderList({ + required this.tabs, + required this.hiddenTabs, + required this.onReorder, + required this.onTogglePin, + this.flat = false, + }); + + @override + Widget build(BuildContext context) { + final colors = context.nooColors; + final list = ReorderableListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + buildDefaultDragHandles: false, + itemCount: tabs.length, + onReorderItem: onReorder, + itemBuilder: (context, index) { + final tab = tabs[index]; + final pinned = !hiddenTabs.contains(tab); + return Padding( + key: ValueKey(tab), + padding: EdgeInsets.only(top: index > 0 ? 1 : 0), + child: ReorderableDragStartListener( + index: index, + child: NooTabOrderRow( + icon: tab.icon, + label: tab.label, + pinned: pinned, + onTogglePin: () => onTogglePin(tab, pinned), + ), + ), + ); + }, + ); + return ClipRRect( + borderRadius: BorderRadius.circular(flat ? NooRadii.input : NooRadii.card), + child: DecoratedBox(decoration: BoxDecoration(color: colors.line), child: list), + ); + } +} diff --git a/lib/widgets/share_sheet.dart b/lib/widgets/share_sheet.dart index 8109e25..f89cb3d 100644 --- a/lib/widgets/share_sheet.dart +++ b/lib/widgets/share_sheet.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:intl/intl.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; import 'package:provider/provider.dart'; @@ -10,25 +12,20 @@ import '../models/nextcloud_sharee.dart'; import '../providers/item_operations.dart'; import '../providers/session_controller.dart'; import '../providers/sync_status_controller.dart'; -import 'details/details_sheet.dart' show DetailsHeader; -import 'gradual_bottom_sheet.dart'; - -IconData _shareTypeIcon(ShareType type) { - switch (type) { - case ShareType.user: - return Icons.person_rounded; - case ShareType.group: - return Icons.groups_rounded; - case ShareType.publicLink: - return Icons.link_rounded; - case ShareType.email: - return Icons.email_rounded; - case ShareType.federated: - return Icons.public_rounded; - case ShareType.other: - return Icons.share_rounded; - } -} +import '../theme/design_tokens.dart'; +import 'details/details_sheet.dart' show detailsFileTile, detailsMetaLine; +import 'noo/core/noo_avatar.dart'; +import 'noo/core/noo_button.dart'; +import 'noo/core/noo_chip.dart'; +import 'noo/core/noo_toggle.dart'; +import 'noo/lists/noo_settings_row.dart'; +import 'noo/media/noo_photo_tile.dart'; +import 'noo/noo_layout.dart'; +import 'noo/overlays/noo_dialog.dart'; +import 'noo/overlays/noo_overlay_header.dart'; +import 'noo/overlays/noo_share_parts.dart'; +import 'noo/overlays/noo_sheet.dart'; +import 'noo/overlays/noo_text_field.dart'; /// Builds the "internal link" URL for a file, or null if [fileId] isn't a /// numeric Nextcloud file id (WebDAV `oc:fileid` was missing for this item). @@ -42,22 +39,46 @@ String? internalLinkFor(String serverUrl, String fileId) { return '$url/index.php/f/$fileId'; } -/// Standalone "Share" bottom sheet for a single file/folder — replicates -/// the web client's Sharing panel: search users/teams, "Others with -/// access" (inherited shares), an internal link to copy, an email-share -/// field, and a public link. Every "Share" action in the app (swipe, -/// multi-select, media viewer) opens this same sheet. +String _initial(String s) { + final trimmed = s.trim(); + return trimmed.isEmpty ? '?' : trimmed[0].toUpperCase(); +} + +/// Standalone "Share" bottom sheet/dialog for a single file/folder - +/// `DESIGN_SYSTEM.md` §4 "Share sheet / dialog": share with people, a +/// public link, and (files only) sending the file itself. Every "Share" +/// action in the app (swipe, multi-select, media viewer) opens this same +/// sheet. +/// +/// Only used directly on mobile (bundled behind its own [NooOverlayHeader] +/// by [show]); the desktop path hands this straight to [showNooDialog], +/// which renders the header itself via `leading`/`title`/`subtitle`. class ShareSheet extends StatefulWidget { final NextcloudItem item; - final ScrollController? scrollController; - const ShareSheet({super.key, required this.item, this.scrollController}); + const ShareSheet({super.key, required this.item}); static Future show(BuildContext context, NextcloudItem item) { - return showGradualBottomSheet( + if (NooLayout.isDesktop(context)) { + return showNooDialog( + context, + leading: detailsFileTile(item), + title: item.name, + subtitle: detailsMetaLine(item), + children: [ShareSheet(item: item)], + ); + } + return showNooSheet( context, - builder: (context, scrollController) => - ShareSheet(item: item, scrollController: scrollController), + children: [ + NooOverlayHeader( + leading: detailsFileTile(item), + title: item.name, + subtitle: detailsMetaLine(item), + onClose: () => Navigator.pop(context), + ), + ShareSheet(item: item), + ], ); } @@ -72,15 +93,17 @@ class _ShareSheetState extends State { List _inherited = []; List _searchResults = []; bool _isSearching = false; - bool _isAddingEmail = false; + bool _isAddingPerson = false; + bool _isTogglingLink = false; bool _isSharingFile = false; - final _searchController = TextEditingController(); - final _emailController = TextEditingController(); + bool _showInherited = false; + final _peopleController = TextEditingController(); + final _linkController = TextEditingController(); @override void dispose() { - _searchController.dispose(); - _emailController.dispose(); + _peopleController.dispose(); + _linkController.dispose(); super.dispose(); } @@ -114,6 +137,20 @@ class _ShareSheetState extends State { }); } + /// The single "Name, email or group" field: reuses whichever match is + /// live, or falls back to an email share when the text looks like one - + /// same two underlying actions the pre-rework sheet split across two + /// separate fields. + Future _submitPeopleField() async { + final text = _peopleController.text.trim(); + if (text.isEmpty) return; + if (_searchResults.isNotEmpty) { + await _addSharee(_searchResults.first); + } else if (text.contains('@')) { + await _addEmail(text); + } + } + Future _addSharee(NextcloudSharee sharee) async { final ops = context.read(); final messenger = ScaffoldMessenger.of(context); @@ -135,7 +172,7 @@ class _ShareSheetState extends State { setState(() { _shares = [..._shares, share]; _searchResults = []; - _searchController.clear(); + _peopleController.clear(); }); messenger.showSnackBar( SnackBar( @@ -145,10 +182,8 @@ class _ShareSheetState extends State { ); } - Future _addEmailShare() async { - final email = _emailController.text.trim(); - if (email.isEmpty) return; - setState(() => _isAddingEmail = true); + Future _addEmail(String email) async { + setState(() => _isAddingPerson = true); final ops = context.read(); final messenger = ScaffoldMessenger.of(context); final share = await ops.createShare( @@ -158,7 +193,7 @@ class _ShareSheetState extends State { ); if (!mounted) return; if (share == null) { - setState(() => _isAddingEmail = false); + setState(() => _isAddingPerson = false); messenger.showSnackBar( const SnackBar( content: Text('Could not create email share'), @@ -169,8 +204,8 @@ class _ShareSheetState extends State { } setState(() { _shares = [..._shares, share]; - _emailController.clear(); - _isAddingEmail = false; + _peopleController.clear(); + _isAddingPerson = false; }); messenger.showSnackBar( SnackBar( @@ -183,10 +218,7 @@ class _ShareSheetState extends State { Future _createPublicLink() async { final ops = context.read(); final messenger = ScaffoldMessenger.of(context); - final share = await ops.createShare( - path: widget.item.path, - shareType: 3, - ); + final share = await ops.createShare(path: widget.item.path, shareType: 3); if (!mounted) return; if (share == null) { messenger.showSnackBar( @@ -217,6 +249,25 @@ class _ShareSheetState extends State { } } + /// The link toggle: on creates a public link, off removes every public + /// link share (there's normally at most one - the sheet only ever + /// surfaces the first one - but this cleans up any extras from before the + /// rework's single-link toggle). + Future _toggleLink(bool checked) async { + setState(() => _isTogglingLink = true); + if (checked) { + await _createPublicLink(); + } else { + final links = _shares + .where((s) => s.shareType == ShareType.publicLink) + .toList(); + for (final link in links) { + await _removeShare(link); + } + } + if (mounted) setState(() => _isTogglingLink = false); + } + void _copy(String text, String label) { Clipboard.setData(ClipboardData(text: text)); ScaffoldMessenger.of(context).showSnackBar( @@ -227,11 +278,10 @@ class _ShareSheetState extends State { ); } - /// Downloads the item to a scratch file and hands it to Android's native - /// "Share to..." sheet (`share_plus`) - a completely different action - /// from the rest of this sheet (which shares *within* Nextcloud, via - /// users/links) and the only one here that reads the file's actual - /// bytes. + /// Downloads the item to a scratch file and hands it to the OS's native + /// "Share to..." sheet (`share_plus`) - a completely different action from + /// the rest of this sheet (which shares *within* Nextcloud, via users/ + /// links) and the only one here that reads the file's actual bytes. Future _shareFileDirectly() async { final session = context.read(); final sync = context.read(); @@ -271,61 +321,203 @@ class _ShareSheetState extends State { } } - Widget _sectionHeader(String label) { - final theme = Theme.of(context); - final colorScheme = theme.colorScheme; - return Row( + /// A small popup with the one action the compact [NooPersonAccessRow] + /// permission pill has no room for - removing that person's access. There's + /// no permission-update endpoint on [ItemOperations] yet, so the pill + /// itself is read-only (see the rebuild report). + void _openPersonMenu(NextcloudShare share) { + final label = share.sharedWithDisplayName ?? 'Shared'; + void remove() { + Navigator.pop(context); + _removeShare(share); + } + + final row = NooSettingsRow( + icon: LucideIcons.userMinus, + label: const Text('Remove access'), + danger: true, + onTap: remove, + ); + if (NooLayout.isDesktop(context)) { + showNooDialog(context, title: label, children: [row]); + } else { + showNooSheet( + context, + children: [ + NooOverlayHeader(title: label, onClose: () => Navigator.pop(context)), + row, + ], + ); + } + } + + Widget _peopleSection( + NooColors colors, + SessionController session, + List peopleShares, + String? internalLink, + ) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Text( - label, - style: theme.textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w700, + AbsorbPointer( + absorbing: _isAddingPerson, + child: NooTextField( + controller: _peopleController, + leadingIcon: LucideIcons.search, + placeholder: 'Name, email or group', + onChanged: _search, + onSubmitted: (_) => _submitPeopleField(), + trailing: (_isSearching || _isAddingPerson) + ? const Padding( + padding: EdgeInsets.all(4), + child: SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ) + : null, ), ), - const SizedBox(width: 6), - Icon( - Icons.info_outline_rounded, - size: 16, - color: colorScheme.onSurfaceVariant, + for (final sharee in _searchResults) + NooSettingsRow( + icon: sharee.type == ShareeType.group + ? LucideIcons.users + : LucideIcons.user, + label: Text(sharee.label), + subtitle: sharee.subtitle != null ? Text(sharee.subtitle!) : null, + onTap: () => _addSharee(sharee), + ), + const SizedBox(height: NooSpace.xs), + NooPersonAccessRow( + avatar: NooAvatar(initials: _initial(session.username), current: true), + name: session.username, + owner: true, ), + for (final share in peopleShares) + NooPersonAccessRow( + avatar: NooAvatar( + initials: _initial(share.sharedWithDisplayName ?? share.id), + color: NooPhotoTile.paletteColor( + (share.sharedWithDisplayName ?? share.id).hashCode, + ), + icon: share.shareType == ShareType.group ? LucideIcons.users : null, + ), + name: share.sharedWithDisplayName ?? 'Shared', + subtitle: share.shareType == ShareType.group + ? 'Group' + : (share.shareType == ShareType.email ? 'Invited by email' : null), + permission: (share.permissions & 2) != 0 ? 'Can edit' : 'Can view', + onPermissionTap: () => _openPersonMenu(share), + ), + if (_inherited.isNotEmpty) ...[ + const SizedBox(height: NooSpace.xs), + GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => setState(() => _showInherited = !_showInherited), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: NooSpace.xs), + child: Row( + children: [ + Icon(LucideIcons.users, size: 16, color: colors.fg3), + const SizedBox(width: NooSpace.xs), + Expanded( + child: Text( + 'Others with access (${_inherited.length})', + style: NooText.meta.copyWith(color: colors.fg3), + ), + ), + Icon( + _showInherited ? LucideIcons.chevronUp : LucideIcons.chevronDown, + size: 16, + color: colors.fg3, + ), + ], + ), + ), + ), + if (_showInherited) + for (final share in _inherited) + Padding( + padding: const EdgeInsets.only(bottom: NooSpace.xs), + child: NooPersonAccessRow( + avatar: NooAvatar( + initials: _initial( + share.sharedWithDisplayName ?? share.ownerDisplayName, + ), + ), + name: share.sharedWithDisplayName ?? share.ownerDisplayName, + owner: true, + ownerLabel: 'Inherited', + ), + ), + ], + if (internalLink != null) ...[ + const SizedBox(height: NooSpace.xs), + Row( + children: [ + Icon(LucideIcons.link, size: 18, color: colors.fg3), + const SizedBox(width: NooSpace.sm), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text('Internal link', style: NooText.bodyL.copyWith(color: colors.fg1)), + Text( + 'For people who already have access', + style: NooText.meta.copyWith(color: colors.fg3), + ), + ], + ), + ), + NooButton( + size: NooButtonSize.compact, + variant: NooButtonVariant.secondary, + icon: LucideIcons.copy, + onTap: () => _copy(internalLink, 'internal link'), + child: const Text('Copy'), + ), + ], + ), + ], ], ); } - Widget _shareRow( - NextcloudShare share, { - bool removable = true, - bool copyUrl = false, - }) { - final colorScheme = Theme.of(context).colorScheme; - final label = switch (share.shareType) { - ShareType.publicLink => 'Public link', - _ => share.sharedWithDisplayName ?? 'Shared', - }; - return ListTile( - contentPadding: EdgeInsets.zero, - leading: Icon( - _shareTypeIcon(share.shareType), - color: colorScheme.primary, - ), - title: Text(label), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (copyUrl && share.url != null) - IconButton( - icon: const Icon(Icons.copy_rounded), - tooltip: 'Copy link', - onPressed: () => _copy(share.url!, 'link'), + Widget _linkSection(NextcloudShare link) { + final canEdit = (link.permissions & 2) != 0; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + NooTextField( + controller: _linkController, + mono: true, + readOnly: true, + trailing: NooButton( + size: NooButtonSize.field, + icon: LucideIcons.copy, + onTap: () => _copy(link.url ?? '', 'link'), + child: const Text('Copy link'), + ), + ), + const SizedBox(height: NooSpace.sm), + Wrap( + spacing: NooSpace.xs, + runSpacing: NooSpace.xs, + children: [ + NooChip(child: Text(canEdit ? 'Can edit' : 'View only')), + NooChip( + child: Text( + link.expireDate != null + ? 'Expires ${DateFormat.yMMMd().format(link.expireDate!)}' + : 'No expiry', + ), ), - if (removable) - IconButton( - icon: Icon(Icons.close_rounded, color: colorScheme.error), - tooltip: 'Remove', - onPressed: () => _removeShare(share), - ), - ], - ), + ], + ), + ], ); } @@ -335,170 +527,69 @@ class _ShareSheetState extends State { _requested = true; WidgetsBinding.instance.addPostFrameCallback((_) => _load()); } - final theme = Theme.of(context); - final colorScheme = theme.colorScheme; + final colors = context.nooColors; final session = context.watch(); if (_isLoading) { - return const Center(child: CircularProgressIndicator()); + return const Padding( + padding: EdgeInsets.symmetric(vertical: 40), + child: Center(child: CircularProgressIndicator()), + ); } - final userGroupShares = _shares + final peopleShares = _shares .where( (s) => - s.shareType == ShareType.user || s.shareType == ShareType.group, + s.shareType == ShareType.user || + s.shareType == ShareType.group || + s.shareType == ShareType.email, ) .toList(); final publicLinkShares = _shares .where((s) => s.shareType == ShareType.publicLink) .toList(); - final emailShares = _shares - .where((s) => s.shareType == ShareType.email) - .toList(); + final hasLink = publicLinkShares.isNotEmpty; + if (hasLink) { + final url = publicLinkShares.first.url ?? ''; + if (_linkController.text != url) _linkController.text = url; + } final internalLink = internalLinkFor(session.serverUrl, widget.item.id); - return ListView( - controller: widget.scrollController, - padding: const EdgeInsets.fromLTRB(20, 0, 20, 24), + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - // Same icon/name/meta header as DetailsSheet, so this and its - // sibling per-item bottom sheet open on identical chrome - just a - // bare title `Text` here read as under-designed by comparison. - DetailsHeader( - item: widget.item, - padding: const EdgeInsets.only(top: 12, bottom: 8), + NooShareSection( + title: 'Share with people', + child: _peopleSection(colors, session, peopleShares, internalLink), + ), + const SizedBox(height: 22), + NooShareSection( + title: 'Share link', + trailing: NooToggle( + checked: hasLink, + onChanged: _isTogglingLink ? null : _toggleLink, + ), + caption: hasLink + ? null + : 'Anyone with the link can access this ${widget.item.isFolder ? 'folder' : 'file'}.', + child: hasLink ? _linkSection(publicLinkShares.first) : const SizedBox.shrink(), ), if (!widget.item.isFolder) ...[ - const SizedBox(height: 4), - ListTile( - contentPadding: EdgeInsets.zero, - leading: _isSharingFile - ? const Padding( - padding: EdgeInsets.all(12), - child: SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator(strokeWidth: 2), - ), - ) - : const Icon(Icons.ios_share_rounded), - title: const Text('Share file directly'), - subtitle: const Text('Send the file itself via another app'), - onTap: _isSharingFile ? null : _shareFileDirectly, + const SizedBox(height: 22), + NooShareSection( + title: 'Send file directly', + caption: "Link settings above don't apply - this sends the file's bytes directly.", + child: NooButton( + variant: NooButtonVariant.outline, + size: NooButtonSize.card, + fullWidth: true, + icon: LucideIcons.send, + disabled: _isSharingFile, + onTap: _isSharingFile ? null : _shareFileDirectly, + child: Text(_isSharingFile ? 'Preparing…' : 'Send file directly'), + ), ), - const SizedBox(height: 12), - const Divider(), ], - const SizedBox(height: 12), - _sectionHeader('Internal shares'), - const SizedBox(height: 8), - TextField( - controller: _searchController, - decoration: InputDecoration( - isDense: true, - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 12, - ), - hintText: 'Type names or teams', - prefixIcon: const Icon(Icons.search_rounded), - suffixIcon: _isSearching - ? const Padding( - padding: EdgeInsets.all(12), - child: SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ), - ) - : null, - border: OutlineInputBorder(borderRadius: BorderRadius.circular(16)), - filled: true, - fillColor: colorScheme.surfaceContainerLow, - ), - onChanged: _search, - ), - for (final sharee in _searchResults) - ListTile( - contentPadding: EdgeInsets.zero, - leading: Icon( - sharee.type == ShareeType.group - ? Icons.groups_rounded - : Icons.person_rounded, - ), - title: Text(sharee.label), - subtitle: sharee.subtitle != null ? Text(sharee.subtitle!) : null, - onTap: () => _addSharee(sharee), - ), - for (final share in userGroupShares) _shareRow(share), - if (_inherited.isNotEmpty) - ExpansionTile( - tilePadding: EdgeInsets.zero, - leading: const Icon(Icons.more_horiz_rounded), - title: Text('Others with access (${_inherited.length})'), - children: [ - for (final share in _inherited) - _shareRow(share, removable: false), - ], - ), - if (internalLink != null) - ListTile( - contentPadding: EdgeInsets.zero, - leading: const Icon(Icons.link_rounded), - title: const Text('Internal link'), - subtitle: const Text('For people who already have access'), - trailing: IconButton( - icon: const Icon(Icons.copy_rounded), - tooltip: 'Copy link', - onPressed: () => _copy(internalLink, 'internal link'), - ), - ), - const SizedBox(height: 20), - const Divider(), - const SizedBox(height: 12), - _sectionHeader('External shares'), - const SizedBox(height: 8), - TextField( - controller: _emailController, - keyboardType: TextInputType.emailAddress, - enabled: !_isAddingEmail, - decoration: InputDecoration( - isDense: true, - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 12, - ), - hintText: 'Type an email', - prefixIcon: const Icon(Icons.email_rounded), - suffixIcon: _isAddingEmail - ? const Padding( - padding: EdgeInsets.all(12), - child: SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ), - ) - : null, - border: OutlineInputBorder(borderRadius: BorderRadius.circular(16)), - filled: true, - fillColor: colorScheme.surfaceContainerLow, - ), - onSubmitted: (_) => _addEmailShare(), - ), - for (final share in emailShares) _shareRow(share), - const SizedBox(height: 8), - for (final share in publicLinkShares) _shareRow(share, copyUrl: true), - ListTile( - contentPadding: EdgeInsets.zero, - leading: const Icon(Icons.link_rounded), - title: const Text('Create public link'), - trailing: IconButton( - icon: const Icon(Icons.add_rounded), - tooltip: 'Create public link', - onPressed: _createPublicLink, - ), - ), ], ); } diff --git a/lib/widgets/shell/shell_common.dart b/lib/widgets/shell/shell_common.dart new file mode 100644 index 0000000..9059680 --- /dev/null +++ b/lib/widgets/shell/shell_common.dart @@ -0,0 +1,313 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:provider/provider.dart'; +import '../../models/nextcloud_item.dart'; +import '../../providers/session_controller.dart'; +import '../../theme/design_tokens.dart'; +import '../../views/account_view.dart'; +import '../../views/login_view.dart'; +import '../../views/search_view.dart'; +import '../noo/core/noo_avatar.dart'; +import '../noo/core/noo_search_field.dart'; +import '../noo/lists/noo_grouped_list.dart'; +import '../noo/lists/noo_settings_row.dart'; +import '../noo/noo_layout.dart'; +import '../noo/overlays/noo_dialog.dart'; +import '../noo/overlays/noo_sheet.dart'; + +// Small pieces shared by the mobile drawer/top bar and the desktop +// sidebar/toolbar, so both shells show the same account/storage data and +// open the same screens. + +String accountInitial(String username) => + username.isNotEmpty ? username[0].toUpperCase() : '?'; + +String serverHost(String serverUrl) => + serverUrl.replaceFirst(RegExp(r'^https?://'), ''); + +void openSettings(BuildContext context) { + Navigator.push( + context, + MaterialPageRoute(builder: (_) => const AccountView()), + ); +} + +void openSearch(BuildContext context) { + Navigator.push( + context, + MaterialPageRoute(builder: (_) => const SearchView()), + ); +} + +// A local copy rather than importing `synced_header_scaffold.dart`'s, since +// that legacy header is on its way out with the rework. +String _formatBytes(int bytes) { + if (bytes <= 0) return '0 B'; + if (bytes < 1024) return '$bytes B'; + if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB'; + if (bytes < 1024 * 1024 * 1024) { + return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; + } + return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(2)} GB'; +} + +/// 0-1 share of the quota used (0 when unknown or unlimited). +double quotaFraction(NextcloudUserQuota? quota) => + quota != null && quota.totalBytes > 0 + ? (quota.usedBytes / quota.totalBytes).clamp(0.0, 1.0) + : 0; + +/// "38.2 GB of 100 GB used" (drawer). +String quotaUsedLabel(NextcloudUserQuota? quota) { + if (quota == null) return 'Storage usage unavailable'; + final total = quota.totalBytes > 0 ? _formatBytes(quota.totalBytes) : '∞'; + return '${_formatBytes(quota.usedBytes)} of $total used'; +} + +/// "38.2 GB of 100 GB" (sidebar, next to the "Storage" label). +String quotaDetail(NextcloudUserQuota? quota) { + if (quota == null) return 'Unavailable'; + final total = quota.totalBytes > 0 ? _formatBytes(quota.totalBytes) : '∞'; + return '${_formatBytes(quota.usedBytes)} of $total'; +} + +/// The saved-accounts switcher behind the drawer's chevron and the desktop +/// sidebar's account card: every saved account (tap to switch, same +/// `switchAccount` call - and login-lock gate - as Settings' account list), +/// "Add account", and a shortcut to the full account settings. A sheet on +/// mobile, a dialog on desktop. +void showAccountSwitcher(BuildContext context) { + final session = context.read(); + + List content(BuildContext overlayContext) { + void close() => Navigator.pop(overlayContext); + return [ + NooGroupedList( + label: 'Accounts', + children: [ + for (final account in session.accounts) + _AccountSwitchRow( + name: account.username, + host: serverHost(account.serverUrl), + active: account.id == session.activeAccountId, + onTap: account.id == session.activeAccountId + ? null + : () { + close(); + session.switchAccount(account.id); + }, + ), + NooSettingsRow( + icon: LucideIcons.plus, + accent: true, + label: const Text('Add account'), + onTap: () { + close(); + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const LoginView(isAddingAccount: true), + ), + ); + }, + ), + NooSettingsRow( + icon: LucideIcons.settings, + label: const Text('Manage accounts'), + onTap: () { + close(); + openSettings(context); + }, + ), + ], + ), + ]; + } + + if (NooLayout.isDesktop(context)) { + showNooDialog( + context, + title: 'Switch account', + children: [Builder(builder: (c) => Column(children: content(c)))], + ); + } else { + showNooSheet( + context, + children: [Builder(builder: (c) => Column(children: content(c)))], + ); + } +} + +/// A switcher row: avatar, name over host, and a check on the active one. +/// `NooSettingsRow` only takes an icon, not an avatar, hence its own row. +class _AccountSwitchRow extends StatelessWidget { + final String name; + final String host; + final bool active; + final VoidCallback? onTap; + + const _AccountSwitchRow({ + required this.name, + required this.host, + required this.active, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + final colors = context.nooColors; + return Material( + color: colors.surface, + child: InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: NooSpace.md, + vertical: 10, + ), + child: Row( + spacing: 12, + children: [ + NooAvatar( + initials: accountInitial(name), + current: active, + size: 36, + ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 4, + children: [ + Text( + name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: NooText.body.copyWith( + height: 1.2, + fontWeight: FontWeight.w500, + color: colors.fg1, + ), + ), + Text( + host, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: NooText.meta.copyWith( + height: 1.2, + color: colors.fg3, + ), + ), + ], + ), + ), + if (active) + Icon(LucideIcons.check, size: 20, color: colors.accentText), + ], + ), + ), + ), + ); + } +} + +/// The current user's avatar in the mobile top bar. Tap opens Settings; +/// swiping up/down on it cycles to the next/previous saved account +/// immediately, with no confirmation - a shortcut alongside the full +/// switcher (drawer chevron, Settings' account list). +class ShellAvatarButton extends StatelessWidget { + final double hitBox; + + const ShellAvatarButton({super.key, this.hitBox = 48}); + + Future _handleVerticalSwipe( + BuildContext context, + DragEndDetails details, + ) async { + final velocity = details.primaryVelocity ?? 0; + if (velocity.abs() < 250) return; + + final session = context.read(); + final target = velocity < 0 + ? await session.cycleToNextAccount() + : await session.cycleToPreviousAccount(); + if (target == null || !context.mounted) return; + + HapticFeedback.selectionClick(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Switched to ${target.username}'), + behavior: SnackBarBehavior.floating, + ), + ); + } + + @override + Widget build(BuildContext context) { + final session = context.watch(); + return Tooltip( + message: 'Settings', + child: Semantics( + button: true, + label: 'Settings', + child: GestureDetector( + onVerticalDragEnd: (details) => + _handleVerticalSwipe(context, details), + child: SizedBox.square( + dimension: hitBox, + child: InkResponse( + onTap: () => openSettings(context), + radius: hitBox / 2, + child: Center( + child: NooAvatar( + initials: accountInitial(session.username), + current: true, + size: 32, + ), + ), + ), + ), + ), + ), + ); + } +} + +/// A [NooSearchField] that doesn't take input itself but opens +/// `SearchView` on tap - the shell's search entry point (iOS top bar, +/// desktop toolbar). Search has its own screen with results, so the field +/// here is only a launcher. +class ShellSearchLauncher extends StatelessWidget { + final bool ios; + final bool onSurface; + + const ShellSearchLauncher({ + super.key, + this.ios = false, + this.onSurface = false, + }); + + @override + Widget build(BuildContext context) { + return Semantics( + button: true, + label: 'Search', + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => openSearch(context), + child: AbsorbPointer( + child: ExcludeFocus( + child: NooSearchField( + placeholder: 'Search files', + ios: ios, + onSurface: onSurface, + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/widgets/sort_menu_button.dart b/lib/widgets/sort_menu_button.dart index b8934e3..5977f47 100644 --- a/lib/widgets/sort_menu_button.dart +++ b/lib/widgets/sort_menu_button.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import '../providers/files_controller.dart'; +import '../theme/design_tokens.dart'; String sortFieldLabel(FileSortField field) { switch (field) { @@ -20,21 +21,28 @@ class SortMenuButton extends StatelessWidget { final FileSortField field; final ValueChanged onChanged; + /// An extra control (e.g. an ascending/descending toggle) shown inside + /// the same pill, between the label and the menu's own dropdown chevron - + /// visually one "Name ↑" chip (DESIGN_SYSTEM.md's sort chip) even though + /// the two behaviors (pick a field / flip direction) stay separate taps. + final Widget? trailing; + const SortMenuButton({ super.key, required this.field, required this.onChanged, + this.trailing, }); @override Widget build(BuildContext context) { - final theme = Theme.of(context); - final colorScheme = theme.colorScheme; + final colors = context.nooColors; return PopupMenuButton( initialValue: field, onSelected: onChanged, offset: const Offset(0, 36), + color: colors.surface, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), itemBuilder: (context) => FileSortField.values.map((f) { return PopupMenuItem( @@ -44,44 +52,45 @@ class SortMenuButton extends StatelessWidget { SizedBox( width: 20, child: f == field - ? Icon( - Icons.check_rounded, - size: 18, - color: colorScheme.primary, - ) + ? Icon(Icons.check_rounded, size: 18, color: colors.accentText) : null, ), const SizedBox(width: 8), - Text(sortFieldLabel(f)), + Text(sortFieldLabel(f), style: NooText.body.copyWith(color: colors.fg1)), ], ), ); }).toList(), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 4), + child: Container( + height: 34, + padding: const EdgeInsets.symmetric(horizontal: 12), + decoration: BoxDecoration( + color: colors.surface, + borderRadius: BorderRadius.circular(NooRadii.pill), + ), child: Row( - // A plain Text child in a Row always takes its full intrinsic - // width no matter what maxLines/overflow say - Flexible is what - // actually lets it shrink to whatever space this row has left - // (it's squeezed by favorite/hidden/storage/list-grid toggles - // alongside it) so the ellipsis can kick in instead of - // overflowing. + mainAxisSize: MainAxisSize.min, children: [ + Icon(Icons.swap_vert_rounded, size: 16, color: colors.fg1), + const SizedBox(width: 6), + // A plain Text child in a Row always takes its full intrinsic + // width no matter what maxLines/overflow say - Flexible is what + // actually lets it shrink to whatever space this row has left + // (it's squeezed by hidden/storage/list-grid toggles alongside + // it) so the ellipsis can kick in instead of overflowing. Flexible( child: Text( sortFieldLabel(field), - style: theme.textTheme.bodyMedium?.copyWith( - fontWeight: FontWeight.w600, - color: colorScheme.onSurfaceVariant, + style: NooText.body.copyWith( + fontWeight: FontWeight.w500, + color: colors.fg1, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), ), - Icon( - Icons.arrow_drop_down_rounded, - color: colorScheme.onSurfaceVariant, - ), + ?trailing, + Icon(Icons.arrow_drop_down_rounded, color: colors.fg1), ], ), ), diff --git a/lib/widgets/swipeable_item.dart b/lib/widgets/swipeable_item.dart deleted file mode 100644 index 6f04ddc..0000000 --- a/lib/widgets/swipeable_item.dart +++ /dev/null @@ -1,142 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import '../providers/settings_controller.dart'; - -/// Wraps [child] with the user's configured swipe-left/swipe-right actions -/// (favorite, delete, share, or none) — shared between the Files list view -/// and the Photos grid so both behave identically. Callers own the actual -/// side effects (favoriting, sharing, deleting); this widget only decides -/// which action a given swipe direction maps to and drives the gesture. -class SwipeableItem extends StatelessWidget { - final Key itemKey; - final String itemName; - final SettingsController settings; - final Future Function() onFavorite; - final Future Function() onShare; - final Future Function() onDelete; - final Widget child; - - const SwipeableItem({ - super.key, - required this.itemKey, - required this.itemName, - required this.settings, - required this.onFavorite, - required this.onShare, - required this.onDelete, - required this.child, - }); - - static DismissDirection _directionFor(SwipeAction left, SwipeAction right) { - final hasLeft = left != SwipeAction.none; - final hasRight = right != SwipeAction.none; - if (hasLeft && hasRight) return DismissDirection.horizontal; - if (hasRight) return DismissDirection.startToEnd; - if (hasLeft) return DismissDirection.endToStart; - return DismissDirection.none; - } - - static Widget _background( - BuildContext context, - SwipeAction action, - Alignment alignment, - ) { - if (action == SwipeAction.none) return const SizedBox.shrink(); - final colorScheme = Theme.of(context).colorScheme; - final Color color; - final IconData icon; - switch (action) { - case SwipeAction.delete: - color = colorScheme.error; - icon = Icons.delete_rounded; - case SwipeAction.favorite: - color = Colors.red.shade400; - icon = Icons.favorite_rounded; - case SwipeAction.share: - color = colorScheme.primary; - icon = Icons.share_rounded; - case SwipeAction.none: - return const SizedBox.shrink(); - } - - return Container( - color: color, - alignment: alignment, - padding: const EdgeInsets.symmetric(horizontal: 24), - child: Icon(icon, color: Colors.white), - ); - } - - @override - Widget build(BuildContext context) { - final rightAction = settings.swipeRightAction; - final leftAction = settings.swipeLeftAction; - final direction = _directionFor(leftAction, rightAction); - if (direction == DismissDirection.none) return child; - - return Dismissible( - key: itemKey, - direction: direction, - background: _background(context, rightAction, Alignment.centerLeft), - secondaryBackground: _background( - context, - leftAction, - Alignment.centerRight, - ), - confirmDismiss: (dir) async { - final action = dir == DismissDirection.startToEnd - ? rightAction - : leftAction; - switch (action) { - case SwipeAction.favorite: - HapticFeedback.selectionClick(); - await onFavorite(); - return false; - case SwipeAction.share: - HapticFeedback.selectionClick(); - await onShare(); - return false; - case SwipeAction.delete: - final confirmed = await showDialog( - context: context, - builder: (dialogContext) { - return AlertDialog( - title: const Text('Delete Item'), - content: Text( - 'Delete "$itemName" from the server? This cannot be undone.', - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(dialogContext, false), - child: const Text('Cancel'), - ), - FilledButton( - style: FilledButton.styleFrom( - backgroundColor: Theme.of( - dialogContext, - ).colorScheme.error, - ), - onPressed: () => Navigator.pop(dialogContext, true), - child: const Text('Delete'), - ), - ], - ); - }, - ); - if (confirmed != true) return false; - HapticFeedback.mediumImpact(); - return true; // Actual delete happens in onDismissed. - case SwipeAction.none: - return false; - } - }, - onDismissed: (dir) { - final action = dir == DismissDirection.startToEnd - ? rightAction - : leftAction; - if (action == SwipeAction.delete) onDelete(); - }, - child: child, - ); - } -} diff --git a/lib/widgets/sync_status_badge.dart b/lib/widgets/sync_status_badge.dart deleted file mode 100644 index 8ca54df..0000000 --- a/lib/widgets/sync_status_badge.dart +++ /dev/null @@ -1,34 +0,0 @@ -import 'package:flutter/material.dart'; -import '../models/sync_status.dart'; - -/// A small corner badge over a thumbnail showing device-sync status - a -/// `cloud_done` badge once a file's mirrored locally, `sync` while it's -/// actively being transferred. Nothing is drawn for `none`/`conflict` -/// (conflicts surface in the sync header, not as per-tile noise). -class SyncStatusBadge extends StatelessWidget { - final SyncItemStatus status; - - const SyncStatusBadge({super.key, required this.status}); - - @override - Widget build(BuildContext context) { - if (status != SyncItemStatus.syncing && status != SyncItemStatus.synced) { - return const SizedBox.shrink(); - } - final colorScheme = Theme.of(context).colorScheme; - final icon = status == SyncItemStatus.syncing - ? Icons.sync_rounded - : Icons.cloud_done_rounded; - - return Container( - width: 18, - height: 18, - decoration: BoxDecoration( - color: colorScheme.surface, - shape: BoxShape.circle, - border: Border.all(color: colorScheme.surface, width: 1.5), - ), - child: Icon(icon, size: 13, color: colorScheme.primary), - ); - } -} diff --git a/lib/widgets/tabs/tab_day_groups.dart b/lib/widgets/tabs/tab_day_groups.dart new file mode 100644 index 0000000..c54ff0e --- /dev/null +++ b/lib/widgets/tabs/tab_day_groups.dart @@ -0,0 +1,109 @@ +import 'package:flutter/widgets.dart'; +import 'package:intl/intl.dart'; +import '../../theme/design_tokens.dart'; + +/// Day-bucketing shared by Recent (Today/Yesterday/This week/Earlier) and +/// Activity (one group per calendar day) - see `DESIGN_SYSTEM.md` §4. + +bool _isSameDay(DateTime a, DateTime b) => + a.year == b.year && a.month == b.month && a.day == b.day; + +/// One labelled bucket of [items], already in their original (newest-first) +/// order. +class TabDayGroup { + final String label; + final List items; + const TabDayGroup(this.label, this.items); +} + +/// Buckets newest-first [items] into Today / Yesterday / This week / +/// Earlier. Empty buckets are omitted. +List> groupByRecentBucket( + List items, + DateTime Function(T item) dateOf, +) { + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + final yesterday = today.subtract(const Duration(days: 1)); + // "This week" reaches back 6 days before today (7 days inclusive) - + // anything older falls to "Earlier". + final weekStart = today.subtract(const Duration(days: 6)); + + final todayItems = []; + final yesterdayItems = []; + final weekItems = []; + final earlierItems = []; + + for (final item in items) { + final d = dateOf(item); + final day = DateTime(d.year, d.month, d.day); + if (_isSameDay(day, today)) { + todayItems.add(item); + } else if (_isSameDay(day, yesterday)) { + yesterdayItems.add(item); + } else if (!day.isBefore(weekStart)) { + weekItems.add(item); + } else { + earlierItems.add(item); + } + } + + return [ + if (todayItems.isNotEmpty) TabDayGroup('Today', todayItems), + if (yesterdayItems.isNotEmpty) TabDayGroup('Yesterday', yesterdayItems), + if (weekItems.isNotEmpty) TabDayGroup('This week', weekItems), + if (earlierItems.isNotEmpty) TabDayGroup('Earlier', earlierItems), + ]; +} + +/// Buckets newest-first [items] one group per calendar day (Activity's +/// "feed grouped by day" - unlike [groupByRecentBucket], days past +/// yesterday each keep their own header rather than folding into "This +/// week"/"Earlier"). +List> groupByCalendarDay( + List items, + DateTime Function(T item) dateOf, +) { + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + final yesterday = today.subtract(const Duration(days: 1)); + + final order = []; + final groups = >{}; + for (final item in items) { + final d = dateOf(item); + final day = DateTime(d.year, d.month, d.day); + final bucket = groups.putIfAbsent(day, () { + order.add(day); + return []; + }); + bucket.add(item); + } + + String labelFor(DateTime day) { + if (_isSameDay(day, today)) return 'Today'; + if (_isSameDay(day, yesterday)) return 'Yesterday'; + return DateFormat.yMMMd().format(day); + } + + return [for (final day in order) TabDayGroup(labelFor(day), groups[day]!)]; +} + +/// The "Today"/"Yesterday"/... label above a day group - `DESIGN_SYSTEM.md` +/// §2's grouped-list label style (13/600 fg-2, inset 8px). Standalone +/// (rather than `NooGroupedList.label`) for Activity, whose items aren't a +/// `NooGroupedList` card. +class TabGroupLabel extends StatelessWidget { + final String label; + + const TabGroupLabel(this.label, {super.key}); + + @override + Widget build(BuildContext context) { + final colors = context.nooColors; + return Padding( + padding: const EdgeInsets.fromLTRB(8, 0, 8, 8), + child: Text(label, style: NooText.label.copyWith(color: colors.fg2)), + ); + } +} diff --git a/lib/widgets/tabs/tab_location.dart b/lib/widgets/tabs/tab_location.dart new file mode 100644 index 0000000..2a94f27 --- /dev/null +++ b/lib/widgets/tabs/tab_location.dart @@ -0,0 +1,10 @@ +/// Turns a full item path plus its own name into a display-friendly parent +/// folder label - shared by Recent ("action + location") and Trash +/// ("original location") per `DESIGN_SYSTEM.md` §4. +String tabLocationLabel(String fullPath, String name) { + final folder = fullPath.length >= name.length + ? fullPath.substring(0, fullPath.length - name.length) + : fullPath; + final trimmed = folder.replaceAll(RegExp(r'^/+|/+$'), ''); + return trimmed.isEmpty ? 'Home' : trimmed; +} diff --git a/lib/widgets/tabs/tab_state_slivers.dart b/lib/widgets/tabs/tab_state_slivers.dart new file mode 100644 index 0000000..97c5b6f --- /dev/null +++ b/lib/widgets/tabs/tab_state_slivers.dart @@ -0,0 +1,96 @@ +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import '../../theme/design_tokens.dart'; +import '../noo/core/noo_button.dart'; + +/// Shared loading/error/empty slivers for Recent/Activity/Trash/Shares (and +/// any future tab shaped like them: a `CustomScrollView` over one flat +/// list), restyled with noo tokens instead of each screen hand-rolling the +/// same centered column - see `FilesView`'s identical error/empty layout, +/// which this mirrors. + +/// Centered spinner for a tab's first load (list still empty). +const Widget tabLoadingSliver = SliverFillRemaining( + hasScrollBody: false, + child: Center(child: CircularProgressIndicator()), +); + +/// Centered error state: icon, title, message, and a Retry button. +Widget tabErrorSliver( + BuildContext context, { + required String title, + required String message, + required VoidCallback onRetry, +}) { + final colors = context.nooColors; + return SliverFillRemaining( + hasScrollBody: false, + child: Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(LucideIcons.circleAlert, size: 56, color: colors.danger), + const SizedBox(height: 16), + Text( + title, + textAlign: TextAlign.center, + style: NooText.cardTitle.copyWith(color: colors.danger), + ), + const SizedBox(height: 8), + Text( + message, + textAlign: TextAlign.center, + style: NooText.body.copyWith(color: colors.fg3), + ), + const SizedBox(height: 20), + NooButton( + variant: NooButtonVariant.secondary, + size: NooButtonSize.field, + icon: LucideIcons.refreshCw, + onTap: onRetry, + child: const Text('Retry'), + ), + ], + ), + ), + ), + ); +} + +/// Centered empty state: an icon over a message. +Widget tabEmptySliver( + BuildContext context, { + required IconData icon, + required String message, +}) { + final colors = context.nooColors; + return SliverFillRemaining( + hasScrollBody: false, + child: Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, size: 56, color: colors.fg3), + const SizedBox(height: 12), + Text( + message, + textAlign: TextAlign.center, + style: NooText.cardTitle.copyWith(color: colors.fg2), + ), + ], + ), + ), + ), + ); +} + +/// Bottom padding sliver so the last row/card clears the bottom nav/FAB - +/// same trailing pair every rebuilt tab list ends on. +const List tabBottomInsetSlivers = [ + SliverToBoxAdapter(child: SizedBox(height: 100)), + SliverFillRemaining(hasScrollBody: false, child: SizedBox()), +]; diff --git a/lib/widgets/viewer/media_action_bar.dart b/lib/widgets/viewer/media_action_bar.dart new file mode 100644 index 0000000..ea95c1c --- /dev/null +++ b/lib/widgets/viewer/media_action_bar.dart @@ -0,0 +1,98 @@ +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import '../../theme/design_tokens.dart'; +import '../frosted_glass_container.dart'; +import 'viewer_icon_button.dart'; + +/// The floating action bar (share/favorite/open/download/delete/details) +/// overlaid on the media viewer - kept on `FrostedGlassContainer`'s blurred +/// chrome as a deliberate exception to the design system's flat product UI +/// (see `file_viewer_screen.dart`'s build() comment for why), unlike the +/// now-flat bottom nav bar (`bottom_nav_bar.dart`) this screen sits above. +class MediaActionBar extends StatelessWidget { + final bool isFavorite; + final bool isBusy; + final bool showServerActions; + final VoidCallback onShare; + final VoidCallback onFavorite; + final VoidCallback onDelete; + final VoidCallback onOpenExternally; + final VoidCallback onDownload; + final VoidCallback onDetails; + + const MediaActionBar({ + super.key, + required this.isFavorite, + required this.isBusy, + required this.showServerActions, + required this.onShare, + required this.onFavorite, + required this.onDelete, + required this.onOpenExternally, + required this.onDownload, + required this.onDetails, + }); + + @override + Widget build(BuildContext context) { + final colors = context.nooColors; + + return SafeArea( + child: Padding( + padding: const EdgeInsets.only(bottom: 20, left: 20, right: 20), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 480), + child: FrostedGlassContainer( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 6), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + ViewerIconButton( + icon: LucideIcons.share2, + tooltip: 'Share', + onTap: isBusy ? null : onShare, + ), + if (showServerActions) + ViewerIconButton( + // Matches the star/star-off convention Files' own + // selection toolbar uses for favorite/unfavorite + // (see `files_view.dart`) rather than a filled heart. + icon: isFavorite ? LucideIcons.starOff : LucideIcons.star, + tooltip: isFavorite + ? 'Remove from favorites' + : 'Favorite', + onTap: onFavorite, + ), + ViewerIconButton( + icon: LucideIcons.externalLink, + tooltip: 'Open externally', + onTap: isBusy ? null : onOpenExternally, + ), + if (showServerActions) + ViewerIconButton( + icon: LucideIcons.download, + tooltip: 'Download', + onTap: isBusy ? null : onDownload, + ), + if (showServerActions) + ViewerIconButton( + icon: LucideIcons.trash2, + tooltip: 'Delete', + color: colors.danger, + onTap: isBusy ? null : onDelete, + ), + ViewerIconButton( + icon: LucideIcons.info, + tooltip: 'Details', + onTap: onDetails, + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/widgets/viewer/media_image_preview.dart b/lib/widgets/viewer/media_image_preview.dart new file mode 100644 index 0000000..79f2af1 --- /dev/null +++ b/lib/widgets/viewer/media_image_preview.dart @@ -0,0 +1,134 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import '../../theme/design_tokens.dart'; + +/// Pinch-zoom/pan/double-tap-to-zoom image page for [FileViewerScreen]'s +/// swipeable `PageView` - one instance per image, from either a server URL +/// or an on-disk path (offline mode). +class MediaImagePreview extends StatefulWidget { + final String? url; + final Map headers; + + /// When set, read the image from this on-disk path instead of [url] - + /// already-resolved by the caller (see + /// `FileViewerScreen.localPathResolver`'s doc comment). + final String? localPath; + + const MediaImagePreview({ + super.key, + required this.url, + required this.headers, + this.localPath, + }); + + @override + State createState() => _MediaImagePreviewState(); +} + +class _MediaImagePreviewState extends State + with SingleTickerProviderStateMixin { + static const _doubleTapScale = 3.0; + + final TransformationController _transformController = + TransformationController(); + late final AnimationController _animController; + Animation? _animation; + Offset _doubleTapPosition = Offset.zero; + + @override + void initState() { + super.initState(); + _animController = + AnimationController( + vsync: this, + duration: const Duration(milliseconds: 220), + )..addListener(() { + if (_animation != null) { + _transformController.value = _animation!.value; + } + }); + } + + @override + void dispose() { + _animController.dispose(); + _transformController.dispose(); + super.dispose(); + } + + void _onDoubleTapDown(TapDownDetails details) { + _doubleTapPosition = details.localPosition; + } + + void _onDoubleTap() { + final isZoomedIn = _transformController.value.getMaxScaleOnAxis() > 1.01; + final Matrix4 endMatrix; + if (isZoomedIn) { + endMatrix = Matrix4.identity(); + } else { + final p = _doubleTapPosition; + endMatrix = Matrix4.identity() + ..translateByDouble( + -p.dx * (_doubleTapScale - 1), + -p.dy * (_doubleTapScale - 1), + 0, + 1, + ) + ..scaleByDouble(_doubleTapScale, _doubleTapScale, _doubleTapScale, 1); + } + _animation = Matrix4Tween( + begin: _transformController.value, + end: endMatrix, + ).animate(CurveTween(curve: Curves.easeOut).animate(_animController)); + _animController.forward(from: 0); + } + + @override + Widget build(BuildContext context) { + final colors = context.nooColors; + return SizedBox.expand( + child: GestureDetector( + onDoubleTapDown: _onDoubleTapDown, + onDoubleTap: _onDoubleTap, + child: InteractiveViewer( + transformationController: _transformController, + minScale: 0.8, + maxScale: 5.0, + child: Center( + child: widget.localPath != null + ? Image.file( + File(widget.localPath!), + fit: BoxFit.contain, + errorBuilder: (context, error, stack) => Center( + child: Icon( + LucideIcons.imageOff, + color: colors.fg3, + size: 64, + ), + ), + ) + : Image.network( + widget.url!, + headers: widget.headers, + fit: BoxFit.contain, + loadingBuilder: (context, child, progress) { + if (progress == null) return child; + return Center( + child: CircularProgressIndicator(color: colors.accent), + ); + }, + errorBuilder: (context, error, stack) => Center( + child: Icon( + LucideIcons.imageOff, + color: colors.fg3, + size: 64, + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/widgets/viewer/media_pdf_preview.dart b/lib/widgets/viewer/media_pdf_preview.dart new file mode 100644 index 0000000..16df16d --- /dev/null +++ b/lib/widgets/viewer/media_pdf_preview.dart @@ -0,0 +1,98 @@ +import 'dart:io'; +import 'dart:typed_data'; +import 'package:flutter/material.dart'; +import 'package:pdfx/pdfx.dart'; +import '../../models/nextcloud_item.dart'; +import '../../providers/session_controller.dart'; +import '../../theme/design_tokens.dart'; + +/// Pinch-zoomable PDF page viewer for [FileViewerScreen]'s static (non- +/// swipeable) preview path. +class MediaPdfPreview extends StatefulWidget { + final NextcloudItem item; + final SessionController session; + + /// When set, read bytes from the local path it resolves to instead of + /// fetching from the server - see + /// `FileViewerScreen.localPathResolver`'s doc comment. + final Future Function(NextcloudItem item)? localPathResolver; + + const MediaPdfPreview({ + super.key, + required this.item, + required this.session, + this.localPathResolver, + }); + + @override + State createState() => _MediaPdfPreviewState(); +} + +class _MediaPdfPreviewState extends State { + PdfControllerPinch? _controller; + String? _error; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + try { + final Uint8List bytes; + if (widget.localPathResolver != null) { + final localPath = await widget.localPathResolver!(widget.item); + if (localPath == null) { + if (mounted) { + setState(() => _error = 'No longer available offline'); + } + return; + } + bytes = await File(localPath).readAsBytes(); + } else { + bytes = Uint8List.fromList( + await widget.session.service!.fetchBytes(widget.item.path), + ); + } + if (!mounted) return; + setState(() { + _controller = PdfControllerPinch( + document: PdfDocument.openData(bytes), + ); + }); + } catch (e) { + if (mounted) setState(() => _error = e.toString()); + } + } + + @override + void dispose() { + _controller?.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final colors = context.nooColors; + if (_error != null) { + return Center( + child: Text( + 'Could not load PDF: $_error', + style: TextStyle(color: colors.fg2), + ), + ); + } + if (_controller == null) { + return Center(child: CircularProgressIndicator(color: colors.accent)); + } + // pdfx's own default minScale (1.0 = the page's true/100% size) is + // often *larger* than the fit-to-width size a wide page first renders + // at, since that initial render is just normal box layout, not the + // InteractiveViewer transform pinching engages on first touch - so the + // moment you touch the page it snaps up to 1.0 and, with the default + // floor, can never pinch back down past it. A low floor here lets you + // zoom back out past that to the fit-width view you started at. + return PdfViewPinch(controller: _controller!, minScale: 0.3); + } +} diff --git a/lib/widgets/viewer/media_text_preview.dart b/lib/widgets/viewer/media_text_preview.dart new file mode 100644 index 0000000..739fb3e --- /dev/null +++ b/lib/widgets/viewer/media_text_preview.dart @@ -0,0 +1,88 @@ +import 'dart:convert'; +import 'dart:io'; +import 'package:flutter/material.dart'; +import '../../models/nextcloud_item.dart'; +import '../../providers/session_controller.dart'; +import '../../theme/design_tokens.dart'; + +/// Plain monospace text-file preview for [FileViewerScreen]'s static (non- +/// swipeable) preview path (`_textPreviewExtensions`). +class MediaTextPreview extends StatefulWidget { + final NextcloudItem item; + final SessionController session; + + /// When set, read bytes from the local path it resolves to instead of + /// fetching from the server - see + /// `FileViewerScreen.localPathResolver`'s doc comment. + final Future Function(NextcloudItem item)? localPathResolver; + + const MediaTextPreview({ + super.key, + required this.item, + required this.session, + this.localPathResolver, + }); + + @override + State createState() => _MediaTextPreviewState(); +} + +class _MediaTextPreviewState extends State { + String? _content; + String? _error; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + try { + final List bytes; + if (widget.localPathResolver != null) { + final localPath = await widget.localPathResolver!(widget.item); + if (localPath == null) { + if (mounted) { + setState(() => _error = 'No longer available offline'); + } + return; + } + bytes = await File(localPath).readAsBytes(); + } else { + bytes = await widget.session.service!.fetchBytes(widget.item.path); + } + if (mounted) { + setState(() => _content = utf8.decode(bytes, allowMalformed: true)); + } + } catch (e) { + if (mounted) setState(() => _error = e.toString()); + } + } + + @override + Widget build(BuildContext context) { + final colors = context.nooColors; + if (_error != null) { + return Center( + child: Text( + 'Could not load file: $_error', + style: TextStyle(color: colors.fg2), + ), + ); + } + if (_content == null) { + return Center(child: CircularProgressIndicator(color: colors.accent)); + } + return Container( + color: colors.surface, + padding: const EdgeInsets.all(20), + child: SingleChildScrollView( + child: SelectableText( + _content!, + style: NooText.mono.copyWith(color: colors.fg1, height: 1.5), + ), + ), + ); + } +} diff --git a/lib/widgets/viewer/media_unsupported_preview.dart b/lib/widgets/viewer/media_unsupported_preview.dart new file mode 100644 index 0000000..00eb0cb --- /dev/null +++ b/lib/widgets/viewer/media_unsupported_preview.dart @@ -0,0 +1,69 @@ +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import '../../models/nextcloud_item.dart'; +import '../../theme/design_tokens.dart'; +import '../noo/core/noo_button.dart'; + +/// Fallback for [FileViewerScreen]'s static preview path when the file type +/// has no inline preview at all (not an image/video/pdf/text file) - just +/// name, a generic file icon, and the "open with"/"details" escape hatches. +class MediaUnsupportedPreview extends StatelessWidget { + final NextcloudItem item; + final bool isBusy; + final VoidCallback onOpenExternally; + final VoidCallback onOpenDetails; + + const MediaUnsupportedPreview({ + super.key, + required this.item, + required this.isBusy, + required this.onOpenExternally, + required this.onOpenDetails, + }); + + @override + Widget build(BuildContext context) { + final colors = context.nooColors; + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(LucideIcons.file, color: colors.fg3, size: 72), + const SizedBox(height: 16), + Text( + item.name, + textAlign: TextAlign.center, + style: NooText.cardTitle.copyWith( + fontSize: 16, + color: colors.fg1, + ), + ), + const SizedBox(height: 8), + Text( + 'No inline preview for this file type.', + style: NooText.meta.copyWith(color: colors.fg3), + ), + const SizedBox(height: 28), + NooButton( + size: NooButtonSize.card, + icon: LucideIcons.externalLink, + disabled: isBusy, + onTap: onOpenExternally, + child: const Text('Open with...'), + ), + const SizedBox(height: 12), + NooButton( + variant: NooButtonVariant.outline, + size: NooButtonSize.card, + icon: LucideIcons.info, + onTap: onOpenDetails, + child: const Text('Details'), + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/viewer/media_video_preview.dart b/lib/widgets/viewer/media_video_preview.dart new file mode 100644 index 0000000..eac39f5 --- /dev/null +++ b/lib/widgets/viewer/media_video_preview.dart @@ -0,0 +1,310 @@ +import 'dart:io'; +import 'dart:math' as math; +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:provider/provider.dart'; +import 'package:video_player/video_player.dart'; +import '../../providers/settings_controller.dart'; +import '../../theme/design_tokens.dart'; +import '../frosted_glass_container.dart'; +import '../seek_bar_painter.dart'; +import 'viewer_icon_button.dart'; + +/// Playing video page for [FileViewerScreen]'s swipeable `PageView`, plus +/// its floating transport controls (play/pause, position, mute, seek bar). +class MediaVideoPreview extends StatefulWidget { + final String? url; + final Map headers; + final bool isActive; + + /// When set, read the video from this on-disk path instead of [url] - + /// already-resolved by the caller (see + /// `FileViewerScreen.localPathResolver`'s doc comment). + final String? localPath; + + /// Distance from the bottom of the screen the transport controls should + /// sit at. Passed in by the parent so the controls can track the floating + /// action bar: resting just above it when visible, sliding down to hug + /// the screen edge when the bar (and rest of the chrome) is hidden. + final double controlsBottomOffset; + + const MediaVideoPreview({ + super.key, + required this.url, + required this.headers, + this.localPath, + this.isActive = true, + this.controlsBottomOffset = 24, + }); + + @override + State createState() => _MediaVideoPreviewState(); +} + +class _MediaVideoPreviewState extends State { + VideoPlayerController? _controller; + String? _error; + + @override + void initState() { + super.initState(); + _init(); + } + + Future _init() async { + try { + final controller = widget.localPath != null + ? VideoPlayerController.file(File(widget.localPath!)) + : VideoPlayerController.networkUrl( + Uri.parse(widget.url!), + httpHeaders: widget.headers, + ); + await controller.initialize(); + if (!mounted) { + controller.dispose(); + return; + } + setState(() => _controller = controller); + if (widget.isActive) controller.play(); + } catch (e) { + if (mounted) setState(() => _error = e.toString()); + } + } + + @override + void didUpdateWidget(covariant MediaVideoPreview oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.isActive != widget.isActive) { + if (widget.isActive) { + _controller?.play(); + } else { + _controller?.pause(); + } + } + } + + @override + void dispose() { + _controller?.dispose(); + super.dispose(); + } + + String _formatDuration(Duration d) { + final minutes = d.inMinutes.remainder(60); + final seconds = d.inSeconds.remainder(60).toString().padLeft(2, '0'); + return '$minutes:$seconds'; + } + + @override + Widget build(BuildContext context) { + final colors = context.nooColors; + final progressBarStyle = context + .watch() + .mediaProgressBarStyle; + if (_error != null) { + return Center( + child: Text( + 'Could not play video: $_error', + style: TextStyle(color: colors.fg2), + ), + ); + } + final controller = _controller; + if (controller == null || !controller.value.isInitialized) { + return Center(child: CircularProgressIndicator(color: colors.accent)); + } + + return SizedBox.expand( + child: Stack( + alignment: Alignment.center, + children: [ + Center( + child: AspectRatio( + aspectRatio: controller.value.aspectRatio, + child: VideoPlayer(controller), + ), + ), + AnimatedPositioned( + duration: const Duration(milliseconds: 220), + curve: Curves.easeInOutCubic, + left: 16, + right: 16, + bottom: widget.controlsBottomOffset, + child: AnimatedBuilder( + animation: controller, + builder: (context, _) { + final fg = colors.fg1; + // Same frosted-glass treatment (and user opacity/blur + // settings) as the back button and media action bar, so the + // transport controls match the rest of the app's chrome. + return FrostedGlassContainer( + borderRadius: 24, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 4, + vertical: 4, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + ViewerIconButton( + icon: controller.value.isPlaying + ? LucideIcons.pause + : LucideIcons.play, + tooltip: controller.value.isPlaying + ? 'Pause' + : 'Play', + onTap: () => controller.value.isPlaying + ? controller.pause() + : controller.play(), + ), + Expanded( + child: Text( + '${_formatDuration(controller.value.position)} / ${_formatDuration(controller.value.duration)}', + textAlign: TextAlign.center, + style: NooText.meta.copyWith(color: fg), + ), + ), + ViewerIconButton( + icon: controller.value.volume == 0 + ? LucideIcons.volumeX + : LucideIcons.volume2, + tooltip: controller.value.volume == 0 + ? 'Unmute' + : 'Mute', + onTap: () => controller.setVolume( + controller.value.volume == 0 ? 1 : 0, + ), + ), + ], + ), + Padding( + padding: const EdgeInsets.fromLTRB(12, 0, 12, 8), + child: WavySeekBar( + controller: controller, + playedColor: fg, + trackColor: fg.withValues(alpha: 0.3), + style: progressBarStyle, + ), + ), + ], + ), + ), + ); + }, + ), + ), + ], + ), + ); + } +} + +/// A Material You "expressive" wavy seek bar: the played portion of the +/// track animates as a travelling sine wave while playing and settles flat +/// when paused, matching the Android 16+ media player style. Drag or tap +/// anywhere on it to seek. Still driven by [SeekBarPainter] (shared with the +/// Settings style picker's preview) - only [playedColor]/[trackColor] come +/// from [NooColors] now instead of `ColorScheme`. +class WavySeekBar extends StatefulWidget { + final VideoPlayerController controller; + final Color playedColor; + final Color trackColor; + final MediaProgressBarStyle style; + + const WavySeekBar({ + super.key, + required this.controller, + required this.playedColor, + required this.trackColor, + required this.style, + }); + + @override + State createState() => _WavySeekBarState(); +} + +class _WavySeekBarState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _waveController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 900), + ); + double? _dragRatio; + + @override + void dispose() { + _waveController.dispose(); + super.dispose(); + } + + void _seekToRatio(double ratio) { + final duration = widget.controller.value.duration; + widget.controller.seekTo(duration * ratio.clamp(0.0, 1.0)); + } + + @override + Widget build(BuildContext context) { + final value = widget.controller.value; + final animatesWave = + widget.style == MediaProgressBarStyle.wavy || + widget.style == MediaProgressBarStyle.squiggly; + final isPlaying = animatesWave && value.isPlaying && _dragRatio == null; + if (isPlaying && !_waveController.isAnimating) { + _waveController.repeat(); + } else if (!isPlaying && _waveController.isAnimating) { + _waveController.stop(); + } + + final durationMs = value.duration.inMilliseconds; + final baseRatio = durationMs > 0 + ? value.position.inMilliseconds / durationMs + : 0.0; + final ratio = (_dragRatio ?? baseRatio).clamp(0.0, 1.0); + + return SizedBox( + height: 28, + child: LayoutBuilder( + builder: (context, constraints) { + void updateFromDx(double dx) { + setState( + () => _dragRatio = (dx / constraints.maxWidth).clamp(0.0, 1.0), + ); + } + + return GestureDetector( + behavior: HitTestBehavior.opaque, + onHorizontalDragStart: (details) => + updateFromDx(details.localPosition.dx), + onHorizontalDragUpdate: (details) => + updateFromDx(details.localPosition.dx), + onHorizontalDragEnd: (_) { + if (_dragRatio != null) _seekToRatio(_dragRatio!); + setState(() => _dragRatio = null); + }, + onTapUp: (details) => + _seekToRatio(details.localPosition.dx / constraints.maxWidth), + child: AnimatedBuilder( + animation: _waveController, + builder: (context, _) { + return CustomPaint( + size: Size(constraints.maxWidth, 28), + painter: SeekBarPainter( + style: widget.style, + progress: ratio, + phase: _waveController.value * 2 * math.pi, + animate: isPlaying, + playedColor: widget.playedColor, + trackColor: widget.trackColor, + ), + ); + }, + ), + ); + }, + ), + ); + } +} diff --git a/lib/widgets/viewer/viewer_icon_button.dart b/lib/widgets/viewer/viewer_icon_button.dart new file mode 100644 index 0000000..08daf54 --- /dev/null +++ b/lib/widgets/viewer/viewer_icon_button.dart @@ -0,0 +1,48 @@ +import 'package:flutter/material.dart'; +import '../../theme/design_tokens.dart'; + +/// Icon-only action button for the media viewer's floating chrome (top bar, +/// bottom action bar, video transport controls) - a fixed 22px icon in a +/// round hit box, colored from [NooColors] rather than Material's +/// [ColorScheme]. Kept as one small widget (matching the pre-rework +/// `_ActionIconButton` it replaces - see `styling.md`) so every floating +/// control in this screen looks the same instead of drifting apart across +/// the three call sites that need one. +class ViewerIconButton extends StatelessWidget { + final IconData icon; + final String tooltip; + final VoidCallback? onTap; + + /// Overrides the default `fg-1` (e.g. `danger` for delete). Ignored while + /// [onTap] is null - a disabled button always dims to the same faded fg-1 + /// regardless of its normal color. + final Color? color; + + const ViewerIconButton({ + super.key, + required this.icon, + required this.tooltip, + required this.onTap, + this.color, + }); + + @override + Widget build(BuildContext context) { + final colors = context.nooColors; + final fg = onTap == null + ? colors.fg1.withValues(alpha: 0.4) + : (color ?? colors.fg1); + + return Tooltip( + message: tooltip, + child: InkWell( + onTap: onTap, + customBorder: const CircleBorder(), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + child: Icon(icon, color: fg, size: 22), + ), + ), + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index 9bfe81a..8622330 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -9,14 +9,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" - archive: - dependency: transitive - description: - name: archive - sha256: "6c5bcd986e06b94e3c40244af471750840a3d2341d1f9763a1100a14add517b4" - url: "https://pub.dev" - source: hosted - version: "4.3.0" args: dependency: transitive description: @@ -49,22 +41,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" - checked_yaml: - dependency: transitive - description: - name: checked_yaml - sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" - url: "https://pub.dev" - source: hosted - version: "2.0.4" - cli_util: - dependency: transitive - description: - name: cli_util - sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c - url: "https://pub.dev" - source: hosted - version: "0.4.2" clock: dependency: transitive description: @@ -261,10 +237,10 @@ packages: dependency: "direct main" description: name: file_saver - sha256: "017a127de686af2d2fbbd64afea97052d95f2a0f87d19d25b87e097407bf9c1e" + sha256: "3bf1cede4a6c35fb09b597373993f990c1b2abb82dcccce9bed26c174180ff6e" url: "https://pub.dev" source: hosted - version: "0.2.14" + version: "0.5.0" fixnum: dependency: transitive description: @@ -278,14 +254,11 @@ packages: description: flutter source: sdk version: "0.0.0" - flutter_launcher_icons: - dependency: "direct dev" - description: - name: flutter_launcher_icons - sha256: "10f13781741a2e3972126fae08393d3c4e01fa4cd7473326b94b72cf594195e7" - url: "https://pub.dev" - source: hosted - version: "0.14.4" + flutter_driver: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" flutter_lints: dependency: "direct dev" description: @@ -360,6 +333,11 @@ packages: description: flutter source: sdk version: "0.0.0" + fuchsia_remote_debug_protocol: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" google_fonts: dependency: "direct main" description: @@ -400,14 +378,11 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" - image: - dependency: transitive - description: - name: image - sha256: a1e7f4951e538a568e14b856702afc9ae1d2f4b202daced8d22c1b9cd211ce89 - url: "https://pub.dev" - source: hosted - version: "4.10.1" + integration_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" intl: dependency: "direct main" description: @@ -440,14 +415,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" - json_annotation: - dependency: transitive - description: - name: json_annotation - sha256: "2a743920d81b7910627f68ee2c9ac1fc0bfee32b9fc3403587d7c6791ca12f80" - url: "https://pub.dev" - source: hosted - version: "4.12.0" leak_tracker: dependency: transitive description: @@ -528,6 +495,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.0" + lucide_icons_flutter: + dependency: "direct main" + description: + name: lucide_icons_flutter + sha256: "50a21003cfe094e33b87e780d62ed5be84c32273dec1ba04547aab7df3cba00c" + url: "https://pub.dev" + source: hosted + version: "3.1.20" marquee: dependency: "direct main" description: @@ -816,14 +791,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" - posix: + process: dependency: transitive description: - name: posix - sha256: bc1bad54ad2b735816e31f8d4600cfde6c7839975085ddfbca48b6c9f7c4044e + name: process + sha256: "4242ba3508d37e01808bdf71ad1d5bb93a8d671bf2e7450e6b1b353fb0808891" url: "https://pub.dev" source: hosted - version: "6.5.2" + version: "5.0.6" provider: dependency: "direct main" description: @@ -957,6 +932,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + sync_http: + dependency: transitive + description: + name: sync_http + sha256: "7f0cd72eca000d2e026bcd6f990b81d0ca06022ef4e32fb257b30d3d1014a961" + url: "https://pub.dev" + source: hosted + version: "0.3.1" synchronized: dependency: transitive description: @@ -1101,6 +1084,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + webdriver: + dependency: transitive + description: + name: webdriver + sha256: "28b82ec894fed45dd71c23ba62d1af973ed97dd59a4f5790a4d38b0b13e5657e" + url: "https://pub.dev" + source: hosted + version: "3.2.0" webview_flutter: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index b70eff4..b24692f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -45,12 +45,13 @@ dependencies: dio: ^5.11.1 path_provider: ^2.1.5 open_file: ^3.5.10 - file_saver: ^0.2.14 + file_saver: ^0.5.0 video_player: ^2.9.5 - pdfx: ^2.9.2 + pdfx: ^2.11.0 file_picker: ^13.1.0 share_plus: ^13.3.0 material_symbols_icons: ^4.2960.0 + lucide_icons_flutter: ^3.1.20 shared_preferences: ^2.5.5 marquee: ^2.3.0 local_auth: ^2.3.0 @@ -62,20 +63,17 @@ dev_dependencies: flutter_test: sdk: flutter + # Drives tool/screenshots.sh (Play Store screenshots against a fake-data + # demo server) - dev only, never part of the release build. + integration_test: + sdk: flutter + # The "flutter_lints" package below contains a set of recommended lints to # encourage good coding practices. The lint set provided by the package is # activated in the `analysis_options.yaml` file located at the root of your # package. See that file for information about deactivating specific lint # rules and activating additional ones. flutter_lints: ^6.0.0 - flutter_launcher_icons: ^0.14.4 - -flutter_launcher_icons: - android: true - image_path: "assets/icon/app_icon.png" - adaptive_icon_background: "assets/icon/app_icon_background.png" - adaptive_icon_foreground: "assets/icon/app_icon_foreground.png" - adaptive_icon_monochrome: "assets/icon/app_icon_monochrome.png" # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec diff --git a/test/widget_test.dart b/test/widget_test.dart index d98c857..7fcb28f 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -114,7 +114,7 @@ void main() { await tester.pumpAndSettle(); expect(find.text('Noo'), findsOneWidget); - expect(find.text('Server Address'), findsOneWidget); + expect(find.text('cloud.example.com'), findsOneWidget); expect(find.text('Continue'), findsOneWidget); }); } diff --git a/test/widgets/noo/core_test.dart b/test/widgets/noo/core_test.dart new file mode 100644 index 0000000..0720492 --- /dev/null +++ b/test/widgets/noo/core_test.dart @@ -0,0 +1,265 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:noo/theme/design_tokens.dart'; +import 'package:noo/widgets/noo/core/noo_avatar.dart'; +import 'package:noo/widgets/noo/core/noo_badge.dart'; +import 'package:noo/widgets/noo/core/noo_button.dart'; +import 'package:noo/widgets/noo/core/noo_chip.dart'; +import 'package:noo/widgets/noo/core/noo_fab.dart'; +import 'package:noo/widgets/noo/core/noo_progress_bar.dart'; +import 'package:noo/widgets/noo/core/noo_search_field.dart'; +import 'package:noo/widgets/noo/core/noo_segmented_control.dart'; +import 'package:noo/widgets/noo/core/noo_toggle.dart'; + +import 'noo_test_utils.dart'; + +void main() { + setUpNooTests(); + + group('NooAvatar', () { + testNooWidgets('current user uses accent-soft/accent-text', (tester, theme, c) async { + await pumpNoo(tester, const NooAvatar(initials: 'AY', current: true), theme: theme); + expect(find.text('AY'), findsOneWidget); + expect(decorationOf(tester, find.text('AY')).color, c.accentSoft); + expect(tester.widget(find.text('AY')).style!.color, c.accentText); + }); + + testNooWidgets('palette avatar uses fixed dark text', (tester, theme, c) async { + await pumpNoo(tester, NooAvatar(initials: 'JD', color: NooColors.avatarPalette[3]), theme: theme); + expect(decorationOf(tester, find.text('JD')).color, NooColors.avatarPalette[3]); + expect(tester.widget(find.text('JD')).style!.color, NooColors.avatarTextColor); + }); + + testWidgets('icon avatar and size', (tester) async { + await pumpNoo(tester, const NooAvatar(icon: LucideIcons.users, size: 48)); + expect(find.byIcon(LucideIcons.users), findsOneWidget); + expect(tester.getSize(find.byType(NooAvatar)), const Size(48, 48)); + }); + }); + + group('NooBadge', () { + testNooWidgets('tones map to soft/strong pairs', (tester, theme, c) async { + await pumpNoo( + tester, + const Column( + mainAxisSize: MainAxisSize.min, + children: [ + NooBadge(child: Text('ok')), + NooBadge(tone: NooBadgeTone.danger, child: Text('bad')), + ], + ), + theme: theme, + ); + expect(decorationOf(tester, find.text('ok')).color, c.successSoft); + expect(decorationOf(tester, find.text('bad')).color, c.dangerSoft); + expect(tester.getSize(find.byType(NooBadge).first).height, 24); + }); + }); + + group('NooButton', () { + testNooWidgets('primary renders and fires onTap', (tester, theme, c) async { + var taps = 0; + await pumpNoo( + tester, + NooButton(icon: LucideIcons.upload, onTap: () => taps++, child: const Text('Upload')), + theme: theme, + ); + expect(decorationOf(tester, find.text('Upload')).color, c.accent); + await tester.tap(find.text('Upload')); + await tester.pumpAndSettle(); + expect(taps, 1); + }); + + testWidgets('disabled ignores taps', (tester) async { + var taps = 0; + await pumpNoo(tester, NooButton(disabled: true, onTap: () => taps++, child: const Text('No'))); + await tester.tap(find.text('No'), warnIfMissed: false); + await tester.pumpAndSettle(); + expect(taps, 0); + }); + + testWidgets('shrink-wraps unless fullWidth, heights follow size', (tester) async { + await pumpNoo( + tester, + const Column( + mainAxisSize: MainAxisSize.min, + children: [ + NooButton(key: Key('a'), size: NooButtonSize.cta, child: Text('A')), + NooButton(key: Key('b'), fullWidth: true, child: Text('B')), + ], + ), + ); + final a = tester.getSize(find.byKey(const Key('a'))); + final b = tester.getSize(find.byKey(const Key('b'))); + expect(a.height, NooSizes.buttonCta); + expect(a.width, lessThan(200)); + expect(b.height, NooSizes.buttonToolbar); + expect(b.width, greaterThan(700)); + }); + + testNooWidgets('variants pick matching fill', (tester, theme, c) async { + await pumpNoo( + tester, + const Column( + mainAxisSize: MainAxisSize.min, + children: [ + NooButton(variant: NooButtonVariant.tonal, child: Text('t')), + NooButton(variant: NooButtonVariant.secondary, child: Text('s')), + NooButton(variant: NooButtonVariant.danger, child: Text('d')), + ], + ), + theme: theme, + ); + expect(decorationOf(tester, find.text('t')).color, c.accentSoft); + expect(decorationOf(tester, find.text('s')).color, c.surface2); + expect(decorationOf(tester, find.text('d')).color, c.dangerSoft); + }); + }); + + group('NooChip', () { + testNooWidgets('selected vs idle fill, onTap fires', (tester, theme, c) async { + var taps = 0; + await pumpNoo( + tester, + Column( + mainAxisSize: MainAxisSize.min, + children: [ + NooChip(selected: true, onTap: () => taps++, child: const Text('Name')), + const NooChip(trailing: NooChipTrailing.menu, child: Text('Type')), + ], + ), + theme: theme, + ); + expect(decorationOf(tester, find.text('Name')).color, c.accentSoft); + expect(decorationOf(tester, find.text('Type')).color, c.surface); + await tester.tap(find.text('Name')); + expect(taps, 1); + }); + + testWidgets('outline chip is 32px with a line border', (tester) async { + await pumpNoo(tester, const NooChip(outline: true, child: Text('Modified'))); + expect(tester.getSize(find.byType(NooChip)).height, 32); + final border = decorationOf(tester, find.text('Modified')).border as Border; + expect(border.top.color, NooColors.light.line); + }); + }); + + group('NooFab', () { + testNooWidgets('shows label and fires onTap', (tester, theme, c) async { + var taps = 0; + await pumpNoo(tester, NooFab(onTap: () => taps++), theme: theme); + expect(find.text('Upload'), findsOneWidget); + expect(tester.getSize(find.byType(NooFab)).height, 56); + expect(tester.getSize(find.byType(NooFab)).width, lessThan(200)); + await tester.tap(find.byType(NooFab)); + expect(taps, 1); + }); + }); + + group('NooProgressBar', () { + testNooWidgets('clamps value and uses track/fill tokens', (tester, theme, c) async { + await pumpNoo(tester, const SizedBox(width: 200, child: NooProgressBar(value: 1.7)), theme: theme); + final bar = tester.widget(find.byType(LinearProgressIndicator)); + expect(bar.value, 1.0); + expect(bar.backgroundColor, c.surface3); + expect(bar.valueColor!.value, c.accent); + expect(tester.getSize(find.byType(NooProgressBar)).height, 6); + }); + }); + + group('NooSearchField', () { + testNooWidgets('placeholder, typing, surface fill', (tester, theme, c) async { + String? typed; + await pumpNoo( + tester, + SizedBox(width: 260, child: NooSearchField(onSurface: true, onChanged: (v) => typed = v)), + theme: theme, + ); + expect(find.text('Search'), findsOneWidget); + expect(decorationOf(tester, find.byType(TextField)).color, c.surface2); + await tester.enterText(find.byType(TextField), 'cats'); + expect(typed, 'cats'); + }); + }); + + group('NooSegmentedControl', () { + Widget host() { + var value = 'list'; + return StatefulBuilder( + builder: (context, setState) => NooSegmentedControl( + value: value, + onChanged: (v) => setState(() => value = v), + options: const [ + NooSegmentOption(value: 'list', label: 'List'), + NooSegmentOption(value: 'grid', label: 'Grid'), + ], + ), + ); + } + + testNooWidgets('tapping a segment moves the active fill', (tester, theme, c) async { + await pumpNoo(tester, host(), theme: theme); + expect(decorationOf(tester, find.text('List')).color, c.accentSoft); + expect(tester.widget(find.text('Grid')).style!.color, c.fg2); + + await tester.tap(find.text('Grid')); + await tester.pump(); + expect(decorationOf(tester, find.text('Grid')).color, c.accentSoft); + expect(tester.widget(find.text('Grid')).style!.color, c.accentText); + expect(tester.widget(find.text('List')).style!.color, c.fg2); + }); + + testWidgets('icon-only segments are 36x28', (tester) async { + await pumpNoo( + tester, + NooSegmentedControl( + value: 0, + iconOnly: true, + options: const [ + NooSegmentOption(value: 0, icon: LucideIcons.list), + NooSegmentOption(value: 1, icon: LucideIcons.layoutGrid), + ], + ), + ); + expect(find.byType(Text), findsNothing); + // 2 x 36 wide segments + 3px track padding each side. + expect(tester.getSize(find.byType(NooSegmentedControl)), const Size(78, 34)); + }); + }); + + group('NooToggle', () { + testNooWidgets('tapping flips state and track colour', (tester, theme, c) async { + var on = false; + await pumpNoo( + tester, + StatefulBuilder( + builder: (context, setState) => + NooToggle(checked: on, onChanged: (v) => setState(() => on = v)), + ), + theme: theme, + ); + expect(tester.getSize(find.byType(NooToggle)), const Size(48, 28)); + Color track() => (tester.widget(find.byType(AnimatedContainer)).decoration! + as BoxDecoration) + .color!; + expect(track(), c.surface3); + + await tester.tap(find.byType(NooToggle)); + await tester.pumpAndSettle(); + expect(on, isTrue); + expect(track(), c.accent); + + await tester.tap(find.byType(NooToggle)); + await tester.pumpAndSettle(); + expect(on, isFalse); + }); + + testWidgets('desktop size and null onChanged is inert', (tester) async { + await pumpNoo(tester, const NooToggle(checked: true, desktop: true)); + expect(tester.getSize(find.byType(NooToggle)), const Size(44, 26)); + await tester.tap(find.byType(NooToggle)); + await tester.pumpAndSettle(); + }); + }); +} diff --git a/test/widgets/noo/lists_test.dart b/test/widgets/noo/lists_test.dart new file mode 100644 index 0000000..b3fba59 --- /dev/null +++ b/test/widgets/noo/lists_test.dart @@ -0,0 +1,224 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:noo/widgets/noo/core/noo_progress_bar.dart'; +import 'package:noo/widgets/noo/core/noo_toggle.dart'; +import 'package:noo/widgets/noo/lists/noo_banner.dart'; +import 'package:noo/widgets/noo/lists/noo_grouped_list.dart'; +import 'package:noo/widgets/noo/lists/noo_settings_row.dart'; +import 'package:noo/widgets/noo/lists/noo_summary_card.dart'; +import 'package:noo/widgets/noo/lists/noo_tab_order_row.dart'; + +import 'noo_test_utils.dart'; + +void main() { + setUpNooTests(); + + group('NooBanner', () { + testNooWidgets('renders text and fires action', (tester, theme, c) async { + var taps = 0; + await pumpNoo( + tester, + NooBanner( + actionLabel: 'Empty trash', + onAction: () => taps++, + child: const Text('Items are deleted after 30 days'), + ), + theme: theme, + ); + expect(find.byIcon(LucideIcons.info), findsOneWidget); + expect(decorationOf(tester, find.text('Empty trash')).color, c.surface); + expect(tester.widget(find.text('Empty trash')).style!.color, c.danger); + await tester.tap(find.text('Empty trash')); + expect(taps, 1); + }); + + testNooWidgets('non-danger action uses accent text', (tester, theme, c) async { + await pumpNoo( + tester, + const NooBanner(actionLabel: 'Undo', actionIsDanger: false, child: Text('x')), + theme: theme, + ); + expect(tester.widget(find.text('Undo')).style!.color, c.accentText); + }); + }); + + group('NooGroupedList', () { + testNooWidgets('label, aside, 1px line gaps and footer', (tester, theme, c) async { + await pumpNoo( + tester, + SizedBox( + width: 360, + child: NooGroupedList( + label: 'Accounts', + aside: '2', + footer: const Text('Footer note'), + children: [ + Container(key: const Key('r1'), height: 52, color: c.surface), + Container(key: const Key('r2'), height: 52, color: c.surface), + ], + ), + ), + theme: theme, + ); + expect(find.text('Accounts'), findsOneWidget); + expect(find.text('2'), findsOneWidget); + expect(find.text('Footer note'), findsOneWidget); + expect(tester.widget(find.text('Accounts')).style!.color, c.fg2); + final gap = tester.getTopLeft(find.byKey(const Key('r2'))).dy - + tester.getBottomLeft(find.byKey(const Key('r1'))).dy; + expect(gap, 1); + // Rows paint their own surface; the gaps show the list's line fill. + expect(decorationOf(tester, find.byKey(const Key('r1'))).color, c.line); + }); + }); + + group('NooSettingsRow', () { + testNooWidgets('value row shows chevron and fires onTap', (tester, theme, c) async { + var taps = 0; + await pumpNoo( + tester, + SizedBox( + width: 360, + child: NooGroupedList( + children: [ + NooSettingsRow( + icon: LucideIcons.palette, + label: const Text('Theme'), + value: 'System', + onTap: () => taps++, + ), + ], + ), + ), + theme: theme, + ); + expect(find.text('System'), findsOneWidget); + expect(find.byIcon(LucideIcons.chevronRight), findsOneWidget); + expect(tester.getSize(find.byType(NooSettingsRow)).height, 52); + await tester.tap(find.text('Theme')); + await tester.pumpAndSettle(); + expect(taps, 1); + }); + + testWidgets('subtitle row is 60px, trailing wins over value', (tester) async { + await pumpNoo( + tester, + const SizedBox( + width: 600, + child: NooGroupedList( + children: [ + NooSettingsRow( + label: Text('Sync on Wi-Fi only'), + subtitle: Text('Saves mobile data'), + value: 'ignored', + trailing: NooToggle(checked: true), + ), + ], + ), + ), + ); + expect(find.text('ignored'), findsNothing); + expect(find.byType(NooToggle), findsOneWidget); + expect(tester.getSize(find.byType(NooSettingsRow)).height, 60); + }); + + testWidgets('keeps its 52px height inside a tall bounded parent', (tester) async { + await pumpNoo( + tester, + const SizedBox( + width: 360, + height: 400, + child: Align( + alignment: Alignment.topCenter, + child: NooSettingsRow(label: Text('Theme'), value: 'System'), + ), + ), + ); + expect(tester.getSize(find.byType(NooSettingsRow)).height, 52); + }); + + testNooWidgets('danger row uses danger colour', (tester, theme, c) async { + await pumpNoo( + tester, + const SizedBox( + width: 360, + child: NooSettingsRow(icon: LucideIcons.logOut, label: Text('Log out'), danger: true), + ), + theme: theme, + ); + final style = DefaultTextStyle.of(tester.element(find.text('Log out'))).style; + expect(style.color, c.danger); + expect(tester.widget(find.byIcon(LucideIcons.logOut)).color, c.danger); + }); + }); + + group('NooSummaryCard', () { + testNooWidgets('stat, caption, progress, meta, action', (tester, theme, c) async { + await pumpNoo( + tester, + const SizedBox( + width: 360, + child: NooSummaryCard( + stat: '2.4 GB', + caption: 'Offline', + progress: 0.4, + meta: 'Synced 2 min ago', + action: Text('Sync now'), + ), + ), + theme: theme, + ); + for (final t in ['2.4 GB', 'Offline', 'Synced 2 min ago', 'Sync now']) { + expect(find.text(t), findsOneWidget); + } + expect(find.byType(NooProgressBar), findsOneWidget); + expect(decorationOf(tester, find.text('2.4 GB')).color, c.surface); + expect(tester.widget(find.text('2.4 GB')).style!.color, c.fg1); + }); + + testNooWidgets('danger tone', (tester, theme, c) async { + await pumpNoo( + tester, + const SizedBox( + width: 360, + child: NooSummaryCard(stat: '3', caption: 'Errors', tone: NooSummaryCardTone.danger), + ), + theme: theme, + ); + expect(find.byType(NooProgressBar), findsNothing); + expect(decorationOf(tester, find.text('3')).color, c.dangerSoft); + expect(tester.widget(find.text('3')).style!.color, c.danger); + expect(tester.widget(find.text('Errors')).style!.color, c.danger); + }); + }); + + group('NooTabOrderRow', () { + testNooWidgets('pin state drives icon/fill, toggle fires', (tester, theme, c) async { + var pinned = true; + await pumpNoo( + tester, + SizedBox( + width: 360, + child: StatefulBuilder( + builder: (context, setState) => NooTabOrderRow( + icon: LucideIcons.folder, + label: 'Files', + pinned: pinned, + onTogglePin: () => setState(() => pinned = !pinned), + ), + ), + ), + theme: theme, + ); + expect(find.text('Files'), findsOneWidget); + expect(decorationOf(tester, find.byIcon(LucideIcons.pin)).color, c.accentSoft); + + await tester.tap(find.byIcon(LucideIcons.pin)); + await tester.pump(); + expect(pinned, isFalse); + expect(find.byIcon(LucideIcons.pin), findsNothing); + expect(decorationOf(tester, find.byIcon(LucideIcons.pinOff)).color, c.surface2); + }); + }); +} diff --git a/test/widgets/noo/noo_test_utils.dart b/test/widgets/noo/noo_test_utils.dart new file mode 100644 index 0000000..574776c --- /dev/null +++ b/test/widgets/noo/noo_test_utils.dart @@ -0,0 +1,67 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:noo/theme/app_theme.dart'; +import 'package:noo/theme/design_tokens.dart'; + +/// Shared setup for the design-system component tests. Google Fonts can't +/// fetch in the sandbox, so runtime fetching is off (see standards.md +/// "Testing"); the resulting missing-font load errors are async and don't +/// affect layout assertions. +void setUpNooTests() { + setUpAll(() => GoogleFonts.config.allowRuntimeFetching = false); +} + +/// Themes built through the real [AppTheme] (non-dynamic), so the +/// [NooColors] extension is registered exactly as in the app. Built lazily +/// inside test bodies - AppTheme touches Google Fonts, which needs the test +/// binding to exist first. +ThemeData nooTheme(Brightness brightness) => brightness == Brightness.light + ? AppTheme.light(AppTheme.defaultNextcloudBlue, useDynamicColor: false) + : AppTheme.dark(AppTheme.defaultNextcloudBlue, useDynamicColor: false); + +Future pumpNoo( + WidgetTester tester, + Widget child, { + ThemeData? theme, + Size surfaceSize = const Size(800, 900), +}) async { + await tester.binding.setSurfaceSize(surfaceSize); + addTearDown(() => tester.binding.setSurfaceSize(null)); + await tester.pumpWidget( + MaterialApp( + theme: theme ?? nooTheme(Brightness.light), + home: Scaffold( + body: Center( + child: Padding(padding: const EdgeInsets.all(16), child: child), + ), + ), + ), + ); +} + +/// Runs [body] once per theme, each as its own named test. +void testNooWidgets( + String description, + Future Function(WidgetTester tester, ThemeData theme, NooColors colors) body, +) { + for (final brightness in Brightness.values) { + testWidgets('$description (${brightness.name})', (tester) async { + final theme = nooTheme(brightness); + await body(tester, theme, theme.extension()!); + }); + } +} + +/// The nearest colored [BoxDecoration] painted around [finder] (Container +/// and AnimatedContainer both build a [DecoratedBox]). +BoxDecoration decorationOf(WidgetTester tester, Finder finder) { + final boxes = find.ancestor( + of: finder, + matching: find.byWidgetPredicate( + (w) => w is DecoratedBox && w.decoration is BoxDecoration && (w.decoration as BoxDecoration).color != null, + ), + matchRoot: true, + ); + return tester.widget(boxes.first).decoration as BoxDecoration; +} diff --git a/test/widgets/noo/overlays_test.dart b/test/widgets/noo/overlays_test.dart new file mode 100644 index 0000000..d2a6a85 --- /dev/null +++ b/test/widgets/noo/overlays_test.dart @@ -0,0 +1,336 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:noo/theme/design_tokens.dart'; +import 'package:noo/widgets/noo/core/noo_avatar.dart'; +import 'package:noo/widgets/noo/core/noo_button.dart'; +import 'package:noo/widgets/noo/overlays/noo_dialog.dart'; +import 'package:noo/widgets/noo/overlays/noo_overlay_header.dart'; +import 'package:noo/widgets/noo/overlays/noo_share_parts.dart'; +import 'package:noo/widgets/noo/overlays/noo_sheet.dart'; +import 'package:noo/widgets/noo/overlays/noo_text_field.dart'; + +import 'noo_test_utils.dart'; + +/// A button that opens an overlay from a context below MaterialApp. +Widget _launcher(void Function(BuildContext) open) => Builder( + builder: (context) => TextButton(onPressed: () => open(context), child: const Text('open')), +); + +Finder _dialogCard() => find.byWidgetPredicate( + (w) => w is Container && w.decoration is BoxDecoration && (w.decoration as BoxDecoration).boxShadow != null, +); + +void main() { + setUpNooTests(); + + group('NooOverlayHeader', () { + testNooWidgets('title, subtitle, leading and close', (tester, theme, c) async { + var closed = 0; + await pumpNoo( + tester, + SizedBox( + width: 360, + child: NooOverlayHeader( + leading: const Icon(LucideIcons.fileText), + title: 'Report.pdf', + subtitle: '2 MB · Documents', + onClose: () => closed++, + ), + ), + theme: theme, + ); + expect(find.text('Report.pdf'), findsOneWidget); + expect(tester.widget(find.text('2 MB · Documents')).style!.color, c.fg3); + expect(decorationOf(tester, find.byIcon(LucideIcons.x)).color, c.surface2); + await tester.tap(find.byIcon(LucideIcons.x)); + expect(closed, 1); + }); + + testWidgets('no close button without onClose', (tester) async { + await pumpNoo(tester, const SizedBox(width: 360, child: NooOverlayHeader(title: 'T'))); + expect(find.byIcon(LucideIcons.x), findsNothing); + }); + }); + + group('showNooSheet', () { + testNooWidgets('shows children on a surface sheet and dismisses', (tester, theme, c) async { + await pumpNoo( + tester, + _launcher((ctx) => showNooSheet(ctx, children: const [Text('Section A'), Text('Section B')])), + theme: theme, + ); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + expect(find.text('Section A'), findsOneWidget); + expect(find.text('Section B'), findsOneWidget); + final sheet = tester.widget(find.byType(BottomSheet)); + expect(sheet.backgroundColor, c.surface); + final gap = tester.getTopLeft(find.text('Section B')).dy - + tester.getBottomLeft(find.text('Section A')).dy; + expect(gap, 22); + + await tester.tapAt(const Offset(10, 10)); + await tester.pumpAndSettle(); + expect(find.text('Section A'), findsNothing); + }); + }); + + group('NooDialog', () { + testNooWidgets('card is 540 wide, r24, single dialog shadow', (tester, theme, c) async { + await pumpNoo( + tester, + NooDialog(title: 'Share "Report.pdf"', onClose: () {}, children: const [Text('body')]), + theme: theme, + surfaceSize: const Size(1200, 900), + ); + final deco = decorationOf(tester, find.text('body')); + expect(deco.color, c.surface); + expect(deco.borderRadius, BorderRadius.circular(NooRadii.dialog)); + expect(deco.boxShadow, const [nooDialogShadow]); + final card = _dialogCard(); + expect(tester.getSize(card).width, 540); + }); + + testWidgets('narrow screens shrink the card to fit with a 24px margin', (tester) async { + await tester.binding.setSurfaceSize(const Size(400, 800)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + await tester.pumpWidget( + MaterialApp( + theme: nooTheme(Brightness.light), + home: const NooDialog(title: 'T', children: [Text('body')]), + ), + ); + final card = _dialogCard(); + expect(tester.getSize(card).width, 400 - 48); + }); + + testNooWidgets('header close is a 32px surface-2 circle', (tester, theme, c) async { + var closed = 0; + await pumpNoo( + tester, + NooDialogHeader( + leading: const Icon(LucideIcons.fileText), + title: 'Share "Report.pdf"', + subtitle: '2 MB · Documents', + onClose: () => closed++, + ), + theme: theme, + ); + expect(tester.getSize(find.byType(NooCloseButton)), const Size(32, 32)); + expect(decorationOf(tester, find.byIcon(LucideIcons.x)).color, c.surface2); + await tester.tap(find.byType(NooCloseButton)); + expect(closed, 1); + }); + + testNooWidgets('showNooDialog uses the scrim and closes via the button', (tester, theme, c) async { + Future? result; + await pumpNoo( + tester, + _launcher( + (ctx) => result = showNooDialog( + ctx, + title: 'Share "Report.pdf"', + children: const [Text('dialog body')], + ), + ), + theme: theme, + surfaceSize: const Size(1200, 900), + ); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + expect(find.text('dialog body'), findsOneWidget); + final barrier = tester.widgetList(find.byType(ModalBarrier)).last; + expect(barrier.color, c.scrim); + + await tester.tap(find.byType(NooCloseButton)); + await tester.pumpAndSettle(); + expect(find.text('dialog body'), findsNothing); + expect(await result, isNull); + }); + + testWidgets('barrier tap dismisses unless disabled', (tester) async { + await pumpNoo( + tester, + _launcher( + (ctx) => showNooDialog( + ctx, + title: 'T', + barrierDismissible: false, + children: const [Text('sticky')], + ), + ), + surfaceSize: const Size(1200, 900), + ); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + await tester.tapAt(const Offset(5, 5)); + await tester.pumpAndSettle(); + expect(find.text('sticky'), findsOneWidget); + }); + + testWidgets('long content scrolls instead of overflowing', (tester) async { + await pumpNoo( + tester, + _launcher( + (ctx) => showNooDialog( + ctx, + title: 'T', + children: [for (var i = 0; i < 40; i++) SizedBox(height: 60, child: Text('row $i'))], + ), + ), + surfaceSize: const Size(1200, 600), + ); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + expect(find.byType(SingleChildScrollView), findsOneWidget); + }); + }); + + group('NooTextField', () { + testNooWidgets('surface-2, r14, fg-3 placeholder, typing', (tester, theme, c) async { + String? typed; + await pumpNoo( + tester, + SizedBox( + width: 400, + child: NooTextField(placeholder: 'Name, email or group', onChanged: (v) => typed = v), + ), + theme: theme, + ); + final deco = decorationOf(tester, find.byType(TextField)); + expect(deco.color, c.surface2); + expect(deco.borderRadius, BorderRadius.circular(NooRadii.input)); + final field = tester.widget(find.byType(TextField)); + expect(field.decoration!.hintStyle!.color, c.fg3); + expect(field.style!.color, c.fg1); + await tester.enterText(find.byType(TextField), 'ali'); + expect(typed, 'ali'); + }); + + testNooWidgets('mono URL field with inline Copy link button', (tester, theme, c) async { + var copied = 0; + await pumpNoo( + tester, + SizedBox( + width: 480, + child: NooTextField( + controller: TextEditingController(text: 'https://cloud.example/s/abc'), + mono: true, + readOnly: true, + onSurface: false, + trailing: NooButton( + size: NooButtonSize.field, + onTap: () => copied++, + child: const Text('Copy link'), + ), + ), + ), + theme: theme, + ); + final field = tester.widget(find.byType(TextField)); + expect(field.style!.fontFamily, NooText.mono.fontFamily); + expect(field.readOnly, isTrue); + expect(decorationOf(tester, find.byType(TextField)).color, c.surface); + expect(tester.getSize(find.byType(NooButton)).height, NooSizes.buttonField); + await tester.tap(find.text('Copy link')); + await tester.pumpAndSettle(); + expect(copied, 1); + }); + }); + + group('Share parts', () { + testNooWidgets('NooShareSection title, trailing and caption', (tester, theme, c) async { + await pumpNoo( + tester, + const SizedBox( + width: 400, + child: NooShareSection( + title: 'Send file directly', + trailing: Icon(LucideIcons.link), + caption: "Link settings don't apply", + child: Text('content'), + ), + ), + theme: theme, + ); + expect(tester.widget(find.text('Send file directly')).style!.color, c.fg1); + expect(tester.widget(find.text("Link settings don't apply")).style!.color, c.fg3); + expect(find.byIcon(LucideIcons.link), findsOneWidget); + expect(find.text('content'), findsOneWidget); + }); + + testNooWidgets('owner row is static, others get a tappable pill', (tester, theme, c) async { + var taps = 0; + await pumpNoo( + tester, + SizedBox( + width: 400, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const NooPersonAccessRow( + avatar: NooAvatar(initials: 'AY', current: true), + name: 'Ayushya (you)', + subtitle: 'ayushya@example.com', + owner: true, + ), + NooPersonAccessRow( + avatar: const NooAvatar(initials: 'JD'), + name: 'Jane Doe', + onPermissionTap: () => taps++, + ), + ], + ), + ), + theme: theme, + ); + expect(find.text('Owner'), findsOneWidget); + expect(tester.widget(find.text('Owner')).style!.color, c.fg3); + expect(find.byType(NooPermissionPill), findsOneWidget); + expect(find.byIcon(LucideIcons.chevronDown), findsOneWidget); + expect(decorationOf(tester, find.text('Can edit')).color, c.surface2); + + await tester.tap(find.text('Can edit')); + expect(taps, 1); + }); + + testWidgets('same parts lay out inside the sheet and the dialog', (tester) async { + List parts() => [ + NooShareSection( + title: 'Share with people', + child: const NooTextField(placeholder: 'Name, email or group'), + ), + const NooPersonAccessRow(avatar: NooAvatar(initials: 'JD'), name: 'Jane', permission: 'Can view'), + ]; + await pumpNoo( + tester, + Column( + mainAxisSize: MainAxisSize.min, + children: [ + _launcher((ctx) => showNooSheet(ctx, children: parts())), + Builder( + builder: (ctx) => TextButton( + onPressed: () => showNooDialog(ctx, title: 'Share', children: parts()), + child: const Text('dialog'), + ), + ), + ], + ), + surfaceSize: const Size(1200, 900), + ); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + expect(find.text('Can view'), findsOneWidget); + expect(tester.takeException(), isNull); + await tester.tapAt(const Offset(5, 5)); + await tester.pumpAndSettle(); + + await tester.tap(find.text('dialog')); + await tester.pumpAndSettle(); + expect(find.text('Can view'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + }); +} diff --git a/test_driver/integration_test.dart b/test_driver/integration_test.dart new file mode 100644 index 0000000..9cc6fd1 --- /dev/null +++ b/test_driver/integration_test.dart @@ -0,0 +1,33 @@ +// Host-side driver for integration_test/store_screenshots_test.dart (run via +// tool/screenshots.sh). Each time the test asks for a screenshot this saves +// a PNG named after it into $SCREENSHOT_DIR. +// +// These bytes are the Flutter surface only (no OS status bar) - an earlier +// version of this file tried to re-capture the screen with `adb exec-out +// screencap` for a "real" screenshot including it, but on Android, +// `onScreenshot` only fires once the whole test has finished (see +// `IntegrationTestWidgetsFlutterBinding`/`integrationDriver`'s source: the +// native platform channel hands screenshot bytes straight to `reportData` +// with no round trip to the driver, and the driver's first `requestData` +// call blocks until every `testWidgets` body in the file has returned) - so +// every `adb screencap` call was capturing the same final frame, regardless +// of which named screenshot it was meant to be. Saving these bytes directly +// is what actually gives each name its own distinct image. +import 'dart:io'; + +import 'package:integration_test/integration_test_driver_extended.dart'; + +Future main() async { + final outDir = Directory( + Platform.environment['SCREENSHOT_DIR'] ?? 'store_listing/screenshots', + )..createSync(recursive: true); + + await integrationDriver( + onScreenshot: + (String name, List bytes, [Map? args]) async { + File('${outDir.path}/$name.png').writeAsBytesSync(bytes); + stdout.writeln('saved $name.png'); + return true; + }, + ); +} diff --git a/tool/demo_server/README.md b/tool/demo_server/README.md new file mode 100644 index 0000000..2c04911 --- /dev/null +++ b/tool/demo_server/README.md @@ -0,0 +1,48 @@ +# Demo server for Play Store screenshots + +`tool/screenshots.sh` takes the Play Store screenshots against a **throwaway +Nextcloud that only contains fake data**, so nothing real can appear in them. +This folder is that server: + +- `docker-compose.yml` - a single Nextcloud container (SQLite, nothing + persisted; `docker compose down -v` wipes it). +- `seed.py` - creates the users and fills them with invented content: landscape + "photos" drawn with Pillow, tiny generated PDFs, made-up documents, favorites, + shares (to a second user and a public link), trash, a filler file for the + storage quota, and edits by a second user so the Activity feed has more than + one author. It ends by printing `APP_PASSWORD=...` - an app password for the + demo user, so the app can start logged in without the browser login step. + +## Running it + +Most of the time just run the whole pipeline from the repo root: + +```bash +bash tool/screenshots.sh +``` + +(Git Bash is fine on Windows.) You need **Docker**, **Python 3**, **Flutter** +and an **Android SDK** (`ANDROID_HOME`). Pillow and `requests` are installed +into `tool/demo_server/.venv` automatically if your Python lacks them. The +script installs the emulator and a system image through `sdkmanager` on first +use. Options (`HEADLESS=1`, `DEMO_PORT`, `DEMO_HOST`, `KEEP_SERVER=1`, +`ANDROID_SERIAL`, ...) are documented at the top of the script. + +To poke at the server yourself: + +```bash +docker compose -f tool/demo_server/docker-compose.yml up -d +python3 tool/demo_server/seed.py # prints APP_PASSWORD=... +# browse http://localhost (Alex / alex-demo-pass, admin / demo-admin-password) +docker compose -f tool/demo_server/docker-compose.yml down -v +``` + +`python3 tool/demo_server/seed.py --generate-only DIR` writes the generated +files to `DIR` without any server - handy for previewing the fake content. + +## Before you upload + +Look at every image. The known places where real data could still show up: +the server address and username (sync header panel, Settings > Accounts), the +avatar initial, notification icons in the status bar, and anything left over +from a previously used emulator profile (the script always wipes its own). diff --git a/tool/demo_server/docker-compose.yml b/tool/demo_server/docker-compose.yml new file mode 100644 index 0000000..dca4427 --- /dev/null +++ b/tool/demo_server/docker-compose.yml @@ -0,0 +1,16 @@ +# A throwaway Nextcloud that only ever holds fake data - the backend the Play +# Store screenshots are taken against (see tool/screenshots.sh). Nothing is +# persisted: `docker compose down -v` wipes it completely. +services: + nextcloud: + image: ${NEXTCLOUD_IMAGE:-nextcloud:apache} + ports: + - "${DEMO_PORT:-80}:80" + environment: + # SQLite keeps this to a single container. + SQLITE_DATABASE: nextcloud + NEXTCLOUD_ADMIN_USER: admin + NEXTCLOUD_ADMIN_PASSWORD: demo-admin-password + # `localhost` is how the Android emulator reaches this server (via + # `adb reverse`); DEMO_HOST is the optional friendlier hostname. + NEXTCLOUD_TRUSTED_DOMAINS: "localhost 127.0.0.1 10.0.2.2 ${DEMO_HOST:-demo.example.com}" diff --git a/tool/demo_server/requirements.txt b/tool/demo_server/requirements.txt new file mode 100644 index 0000000..ff99c38 --- /dev/null +++ b/tool/demo_server/requirements.txt @@ -0,0 +1,2 @@ +requests>=2.31 +Pillow>=10.0 diff --git a/tool/demo_server/seed.py b/tool/demo_server/seed.py new file mode 100644 index 0000000..203e2c0 --- /dev/null +++ b/tool/demo_server/seed.py @@ -0,0 +1,348 @@ +#!/usr/bin/env python3 +"""Fills a throwaway Nextcloud with fake data for the Play Store screenshots. + +Everything here is invented or procedurally generated (landscape "photos" +drawn with Pillow, tiny hand-built PDFs, made-up names), so nothing real - +and nothing with a licence or a face in it - can end up in a screenshot. + + python3 seed.py seed http://localhost (DEMO_BASE_URL) + python3 seed.py --generate-only D write the fake files under D, no server + +On success the LAST line printed to stdout is `APP_PASSWORD=` - an app +password for the demo user, which tool/screenshots.sh hands to the app so it +can start logged in without going through the browser login flow. All +progress output goes to stderr. +""" +import argparse +import io +import math +import os +import random +import shutil +import subprocess +import sys +import tempfile +import time +from urllib.parse import quote + +from PIL import Image, ImageDraw, ImageFilter + +BASE = os.environ.get("DEMO_BASE_URL", "http://localhost").rstrip("/") +ADMIN = ("admin", os.environ.get("NEXTCLOUD_ADMIN_PASSWORD", "demo-admin-password")) +FILLER_MB = int(os.environ.get("DEMO_FILLER_MB", "300")) + +# The account the screenshots are taken as, and a second user to share with. +USERS = { + "Alex": ("alex-demo-pass", "Alex Morgan", "alex@example.com", "15 GB"), + "Sam": ("sam-demo-pass", "Sam Rivera", "sam@example.com", "5 GB"), +} + + +def log(message): + print(message, file=sys.stderr, flush=True) + + +# --------------------------------------------------------------- content --- + +# (sky top, sky bottom, sun, far hills, mid hills, near hills) +PALETTES = [ + ((250, 176, 120), (255, 226, 170), (255, 250, 220), (152, 120, 150), (108, 90, 128), (62, 60, 96)), + ((70, 130, 200), (190, 225, 245), (255, 255, 240), (110, 150, 170), (70, 118, 130), (40, 84, 90)), + ((30, 40, 90), (240, 130, 110), (255, 220, 190), (90, 60, 110), (60, 44, 84), (30, 28, 56)), + ((110, 180, 190), (230, 245, 235), (255, 245, 200), (130, 180, 150), (84, 140, 110), (44, 98, 76)), + ((240, 150, 90), (255, 214, 150), (255, 240, 200), (200, 130, 90), (160, 96, 70), (110, 66, 56)), + ((20, 24, 60), (70, 60, 120), (240, 240, 255), (50, 50, 100), (34, 36, 80), (18, 20, 50)), + ((150, 200, 230), (245, 250, 255), (255, 255, 255), (170, 190, 210), (130, 156, 180), (90, 120, 150)), + ((200, 120, 160), (250, 200, 190), (255, 235, 210), (170, 100, 140), (126, 78, 116), (80, 54, 90)), +] + + +def _mix(a, b, t): + return tuple(int(a[i] + (b[i] - a[i]) * t) for i in range(3)) + + +def landscape(seed, size=(1600, 1067)): + """A soft, layered-hills landscape - reads as a photo at thumbnail size.""" + rnd = random.Random(seed) + top, bottom, sun, far, mid, near = PALETTES[seed % len(PALETTES)] + w, h = size + img = Image.new("RGB", size) + draw = ImageDraw.Draw(img) + for y in range(h): + draw.line([(0, y), (w, y)], fill=_mix(top, bottom, min(1.0, y / (h * 0.75)))) + sx, sy, sr = rnd.randint(w // 5, 4 * w // 5), rnd.randint(h // 6, h // 3), rnd.randint(h // 12, h // 7) + draw.ellipse([sx - sr, sy - sr, sx + sr, sy + sr], fill=sun) + for base, amp, color in ((0.62, 0.10, far), (0.72, 0.13, mid), (0.84, 0.10, near)): + phase = [rnd.uniform(0, math.tau) for _ in range(3)] + freq = [rnd.uniform(1.0, 2.0), rnd.uniform(2.5, 4.0), rnd.uniform(5.0, 8.0)] + weight = (1.0, 0.5, 0.2) + pts = [(0, h)] + for x in range(0, w + 8, 8): + v = sum(wt * math.sin(x / w * math.tau * f + p) for wt, f, p in zip(weight, freq, phase)) + pts.append((x, int(h * base + h * amp * v / 1.7))) + pts.append((w, h)) + draw.polygon(pts, fill=color) + img = img.filter(ImageFilter.GaussianBlur(0.8)) + out = io.BytesIO() + img.save(out, "JPEG", quality=86) + return out.getvalue() + + +def pdf(title, lines): + """A tiny but valid one-page PDF.""" + + def esc(s): + return s.replace("\\", "\\\\").replace("(", "\\(").replace(")", "\\)") + + body = "BT /F1 26 Tf 72 720 Td (%s) Tj ET\n" % esc(title) + for i, line in enumerate(lines): + body += "BT /F1 12 Tf 72 %d Td (%s) Tj ET\n" % (680 - 20 * i, esc(line)) + stream = body.encode("latin-1", "replace") + objs = [ + b"<< /Type /Catalog /Pages 2 0 R >>", + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R " + b"/Resources << /Font << /F1 5 0 R >> >> >>", + b"<< /Length %d >>\nstream\n" % len(stream) + stream + b"\nendstream", + b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", + ] + out = bytearray(b"%PDF-1.4\n") + offsets = [] + for i, obj in enumerate(objs, start=1): + offsets.append(len(out)) + out += b"%d 0 obj\n" % i + obj + b"\nendobj\n" + xref = len(out) + out += b"xref\n0 %d\n0000000000 65535 f \n" % (len(objs) + 1) + for off in offsets: + out += b"%010d 00000 n \n" % off + out += b"trailer\n<< /Size %d /Root 1 0 R >>\nstartxref\n%d\n%%%%EOF\n" % (len(objs) + 1, xref) + return bytes(out) + + +def text(body): + return body.strip().encode("utf-8") + b"\n" + + +def video(): + """A short test-pattern clip if ffmpeg is around, else None (skipped).""" + if not shutil.which("ffmpeg"): + return None + with tempfile.TemporaryDirectory() as tmp: + path = os.path.join(tmp, "clip.mp4") + result = subprocess.run( + ["ffmpeg", "-y", "-loglevel", "error", "-f", "lavfi", "-i", "testsrc2=duration=6:size=640x360:rate=24", + "-pix_fmt", "yuv420p", path], + capture_output=True, + ) + if result.returncode != 0: + return None + with open(path, "rb") as f: + return f.read() + + +def build_content(): + """(path, bytes) for everything the demo user owns, plus what to favorite/delete.""" + files = [] + + def add(path, data): + if data is not None: + files.append((path, data)) + + add("/Welcome.md", text("# Welcome\n\nYour files, everywhere you go.")) + add("/Vacation ideas.txt", text("Lisbon\nKyoto\nPatagonia\nIceland in winter")) + + add("/Documents/Project brief.pdf", pdf("Project brief", ["Goals for the quarter", "Milestones and owners", "Open questions"])) + add("/Documents/Quarterly report.pdf", pdf("Quarterly report", ["Revenue up 12 percent", "Churn down 3 percent", "Hiring plan"])) + add("/Documents/Meeting notes.md", text("# Meeting notes\n\n- Ship the beta on Friday\n- Review pricing\n- Book the offsite")) + add("/Documents/Budget 2026.xlsx", os.urandom(24_000)) + add("/Documents/Contracts/Lease agreement.pdf", pdf("Lease agreement", ["Term: 12 months", "Rent due on the 1st"])) + add("/Documents/Contracts/NDA - template.pdf", pdf("Mutual NDA", ["Standard template"])) + + trips = ["Lake sunrise", "Mountain pass", "Old town", "Beach day", "Desert road", "Forest trail", "City lights"] + for i, name in enumerate(trips): + add("/Trips/%s.jpg" % name, landscape(i + 1)) + add("/Trips/Trip itinerary.pdf", pdf("Trip itinerary", ["Day 1 Arrive and settle in", "Day 2 Old town walk", "Day 3 Coast day trip"])) + + for i in range(8): + add("/Photos/Summer 2026/IMG_%04d.jpg" % (2041 + i), landscape(20 + i)) + add("/Photos/Summer 2026/Clip - hike.mp4", video()) + + add("/Recipes/Pasta al limone.md", text("# Pasta al limone\n\nSpaghetti, lemon, butter, parmesan, black pepper.")) + add("/Recipes/Sourdough.md", text("# Sourdough\n\n500g flour, 350g water, 100g starter, 10g salt.")) + add("/Recipes/Shopping list.txt", text("Lemons\nParmesan\nFlour\nCoffee beans")) + + add("/Projects/Website redesign/Wireframes.pdf", pdf("Wireframes", ["Home", "Pricing", "Sign up"])) + add("/Projects/Website redesign/Design tokens.json", text('{"radius": 12, "primary": "#2a6b50"}')) + add("/Projects/Website redesign/notes.txt", text("Kickoff next Tuesday.\nCollect brand assets.")) + + favorites = ["/Trips/Lake sunrise.jpg", "/Trips/Mountain pass.jpg", "/Documents/Project brief.pdf", "/Recipes/Sourdough.md"] + + # Created, then deleted - they end up in the Trash tab. + trashed = [ + ("/Old draft.txt", text("Superseded draft.")), + ("/Screenshot 2026-03-14.jpg", landscape(40, (1080, 1920))), + ("/Receipts (old)/March.pdf", pdf("Receipt", ["March"])), + ] + return files, favorites, trashed + + +# ---------------------------------------------------------------- server --- + + +class ZeroStream: + """`size` zero bytes, streamed - a big filler file without holding it in memory.""" + + def __init__(self, size): + self.size, self.pos = size, 0 + + def __len__(self): + return self.size + + def read(self, n=-1): + n = self.size - self.pos if n is None or n < 0 else min(n, self.size - self.pos) + self.pos += n + return b"\0" * n + + +class Server: + def __init__(self): + import requests # imported lazily so --generate-only needs no network libs + + self.requests = requests + + def _call(self, method, url, auth, **kw): + headers = kw.pop("headers", {}) + headers.setdefault("OCS-APIRequest", "true") + return self.requests.request(method, url, auth=auth, headers=headers, timeout=600, **kw) + + def wait_ready(self): + log("Waiting for Nextcloud to finish installing (first start takes a minute or two)...") + deadline = time.time() + 600 + while time.time() < deadline: + try: + r = self.requests.get(BASE + "/status.php", timeout=5) + if r.ok and r.json().get("installed") and not r.json().get("maintenance"): + return + except Exception: + pass + time.sleep(3) + sys.exit("Nextcloud did not become ready in time") + + def ocs(self, method, path, auth, **kw): + r = self._call(method, BASE + path, auth, params={"format": "json"}, **kw) + try: + return r.json()["ocs"] + except Exception: + return {"meta": {"statuscode": r.status_code}, "data": {}} + + def create_user(self, user, password, display, email, quota): + res = self.ocs( + "POST", "/ocs/v1.php/cloud/users", ADMIN, + data={"userid": user, "password": password, "displayName": display, "email": email, "quota": quota}, + ) + code = res["meta"]["statuscode"] + if code not in (100, 200, 102): # 102 = already exists + sys.exit("Could not create user %s: %s" % (user, res)) + + def dav(self, method, user, path, **kw): + auth = (user, USERS[user][0]) + url = "%s/remote.php/dav/files/%s%s" % (BASE, user, quote(path)) + return self._call(method, url, auth, **kw) + + def mkdirs(self, user, path): + built = "" + for part in [p for p in path.split("/") if p]: + built += "/" + part + self.dav("MKCOL", user, built) + + def put(self, user, path, data): + parent = path.rsplit("/", 1)[0] + if parent: + self.mkdirs(user, parent) + r = self.dav("PUT", user, path, data=data) + if r.status_code not in (200, 201, 204): + sys.exit("PUT %s failed: %s %s" % (path, r.status_code, r.text[:200])) + + def favorite(self, user, path): + body = ( + '' + "1" + ) + self.dav("PROPPATCH", user, path, data=body, headers={"Content-Type": "application/xml"}) + + def delete(self, user, path): + self.dav("DELETE", user, path) + + def share(self, user, path, share_type, share_with=None, permissions=31): + data = {"path": path, "shareType": share_type, "permissions": permissions} + if share_with: + data["shareWith"] = share_with + auth = (user, USERS[user][0]) + self.ocs("POST", "/ocs/v2.php/apps/files_sharing/api/v1/shares", auth, data=data) + + def app_password(self, user): + auth = (user, USERS[user][0]) + res = self.ocs("GET", "/ocs/v2.php/core/getapppassword", auth) + token = (res.get("data") or {}).get("apppassword") + if not token: + sys.exit("Could not get an app password for %s: %s" % (user, res)) + return token + + +def seed_server(): + server = Server() + server.wait_ready() + + for user, (password, display, email, quota) in USERS.items(): + log("Creating user %s" % user) + server.create_user(user, password, display, email, quota) + + files, favorites, trashed = build_content() + log("Uploading %d files as Alex" % len(files)) + for path, data in files: + server.put("Alex", path, data) + if FILLER_MB > 0: + log("Uploading a %d MB filler file so the storage quota isn't empty" % FILLER_MB) + server.put("Alex", "/Backups/Laptop backup.zip", ZeroStream(FILLER_MB * 1024 * 1024)) + + for path in favorites: + server.favorite("Alex", path) + + log("Creating shares") + server.share("Alex", "/Trips", 0, "Sam", permissions=31) + server.share("Alex", "/Documents/Project brief.pdf", 3, permissions=1) + server.put("Sam", "/Team notes/Roadmap.md", text("# Roadmap\n\nQ3: beta. Q4: launch.")) + server.put("Sam", "/Team notes/Standup.md", text("Daily standup at 9:30.")) + server.share("Sam", "/Team notes", 0, "Alex", permissions=31) + # Sam edits inside the folder Alex shared - gives the Activity feed a second author. + server.put("Sam", "/Trips/Suggestions.txt", text("Add the coast road stop!")) + + log("Filling the trash") + for path, data in trashed: + server.put("Alex", path, data) + for path, _ in trashed: + server.delete("Alex", path) + server.delete("Alex", "/Receipts (old)") + + token = server.app_password("Alex") + print("APP_PASSWORD=" + token, flush=True) + + +def generate_only(target): + files, favorites, trashed = build_content() + for path, data in files + trashed: + dest = os.path.join(target, "Alex" + path) + os.makedirs(os.path.dirname(dest), exist_ok=True) + with open(dest, "wb") as f: + f.write(data) + log("Wrote %d files under %s (favorites: %d)" % (len(files) + len(trashed), target, len(favorites))) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--generate-only", metavar="DIR", help="write the fake files locally instead of seeding a server") + args = parser.parse_args() + if args.generate_only: + generate_only(args.generate_only) + else: + seed_server() diff --git a/tool/finalize_screenshots.py b/tool/finalize_screenshots.py new file mode 100644 index 0000000..bfe3613 --- /dev/null +++ b/tool/finalize_screenshots.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 +"""Makes captured screenshots Play Store ready and reports anything that isn't. + +Play wants JPEG or 24-bit PNG (no alpha), each side 320-3840 px, and the long +side at most twice the short side. The captured PNGs are 32-bit RGBA, so this +flattens them to RGB in place, then checks the dimensions. + + python3 tool/finalize_screenshots.py store_listing/screenshots +""" +import sys +from pathlib import Path + +from PIL import Image + + +def main(directory): + files = sorted(Path(directory).glob("*.png")) + if not files: + sys.exit("No PNGs found in %s" % directory) + + problems = 0 + for path in files: + with Image.open(path) as im: + im.load() + rgb = Image.new("RGB", im.size, (255, 255, 255)) + rgb.paste(im, mask=im.getchannel("A") if "A" in im.getbands() else None) + rgb.save(path, "PNG", optimize=True) + + w, h = rgb.size + notes = [] + if min(w, h) < 320 or max(w, h) > 3840: + notes.append("side outside 320-3840 px") + if max(w, h) > 2 * min(w, h): + notes.append("aspect ratio over 2:1") + problems += bool(notes) + print("%-32s %4dx%-4d %s" % (path.name, w, h, "; ".join(notes) or "ok")) + + print("\n%d screenshots, %d with problems" % (len(files), problems)) + sys.exit(1 if problems else 0) + + +if __name__ == "__main__": + main(sys.argv[1] if len(sys.argv) > 1 else "store_listing/screenshots") diff --git a/tool/screenshots.sh b/tool/screenshots.sh new file mode 100644 index 0000000..d85170a --- /dev/null +++ b/tool/screenshots.sh @@ -0,0 +1,273 @@ +#!/usr/bin/env bash +# Generates the Play Store screenshots - without any real data. +# +# What it does, end to end: +# 1. starts a throwaway Nextcloud in Docker and fills it with fake data +# 2. creates + boots a clean Android emulator (1080x1920, demo-mode status bar) +# 3. runs integration_test/store_screenshots_test.dart against it, which +# logs in as the fake user and walks through the app's tabs and states +# 4. leaves flattened, size-checked PNGs in store_listing/screenshots/ +# +# Works on Linux, macOS and Git Bash on Windows. Needs: docker, python3, +# flutter, and an Android SDK (ANDROID_HOME / ANDROID_SDK_ROOT). See +# tool/demo_server/README.md. +# +# Options (environment variables): +# SCREENSHOT_DIR output directory (store_listing/screenshots) +# ANDROID_SERIAL use this already-running device instead of the emulator +# HEADLESS=1 run the emulator without a window +# DEMO_PORT host port for the demo server (80; anything else shows +# up as ":port" in the app's header) +# DEMO_HOST show this hostname instead of "localhost" in the app, +# e.g. cloud.example.com (best effort - edits the +# emulator's hosts file, which needs a rootable image) +# KEEP_SERVER=1 leave the demo server running afterwards +# KEEP_EMULATOR=1 leave the emulator running afterwards +# SCREEN_W / SCREEN_H / SCREEN_DENSITY emulator screen (1080 / 1920 / 440); +# Play needs the long side <= 2x the short side +set -euo pipefail + +cd "$(dirname "$0")/.." +REPO_ROOT="$PWD" + +# Git Bash would otherwise rewrite device paths such as /system/etc/hosts. +export MSYS_NO_PATHCONV=1 + +AVD_NAME="${AVD_NAME:-noo_screenshots}" +API_LEVEL="${SCREENSHOT_API:-36}" +SYSTEM_IMAGE="system-images;android-${API_LEVEL};google_apis;x86_64" +SCREEN_W="${SCREEN_W:-1080}" +SCREEN_H="${SCREEN_H:-1920}" +SCREEN_DENSITY="${SCREEN_DENSITY:-440}" +OUT_DIR="${SCREENSHOT_DIR:-store_listing/screenshots}" +DEMO_PORT="${DEMO_PORT:-80}" +DEMO_HOST="${DEMO_HOST:-}" +export DEMO_PORT DEMO_HOST + +log() { printf '\n==> %s\n' "$*" >&2; } +die() { printf 'error: %s\n' "$*" >&2; exit 1; } + +# Two easy mistakes worth catching up front rather than as a confusing +# "missing prerequisites" list. +if [ "$(id -u)" = 0 ]; then + die "don't run this as root/with sudo - it would create root-owned files and use root's home for the emulator." +fi +if grep -qi microsoft /proc/version 2>/dev/null; then + die "this is WSL, which can't see your Windows Flutter/Android SDK (and can't run the emulator well). On Windows run it from Git Bash instead." +fi + +# ---------------------------------------------------------------- tooling --- + +# Windows env vars hold C:\... paths; convert for bash. +to_unix() { if command -v cygpath >/dev/null 2>&1; then cygpath -u "$1"; else printf '%s' "$1"; fi; } + +SDK_ROOT="" +for candidate in "${ANDROID_HOME:-}" "${ANDROID_SDK_ROOT:-}" "$HOME/Android/Sdk" \ + "$HOME/Library/Android/sdk" "${LOCALAPPDATA:-}/Android/Sdk"; do + if [ -n "$candidate" ] && [ -d "$(to_unix "$candidate")" ]; then + SDK_ROOT="$(to_unix "$candidate")" + break + fi +done + +# Finds a tool by name on PATH or in the SDK, including Windows .bat/.exe. +find_tool() { + local name="$1" dir ext + for ext in "" .bat .exe; do + if command -v "$name$ext" >/dev/null 2>&1; then command -v "$name$ext"; return 0; fi + done + for dir in "$SDK_ROOT/cmdline-tools/latest/bin" "$SDK_ROOT/platform-tools" "$SDK_ROOT/emulator"; do + for ext in "" .bat .exe; do + if [ -x "$dir/$name$ext" ]; then printf '%s' "$dir/$name$ext"; return 0; fi + done + done + return 1 +} + +missing=() +require() { # + command -v "$1" >/dev/null 2>&1 || missing+=("$1 - $2") +} +require docker "install Docker (Desktop on Windows/macOS)" +require flutter "install Flutter and put it on PATH" +PY="$(command -v python3 || command -v python || true)" +[ -n "$PY" ] || missing+=("python3 - install Python 3") +ADB="$(find_tool adb || true)" +[ -n "$ADB" ] || missing+=("adb - install Android platform-tools (or set ANDROID_HOME)") +if [ -z "${ANDROID_SERIAL:-}" ]; then + SDKMANAGER="$(find_tool sdkmanager || true)" + AVDMANAGER="$(find_tool avdmanager || true)" + [ -n "$SDKMANAGER" ] && [ -n "$AVDMANAGER" ] || + missing+=("sdkmanager/avdmanager - install the Android cmdline-tools (or set ANDROID_HOME)") +fi +if [ "${#missing[@]}" -gt 0 ]; then + printf 'Missing prerequisites:\n' >&2 + printf ' - %s\n' "${missing[@]}" >&2 + exit 1 +fi + +if docker compose version >/dev/null 2>&1; then + DC=(docker compose) +else + DC=(docker-compose) +fi +DC+=(-f tool/demo_server/docker-compose.yml) +adb() { "$ADB" "$@"; } + +# Python with Pillow + requests: use the system one if it has them, else a +# local virtualenv (tool/demo_server/.venv, gitignored). +if ! "$PY" -c 'import PIL, requests' >/dev/null 2>&1; then + log "Setting up a Python virtualenv (Pillow, requests)" + "$PY" -m venv tool/demo_server/.venv + if [ -x tool/demo_server/.venv/bin/python ]; then + PY="$REPO_ROOT/tool/demo_server/.venv/bin/python" + else + PY="$REPO_ROOT/tool/demo_server/.venv/Scripts/python" + fi + "$PY" -m pip install -q -r tool/demo_server/requirements.txt +fi + +# ---------------------------------------------------------------- cleanup --- + +STARTED_EMULATOR=0 +EMULATOR_PID="" +cleanup() { + local code=$? + set +e + if [ -n "${SERIAL:-}" ]; then + adb -s "$SERIAL" shell am broadcast -a com.android.systemui.demo -e command exit >/dev/null 2>&1 + adb -s "$SERIAL" reverse --remove-all >/dev/null 2>&1 + fi + if [ "$STARTED_EMULATOR" = 1 ] && [ "${KEEP_EMULATOR:-0}" != 1 ]; then + adb -s "$SERIAL" emu kill >/dev/null 2>&1 + fi + if [ "${KEEP_SERVER:-0}" != 1 ]; then + "${DC[@]}" down -v >/dev/null 2>&1 + fi + exit "$code" +} +trap cleanup EXIT + +# ---------------------------------------------------------- demo server --- + +log "Starting a fresh demo Nextcloud (port $DEMO_PORT)" +"${DC[@]}" down -v >/dev/null 2>&1 || true +"${DC[@]}" up -d + +log "Seeding fake data" +APP_PASSWORD="$(DEMO_BASE_URL="http://localhost:$DEMO_PORT" "$PY" tool/demo_server/seed.py | grep '^APP_PASSWORD=' | tail -n1 | cut -d= -f2-)" +[ -n "$APP_PASSWORD" ] || die "the seed script did not return an app password" + +# ---------------------------------------------------------------- device --- + +if [ -n "${ANDROID_SERIAL:-}" ]; then + SERIAL="$ANDROID_SERIAL" + log "Using existing device $SERIAL" +else + log "Preparing the emulator" + [ -n "$SDK_ROOT" ] || die "set ANDROID_HOME to your Android SDK" + EMULATOR="$(find_tool emulator || true)" + if [ -z "$EMULATOR" ] || ! [ -d "$SDK_ROOT/system-images/android-${API_LEVEL}/google_apis/x86_64" ]; then + log "Installing the emulator + system image (one-off, a few GB)" + yes | "$SDKMANAGER" --licenses >/dev/null 2>&1 || true + "$SDKMANAGER" "emulator" "platform-tools" "$SYSTEM_IMAGE" + EMULATOR="$(find_tool emulator)" + fi + + # avdmanager also prints a harmless "Could not load devices from + # .../devices.xml" error for system images that ship no devices.xml, so its + # output is hidden and success is judged by whether the AVD exists. + echo no | "$AVDMANAGER" create avd --force -n "$AVD_NAME" -k "$SYSTEM_IMAGE" -d pixel_6 >/dev/null 2>&1 || true + AVD_DIR="$(to_unix "${ANDROID_AVD_HOME:-$HOME/.android/avd}")/$AVD_NAME.avd" + [ -d "$AVD_DIR" ] || die "could not find the AVD at $AVD_DIR (set ANDROID_AVD_HOME)" + set_ini() { # : replace or append in the AVD's config.ini + if grep -q "^$1=" "$AVD_DIR/config.ini"; then + sed -i.bak "s|^$1=.*|$1=$2|" "$AVD_DIR/config.ini" && rm -f "$AVD_DIR/config.ini.bak" + else + echo "$1=$2" >> "$AVD_DIR/config.ini" + fi + } + sed -i.bak '/^skin\.path=/d' "$AVD_DIR/config.ini" && rm -f "$AVD_DIR/config.ini.bak" + set_ini skin.name "${SCREEN_W}x${SCREEN_H}" + set_ini hw.lcd.width "$SCREEN_W" + set_ini hw.lcd.height "$SCREEN_H" + set_ini hw.lcd.density "$SCREEN_DENSITY" + + log "Booting the emulator" + EMULATOR_FLAGS=(-avd "$AVD_NAME" -no-snapshot -wipe-data -no-audio -no-boot-anim -gpu swiftshader_indirect) + [ "${HEADLESS:-0}" = 1 ] && EMULATOR_FLAGS+=(-no-window) + "$EMULATOR" "${EMULATOR_FLAGS[@]}" >/tmp/noo_emulator.log 2>&1 & + EMULATOR_PID=$! + STARTED_EMULATOR=1 + + adb wait-for-device + for _ in $(seq 1 150); do + [ "$(adb shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')" = 1 ] && break + sleep 2 + done + [ "$(adb shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')" = 1 ] || die "the emulator did not finish booting (see /tmp/noo_emulator.log)" + SERIAL="$(adb devices | awk '/^emulator-/{print $1; exit}')" +fi +export ANDROID_SERIAL="$SERIAL" + +log "Configuring the device" +adb shell input keyevent 82 >/dev/null 2>&1 || true # dismiss keyguard +adb shell svc power stayon true >/dev/null 2>&1 || true # never sleep mid-run + +# Make the demo server reachable from the device as localhost. Binding a port +# below 1024 (the default 80) on the device needs root - the google_apis +# emulator images allow `adb root` - otherwise fall back to a high device +# port, which then shows up as ":8080" in the app. +DEVICE_PORT="$DEMO_PORT" +if [ "$DEMO_PORT" -lt 1024 ]; then + adb root >/dev/null 2>&1 || true + sleep 2 + adb wait-for-device +fi +if ! adb reverse "tcp:$DEVICE_PORT" "tcp:$DEMO_PORT" >/dev/null 2>&1; then + DEVICE_PORT=8080 + printf 'note: could not bind port %s on the device (no root); using %s instead\n' "$DEMO_PORT" "$DEVICE_PORT" >&2 + adb reverse "tcp:$DEVICE_PORT" "tcp:$DEMO_PORT" || die "adb reverse failed" +fi + +SERVER_HOST="localhost" +if [ -n "$DEMO_HOST" ]; then + # Best effort: make DEMO_HOST resolve to the (reverse-forwarded) loopback. + if adb root >/dev/null 2>&1 && sleep 2 && adb remount >/dev/null 2>&1 && + adb shell "echo '127.0.0.1 $DEMO_HOST' >> /system/etc/hosts" >/dev/null 2>&1; then + SERVER_HOST="$DEMO_HOST" + else + printf 'warning: could not edit the device hosts file; showing "localhost" instead of %s\n' "$DEMO_HOST" >&2 + fi +fi +SERVER_URL="http://$SERVER_HOST" +[ "$DEVICE_PORT" = 80 ] || SERVER_URL="$SERVER_URL:$DEVICE_PORT" + +# Clean status bar: 12:00, full battery, full Wi-Fi, no notification icons. +adb shell settings put global sysui_demo_allowed 1 +demo() { adb shell am broadcast -a com.android.systemui.demo -e command "$@" >/dev/null; } +demo enter +demo clock -e hhmm 1200 +demo battery -e level 100 -e plugged false +demo network -e wifi show -e level 4 -e fully true -e mobile hide +demo notifications -e visible false + +# --------------------------------------------------------------- capture --- + +log "Capturing screenshots (this drives the app for a few minutes)" +rm -rf "$OUT_DIR" +mkdir -p "$OUT_DIR" +SCREENSHOT_DIR="$OUT_DIR" flutter drive \ + --driver=test_driver/integration_test.dart \ + --target=integration_test/store_screenshots_test.dart \ + -d "$SERIAL" \ + --dart-define="DEMO_SERVER_URL=$SERVER_URL" \ + --dart-define="DEMO_USERNAME=Alex" \ + --dart-define="DEMO_APP_PASSWORD=$APP_PASSWORD" + +log "Making them Play Store ready" +"$PY" tool/finalize_screenshots.py "$OUT_DIR" + +log "Done: $OUT_DIR" +printf '\nBefore uploading, LOOK at every image for anything real: the server\n' >&2 +printf 'address/username in the header and Settings, notification icons, names.\n' >&2 diff --git a/tool/upload_play_bundle.py b/tool/upload_play_bundle.py new file mode 100644 index 0000000..d251142 --- /dev/null +++ b/tool/upload_play_bundle.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +"""Uploads an .aab to the Play Console's app bundle library, without +touching any release track. + +Play Console itself doesn't expose "just upload, don't release" as an +action - every path through the UI attaches a bundle to a track. The Play +Developer API does support it though: open an edit, upload the bundle, +commit the edit, and stop there. The result shows up under Release > App +bundle explorer and nowhere else - nothing goes out to testers or +production until it's promoted to a track by hand, whenever that's wanted. + +Needs: + GOOGLE_PLAY_PACKAGE_NAME e.g. dev.ayushya.noo + GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_PATH path to a service account key file + +The Play Developer API can't create an app's first release - Google +requires at least one manual upload through the Play Console web UI (to +any track) before it'll accept API calls for that app at all. + + python3 tool/upload_play_bundle.py path/to/app-release.aab +""" +import os +import sys + +from google.oauth2 import service_account +from googleapiclient.discovery import build + +SCOPES = ["https://www.googleapis.com/auth/androidpublisher"] + + +def main(bundle_path): + package_name = os.environ["GOOGLE_PLAY_PACKAGE_NAME"] + key_path = os.environ["GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_PATH"] + creds = service_account.Credentials.from_service_account_file( + key_path, scopes=SCOPES + ) + service = build("androidpublisher", "v3", credentials=creds) + + edit_id = service.edits().insert(body={}, packageName=package_name).execute()["id"] + + bundle = ( + service.edits() + .bundles() + .upload(editId=edit_id, packageName=package_name, media_body=bundle_path) + .execute() + ) + print("Uploaded versionCode %s" % bundle["versionCode"]) + + service.edits().commit(editId=edit_id, packageName=package_name).execute() + print( + "Committed - visible under Release > App bundle explorer in Play " + "Console. Not attached to any track, so nothing goes out to " + "testers or production until it's promoted by hand." + ) + + +if __name__ == "__main__": + main(sys.argv[1]) From c2540ec2cedc700c56feda29b69a51a9d7a91840 Mon Sep 17 00:00:00 2001 From: ayushya Date: Sun, 27 Sep 2026 12:34:06 -0400 Subject: [PATCH 02/29] 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 --- lib/views/file_viewer_screen.dart | 135 ++++++++++--- lib/widgets/frosted_glass_container.dart | 40 ++-- lib/widgets/viewer/media_action_bar.dart | 42 ++-- lib/widgets/viewer/media_video_preview.dart | 207 +++++++++++--------- lib/widgets/viewer/viewer_icon_button.dart | 24 +-- 5 files changed, 272 insertions(+), 176 deletions(-) diff --git a/lib/views/file_viewer_screen.dart b/lib/views/file_viewer_screen.dart index 2a62a7f..1dc4df4 100644 --- a/lib/views/file_viewer_screen.dart +++ b/lib/views/file_viewer_screen.dart @@ -4,6 +4,7 @@ import 'package:open_file/open_file.dart'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; import 'package:provider/provider.dart'; +import 'package:video_player/video_player.dart'; import '../models/nextcloud_item.dart'; import '../providers/item_operations.dart'; import '../providers/session_controller.dart'; @@ -141,6 +142,12 @@ class _FileViewerScreenState extends State { late int _currentIndex; late final PageController _pageController; + /// The active page's video controller, once `MediaVideoPreview` reports + /// it - only ever set by the *current* page (see `_buildBody`), so a + /// neighboring `PageView` page can't steal the transport row. Drives + /// `VideoTransportControls` inside the bottom `MediaActionBar` panel. + VideoPlayerController? _videoController; + NextcloudItem get _currentItem => _mediaItems[_currentIndex]; bool get _isOffline => widget.localPathResolver != null; @@ -402,6 +409,28 @@ class _FileViewerScreenState extends State { backgroundColor: colors.fg1.withValues(alpha: 0.16), ), ), + // Scrim behind the top bar, per the approved canvas + // (https://claude.ai/artifact/3AGPqqMdkLSC2ypCh2CQs4): a plain + // gradient fade, not `FrostedGlassContainer`'s blur - just + // enough to keep the back button/title legible over bright + // media without another blurred layer stacked on the bar itself. + if (_isSwipeable) + IgnorePointer( + child: AnimatedOpacity( + duration: const Duration(milliseconds: 220), + opacity: _controlsVisible ? 1 : 0, + child: Container( + height: 150, + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0x8C000000), Colors.transparent], + ), + ), + ), + ), + ), AnimatedSlide( duration: const Duration(milliseconds: 220), curve: Curves.easeInOutCubic, @@ -409,35 +438,41 @@ class _FileViewerScreenState extends State { child: IgnorePointer( ignoring: !_controlsVisible, child: SafeArea( - child: Padding( - padding: const EdgeInsets.fromLTRB(20, 12, 20, 0), - child: FrostedGlassContainer( - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 6, - vertical: 6, + bottom: false, + child: FrostedGlassContainer( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 6, + ), + child: IconTheme.merge( + // Fixed white, not `colors.fg1`: this bar always + // sits on the dark translucent panel over a black + // media stage (see `FrostedGlassContainer`'s doc + // comment), regardless of the app's light/dark + // theme. + data: const IconThemeData( + color: Colors.white, + size: 24, ), - child: IconTheme.merge( - data: IconThemeData(color: colors.fg1, size: 24), - child: Row( - children: [ - NooTopBarButton( - icon: LucideIcons.arrowLeft, - tooltip: 'Back', - onTap: () => Navigator.pop(context), - ), - const SizedBox(width: 4), - Expanded( - child: MarqueeTitle( - text: _currentItem.name, - style: NooText.label.copyWith( - color: colors.fg1, - ), + child: Row( + children: [ + NooTopBarButton( + icon: LucideIcons.arrowLeft, + tooltip: 'Back', + onTap: () => Navigator.pop(context), + ), + const SizedBox(width: 4), + Expanded( + child: MarqueeTitle( + text: _currentItem.name, + style: NooText.label.copyWith( + color: Colors.white, ), ), - const SizedBox(width: 10), - ], - ), + ), + const SizedBox(width: 10), + ], ), ), ), @@ -445,6 +480,26 @@ class _FileViewerScreenState extends State { ), ), ), + if (_isSwipeable) + Align( + alignment: Alignment.bottomCenter, + child: IgnorePointer( + child: AnimatedOpacity( + duration: const Duration(milliseconds: 220), + opacity: _controlsVisible ? 1 : 0, + child: Container( + height: 260, + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.bottomCenter, + end: Alignment.topCenter, + colors: [Color(0x99000000), Colors.transparent], + ), + ), + ), + ), + ), + ), Align( alignment: Alignment.bottomCenter, child: AnimatedSlide( @@ -460,6 +515,12 @@ class _FileViewerScreenState extends State { // server - hidden rather than shown-and-failing while // browsing an already-local file from the Offline tab. showServerActions: !_isOffline, + transportControls: + _isSwipeable && + _currentItem.type == NextcloudItemType.video && + _videoController != null + ? VideoTransportControls(controller: _videoController!) + : null, onShare: () => ShareSheet.show(context, _currentItem), onFavorite: () => _toggleFavorite(ops), onDelete: () => _deleteCurrentItem(ops), @@ -484,9 +545,17 @@ class _FileViewerScreenState extends State { return PageView.builder( controller: _pageController, itemCount: _mediaItems.length, - onPageChanged: (index) => setState(() => _currentIndex = index), + onPageChanged: (index) => setState(() { + _currentIndex = index; + // The outgoing page's `onController` stops being passed below (it's + // no longer the active index) and reports null on its own dispose, + // but that can lag a frame or two behind this rebuild - clear + // eagerly so the transport row never briefly shows the old video. + _videoController = null; + }), itemBuilder: (context, index) { final mediaItem = _mediaItems[index]; + final isActive = index == _currentIndex; if (!_isOffline) { if (mediaItem.type == NextcloudItemType.image) { return MediaImagePreview( @@ -499,8 +568,10 @@ class _FileViewerScreenState extends State { key: ValueKey(mediaItem.id), url: session.service!.fileUrl(mediaItem.path), headers: session.service!.authHeaders, - isActive: index == _currentIndex, - controlsBottomOffset: _controlsVisible ? 108 : 24, + isActive: isActive, + onController: isActive + ? (c) => setState(() => _videoController = c) + : null, ); } // Offline: each swiped-to item resolves its own local path on @@ -540,8 +611,10 @@ class _FileViewerScreenState extends State { url: null, headers: const {}, localPath: localPath, - isActive: index == _currentIndex, - controlsBottomOffset: _controlsVisible ? 108 : 24, + isActive: isActive, + onController: isActive + ? (c) => setState(() => _videoController = c) + : null, ); }, ); diff --git a/lib/widgets/frosted_glass_container.dart b/lib/widgets/frosted_glass_container.dart index 93e957a..4015a9d 100644 --- a/lib/widgets/frosted_glass_container.dart +++ b/lib/widgets/frosted_glass_container.dart @@ -1,9 +1,15 @@ import 'dart:ui'; import 'package:flutter/material.dart'; -/// The frosted-glass pill background shared by all floating chrome in the -/// app (bottom nav bar, media viewer action bar): blurred backdrop, a -/// translucent tonal fill, a faint outline, and a soft drop shadow. +/// The blurred/translucent chrome for the media viewer's overlay bars +/// (`file_viewer_screen.dart`'s top bar, `MediaActionBar`, the video +/// transport row) - the one deliberate exception to the design system's +/// flat, no-shadow product UI (DESIGN_SYSTEM.md 1.4), since this chrome +/// floats over photo/video content rather than over the app's own +/// surfaces. No shadow, no outline, no `ColorScheme` tint: a fixed dark +/// translucent fill (`color`) that reads correctly over any media, +/// regardless of the app's light/dark theme - matching the canvas at +/// https://claude.ai/artifact/3AGPqqMdkLSC2ypCh2CQs4. class FrostedGlassContainer extends StatelessWidget { final Widget child; final double borderRadius; @@ -17,39 +23,31 @@ class FrostedGlassContainer extends StatelessWidget { /// adjustable. final double opacity; + /// The tint under the blur. Defaults to the design's fixed dark tone + /// (`#141311`, the spec's `fg-1` dark value) - deliberately not a + /// `ColorScheme`/`NooColors` lookup, since this chrome always sits over a + /// black media stage, not over a themed surface. + final Color color; + const FrostedGlassContainer({ super.key, required this.child, - this.borderRadius = 32, - this.blurSigma = 28, + this.borderRadius = 0, + this.blurSigma = 20, this.opacity = 0.55, + this.color = const Color(0xFF141311), }); @override Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - return ClipRRect( borderRadius: BorderRadius.circular(borderRadius), child: BackdropFilter( filter: ImageFilter.blur(sigmaX: blurSigma, sigmaY: blurSigma), child: Container( decoration: BoxDecoration( - color: colorScheme.surfaceContainerHighest.withValues( - alpha: opacity, - ), + color: color.withValues(alpha: opacity), borderRadius: BorderRadius.circular(borderRadius), - border: Border.all( - color: colorScheme.outlineVariant.withValues(alpha: 0.35), - width: 1, - ), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.14), - blurRadius: 24, - offset: const Offset(0, 8), - ), - ], ), child: child, ), diff --git a/lib/widgets/viewer/media_action_bar.dart b/lib/widgets/viewer/media_action_bar.dart index ea95c1c..208c055 100644 --- a/lib/widgets/viewer/media_action_bar.dart +++ b/lib/widgets/viewer/media_action_bar.dart @@ -4,15 +4,19 @@ import '../../theme/design_tokens.dart'; import '../frosted_glass_container.dart'; import 'viewer_icon_button.dart'; -/// The floating action bar (share/favorite/open/download/delete/details) -/// overlaid on the media viewer - kept on `FrostedGlassContainer`'s blurred -/// chrome as a deliberate exception to the design system's flat product UI -/// (see `file_viewer_screen.dart`'s build() comment for why), unlike the -/// now-flat bottom nav bar (`bottom_nav_bar.dart`) this screen sits above. +/// The bottom panel overlaid on the media viewer: an optional transport row +/// ([transportControls], video only) above the action row +/// (share/favorite/open/download/delete/details) - one continuous flush, +/// full-width `FrostedGlassContainer` panel, not two separate floating +/// pills. Kept on that blurred chrome as a deliberate exception to the +/// design system's flat product UI (see `file_viewer_screen.dart`'s +/// build() comment for why), unlike the now-flat bottom nav bar +/// (`bottom_nav_bar.dart`) this screen sits above. class MediaActionBar extends StatelessWidget { final bool isFavorite; final bool isBusy; final bool showServerActions; + final Widget? transportControls; final VoidCallback onShare; final VoidCallback onFavorite; final VoidCallback onDelete; @@ -25,6 +29,7 @@ class MediaActionBar extends StatelessWidget { required this.isFavorite, required this.isBusy, required this.showServerActions, + this.transportControls, required this.onShare, required this.onFavorite, required this.onDelete, @@ -35,15 +40,23 @@ class MediaActionBar extends StatelessWidget { @override Widget build(BuildContext context) { - final colors = context.nooColors; + // This panel always sits on a black media stage regardless of the + // app's own light/dark theme (see FrostedGlassContainer's doc + // comment) - fixed dark-palette values, not `context.nooColors`, so + // favorite/delete stay legible even when the ambient theme is light. + const dark = NooColors.dark; return SafeArea( - child: Padding( - padding: const EdgeInsets.only(bottom: 20, left: 20, right: 20), - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 480), - child: FrostedGlassContainer( - child: Padding( + top: false, + child: FrostedGlassContainer( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (transportControls != null) ...[ + transportControls!, + Divider(height: 1, color: Colors.white.withValues(alpha: 0.14)), + ], + Padding( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 6), child: Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, @@ -62,6 +75,7 @@ class MediaActionBar extends StatelessWidget { tooltip: isFavorite ? 'Remove from favorites' : 'Favorite', + color: isFavorite ? dark.accentText : null, onTap: onFavorite, ), ViewerIconButton( @@ -79,7 +93,7 @@ class MediaActionBar extends StatelessWidget { ViewerIconButton( icon: LucideIcons.trash2, tooltip: 'Delete', - color: colors.danger, + color: dark.danger, onTap: isBusy ? null : onDelete, ), ViewerIconButton( @@ -90,7 +104,7 @@ class MediaActionBar extends StatelessWidget { ], ), ), - ), + ], ), ), ); diff --git a/lib/widgets/viewer/media_video_preview.dart b/lib/widgets/viewer/media_video_preview.dart index eac39f5..51576ae 100644 --- a/lib/widgets/viewer/media_video_preview.dart +++ b/lib/widgets/viewer/media_video_preview.dart @@ -6,12 +6,16 @@ import 'package:provider/provider.dart'; import 'package:video_player/video_player.dart'; import '../../providers/settings_controller.dart'; import '../../theme/design_tokens.dart'; -import '../frosted_glass_container.dart'; import '../seek_bar_painter.dart'; import 'viewer_icon_button.dart'; -/// Playing video page for [FileViewerScreen]'s swipeable `PageView`, plus -/// its floating transport controls (play/pause, position, mute, seek bar). +/// Playing video page for [FileViewerScreen]'s swipeable `PageView`. Just +/// the video surface - the transport controls ([VideoTransportControls]) +/// are built by the parent, inside the same bottom panel as +/// [MediaActionBar], once this reports its controller up via +/// [onController]. Keeping playback lifecycle (`initState`/`dispose`) here +/// and the controls' *presentation* in the parent is what lets both sit in +/// one continuous panel without the parent owning playback. class MediaVideoPreview extends StatefulWidget { final String? url; final Map headers; @@ -22,11 +26,12 @@ class MediaVideoPreview extends StatefulWidget { /// `FileViewerScreen.localPathResolver`'s doc comment). final String? localPath; - /// Distance from the bottom of the screen the transport controls should - /// sit at. Passed in by the parent so the controls can track the floating - /// action bar: resting just above it when visible, sliding down to hug - /// the screen edge when the bar (and rest of the chrome) is hidden. - final double controlsBottomOffset; + /// Reports the live controller once initialized, and `null` on dispose, + /// so the parent can render [VideoTransportControls] for it. The parent + /// only passes this for the *active* page (see `file_viewer_screen.dart`'s + /// `_buildBody`) - a neighboring, inactive `PageView` page never reports + /// up, so an adjacent video can't steal the transport row. + final ValueChanged? onController; const MediaVideoPreview({ super.key, @@ -34,7 +39,7 @@ class MediaVideoPreview extends StatefulWidget { required this.headers, this.localPath, this.isActive = true, - this.controlsBottomOffset = 24, + this.onController, }); @override @@ -65,6 +70,7 @@ class _MediaVideoPreviewState extends State { return; } setState(() => _controller = controller); + widget.onController?.call(controller); if (widget.isActive) controller.play(); } catch (e) { if (mounted) setState(() => _error = e.toString()); @@ -81,26 +87,32 @@ class _MediaVideoPreviewState extends State { _controller?.pause(); } } + // Newly given a callback (this page just became active) and already + // initialized: report it, but not synchronously - `didUpdateWidget` + // runs as part of the *parent's* own widget-tree update (the + // `onPageChanged` setState that made this page active), and the + // parent's `onController` calls `setState` itself, which would trip + // "setState called during build" if invoked in the same pass. Defer to + // the next frame instead of waiting for `_init` (which won't run again). + if (oldWidget.onController == null && + widget.onController != null && + _controller != null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) widget.onController!(_controller); + }); + } } @override void dispose() { + widget.onController?.call(null); _controller?.dispose(); super.dispose(); } - String _formatDuration(Duration d) { - final minutes = d.inMinutes.remainder(60); - final seconds = d.inSeconds.remainder(60).toString().padLeft(2, '0'); - return '$minutes:$seconds'; - } - @override Widget build(BuildContext context) { final colors = context.nooColors; - final progressBarStyle = context - .watch() - .mediaProgressBarStyle; if (_error != null) { return Center( child: Text( @@ -115,89 +127,88 @@ class _MediaVideoPreviewState extends State { } return SizedBox.expand( - child: Stack( - alignment: Alignment.center, - children: [ - Center( - child: AspectRatio( - aspectRatio: controller.value.aspectRatio, - child: VideoPlayer(controller), - ), - ), - AnimatedPositioned( - duration: const Duration(milliseconds: 220), - curve: Curves.easeInOutCubic, - left: 16, - right: 16, - bottom: widget.controlsBottomOffset, - child: AnimatedBuilder( - animation: controller, - builder: (context, _) { - final fg = colors.fg1; - // Same frosted-glass treatment (and user opacity/blur - // settings) as the back button and media action bar, so the - // transport controls match the rest of the app's chrome. - return FrostedGlassContainer( - borderRadius: 24, - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 4, - vertical: 4, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Row( - children: [ - ViewerIconButton( - icon: controller.value.isPlaying - ? LucideIcons.pause - : LucideIcons.play, - tooltip: controller.value.isPlaying - ? 'Pause' - : 'Play', - onTap: () => controller.value.isPlaying - ? controller.pause() - : controller.play(), - ), - Expanded( - child: Text( - '${_formatDuration(controller.value.position)} / ${_formatDuration(controller.value.duration)}', - textAlign: TextAlign.center, - style: NooText.meta.copyWith(color: fg), - ), - ), - ViewerIconButton( - icon: controller.value.volume == 0 - ? LucideIcons.volumeX - : LucideIcons.volume2, - tooltip: controller.value.volume == 0 - ? 'Unmute' - : 'Mute', - onTap: () => controller.setVolume( - controller.value.volume == 0 ? 1 : 0, - ), - ), - ], - ), - Padding( - padding: const EdgeInsets.fromLTRB(12, 0, 12, 8), - child: WavySeekBar( - controller: controller, - playedColor: fg, - trackColor: fg.withValues(alpha: 0.3), - style: progressBarStyle, - ), - ), - ], + child: Center( + child: AspectRatio( + aspectRatio: controller.value.aspectRatio, + child: VideoPlayer(controller), + ), + ), + ); + } +} + +/// The transport row content (play/pause, position, mute, seek bar) for +/// [controller] - no panel chrome of its own. Rendered by +/// `file_viewer_screen.dart` inside the same bottom panel as +/// [MediaActionBar], directly above the action row, once +/// [MediaVideoPreview.onController] reports a live controller. +class VideoTransportControls extends StatelessWidget { + final VideoPlayerController controller; + + const VideoTransportControls({super.key, required this.controller}); + + String _formatDuration(Duration d) { + final minutes = d.inMinutes.remainder(60); + final seconds = d.inSeconds.remainder(60).toString().padLeft(2, '0'); + return '$minutes:$seconds'; + } + + @override + Widget build(BuildContext context) { + final progressBarStyle = context + .watch() + .mediaProgressBarStyle; + return AnimatedBuilder( + animation: controller, + builder: (context, _) { + const fg = Colors.white; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + ViewerIconButton( + icon: controller.value.isPlaying + ? LucideIcons.pause + : LucideIcons.play, + tooltip: controller.value.isPlaying ? 'Pause' : 'Play', + onTap: () => controller.value.isPlaying + ? controller.pause() + : controller.play(), + ), + Expanded( + child: Text( + '${_formatDuration(controller.value.position)} / ${_formatDuration(controller.value.duration)}', + textAlign: TextAlign.center, + style: NooText.meta.copyWith(color: fg), ), ), - ); - }, - ), + ViewerIconButton( + icon: controller.value.volume == 0 + ? LucideIcons.volumeX + : LucideIcons.volume2, + tooltip: controller.value.volume == 0 ? 'Unmute' : 'Mute', + onTap: () => controller.setVolume( + controller.value.volume == 0 ? 1 : 0, + ), + ), + ], + ), + Padding( + padding: const EdgeInsets.fromLTRB(12, 0, 12, 8), + child: WavySeekBar( + controller: controller, + playedColor: fg, + trackColor: fg.withValues(alpha: 0.3), + style: progressBarStyle, + ), + ), + ], ), - ], - ), + ); + }, ); } } diff --git a/lib/widgets/viewer/viewer_icon_button.dart b/lib/widgets/viewer/viewer_icon_button.dart index 08daf54..1505b4e 100644 --- a/lib/widgets/viewer/viewer_icon_button.dart +++ b/lib/widgets/viewer/viewer_icon_button.dart @@ -1,21 +1,22 @@ import 'package:flutter/material.dart'; -import '../../theme/design_tokens.dart'; /// Icon-only action button for the media viewer's floating chrome (top bar, /// bottom action bar, video transport controls) - a fixed 22px icon in a -/// round hit box, colored from [NooColors] rather than Material's -/// [ColorScheme]. Kept as one small widget (matching the pre-rework -/// `_ActionIconButton` it replaces - see `styling.md`) so every floating -/// control in this screen looks the same instead of drifting apart across -/// the three call sites that need one. +/// round hit box. Colored white by default, not from [NooColors]/ +/// [ColorScheme]: this chrome always sits on the dark translucent panel +/// over a black media stage (see `FrostedGlassContainer`'s doc comment), +/// regardless of the app's light/dark theme, so a theme-derived color would +/// go near-invisible in light mode. Kept as one small widget so every +/// floating control in this screen looks the same instead of drifting +/// apart across the three call sites that need one. class ViewerIconButton extends StatelessWidget { final IconData icon; final String tooltip; final VoidCallback? onTap; - /// Overrides the default `fg-1` (e.g. `danger` for delete). Ignored while - /// [onTap] is null - a disabled button always dims to the same faded fg-1 - /// regardless of its normal color. + /// Overrides the default white (e.g. `danger` for delete, `accent-text` + /// for a favorited state). Ignored while [onTap] is null - a disabled + /// button always dims to faded white regardless of its normal color. final Color? color; const ViewerIconButton({ @@ -28,10 +29,9 @@ class ViewerIconButton extends StatelessWidget { @override Widget build(BuildContext context) { - final colors = context.nooColors; final fg = onTap == null - ? colors.fg1.withValues(alpha: 0.4) - : (color ?? colors.fg1); + ? Colors.white.withValues(alpha: 0.4) + : (color ?? Colors.white); return Tooltip( message: tooltip, From d2c34b000f266d71b501ff5e0f662bd077cbb326 Mon Sep 17 00:00:00 2001 From: ayushya Date: Mon, 28 Sep 2026 11:27:03 -0400 Subject: [PATCH 03/29] 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. --- .../context/design-system/DESIGN_SYSTEM.md | 27 +- lib/views/favorites_view.dart | 161 ++------- lib/views/files_view.dart | 133 +++----- lib/views/photos_view.dart | 315 ++++++------------ lib/widgets/noo/lists/noo_selection_bar.dart | 308 +++++++++++++++++ test/widgets/noo/lists_test.dart | 217 +++++++++++- 6 files changed, 716 insertions(+), 445 deletions(-) create mode 100644 lib/widgets/noo/lists/noo_selection_bar.dart diff --git a/.claude/context/design-system/DESIGN_SYSTEM.md b/.claude/context/design-system/DESIGN_SYSTEM.md index 6e49ad2..e5a2383 100644 --- a/.claude/context/design-system/DESIGN_SYSTEM.md +++ b/.claude/context/design-system/DESIGN_SYSTEM.md @@ -10,7 +10,7 @@ Reference files in this project: - `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 the screens this doc originally had no recipe for: Media viewer, Search, the Upload/Move/Copy destination picker, and the Details sheet's tab switch. 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. +- [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. --- @@ -160,6 +160,31 @@ Use **Lucide** (pinned to `lucide-static@0.460.0`) at a 1.8 stroke. Sizes are 14 - The block has a 20px icon above a 12/600 label. - 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. +- 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. diff --git a/lib/views/favorites_view.dart b/lib/views/favorites_view.dart index 9883f47..56404a5 100644 --- a/lib/views/favorites_view.dart +++ b/lib/views/favorites_view.dart @@ -21,6 +21,7 @@ import '../widgets/noo/files/noo_file_row.dart'; import '../widgets/noo/files/noo_file_table.dart'; import '../widgets/noo/files/noo_file_tile.dart'; import '../widgets/noo/lists/noo_grouped_list.dart'; +import '../widgets/noo/lists/noo_selection_bar.dart'; import '../widgets/noo/lists/noo_settings_row.dart'; import '../widgets/noo/media/noo_grid_card.dart'; import '../widgets/noo/noo_layout.dart'; @@ -55,92 +56,23 @@ class FavoritesView extends StatefulWidget { State createState() => _FavoritesViewState(); } -class _FavoritesViewState extends State - with SingleTickerProviderStateMixin { +class _FavoritesViewState extends State { bool _requested = false; final Set _selectedIds = {}; - final ScrollController _selectionActionsScrollController = ScrollController(); - final List _scrollHintControllers = []; bool get _isSelecting => _selectedIds.isNotEmpty; void _toggleSelection(NextcloudItem item) { HapticFeedback.selectionClick(); - final enteringSelection = _selectedIds.isEmpty; setState(() { if (!_selectedIds.remove(item.id)) _selectedIds.add(item.id); }); - if (enteringSelection && _isSelecting) { - WidgetsBinding.instance.addPostFrameCallback( - (_) => _playScrollHint(_selectionActionsScrollController), - ); - } } void _clearSelection() { setState(_selectedIds.clear); } - /// Mirrors `FilesView`/`PhotosView`'s identical scroll-hint - see their - /// doc comment: nudges the selection actions row right and back, once, - /// the first time a selection starts. No-ops if there's nothing to - /// scroll (row already fits). - Future _playScrollHint(ScrollController scrollController) async { - await Future.delayed(const Duration(milliseconds: 500)); - // `hasClients` only means a position is attached, not that it's - // finished its first layout - reading maxScrollExtent before that - // throws (min/maxScrollExtent are still null internally). - if (!mounted || - !scrollController.hasClients || - !scrollController.position.hasContentDimensions) { - return; - } - final maxExtent = scrollController.position.maxScrollExtent; - if (maxExtent <= 0) return; - final double peak = maxExtent < 36 ? maxExtent : 36; - final controller = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 900), - ); - _scrollHintControllers.add(controller); - final hint = TweenSequence([ - TweenSequenceItem( - tween: Tween( - begin: 0.0, - end: peak, - ).chain(CurveTween(curve: Curves.easeInOutSine)), - weight: 50, - ), - TweenSequenceItem( - tween: Tween( - begin: peak, - end: 0.0, - ).chain(CurveTween(curve: Curves.easeInOutSine)), - weight: 50, - ), - ]).animate(controller); - void onTick() { - if (scrollController.hasClients) { - scrollController.jumpTo(hint.value); - } - } - - hint.addListener(onTick); - await controller.forward(); - hint.removeListener(onTick); - _scrollHintControllers.remove(controller); - controller.dispose(); - } - - @override - void dispose() { - for (final controller in _scrollHintControllers) { - controller.dispose(); - } - _selectionActionsScrollController.dispose(); - super.dispose(); - } - /// A favorited folder switches to the Files tab, navigated there; a /// favorited file just opens directly from here, like Files/Photos do - /// no need to reposition Files first since dismissing the viewer lands @@ -166,40 +98,40 @@ class _FavoritesViewState extends State List _buildSelectionActions(List selected) { return [ SelectionAction( - icon: Icons.favorite_border_rounded, + icon: LucideIcons.starOff, label: 'Remove from favorites', onTap: () => _unfavoriteSelected(selected), ), SelectionAction( - icon: Icons.share_rounded, + icon: LucideIcons.share2, label: 'Share', onTap: () => selected.length == 1 ? ShareSheet.show(context, selected.single) : _shareSelected(context, selected), ), SelectionAction( - icon: Icons.download_rounded, + icon: LucideIcons.download, label: 'Download', onTap: () => _downloadSelected(context, selected), ), SelectionAction( - icon: Icons.delete_outline_rounded, + icon: LucideIcons.trash2, label: 'Delete', onTap: () => _confirmDeleteSelected(context, selected), ), SelectionAction( - icon: Icons.copy_rounded, + icon: LucideIcons.copy, label: 'Copy', onTap: () => _moveOrCopySelected(selected, copy: true), ), SelectionAction( - icon: Icons.drive_file_move_rounded, + icon: LucideIcons.folderInput, label: 'Move', onTap: () => _moveOrCopySelected(selected, copy: false), ), if (selected.length == 1) SelectionAction( - icon: Icons.info_outline_rounded, + icon: LucideIcons.info, label: 'Details', onTap: () => DetailsSheet.show(context, selected.single), ), @@ -477,7 +409,11 @@ class _FavoritesViewState extends State col3: item.isFolder ? null : formatBytes(item.size), favorite: item.isFavorite, selected: isSelected, - thumbnail: _thumbnailFor(context, item, extent: NooFileTileSize.desktop.extent), + thumbnail: _thumbnailFor( + context, + item, + extent: NooFileTileSize.desktop.extent, + ), onTap: () { if (_isSelecting) { _toggleSelection(item); @@ -639,7 +575,12 @@ class _FavoritesViewState extends State childAspectRatio: isDesktop ? 0.92 : 0.85, ), delegate: SliverChildBuilderDelegate((context, index) { - return _buildGridCard(context, favorites[index], siblings, isDesktop); + return _buildGridCard( + context, + favorites[index], + siblings, + isDesktop, + ); }, childCount: favorites.length), ), ) @@ -673,11 +614,7 @@ class _FavoritesViewState extends State }, ); } - return _buildTableRow( - context, - favorites[index - 1], - siblings, - ); + return _buildTableRow(context, favorites[index - 1], siblings); }, childCount: favorites.length + 1), ), ) @@ -719,56 +656,12 @@ class _FavoritesViewState extends State BuildContext context, List selectedItems, ) { - final colors = context.nooColors; - return Padding( - padding: const EdgeInsets.symmetric(horizontal: NooSpace.sm), - child: Row( - children: [ - SizedBox( - width: MediaQuery.of(context).size.width * 0.5, - child: Align( - alignment: Alignment.centerLeft, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - icon: Icon(LucideIcons.x, size: 20, color: colors.fg1), - tooltip: 'Cancel selection', - onPressed: _clearSelection, - visualDensity: VisualDensity.compact, - ), - const SizedBox(width: 8), - Text( - '${selectedItems.length} selected', - style: NooText.cardTitle.copyWith( - fontSize: 17, - color: colors.fg1, - ), - ), - ], - ), - ), - ), - Expanded( - child: SingleChildScrollView( - controller: _selectionActionsScrollController, - scrollDirection: Axis.horizontal, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - for (final action in _buildSelectionActions(selectedItems)) - IconButton( - icon: Icon(action.icon, size: 20, color: colors.fg1), - tooltip: action.label, - onPressed: action.onTap, - visualDensity: VisualDensity.compact, - ), - ], - ), - ), - ), - ], - ), + return NooSelectionBar( + count: selectedItems.length, + actions: _buildSelectionActions(selectedItems), + onClose: _clearSelection, + isDesktop: NooLayout.isDesktop(context), + iosStyle: NooLayout.iosStyle(context), ); } } diff --git a/lib/views/files_view.dart b/lib/views/files_view.dart index 1037369..568032f 100644 --- a/lib/views/files_view.dart +++ b/lib/views/files_view.dart @@ -35,6 +35,7 @@ import '../widgets/noo/files/noo_file_tile.dart'; import '../widgets/noo/files/noo_status_icon.dart'; import '../widgets/noo/files/noo_swipe_action.dart'; import '../widgets/noo/lists/noo_grouped_list.dart'; +import '../widgets/noo/lists/noo_selection_bar.dart'; import '../widgets/noo/lists/noo_settings_row.dart'; import '../widgets/noo/lists/noo_summary_card.dart'; import '../widgets/noo/media/noo_grid_card.dart'; @@ -231,7 +232,6 @@ class _FilesViewState extends State final Set _selectedIds = {}; int _lastPathDepth = 1; final ScrollController _controlsScrollController = ScrollController(); - final ScrollController _selectionActionsScrollController = ScrollController(); final List _scrollHintControllers = []; bool get _isSelecting => _selectedIds.isNotEmpty; @@ -247,9 +247,8 @@ class _FilesViewState extends State /// The on-device copy of [item] for the Offline tab; null online, where /// thumbnails come from server previews instead. - File? _localFileFor(BuildContext context, NextcloudItem item) => _offline - ? context.read().localFileFor(item) - : null; + File? _localFileFor(BuildContext context, NextcloudItem item) => + _offline ? context.read().localFileFor(item) : null; // (Online only - the Offline tab always reloads on first build, since a // local listing has no cheap "already loaded" signal.) @@ -340,21 +339,14 @@ class _FilesViewState extends State controller.dispose(); } _controlsScrollController.dispose(); - _selectionActionsScrollController.dispose(); super.dispose(); } void _toggleSelection(NextcloudItem item) { HapticFeedback.selectionClick(); - final enteringSelection = _selectedIds.isEmpty; setState(() { if (!_selectedIds.remove(item.id)) _selectedIds.add(item.id); }); - if (enteringSelection && _isSelecting) { - WidgetsBinding.instance.addPostFrameCallback( - (_) => _playScrollHint(_selectionActionsScrollController), - ); - } } void _clearSelection() { @@ -766,7 +758,9 @@ class _FilesViewState extends State Icon(LucideIcons.circleAlert, size: 56, color: colors.danger), const SizedBox(height: 16), Text( - _offline ? 'Could not read local files' : 'WebDAV Sync Error', + _offline + ? 'Could not read local files' + : 'WebDAV Sync Error', style: NooText.cardTitle.copyWith(color: colors.danger), ), const SizedBox(height: 8), @@ -814,7 +808,12 @@ class _FilesViewState extends State else if (files.isGridView) SliverPadding( key: const ValueKey('files-grid'), - padding: EdgeInsets.fromLTRB(gutter, NooSpace.xs, gutter, NooSpace.lg), + padding: EdgeInsets.fromLTRB( + gutter, + NooSpace.xs, + gutter, + NooSpace.lg, + ), sliver: SliverGrid( gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: isDesktop ? 5 : 2, @@ -838,7 +837,11 @@ class _FilesViewState extends State key: const ValueKey('files-table-header'), padding: EdgeInsets.fromLTRB(gutter, NooSpace.xs, gutter, 0), sliver: SliverToBoxAdapter( - child: _buildDesktopHeader(context, files, browser.currentFolderPath), + child: _buildDesktopHeader( + context, + files, + browser.currentFolderPath, + ), ), ), SliverPadding( @@ -872,7 +875,12 @@ class _FilesViewState extends State key: ValueKey('${browser.currentFolderPath}::${item.id}'), index: index, fromRight: navigatingDeeper, - child: _buildMobileRow(context, item, index, browser.items.length), + child: _buildMobileRow( + context, + item, + index, + browser.items.length, + ), ); }, childCount: browser.items.length), ), @@ -955,7 +963,9 @@ class _FilesViewState extends State stat: stat, caption: caption, meta: meta, - tone: hasConflicts ? NooSummaryCardTone.danger : NooSummaryCardTone.normal, + tone: hasConflicts + ? NooSummaryCardTone.danger + : NooSummaryCardTone.normal, action: NooButton( variant: NooButtonVariant.tonal, size: NooButtonSize.compact, @@ -968,75 +978,20 @@ class _FilesViewState extends State ); } - /// Takes over the controls row's own sticky slot while selecting - a - /// close button, the "N selected" count, and the horizontally-scrollable - /// bulk actions (in accent-text, per `DESIGN_SYSTEM.md`'s pinned - /// selection bar). + /// Takes over the controls row's own sticky slot while selecting - see + /// `NooSelectionBar` (design canvas + /// https://claude.ai/artifact/3AGPqqMdkLSC2ypCh2CQs4, "Selection action + /// bar" - DESIGN_SYSTEM.md has no §4 recipe of its own for this). Widget _buildSelectionBar( BuildContext context, List selectedItems, ) { - final colors = context.nooColors; - return Padding( - padding: const EdgeInsets.symmetric(horizontal: NooSpace.sm), - child: Row( - children: [ - SizedBox( - width: MediaQuery.of(context).size.width * 0.5, - child: Align( - alignment: Alignment.centerLeft, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - icon: Icon(LucideIcons.x, size: 20, color: colors.fg1), - tooltip: 'Cancel selection', - onPressed: _clearSelection, - visualDensity: VisualDensity.compact, - ), - const SizedBox(width: 8), - Text( - '${selectedItems.length} selected', - style: NooText.cardTitle.copyWith( - fontSize: 17, - color: colors.fg1, - ), - ), - ], - ), - ), - ), - Expanded( - child: SingleChildScrollView( - controller: _selectionActionsScrollController, - scrollDirection: Axis.horizontal, - // Left-aligned (not anchored to the trailing edge) so the - // first action's left edge sits at a fixed spot - lining up - // with the controls row's own first icon directly below it - - // regardless of how many actions there are. - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - for (final action in _buildSelectionActions( - context, - selectedItems, - )) - IconButton( - icon: Icon( - action.icon, - size: 20, - color: colors.accentText, - ), - tooltip: action.label, - onPressed: action.onTap, - visualDensity: VisualDensity.compact, - ), - ], - ), - ), - ), - ], - ), + return NooSelectionBar( + count: selectedItems.length, + actions: _buildSelectionActions(context, selectedItems), + onClose: _clearSelection, + isDesktop: NooLayout.isDesktop(context), + iosStyle: NooLayout.iosStyle(context), ); } @@ -1144,9 +1099,7 @@ class _FilesViewState extends State // just some)? Save straight from the local copy instead of a fresh // network fetch through DownloadService - see // SyncStatusController.localSyncedFilePath. - final localPaths = await Future.wait( - files.map(sync.localSyncedFilePath), - ); + final localPaths = await Future.wait(files.map(sync.localSyncedFilePath)); if (localPaths.every((path) => path != null)) { try { for (var i = 0; i < files.length; i++) { @@ -1355,7 +1308,11 @@ class _FilesViewState extends State FilesController files, String folderPath, ) { - const columns = [FileSortField.name, FileSortField.size, FileSortField.dateModified]; + const columns = [ + FileSortField.name, + FileSortField.size, + FileSortField.dateModified, + ]; final field = files.sortFieldFor(folderPath); final sortColumn = columns.indexOf(field); return NooFileTableHeader( @@ -1486,7 +1443,11 @@ class _FilesViewState extends State item: item, service: session.service, localFile: _localFileFor(context, item), - fallback: Icon(kind.icon, color: kind.foreground(colors), size: 32), + fallback: Icon( + kind.icon, + color: kind.foreground(colors), + size: 32, + ), ) : null, thumbnailHeight: NooLayout.isDesktop(context) ? 118 : 104, diff --git a/lib/views/photos_view.dart b/lib/views/photos_view.dart index 794923c..cf5e9b3 100644 --- a/lib/views/photos_view.dart +++ b/lib/views/photos_view.dart @@ -20,6 +20,7 @@ import '../widgets/noo/core/noo_segmented_control.dart'; import '../widgets/noo/core/noo_toggle.dart'; import '../widgets/noo/files/noo_file_kind.dart'; import '../widgets/noo/lists/noo_grouped_list.dart'; +import '../widgets/noo/lists/noo_selection_bar.dart'; import '../widgets/noo/lists/noo_settings_row.dart'; import '../widgets/noo/media/noo_photo_group.dart'; import '../widgets/noo/media/noo_photo_tile.dart'; @@ -57,12 +58,9 @@ class PhotosView extends StatefulWidget { State createState() => _PhotosViewState(); } -class _PhotosViewState extends State - with SingleTickerProviderStateMixin { +class _PhotosViewState extends State { bool _requested = false; final Set _selectedIds = {}; - final ScrollController _selectionActionsScrollController = ScrollController(); - final List _scrollHintControllers = []; // Session-local only (not one of PhotosController's persisted display // prefs) - a pure narrowing of the already-fetched/filtered/sorted list, @@ -74,81 +72,15 @@ class _PhotosViewState extends State void _toggleSelection(NextcloudItem item) { HapticFeedback.selectionClick(); - final enteringSelection = _selectedIds.isEmpty; setState(() { if (!_selectedIds.remove(item.id)) _selectedIds.add(item.id); }); - if (enteringSelection && _isSelecting) { - WidgetsBinding.instance.addPostFrameCallback( - (_) => _playScrollHint(_selectionActionsScrollController), - ); - } } void _clearSelection() { setState(_selectedIds.clear); } - /// A one-shot hint that the selection actions row actually scrolls - - /// mirrors `FilesView`'s identical controls-row hint (see its doc - /// comment): nudges it right and back, once, the first time a selection - /// starts. No-ops if there's nothing to scroll (row already fits). - Future _playScrollHint(ScrollController scrollController) async { - await Future.delayed(const Duration(milliseconds: 500)); - // `hasClients` only means a position is attached, not that it's - // finished its first layout - reading maxScrollExtent before that - // throws (min/maxScrollExtent are still null internally). - if (!mounted || - !scrollController.hasClients || - !scrollController.position.hasContentDimensions) { - return; - } - final maxExtent = scrollController.position.maxScrollExtent; - if (maxExtent <= 0) return; - final double peak = maxExtent < 36 ? maxExtent : 36; - final controller = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 900), - ); - _scrollHintControllers.add(controller); - final hint = TweenSequence([ - TweenSequenceItem( - tween: Tween( - begin: 0.0, - end: peak, - ).chain(CurveTween(curve: Curves.easeInOutSine)), - weight: 50, - ), - TweenSequenceItem( - tween: Tween( - begin: peak, - end: 0.0, - ).chain(CurveTween(curve: Curves.easeInOutSine)), - weight: 50, - ), - ]).animate(controller); - void onTick() { - if (scrollController.hasClients) { - scrollController.jumpTo(hint.value); - } - } - - hint.addListener(onTick); - await controller.forward(); - hint.removeListener(onTick); - _scrollHintControllers.remove(controller); - controller.dispose(); - } - - @override - void dispose() { - for (final controller in _scrollHintControllers) { - controller.dispose(); - } - _selectionActionsScrollController.dispose(); - super.dispose(); - } - /// Mirrors `FilesView._handlePickTap` - Photos has no folders, so this is /// just the matching/toggle/immediate-confirm branch. void _handlePickTap( @@ -181,52 +113,52 @@ class _PhotosViewState extends State if (pick.isPicking) { return [ SelectionAction( - icon: Icons.check_rounded, + icon: LucideIcons.check, label: 'Use ${selected.length} item(s)', onTap: () => pick.confirmPick(selected), ), ]; } + // Matches the star/star-off convention Files' own selection toolbar + // uses for favorite/unfavorite (see `files_view.dart`) rather than a + // filled heart. + final allFavorited = selected.every((i) => i.isFavorite); return [ SelectionAction( - icon: selected.every((i) => i.isFavorite) - ? Icons.favorite_rounded - : Icons.favorite_border_rounded, - label: selected.every((i) => i.isFavorite) - ? 'Remove from favorites' - : 'Favorite', + icon: allFavorited ? LucideIcons.starOff : LucideIcons.star, + label: allFavorited ? 'Remove from favorites' : 'Favorite', onTap: () => _favoriteSelected(selected), ), SelectionAction( - icon: Icons.share_rounded, + icon: LucideIcons.share2, label: 'Share', onTap: () => selected.length == 1 ? ShareSheet.show(context, selected.single) : _shareSelected(context, selected), ), SelectionAction( - icon: Icons.download_rounded, + icon: LucideIcons.download, label: 'Download', onTap: () => _downloadSelected(context, selected), ), SelectionAction( - icon: Icons.delete_outline_rounded, + icon: LucideIcons.trash2, label: 'Delete', onTap: () => _confirmDeleteSelected(context, selected), ), SelectionAction( - icon: Icons.copy_rounded, + icon: LucideIcons.copy, label: 'Copy', onTap: () => _moveOrCopySelected(selected, copy: true), ), SelectionAction( - icon: Icons.drive_file_move_rounded, + icon: LucideIcons.folderInput, label: 'Move', onTap: () => _moveOrCopySelected(selected, copy: false), ), if (selected.length == 1) SelectionAction( - icon: Icons.info_outline_rounded, + icon: LucideIcons.info, label: 'Details', onTap: () => DetailsSheet.show(context, selected.single), ), @@ -462,15 +394,13 @@ class _PhotosViewState extends State const SizedBox(width: 8), NooChip( selected: _typeFilter == _PhotoTypeFilter.image, - onTap: () => - setState(() => _typeFilter = _PhotoTypeFilter.image), + onTap: () => setState(() => _typeFilter = _PhotoTypeFilter.image), child: const Text('Photos'), ), const SizedBox(width: 8), NooChip( selected: _typeFilter == _PhotoTypeFilter.video, - onTap: () => - setState(() => _typeFilter = _PhotoTypeFilter.video), + onTap: () => setState(() => _typeFilter = _PhotoTypeFilter.video), child: const Text('Videos'), ), const SizedBox(width: 8), @@ -491,43 +421,58 @@ class _PhotosViewState extends State showNooSheet( context, children: [ - NooSegmentedControl( - fill: true, - value: photos.sortAscending, - onChanged: (asc) { - if (asc != photos.sortAscending) photos.toggleSortOrder(); - }, - options: const [ - NooSegmentOption( - value: true, - icon: LucideIcons.arrowUp, - label: 'Ascending', - ), - NooSegmentOption( - value: false, - icon: LucideIcons.arrowDown, - label: 'Descending', - ), - ], - ), - NooGroupedList( - children: [ - for (final field in FileSortField.values) - NooSettingsRow( - label: Text(sortFieldLabel(field)), - trailing: field == photos.sortField - ? Icon( - LucideIcons.check, - size: 18, - color: context.nooColors.accentText, - ) - : null, - onTap: () { - photos.setSortField(field); - Navigator.pop(context); + // `showNooSheet`'s `children` are built once, up front - a bare + // checkmark here would freeze at whatever it was when the sheet + // opened, since tapping a row calls `photos.set...`/notifies the + // controller, not this already-built widget tree. `ListenableBuilder` + // re-runs its `builder` on every notification instead, so the + // selection updates live. + ListenableBuilder( + listenable: photos, + builder: (context, _) => Column( + mainAxisSize: MainAxisSize.min, + children: [ + NooSegmentedControl( + fill: true, + value: photos.sortAscending, + onChanged: (asc) { + if (asc != photos.sortAscending) photos.toggleSortOrder(); }, + options: const [ + NooSegmentOption( + value: true, + icon: LucideIcons.arrowUp, + label: 'Ascending', + ), + NooSegmentOption( + value: false, + icon: LucideIcons.arrowDown, + label: 'Descending', + ), + ], ), - ], + const SizedBox(height: 22), + NooGroupedList( + children: [ + for (final field in FileSortField.values) + NooSettingsRow( + label: Text(sortFieldLabel(field)), + trailing: field == photos.sortField + ? Icon( + LucideIcons.check, + size: 18, + color: context.nooColors.accentText, + ) + : null, + onTap: () { + photos.setSortField(field); + Navigator.pop(context); + }, + ), + ], + ), + ], + ), ), ], ); @@ -541,105 +486,59 @@ class _PhotosViewState extends State showNooSheet( context, children: [ - NooGroupedList( - children: [ - NooSettingsRow( - icon: LucideIcons.heart, - label: const Text('Favorites only'), - trailing: NooToggle( - checked: photos.showFavoritesOnly, - onChanged: (_) => photos.toggleFavoritesFilter(), - ), - ), - NooSettingsRow( - icon: LucideIcons.eye, - label: const Text('Show hidden files'), - trailing: NooToggle( - checked: photos.showHidden, - onChanged: (_) => photos.toggleShowHidden(), - ), - ), - NooSettingsRow( - icon: LucideIcons.hardDrive, - label: const Text('External storage'), - trailing: NooToggle( - checked: files.storageScope == StorageScope.external, - onChanged: (external) => files.setStorageScope( - external ? StorageScope.external : StorageScope.cloud, + // See `_showSortSheet`'s comment - `Listenable.merge` since these + // toggles span two controllers. + ListenableBuilder( + listenable: Listenable.merge([photos, files]), + builder: (context, _) => NooGroupedList( + children: [ + NooSettingsRow( + icon: LucideIcons.heart, + label: const Text('Favorites only'), + trailing: NooToggle( + checked: photos.showFavoritesOnly, + onChanged: (_) => photos.toggleFavoritesFilter(), ), ), - ), - ], + NooSettingsRow( + icon: LucideIcons.eye, + label: const Text('Show hidden files'), + trailing: NooToggle( + checked: photos.showHidden, + onChanged: (_) => photos.toggleShowHidden(), + ), + ), + NooSettingsRow( + icon: LucideIcons.hardDrive, + label: const Text('External storage'), + trailing: NooToggle( + checked: files.storageScope == StorageScope.external, + onChanged: (external) => files.setStorageScope( + external ? StorageScope.external : StorageScope.cloud, + ), + ), + ), + ], + ), ), ], ); } /// Replaces the controls row's own sticky slot while selecting - a close - /// button, the "N selected" count, and the horizontally-scrollable bulk - /// actions. Mirrors `FilesView`'s identical selection bar. + /// button, the "N selected" count, and the bulk actions. Mirrors + /// `FilesView`'s identical selection bar. Widget _buildSelectionBar( BuildContext context, PickController pick, List selectedItems, ) { - final colors = context.nooColors; - return Padding( - padding: const EdgeInsets.symmetric(horizontal: NooSpace.sm), - child: Row( - children: [ - SizedBox( - width: MediaQuery.of(context).size.width * 0.5, - child: Align( - alignment: Alignment.centerLeft, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - icon: Icon(LucideIcons.x, size: 20, color: colors.fg1), - tooltip: 'Cancel selection', - onPressed: _clearSelection, - visualDensity: VisualDensity.compact, - ), - const SizedBox(width: 8), - Text( - '${selectedItems.length} selected', - style: NooText.cardTitle.copyWith( - fontSize: 17, - color: colors.fg1, - ), - ), - ], - ), - ), - ), - Expanded( - child: SingleChildScrollView( - controller: _selectionActionsScrollController, - scrollDirection: Axis.horizontal, - // Left-aligned (not anchored to the trailing edge) so the - // first action's left edge sits at a fixed spot - lining up - // with the controls row's own first chip directly below it - - // regardless of how many actions there are. - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - for (final action in _buildSelectionActions( - pick, - selectedItems, - )) - IconButton( - icon: Icon(action.icon, size: 20, color: colors.fg1), - tooltip: action.label, - onPressed: action.onTap, - visualDensity: VisualDensity.compact, - ), - ], - ), - ), - ), - ], - ), + return NooSelectionBar( + count: selectedItems.length, + actions: _buildSelectionActions(pick, selectedItems), + onClose: _clearSelection, + isDesktop: NooLayout.isDesktop(context), + iosStyle: NooLayout.iosStyle(context), ); } diff --git a/lib/widgets/noo/lists/noo_selection_bar.dart b/lib/widgets/noo/lists/noo_selection_bar.dart new file mode 100644 index 0000000..c782849 --- /dev/null +++ b/lib/widgets/noo/lists/noo_selection_bar.dart @@ -0,0 +1,308 @@ +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import '../../../models/selection_action.dart'; +import '../../../theme/design_tokens.dart'; +import '../files/noo_file_row.dart' show NooOverflowButton; +import '../overlays/noo_sheet.dart'; +import 'noo_grouped_list.dart'; +import 'noo_settings_row.dart'; + +/// Bulk actions shown inline before the rest collapse behind "More" - a +/// fixed, non-scrolling width on every platform (design canvas +/// https://claude.ai/artifact/3AGPqqMdkLSC2ypCh2CQs4, "Selection action +/// bar"). Mobile is tighter (icons share the bar with the close circle and +/// count on a ~360dp phone); desktop's labelled pills have a wider toolbar +/// to work with. +const int _kMobileInlineActions = 3; +const int _kDesktopInlineActions = 4; + +/// The multi-select action bar Files/Photos/Favorites swap in for their own +/// sort/filter row while selecting - design canvas +/// https://claude.ai/artifact/3AGPqqMdkLSC2ypCh2CQs4 ("Selection action +/// bar"), since DESIGN_SYSTEM.md has no §4 recipe of its own for this. +/// Replaces the bare row of Material `IconButton`s each screen used to +/// build ad hoc. +/// +/// A `surface` card (radius 20) - selection reads as a distinct mode, not +/// just a row of buttons floating on `bg` - with a close circle, the +/// count, and the bulk actions. This sits in the *content* pane's own +/// pinned header slot on every platform (the shell's desktop toolbar, +/// built separately in `main.dart`, never changes for selection), so +/// mobile and desktop share the same fixed 56px height; only the gutter +/// ([isDesktop] ? 24 : 12, matching `NooLayout.gutter`) and the actions' +/// presentation differ: +/// - Mobile: the first [_kMobileInlineActions] actions as plain 20px +/// accent-text icons, no fill. +/// - Desktop: the first [_kDesktopInlineActions] as labelled tonal pills - +/// danger-soft/danger for the one labelled "Delete", accent-soft/ +/// accent-text otherwise - since desktop has the room for labels. +/// +/// Any actions beyond that inline count sit behind a trailing "More" +/// button that opens the same grouped-list sheet a file row's own overflow +/// menu already uses (see `FilesView._showItemActions`) - never a second +/// scrolling row, which is what this replaced: with up to 9 bulk actions +/// (favorite, share, download, delete, copy, move, rename, sync, +/// details), a horizontally-scrolling row hid actions behind a swipe +/// gesture with no visible cue that there was more to find. +class NooSelectionBar extends StatelessWidget { + final int count; + final List actions; + final VoidCallback onClose; + final bool isDesktop; + final bool iosStyle; + + const NooSelectionBar({ + super.key, + required this.count, + required this.actions, + required this.onClose, + required this.isDesktop, + this.iosStyle = false, + }); + + @override + Widget build(BuildContext context) { + final colors = context.nooColors; + final gutter = isDesktop ? NooSpace.xl : NooSpace.sm; + final inlineCount = isDesktop + ? _kDesktopInlineActions + : _kMobileInlineActions; + final inlineActions = actions.length > inlineCount + ? actions.sublist(0, inlineCount) + : actions; + final overflowActions = actions.length > inlineCount + ? actions.sublist(inlineCount) + : const []; + + // Every screen pins this into a fixed 56px sliver-header slot (the same + // one the sort/filter row otherwise occupies) - the 4px top/bottom + // margin plus the card's own 48px must add up to exactly that, or the + // header overflows (see files_view.dart's `topRow` height comment for + // the same warning about this exact number). + return Padding( + padding: EdgeInsets.symmetric(horizontal: gutter, vertical: NooSpace.xxs), + child: Container( + height: 48, + padding: const EdgeInsets.symmetric(horizontal: 6), + decoration: BoxDecoration( + color: colors.surface, + borderRadius: BorderRadius.circular(NooRadii.card), + ), + child: Row( + children: [ + Semantics( + button: true, + label: 'Cancel selection', + child: GestureDetector( + onTap: onClose, + child: Container( + width: 36, + height: 36, + alignment: Alignment.center, + decoration: BoxDecoration( + color: colors.surface2, + shape: BoxShape.circle, + ), + child: Icon(LucideIcons.x, size: 18, color: colors.fg1), + ), + ), + ), + const SizedBox(width: NooSpace.xs), + Text( + '$count selected', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: NooText.cardTitle.copyWith( + fontSize: 17, + color: colors.fg1, + ), + ), + const SizedBox(width: NooSpace.xs), + Expanded( + child: Align( + alignment: Alignment.centerRight, + child: isDesktop + ? _desktopActions( + context, + colors, + inlineActions, + overflowActions, + ) + : _mobileActions( + context, + colors, + inlineActions, + overflowActions, + ), + ), + ), + ], + ), + ), + ); + } + + Widget _mobileActions( + BuildContext context, + NooColors colors, + List inline, + List overflow, + ) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + for (final action in inline) + Semantics( + button: true, + label: action.label, + child: InkResponse( + onTap: action.onTap, + radius: 20, + child: Padding( + padding: const EdgeInsets.all(10), + child: Icon(action.icon, size: 20, color: colors.accentText), + ), + ), + ), + if (overflow.isNotEmpty) + NooOverflowButton( + iosStyle: iosStyle, + onTap: () => _showOverflowSheet(context, overflow), + ), + ], + ); + } + + Widget _desktopActions( + BuildContext context, + NooColors colors, + List inline, + List overflow, + ) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + for (final action in inline) ...[ + _DesktopActionPill(action: action, colors: colors), + const SizedBox(width: 6), + ], + if (overflow.isNotEmpty) + _DesktopMorePill( + colors: colors, + onTap: () => _showOverflowSheet(context, overflow), + ), + ], + ); + } + + void _showOverflowSheet( + BuildContext context, + List overflow, + ) { + showNooSheet( + context, + children: [ + NooGroupedList( + children: [ + for (final action in overflow) + NooSettingsRow( + icon: action.icon, + label: Text(action.label), + onTap: () { + Navigator.pop(context); + action.onTap(); + }, + ), + ], + ), + ], + ); + } +} + +class _DesktopActionPill extends StatelessWidget { + final SelectionAction action; + final NooColors colors; + + const _DesktopActionPill({required this.action, required this.colors}); + + @override + Widget build(BuildContext context) { + final danger = action.label == 'Delete'; + final bg = danger ? colors.dangerSoft : colors.accentSoft; + final fg = danger ? colors.danger : colors.accentText; + return Material( + color: bg, + borderRadius: BorderRadius.circular(NooRadii.pill), + child: InkWell( + onTap: action.onTap, + borderRadius: BorderRadius.circular(NooRadii.pill), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: SizedBox( + height: 32, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(action.icon, size: 15, color: fg), + const SizedBox(width: 6), + Text( + action.label, + style: NooText.buttonSm.copyWith(color: fg, fontSize: 13), + ), + ], + ), + ), + ), + ), + ); + } +} + +/// The desktop overflow trigger - a secondary pill (matching the Button +/// component's "Secondary" variant) rather than a tonal one, so it reads +/// as "opens a menu" and not as another bulk action next to it. The +/// trailing `chevron-down` mirrors the same "opens a menu" cue `NooChip` +/// uses. +class _DesktopMorePill extends StatelessWidget { + final NooColors colors; + final VoidCallback onTap; + + const _DesktopMorePill({required this.colors, required this.onTap}); + + @override + Widget build(BuildContext context) { + return Semantics( + button: true, + label: 'More actions', + child: Material( + color: colors.surface2, + borderRadius: BorderRadius.circular(NooRadii.pill), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(NooRadii.pill), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: SizedBox( + height: 32, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'More', + style: NooText.buttonSm.copyWith( + color: colors.fg1, + fontSize: 13, + ), + ), + const SizedBox(width: 4), + Icon(LucideIcons.chevronDown, size: 14, color: colors.fg1), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/test/widgets/noo/lists_test.dart b/test/widgets/noo/lists_test.dart index b3fba59..233b93f 100644 --- a/test/widgets/noo/lists_test.dart +++ b/test/widgets/noo/lists_test.dart @@ -1,16 +1,31 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:noo/models/selection_action.dart'; import 'package:noo/widgets/noo/core/noo_progress_bar.dart'; import 'package:noo/widgets/noo/core/noo_toggle.dart'; import 'package:noo/widgets/noo/lists/noo_banner.dart'; import 'package:noo/widgets/noo/lists/noo_grouped_list.dart'; +import 'package:noo/widgets/noo/lists/noo_selection_bar.dart'; import 'package:noo/widgets/noo/lists/noo_settings_row.dart'; import 'package:noo/widgets/noo/lists/noo_summary_card.dart'; import 'package:noo/widgets/noo/lists/noo_tab_order_row.dart'; import 'noo_test_utils.dart'; +/// [n] actions labelled A, B, C, ... each incrementing an entry in [taps] +/// keyed by its own label when tapped. +List _actions(int n, Map taps) => + List.generate(n, (i) { + final label = String.fromCharCode(65 + i); + taps[label] = 0; + return SelectionAction( + icon: LucideIcons.star, + label: label, + onTap: () => taps[label] = taps[label]! + 1, + ); + }); + void main() { setUpNooTests(); @@ -28,15 +43,26 @@ void main() { ); expect(find.byIcon(LucideIcons.info), findsOneWidget); expect(decorationOf(tester, find.text('Empty trash')).color, c.surface); - expect(tester.widget(find.text('Empty trash')).style!.color, c.danger); + expect( + tester.widget(find.text('Empty trash')).style!.color, + c.danger, + ); await tester.tap(find.text('Empty trash')); expect(taps, 1); }); - testNooWidgets('non-danger action uses accent text', (tester, theme, c) async { + testNooWidgets('non-danger action uses accent text', ( + tester, + theme, + c, + ) async { await pumpNoo( tester, - const NooBanner(actionLabel: 'Undo', actionIsDanger: false, child: Text('x')), + const NooBanner( + actionLabel: 'Undo', + actionIsDanger: false, + child: Text('x'), + ), theme: theme, ); expect(tester.widget(find.text('Undo')).style!.color, c.accentText); @@ -44,7 +70,11 @@ void main() { }); group('NooGroupedList', () { - testNooWidgets('label, aside, 1px line gaps and footer', (tester, theme, c) async { + testNooWidgets('label, aside, 1px line gaps and footer', ( + tester, + theme, + c, + ) async { await pumpNoo( tester, SizedBox( @@ -65,7 +95,8 @@ void main() { expect(find.text('2'), findsOneWidget); expect(find.text('Footer note'), findsOneWidget); expect(tester.widget(find.text('Accounts')).style!.color, c.fg2); - final gap = tester.getTopLeft(find.byKey(const Key('r2'))).dy - + final gap = + tester.getTopLeft(find.byKey(const Key('r2'))).dy - tester.getBottomLeft(find.byKey(const Key('r1'))).dy; expect(gap, 1); // Rows paint their own surface; the gaps show the list's line fill. @@ -73,8 +104,131 @@ void main() { }); }); + group('NooSelectionBar', () { + testNooWidgets('mobile: all actions show inline when they fit', ( + tester, + theme, + c, + ) async { + final taps = {}; + await pumpNoo( + tester, + SizedBox( + width: 360, + child: NooSelectionBar( + count: 2, + actions: _actions(2, taps), + onClose: () {}, + isDesktop: false, + ), + ), + theme: theme, + ); + expect(find.text('2 selected'), findsOneWidget); + expect(find.byIcon(LucideIcons.star), findsNWidgets(2)); + expect(find.byIcon(LucideIcons.ellipsisVertical), findsNothing); + await tester.tap(find.byIcon(LucideIcons.star).first); + expect(taps['A'], 1); + }); + + testNooWidgets( + 'mobile: actions beyond the inline count collapse behind More', + (tester, theme, c) async { + final taps = {}; + await pumpNoo( + tester, + // Wide enough that the missing-webfont fallback used in tests + // (Google Fonts network fetch is off, see noo_test_utils.dart) + // doesn't itself force an overflow unrelated to what's tested here. + SizedBox( + width: 500, + child: NooSelectionBar( + count: 5, + actions: _actions(5, taps), + onClose: () {}, + isDesktop: false, + ), + ), + theme: theme, + ); + // Only the first 3 actions render inline - no horizontally- + // scrolling row that could hide the rest with no visible cue. + expect(find.byIcon(LucideIcons.star), findsNWidgets(3)); + expect(find.byIcon(LucideIcons.ellipsisVertical), findsOneWidget); + + await tester.tap(find.byIcon(LucideIcons.ellipsisVertical)); + await tester.pumpAndSettle(); + // The sheet lists exactly the overflowed actions (D, E) - the + // already-inline ones (A, B, C) aren't duplicated in it. + expect(find.text('D'), findsOneWidget); + expect(find.text('E'), findsOneWidget); + expect(find.text('A'), findsNothing); + + await tester.tap(find.text('D')); + await tester.pumpAndSettle(); + expect(taps['D'], 1); + }, + ); + + testNooWidgets( + 'desktop: actions beyond the inline count collapse behind More', + (tester, theme, c) async { + final taps = {}; + await pumpNoo( + tester, + SizedBox( + width: 900, + child: NooSelectionBar( + count: 6, + actions: _actions(6, taps), + onClose: () {}, + isDesktop: true, + ), + ), + theme: theme, + ); + // The first 4 actions render as labelled pills; the rest are behind + // "More". + for (final label in ['A', 'B', 'C', 'D']) { + expect(find.text(label), findsOneWidget); + } + expect(find.text('E'), findsNothing); + expect(find.text('More'), findsOneWidget); + + await tester.tap(find.text('More')); + await tester.pumpAndSettle(); + expect(find.text('E'), findsOneWidget); + expect(find.text('F'), findsOneWidget); + }, + ); + + testNooWidgets('close button fires onClose', (tester, theme, c) async { + var closed = false; + final taps = {}; + await pumpNoo( + tester, + SizedBox( + width: 360, + child: NooSelectionBar( + count: 1, + actions: _actions(1, taps), + onClose: () => closed = true, + isDesktop: false, + ), + ), + theme: theme, + ); + await tester.tap(find.byIcon(LucideIcons.x)); + expect(closed, isTrue); + }); + }); + group('NooSettingsRow', () { - testNooWidgets('value row shows chevron and fires onTap', (tester, theme, c) async { + testNooWidgets('value row shows chevron and fires onTap', ( + tester, + theme, + c, + ) async { var taps = 0; await pumpNoo( tester, @@ -101,7 +255,9 @@ void main() { expect(taps, 1); }); - testWidgets('subtitle row is 60px, trailing wins over value', (tester) async { + testWidgets('subtitle row is 60px, trailing wins over value', ( + tester, + ) async { await pumpNoo( tester, const SizedBox( @@ -123,7 +279,9 @@ void main() { expect(tester.getSize(find.byType(NooSettingsRow)).height, 60); }); - testWidgets('keeps its 52px height inside a tall bounded parent', (tester) async { + testWidgets('keeps its 52px height inside a tall bounded parent', ( + tester, + ) async { await pumpNoo( tester, const SizedBox( @@ -143,18 +301,31 @@ void main() { tester, const SizedBox( width: 360, - child: NooSettingsRow(icon: LucideIcons.logOut, label: Text('Log out'), danger: true), + child: NooSettingsRow( + icon: LucideIcons.logOut, + label: Text('Log out'), + danger: true, + ), ), theme: theme, ); - final style = DefaultTextStyle.of(tester.element(find.text('Log out'))).style; + final style = DefaultTextStyle.of( + tester.element(find.text('Log out')), + ).style; expect(style.color, c.danger); - expect(tester.widget(find.byIcon(LucideIcons.logOut)).color, c.danger); + expect( + tester.widget(find.byIcon(LucideIcons.logOut)).color, + c.danger, + ); }); }); group('NooSummaryCard', () { - testNooWidgets('stat, caption, progress, meta, action', (tester, theme, c) async { + testNooWidgets('stat, caption, progress, meta, action', ( + tester, + theme, + c, + ) async { await pumpNoo( tester, const SizedBox( @@ -182,7 +353,11 @@ void main() { tester, const SizedBox( width: 360, - child: NooSummaryCard(stat: '3', caption: 'Errors', tone: NooSummaryCardTone.danger), + child: NooSummaryCard( + stat: '3', + caption: 'Errors', + tone: NooSummaryCardTone.danger, + ), ), theme: theme, ); @@ -194,7 +369,11 @@ void main() { }); group('NooTabOrderRow', () { - testNooWidgets('pin state drives icon/fill, toggle fires', (tester, theme, c) async { + testNooWidgets('pin state drives icon/fill, toggle fires', ( + tester, + theme, + c, + ) async { var pinned = true; await pumpNoo( tester, @@ -212,13 +391,19 @@ void main() { theme: theme, ); expect(find.text('Files'), findsOneWidget); - expect(decorationOf(tester, find.byIcon(LucideIcons.pin)).color, c.accentSoft); + expect( + decorationOf(tester, find.byIcon(LucideIcons.pin)).color, + c.accentSoft, + ); await tester.tap(find.byIcon(LucideIcons.pin)); await tester.pump(); expect(pinned, isFalse); expect(find.byIcon(LucideIcons.pin), findsNothing); - expect(decorationOf(tester, find.byIcon(LucideIcons.pinOff)).color, c.surface2); + expect( + decorationOf(tester, find.byIcon(LucideIcons.pinOff)).color, + c.surface2, + ); }); }); } From 800e8ebf44a5e84a278902590c794eb48288f35d Mon Sep 17 00:00:00 2001 From: ayushya Date: Mon, 28 Sep 2026 12:08:23 -0400 Subject: [PATCH 04/29] 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. --- .../context/design-system/DESIGN_SYSTEM.md | 8 +- lib/main.dart | 16 +- lib/widgets/noo/core/noo_fab.dart | 48 +++++- lib/widgets/noo/lists/noo_tab_order_row.dart | 14 +- lib/widgets/noo/nav/noo_bottom_bar.dart | 161 ++++++++++++------ lib/widgets/settings/settings_tabs.dart | 61 +++++-- lib/widgets/shell/shell_common.dart | 14 +- test/widgets/noo/core_test.dart | 142 ++++++++++++--- test/widgets/noo/nav_test.dart | 141 +++++++++++++++ 9 files changed, 488 insertions(+), 117 deletions(-) create mode 100644 test/widgets/noo/nav_test.dart diff --git a/.claude/context/design-system/DESIGN_SYSTEM.md b/.claude/context/design-system/DESIGN_SYSTEM.md index e5a2383..0f6cd9d 100644 --- a/.claude/context/design-system/DESIGN_SYSTEM.md +++ b/.claude/context/design-system/DESIGN_SYSTEM.md @@ -108,7 +108,11 @@ Use **Lucide** (pinned to `lucide-static@0.460.0`) at a 1.8 stroke. Sizes are 14 **FAB** (Android only) - An extended pill: 56px tall, accent fill, labelled "Upload". - Sits 16px from the right and bottom edges of the content area. -- Shows only on Files and Photos. +- Stays mounted on every tab; it's only the extended label that's tied to + Files/Photos. Elsewhere it collapses to an icon-only 56px circle (still + 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. **Chip** - A pill, 32–34px tall, 12px horizontal padding, Label 13–14. @@ -254,7 +258,7 @@ Mobile always has **5 pinned tabs in the bottom bar**. The rest go in the drawer |---|---|---| | Status bar | 54px, Dynamic Island | 40px | | Top bar | 44px row: `menu` on the left; `plus` and avatar on the right (all accent-text). Then a 34px large title, then the search field. | 64px: `menu`, 22px title, `search`, avatar | -| 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. The active icon sits in a 56×32 accent-soft pill with a 12px label. 20px gesture bar below. | +| Bottom bar | Surface fill, 1px top line. Icon 24 above a 10px label. Active: accent-text. Idle: fg-3. 34px home indicator below. | Surface fill, 80px. Icon 24 above a 12px label (label space is always reserved, just invisible when idle, so nothing shifts on selection). The active icon sits inside a 56×32 accent-soft pill that slides between tabs as one shared indicator, rather than popping in/out per tab. 20px gesture bar below. | | Upload | `plus` in the nav bar | Extended FAB | | 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. | diff --git a/lib/main.dart b/lib/main.dart index 93d27ec..5adeb99 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -74,8 +74,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) => @@ -481,9 +480,16 @@ class _MainShellViewState extends State { : null, drawer: pickRequest == null ? const AppDrawer() : null, // Android-only extended Upload FAB - iOS uses the top bar's `plus` - // instead (see AppTopBar). - floatingActionButton: canUpload && navStyle == NooNavStyle.android - ? NooFab(onTap: () => showCreateMenu(context)) + // instead (see AppTopBar). Stays mounted across every tab (picking + // aside) and collapses to an icon-only circle off Files/Photos, + // rather than the Scaffold popping it fully in/out on every tab + // switch - see NooFab's [collapsed]. + floatingActionButton: + pickRequest == null && navStyle == NooNavStyle.android + ? NooFab( + collapsed: !canUpload, + onTap: () => showCreateMenu(context), + ) : null, body: tabStack, bottomNavigationBar: BottomNavBar( diff --git a/lib/widgets/noo/core/noo_fab.dart b/lib/widgets/noo/core/noo_fab.dart index 4d79175..985313e 100644 --- a/lib/widgets/noo/core/noo_fab.dart +++ b/lib/widgets/noo/core/noo_fab.dart @@ -5,12 +5,24 @@ import '../../../theme/design_tokens.dart'; /// Android-only extended Upload pill, bottom-right of Files and Photos (Noo /// Design System project, `components/core/FAB.jsx`). 56px tall, accent /// fill - iOS uses a `plus` in the nav bar instead (not yet built). +/// +/// [collapsed] shrinks it to an icon-only circle (no label) on tabs that +/// don't support upload, so the button stays in place and resizes as the +/// user switches tabs instead of the Scaffold popping it fully in/out - +/// the label's width animates away rather than the FAB disappearing. class NooFab extends StatelessWidget { final String label; final IconData icon; final VoidCallback? onTap; + final bool collapsed; - const NooFab({super.key, this.label = 'Upload', this.icon = LucideIcons.plus, this.onTap}); + const NooFab({ + super.key, + this.label = 'Upload', + this.icon = LucideIcons.plus, + this.onTap, + this.collapsed = false, + }); @override Widget build(BuildContext context) { @@ -21,9 +33,16 @@ class NooFab extends StatelessWidget { child: InkWell( onTap: onTap, borderRadius: BorderRadius.circular(NooRadii.pill), - child: Container( + child: AnimatedContainer( + duration: NooMotion.base, + curve: NooMotion.ease, height: 56, - padding: const EdgeInsets.fromLTRB(16, 0, 20, 0), + padding: EdgeInsetsDirectional.fromSTEB( + 16, + 0, + collapsed ? 16 : 20, + 0, + ), // No `alignment:` here - Container without an explicit width // wraps its child in an Align when alignment is set, and Align // EXPANDS to fill all available bounded space (not just the @@ -36,8 +55,27 @@ class NooFab extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ Icon(icon, size: 24, color: Colors.white), - const SizedBox(width: 10), - Text(label, style: NooText.button.copyWith(color: Colors.white)), + ClipRect( + child: AnimatedSize( + duration: NooMotion.base, + curve: NooMotion.ease, + alignment: AlignmentDirectional.centerStart, + child: collapsed + ? const SizedBox(height: 24) + : Row( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(width: 10), + Text( + label, + style: NooText.button.copyWith( + color: Colors.white, + ), + ), + ], + ), + ), + ), ], ), ), diff --git a/lib/widgets/noo/lists/noo_tab_order_row.dart b/lib/widgets/noo/lists/noo_tab_order_row.dart index 0c41c6a..d1f276e 100644 --- a/lib/widgets/noo/lists/noo_tab_order_row.dart +++ b/lib/widgets/noo/lists/noo_tab_order_row.dart @@ -11,24 +11,36 @@ class NooTabOrderRow extends StatelessWidget { final bool pinned; final VoidCallback? onTogglePin; + /// This row's index inside its `ReorderableListView` - when set, only the + /// grip icon starts a drag (`ReorderableDragStartListener` wraps just the + /// icon, not the whole row), so a vertical drag/scroll starting anywhere + /// else on the row reaches the surrounding settings page's own scroll + /// view instead of being grabbed as a reorder. Null renders a plain, + /// non-interactive grip icon (e.g. a standalone preview/test). + final int? dragIndex; + const NooTabOrderRow({ super.key, required this.icon, required this.label, this.pinned = false, this.onTogglePin, + this.dragIndex, }); @override Widget build(BuildContext context) { final colors = context.nooColors; + final grip = Icon(LucideIcons.gripVertical, size: 18, color: colors.fg3); return Container( height: 52, padding: const EdgeInsets.symmetric(horizontal: 10), color: colors.surface, child: Row( children: [ - Icon(LucideIcons.gripVertical, size: 18, color: colors.fg3), + dragIndex == null + ? grip + : ReorderableDragStartListener(index: dragIndex!, child: grip), const SizedBox(width: 12), Icon(icon, size: 20, color: colors.fg2), const SizedBox(width: 12), diff --git a/lib/widgets/noo/nav/noo_bottom_bar.dart b/lib/widgets/noo/nav/noo_bottom_bar.dart index 44b98bb..eafe993 100644 --- a/lib/widgets/noo/nav/noo_bottom_bar.dart +++ b/lib/widgets/noo/nav/noo_bottom_bar.dart @@ -4,6 +4,12 @@ import 'noo_nav_style.dart'; export 'noo_nav_style.dart'; +/// Android indicator geometry, shared between the sliding pill and each +/// item's own icon box so they line up exactly. +const double _kAndroidPillTop = 14; +const double _kAndroidPillWidth = 56; +const double _kAndroidPillHeight = 32; + /// The mobile bottom bar (DESIGN_SYSTEM.md 3, "Mobile"; `iNav`/`aNav` in /// `Mobile Screen.dc.html`). Shows the 5 pinned tabs - which ones, and in /// what order, is the caller's business; this only draws [destinations]. @@ -11,9 +17,11 @@ export 'noo_nav_style.dart'; /// - [NooNavStyle.ios]: surface fill, 1px top `line`, a 50px row (8px top /// padding) of icon 24 over a 10px label. Active: accent-text, 600. /// Idle: fg-3, 500. No ripple. -/// - [NooNavStyle.android]: surface fill, 80px. The active icon sits in a -/// 56x32 accent-soft pill (accent-text icon); label is 12px, fg-1/600 -/// active and fg-2/500 idle (idle icon fg-2). +/// - [NooNavStyle.android]: surface fill, 80px. Every tab shows its 24px +/// icon over a 12px label (label only visible - not removed from layout, +/// so the row never resizes - once selected). The active icon sits in a +/// 56x32 accent-soft pill that slides between tabs as selection moves, +/// rather than popping in/out on the destination item itself. /// /// The home indicator / gesture bar area below the row comes from the /// bottom safe-area inset rather than a fixed 34/20px spacer, so it's @@ -37,25 +45,7 @@ class NooBottomBar extends StatelessWidget { final colors = context.nooColors; final ios = style == NooNavStyle.ios; - final row = Row( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - for (var i = 0; i < destinations.length; i++) - Expanded( - child: ios - ? _IosItem( - destination: destinations[i], - selected: i == selectedIndex, - onTap: () => onSelected(i), - ) - : _AndroidItem( - destination: destinations[i], - selected: i == selectedIndex, - onTap: () => onSelected(i), - ), - ), - ], - ); + final row = ios ? _buildIosRow() : _buildAndroidRow(colors); return Container( decoration: BoxDecoration( @@ -68,6 +58,63 @@ class NooBottomBar extends StatelessWidget { ), ); } + + Widget _buildIosRow() { + return Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (var i = 0; i < destinations.length; i++) + Expanded( + child: _IosItem( + destination: destinations[i], + selected: i == selectedIndex, + onTap: () => onSelected(i), + ), + ), + ], + ); + } + + Widget _buildAndroidRow(NooColors colors) { + return LayoutBuilder( + builder: (context, constraints) { + final itemWidth = constraints.maxWidth / destinations.length; + return Stack( + children: [ + AnimatedPositioned( + duration: NooMotion.base, + curve: NooMotion.ease, + top: _kAndroidPillTop, + left: + itemWidth * selectedIndex + + (itemWidth - _kAndroidPillWidth) / 2, + width: _kAndroidPillWidth, + height: _kAndroidPillHeight, + child: DecoratedBox( + decoration: BoxDecoration( + color: colors.accentSoft, + borderRadius: BorderRadius.circular(NooRadii.pill), + ), + ), + ), + Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (var i = 0; i < destinations.length; i++) + Expanded( + child: _AndroidItem( + destination: destinations[i], + selected: i == selectedIndex, + onTap: () => onSelected(i), + ), + ), + ], + ), + ], + ); + }, + ); + } } class _IosItem extends StatelessWidget { @@ -117,6 +164,11 @@ class _IosItem extends StatelessWidget { } } +/// Icon (over the shared sliding pill, drawn separately by [NooBottomBar]) +/// with its label always below it - the label's space is reserved +/// whether or not it's showing (only its opacity changes), so the icon +/// never shifts vertically as selection changes, and stays aligned with +/// the pill's fixed [_kAndroidPillTop]/[_kAndroidPillHeight]. class _AndroidItem extends StatelessWidget { final NooNavDestination destination; final bool selected; @@ -134,43 +186,42 @@ class _AndroidItem extends StatelessWidget { return Semantics( button: true, selected: selected, - // No ripple: the animated pill is the press/selection feedback. + label: destination.label, child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: onTap, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - AnimatedContainer( - duration: NooMotion.fast, - curve: NooMotion.ease, - width: 56, - height: 32, - alignment: Alignment.center, - decoration: BoxDecoration( - color: selected - ? colors.accentSoft - : colors.accentSoft.withValues(alpha: 0), - borderRadius: BorderRadius.circular(NooRadii.pill), - ), - child: Icon( - destination.icon, - size: 24, - color: selected ? colors.accentText : colors.fg2, - ), - ), - const SizedBox(height: 4), - Text( - destination.label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: (selected ? NooText.navLabelActive : NooText.navLabel) - .copyWith( - height: 1, - color: selected ? colors.fg1 : colors.fg2, + child: Padding( + padding: const EdgeInsets.only(top: _kAndroidPillTop), + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + SizedBox( + height: _kAndroidPillHeight, + child: Center( + child: Icon( + destination.icon, + size: 24, + color: selected ? colors.accentText : colors.fg2, ), - ), - ], + ), + ), + const SizedBox(height: 4), + AnimatedOpacity( + duration: NooMotion.base, + curve: NooMotion.ease, + opacity: selected ? 1 : 0, + child: Text( + destination.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: NooText.navLabelActive.copyWith( + height: 1, + color: colors.fg1, + ), + ), + ), + ], + ), ), ), ); diff --git a/lib/widgets/settings/settings_tabs.dart b/lib/widgets/settings/settings_tabs.dart index f95ea42..94ef147 100644 --- a/lib/widgets/settings/settings_tabs.dart +++ b/lib/widgets/settings/settings_tabs.dart @@ -15,8 +15,13 @@ import 'settings_section.dart'; class SettingsTabsSection extends StatelessWidget { const SettingsTabsSection({super.key}); - void _openDefaultTabPicker(BuildContext context, SettingsController settings) { - final visible = settings.tabOrder.where((t) => !settings.hiddenTabs.contains(t)).toList(); + void _openDefaultTabPicker( + BuildContext context, + SettingsController settings, + ) { + final visible = settings.tabOrder + .where((t) => !settings.hiddenTabs.contains(t)) + .toList(); showSettingsPicker( context, title: 'Default tab', @@ -26,7 +31,11 @@ class SettingsTabsSection extends StatelessWidget { icon: tab.icon, label: Text(tab.label), trailing: tab == settings.defaultTab - ? Icon(LucideIcons.check, size: 18, color: context.nooColors.accentText) + ? Icon( + LucideIcons.check, + size: 18, + color: context.nooColors.accentText, + ) : null, onTap: () { settings.setDefaultTab(tab); @@ -37,7 +46,12 @@ class SettingsTabsSection extends StatelessWidget { ); } - void _handleTogglePin(BuildContext context, SettingsController settings, AppTab tab, bool pinned) { + void _handleTogglePin( + BuildContext context, + SettingsController settings, + AppTab tab, + bool pinned, + ) { // setTabHidden's `hidden` argument is the *new* hidden state, which is // just the tab's current pinned (= visible) state flipped. final error = settings.setTabHidden(tab, pinned); @@ -66,7 +80,9 @@ class SettingsTabsSection extends StatelessWidget { NooSettingsRow( icon: LucideIcons.arrowUpDown, label: const Text('Tap tab to scroll to top'), - subtitle: const Text('Tapping the current bottom bar tab scrolls its list back to the top'), + subtitle: const Text( + 'Tapping the current bottom bar tab scrolls its list back to the top', + ), trailing: NooToggle( checked: settings.tapTabToScrollTop, onChanged: settings.setTapTabToScrollTop, @@ -83,15 +99,21 @@ class SettingsTabsSection extends StatelessWidget { tabs: settings.tabOrder, hiddenTabs: settings.hiddenTabs, flat: desktop, - onReorder: (oldIndex, newIndex) => _handleReorder(settings, oldIndex, newIndex), - onTogglePin: (tab, pinned) => _handleTogglePin(context, settings, tab, pinned), + onReorder: (oldIndex, newIndex) => + _handleReorder(settings, oldIndex, newIndex), + onTogglePin: (tab, pinned) => + _handleTogglePin(context, settings, tab, pinned), ); if (!desktop) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - SettingsSection(title: 'Tabs', subtitle: _subtitle, children: optionRows), + SettingsSection( + title: 'Tabs', + subtitle: _subtitle, + children: optionRows, + ), const SizedBox(height: NooSpace.md), orderList, ], @@ -164,21 +186,24 @@ class _TabOrderList extends StatelessWidget { return Padding( key: ValueKey(tab), padding: EdgeInsets.only(top: index > 0 ? 1 : 0), - child: ReorderableDragStartListener( - index: index, - child: NooTabOrderRow( - icon: tab.icon, - label: tab.label, - pinned: pinned, - onTogglePin: () => onTogglePin(tab, pinned), - ), + child: NooTabOrderRow( + icon: tab.icon, + label: tab.label, + pinned: pinned, + onTogglePin: () => onTogglePin(tab, pinned), + dragIndex: index, ), ); }, ); return ClipRRect( - borderRadius: BorderRadius.circular(flat ? NooRadii.input : NooRadii.card), - child: DecoratedBox(decoration: BoxDecoration(color: colors.line), child: list), + borderRadius: BorderRadius.circular( + flat ? NooRadii.input : NooRadii.card, + ), + child: DecoratedBox( + decoration: BoxDecoration(color: colors.line), + child: list, + ), ); } } diff --git a/lib/widgets/shell/shell_common.dart b/lib/widgets/shell/shell_common.dart index 9059680..77b25d4 100644 --- a/lib/widgets/shell/shell_common.dart +++ b/lib/widgets/shell/shell_common.dart @@ -211,10 +211,10 @@ class _AccountSwitchRow extends StatelessWidget { } } -/// The current user's avatar in the mobile top bar. Tap opens Settings; -/// swiping up/down on it cycles to the next/previous saved account -/// immediately, with no confirmation - a shortcut alongside the full -/// switcher (drawer chevron, Settings' account list). +/// The current user's avatar in the mobile top bar. Tap opens the accounts +/// sheet ([showAccountSwitcher]); swiping up/down on it cycles to the +/// next/previous saved account immediately, with no confirmation - a +/// shortcut alongside the full switcher. class ShellAvatarButton extends StatelessWidget { final double hitBox; @@ -246,17 +246,17 @@ class ShellAvatarButton extends StatelessWidget { Widget build(BuildContext context) { final session = context.watch(); return Tooltip( - message: 'Settings', + message: 'Accounts', child: Semantics( button: true, - label: 'Settings', + label: 'Accounts', child: GestureDetector( onVerticalDragEnd: (details) => _handleVerticalSwipe(context, details), child: SizedBox.square( dimension: hitBox, child: InkResponse( - onTap: () => openSettings(context), + onTap: () => showAccountSwitcher(context), radius: hitBox / 2, child: Center( child: NooAvatar( diff --git a/test/widgets/noo/core_test.dart b/test/widgets/noo/core_test.dart index 0720492..dc718f1 100644 --- a/test/widgets/noo/core_test.dart +++ b/test/widgets/noo/core_test.dart @@ -18,17 +18,39 @@ void main() { setUpNooTests(); group('NooAvatar', () { - testNooWidgets('current user uses accent-soft/accent-text', (tester, theme, c) async { - await pumpNoo(tester, const NooAvatar(initials: 'AY', current: true), theme: theme); + testNooWidgets('current user uses accent-soft/accent-text', ( + tester, + theme, + c, + ) async { + await pumpNoo( + tester, + const NooAvatar(initials: 'AY', current: true), + theme: theme, + ); expect(find.text('AY'), findsOneWidget); expect(decorationOf(tester, find.text('AY')).color, c.accentSoft); expect(tester.widget(find.text('AY')).style!.color, c.accentText); }); - testNooWidgets('palette avatar uses fixed dark text', (tester, theme, c) async { - await pumpNoo(tester, NooAvatar(initials: 'JD', color: NooColors.avatarPalette[3]), theme: theme); - expect(decorationOf(tester, find.text('JD')).color, NooColors.avatarPalette[3]); - expect(tester.widget(find.text('JD')).style!.color, NooColors.avatarTextColor); + testNooWidgets('palette avatar uses fixed dark text', ( + tester, + theme, + c, + ) async { + await pumpNoo( + tester, + NooAvatar(initials: 'JD', color: NooColors.avatarPalette[3]), + theme: theme, + ); + expect( + decorationOf(tester, find.text('JD')).color, + NooColors.avatarPalette[3], + ); + expect( + tester.widget(find.text('JD')).style!.color, + NooColors.avatarTextColor, + ); }); testWidgets('icon avatar and size', (tester) async { @@ -62,7 +84,11 @@ void main() { var taps = 0; await pumpNoo( tester, - NooButton(icon: LucideIcons.upload, onTap: () => taps++, child: const Text('Upload')), + NooButton( + icon: LucideIcons.upload, + onTap: () => taps++, + child: const Text('Upload'), + ), theme: theme, ); expect(decorationOf(tester, find.text('Upload')).color, c.accent); @@ -73,13 +99,18 @@ void main() { testWidgets('disabled ignores taps', (tester) async { var taps = 0; - await pumpNoo(tester, NooButton(disabled: true, onTap: () => taps++, child: const Text('No'))); + await pumpNoo( + tester, + NooButton(disabled: true, onTap: () => taps++, child: const Text('No')), + ); await tester.tap(find.text('No'), warnIfMissed: false); await tester.pumpAndSettle(); expect(taps, 0); }); - testWidgets('shrink-wraps unless fullWidth, heights follow size', (tester) async { + testWidgets('shrink-wraps unless fullWidth, heights follow size', ( + tester, + ) async { await pumpNoo( tester, const Column( @@ -118,14 +149,22 @@ void main() { }); group('NooChip', () { - testNooWidgets('selected vs idle fill, onTap fires', (tester, theme, c) async { + testNooWidgets('selected vs idle fill, onTap fires', ( + tester, + theme, + c, + ) async { var taps = 0; await pumpNoo( tester, Column( mainAxisSize: MainAxisSize.min, children: [ - NooChip(selected: true, onTap: () => taps++, child: const Text('Name')), + NooChip( + selected: true, + onTap: () => taps++, + child: const Text('Name'), + ), const NooChip(trailing: NooChipTrailing.menu, child: Text('Type')), ], ), @@ -138,9 +177,13 @@ void main() { }); testWidgets('outline chip is 32px with a line border', (tester) async { - await pumpNoo(tester, const NooChip(outline: true, child: Text('Modified'))); + await pumpNoo( + tester, + const NooChip(outline: true, child: Text('Modified')), + ); expect(tester.getSize(find.byType(NooChip)).height, 32); - final border = decorationOf(tester, find.text('Modified')).border as Border; + final border = + decorationOf(tester, find.text('Modified')).border as Border; expect(border.top.color, NooColors.light.line); }); }); @@ -155,12 +198,42 @@ void main() { await tester.tap(find.byType(NooFab)); expect(taps, 1); }); + + testNooWidgets('collapsed hides the label and shrinks to a circle', ( + tester, + theme, + c, + ) async { + var taps = 0; + await pumpNoo( + tester, + NooFab(collapsed: true, onTap: () => taps++), + theme: theme, + ); + await tester.pumpAndSettle(); + expect(find.text('Upload'), findsNothing); + final size = tester.getSize(find.byType(NooFab)); + expect(size.height, 56); + expect(size.width, 56); + await tester.tap(find.byType(NooFab)); + expect(taps, 1); + }); }); group('NooProgressBar', () { - testNooWidgets('clamps value and uses track/fill tokens', (tester, theme, c) async { - await pumpNoo(tester, const SizedBox(width: 200, child: NooProgressBar(value: 1.7)), theme: theme); - final bar = tester.widget(find.byType(LinearProgressIndicator)); + testNooWidgets('clamps value and uses track/fill tokens', ( + tester, + theme, + c, + ) async { + await pumpNoo( + tester, + const SizedBox(width: 200, child: NooProgressBar(value: 1.7)), + theme: theme, + ); + final bar = tester.widget( + find.byType(LinearProgressIndicator), + ); expect(bar.value, 1.0); expect(bar.backgroundColor, c.surface3); expect(bar.valueColor!.value, c.accent); @@ -169,11 +242,18 @@ void main() { }); group('NooSearchField', () { - testNooWidgets('placeholder, typing, surface fill', (tester, theme, c) async { + testNooWidgets('placeholder, typing, surface fill', ( + tester, + theme, + c, + ) async { String? typed; await pumpNoo( tester, - SizedBox(width: 260, child: NooSearchField(onSurface: true, onChanged: (v) => typed = v)), + SizedBox( + width: 260, + child: NooSearchField(onSurface: true, onChanged: (v) => typed = v), + ), theme: theme, ); expect(find.text('Search'), findsOneWidget); @@ -198,7 +278,11 @@ void main() { ); } - testNooWidgets('tapping a segment moves the active fill', (tester, theme, c) async { + testNooWidgets('tapping a segment moves the active fill', ( + tester, + theme, + c, + ) async { await pumpNoo(tester, host(), theme: theme); expect(decorationOf(tester, find.text('List')).color, c.accentSoft); expect(tester.widget(find.text('Grid')).style!.color, c.fg2); @@ -224,12 +308,19 @@ void main() { ); expect(find.byType(Text), findsNothing); // 2 x 36 wide segments + 3px track padding each side. - expect(tester.getSize(find.byType(NooSegmentedControl)), const Size(78, 34)); + expect( + tester.getSize(find.byType(NooSegmentedControl)), + const Size(78, 34), + ); }); }); group('NooToggle', () { - testNooWidgets('tapping flips state and track colour', (tester, theme, c) async { + testNooWidgets('tapping flips state and track colour', ( + tester, + theme, + c, + ) async { var on = false; await pumpNoo( tester, @@ -240,9 +331,12 @@ void main() { theme: theme, ); expect(tester.getSize(find.byType(NooToggle)), const Size(48, 28)); - Color track() => (tester.widget(find.byType(AnimatedContainer)).decoration! - as BoxDecoration) - .color!; + Color track() => + (tester + .widget(find.byType(AnimatedContainer)) + .decoration! + as BoxDecoration) + .color!; expect(track(), c.surface3); await tester.tap(find.byType(NooToggle)); diff --git a/test/widgets/noo/nav_test.dart b/test/widgets/noo/nav_test.dart new file mode 100644 index 0000000..3b5df93 --- /dev/null +++ b/test/widgets/noo/nav_test.dart @@ -0,0 +1,141 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:noo/theme/design_tokens.dart'; +import 'package:noo/widgets/noo/nav/noo_bottom_bar.dart'; + +import 'noo_test_utils.dart'; + +const _destinations = [ + NooNavDestination(icon: LucideIcons.folder, label: 'Files'), + NooNavDestination(icon: LucideIcons.images, label: 'Photos'), + NooNavDestination(icon: LucideIcons.star, label: 'Favorites'), +]; + +/// The single sliding accent-soft indicator `NooBottomBar` draws behind the +/// Android row - there should only ever be one, never a per-item pill. +Finder _indicator(WidgetTester tester, NooColors c) => find.byWidgetPredicate( + (w) => + w is DecoratedBox && + w.decoration is BoxDecoration && + (w.decoration as BoxDecoration).color == c.accentSoft, +); + +void main() { + setUpNooTests(); + + group('NooBottomBar', () { + testNooWidgets('android: label sits below the icon, not beside it', ( + tester, + theme, + c, + ) async { + await pumpNoo( + tester, + SizedBox( + width: 360, + height: 80, + child: NooBottomBar( + style: NooNavStyle.android, + destinations: _destinations, + selectedIndex: 0, + onSelected: (_) {}, + ), + ), + theme: theme, + ); + final iconCenter = tester.getCenter(find.byIcon(LucideIcons.folder)); + final labelCenter = tester.getCenter(find.text('Files')); + expect(labelCenter.dy, greaterThan(iconCenter.dy)); + expect((labelCenter.dx - iconCenter.dx).abs(), lessThan(1)); + }); + + testNooWidgets( + 'android: exactly one indicator, it slides to the tapped tab', + (tester, theme, c) async { + var selected = 0; + await pumpNoo( + tester, + StatefulBuilder( + builder: (context, setState) => SizedBox( + width: 360, + height: 80, + child: NooBottomBar( + style: NooNavStyle.android, + destinations: _destinations, + selectedIndex: selected, + onSelected: (i) => setState(() => selected = i), + ), + ), + ), + theme: theme, + ); + expect(_indicator(tester, c), findsOneWidget); + final startX = tester.getTopLeft(_indicator(tester, c)).dx; + + await tester.tap(find.text('Favorites')); + await tester.pumpAndSettle(); + expect(_indicator(tester, c), findsOneWidget); + expect( + tester.getTopLeft(_indicator(tester, c)).dx, + greaterThan(startX), + ); + }, + ); + + testNooWidgets('android: idle labels stay laid out but invisible', ( + tester, + theme, + c, + ) async { + await pumpNoo( + tester, + SizedBox( + width: 360, + height: 80, + child: NooBottomBar( + style: NooNavStyle.android, + destinations: _destinations, + selectedIndex: 0, + onSelected: (_) {}, + ), + ), + theme: theme, + ); + // Idle labels are still in the tree (fixed row height, no jump when + // selection changes) - just invisible. + final opacity = tester.widget( + find.ancestor( + of: find.text('Photos'), + matching: find.byType(AnimatedOpacity), + ), + ); + expect(opacity.opacity, 0); + }); + + testNooWidgets('ios: unchanged icon-above-label layout, no indicator', ( + tester, + theme, + c, + ) async { + await pumpNoo( + tester, + SizedBox( + width: 360, + height: 50, + child: NooBottomBar( + style: NooNavStyle.ios, + destinations: _destinations, + selectedIndex: 1, + onSelected: (_) {}, + ), + ), + theme: theme, + ); + expect(_indicator(tester, c), findsNothing); + final iconCenter = tester.getCenter(find.byIcon(LucideIcons.images)); + final labelCenter = tester.getCenter(find.text('Photos')); + expect(labelCenter.dy, greaterThan(iconCenter.dy)); + }); + }); +} From 254db255a7a65a2d5e06513a53e91d476f8008a7 Mon Sep 17 00:00:00 2001 From: ayushya Date: Mon, 28 Sep 2026 12:21:36 -0400 Subject: [PATCH 05/29] 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. --- .../context/design-system/DESIGN_SYSTEM.md | 11 +- lib/models/selection_action.dart | 75 ++++++++++ lib/providers/settings_controller.dart | 41 ++++++ lib/views/account_view.dart | 29 +++- lib/views/favorites_view.dart | 13 +- lib/views/files_view.dart | 16 ++- lib/views/photos_view.dart | 15 +- lib/widgets/noo/lists/noo_tab_order_row.dart | 37 +++-- lib/widgets/settings/settings_action_bar.dart | 132 ++++++++++++++++++ test/widgets/noo/lists_test.dart | 5 +- 10 files changed, 347 insertions(+), 27 deletions(-) create mode 100644 lib/widgets/settings/settings_action_bar.dart diff --git a/.claude/context/design-system/DESIGN_SYSTEM.md b/.claude/context/design-system/DESIGN_SYSTEM.md index 0f6cd9d..1ab5159 100644 --- a/.claude/context/design-system/DESIGN_SYSTEM.md +++ b/.claude/context/design-system/DESIGN_SYSTEM.md @@ -178,7 +178,9 @@ multi-select) 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. + 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. @@ -317,7 +319,12 @@ Sidebar items are 38px tall with radius 12, an 18px icon and a 14/500 label. The 5. Files cache 6. Appearance 7. Tabs - 8. Swipe on a file + 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 uses one column of grouped lists. Desktop uses a 2-column grid of cards with a 1px line and radius 20. - **Share sheet / dialog:** sections in this order: diff --git a/lib/models/selection_action.dart b/lib/models/selection_action.dart index 4ca4411..1df0a9a 100644 --- a/lib/models/selection_action.dart +++ b/lib/models/selection_action.dart @@ -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 orderSelectionActions( + List actions, + List order, +) { + final byKind = {for (final a in actions) a.kind: a}; + final ordered = []; + for (final kind in order) { + final action = byKind.remove(kind); + if (action != null) ordered.add(action); + } + ordered.addAll(byKind.values); + return ordered; +} diff --git a/lib/providers/settings_controller.dart b/lib/providers/settings_controller.dart index f330885..8517fa1 100644 --- a/lib/providers/settings_controller.dart +++ b/lib/providers/settings_controller.dart @@ -1,6 +1,7 @@ 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'; /// What swiping a Files list-view item left/right does, user-configurable @@ -24,6 +25,7 @@ class SettingsController extends ChangeNotifier { static const _prefUseDynamicColor = 'ui_use_dynamic_color'; static const _prefSeedColor = 'ui_seed_color'; static const _prefTabOrder = 'ui_tab_order'; + static const _prefSelectionActionOrder = 'ui_selection_action_order'; static const _prefHiddenTabs = 'ui_hidden_tabs'; static const _prefDefaultTab = 'ui_default_tab'; static const _prefSwipeLeftAction = 'ui_swipe_left_action'; @@ -47,6 +49,9 @@ class SettingsController extends ChangeNotifier { Set _hiddenTabs = {}; AppTab _defaultTab = AppTab.files; + List _selectionActionOrder = SelectionActionKind.values + .toList(); + SwipeAction _swipeLeftAction = SwipeAction.delete; SwipeAction _swipeRightAction = SwipeAction.favorite; @@ -86,6 +91,12 @@ class SettingsController extends ChangeNotifier { Set 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 get selectionActionOrder => _selectionActionOrder; + /// The tabs the bottom nav bar should actually show, in order. List get visibleTabs => _tabOrder.where((t) => !_hiddenTabs.contains(t)).toList(); @@ -134,6 +145,25 @@ class SettingsController extends ChangeNotifier { _tabOrder = order; } + final savedActionOrderNames = prefs.getStringList( + _prefSelectionActionOrder, + ); + if (savedActionOrderNames != null) { + final order = []; + 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 @@ -234,6 +264,17 @@ class SettingsController extends ChangeNotifier { ); } + void setSelectionActionOrder(List 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. diff --git a/lib/views/account_view.dart b/lib/views/account_view.dart index a294ac7..5861d5e 100644 --- a/lib/views/account_view.dart +++ b/lib/views/account_view.dart @@ -5,6 +5,7 @@ import '../widgets/noo/nav/noo_toolbar.dart'; import '../widgets/noo/noo_layout.dart'; import '../widgets/settings/settings_account_card.dart'; import '../widgets/settings/settings_accounts.dart'; +import '../widgets/settings/settings_action_bar.dart'; import '../widgets/settings/settings_appearance.dart'; import '../widgets/settings/settings_file_sync.dart'; import '../widgets/settings/settings_files_cache.dart'; @@ -12,9 +13,11 @@ import '../widgets/settings/settings_security.dart'; import '../widgets/settings/settings_swipe.dart'; import '../widgets/settings/settings_tabs.dart'; -/// Settings, pushed on top of the shell (DESIGN_SYSTEM.md 4's 8-section +/// Settings, pushed on top of the shell (DESIGN_SYSTEM.md 4's 9-section /// order: account card, accounts, security, file sync, files cache, -/// appearance, tabs, swipe on a file). One column of [SettingsSection]s on +/// appearance, tabs, action bar, swipe on a file). One column of +/// [SettingsSection]s (and [SettingsActionBarSection], which has no option +/// rows of its own to put in one - just the reorder list) on /// mobile; a 2-column grid of cards on desktop - see each /// `widgets/settings/*.dart` file for a section's own content and any /// setting that had to be slotted in or grouped under "Advanced appearance". @@ -29,6 +32,7 @@ class AccountView extends StatelessWidget { SettingsFilesCacheSection(), SettingsAppearanceSection(), SettingsTabsSection(), + SettingsActionBarSection(), SettingsSwipeSection(), ]; @@ -48,7 +52,9 @@ class AccountView extends StatelessWidget { ), body: SafeArea( top: false, - child: desktop ? _DesktopGrid(sections: _sections) : _MobileList(sections: _sections), + child: desktop + ? _DesktopGrid(sections: _sections) + : _MobileList(sections: _sections), ), ); } @@ -61,10 +67,18 @@ class _MobileList extends StatelessWidget { @override Widget build(BuildContext context) { return ListView( - padding: const EdgeInsets.fromLTRB(NooSpace.sm, NooSpace.sm, NooSpace.sm, NooSpace.xxl), + padding: const EdgeInsets.fromLTRB( + NooSpace.sm, + NooSpace.sm, + NooSpace.sm, + NooSpace.xxl, + ), physics: const BouncingScrollPhysics(), children: [ - for (final section in sections) ...[section, const SizedBox(height: NooSpace.xl)], + for (final section in sections) ...[ + section, + const SizedBox(height: NooSpace.xl), + ], ], ); } @@ -112,7 +126,10 @@ class _Column extends StatelessWidget { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - for (final child in children) ...[child, const SizedBox(height: NooSpace.xl)], + for (final child in children) ...[ + child, + const SizedBox(height: NooSpace.xl), + ], ], ); } diff --git a/lib/views/favorites_view.dart b/lib/views/favorites_view.dart index 56404a5..e27eab2 100644 --- a/lib/views/favorites_view.dart +++ b/lib/views/favorites_view.dart @@ -96,13 +96,15 @@ class _FavoritesViewState extends State { /// The bulk actions shown in the sticky selection toolbar for the /// currently-selected items - same set Files/Photos offer. List _buildSelectionActions(List selected) { - return [ + final actions = [ SelectionAction( + kind: SelectionActionKind.favorite, icon: LucideIcons.starOff, label: 'Remove from favorites', onTap: () => _unfavoriteSelected(selected), ), SelectionAction( + kind: SelectionActionKind.share, icon: LucideIcons.share2, label: 'Share', onTap: () => selected.length == 1 @@ -110,32 +112,41 @@ class _FavoritesViewState extends State { : _shareSelected(context, selected), ), SelectionAction( + kind: SelectionActionKind.download, icon: LucideIcons.download, label: 'Download', onTap: () => _downloadSelected(context, selected), ), SelectionAction( + kind: SelectionActionKind.delete, icon: LucideIcons.trash2, label: 'Delete', onTap: () => _confirmDeleteSelected(context, selected), ), SelectionAction( + kind: SelectionActionKind.copy, icon: LucideIcons.copy, label: 'Copy', onTap: () => _moveOrCopySelected(selected, copy: true), ), SelectionAction( + kind: SelectionActionKind.move, icon: LucideIcons.folderInput, label: 'Move', onTap: () => _moveOrCopySelected(selected, copy: false), ), if (selected.length == 1) SelectionAction( + kind: SelectionActionKind.details, icon: LucideIcons.info, label: 'Details', onTap: () => DetailsSheet.show(context, selected.single), ), ]; + return orderSelectionActions( + actions, + context.read().selectionActionOrder, + ); } Future _unfavoriteSelected(List items) async { diff --git a/lib/views/files_view.dart b/lib/views/files_view.dart index 568032f..a8cf266 100644 --- a/lib/views/files_view.dart +++ b/lib/views/files_view.dart @@ -419,6 +419,7 @@ class _FilesViewState extends State if (pick.isPicking) { return [ SelectionAction( + kind: SelectionActionKind.favorite, icon: LucideIcons.check, label: 'Use ${selected.length} item(s)', onTap: () => pick.confirmPick(selected), @@ -427,13 +428,15 @@ class _FilesViewState extends State } final allFavorited = selected.every((i) => i.isFavorite); final allSynced = selected.every((i) => sync.isPathSynced(i.path)); - return [ + final actions = [ SelectionAction( + kind: SelectionActionKind.favorite, icon: allFavorited ? LucideIcons.starOff : LucideIcons.star, label: allFavorited ? 'Remove from favorites' : 'Favorite', onTap: () => _favoriteSelected(context, selected), ), SelectionAction( + kind: SelectionActionKind.share, icon: LucideIcons.share2, label: 'Share', onTap: () => selected.length == 1 @@ -441,43 +444,54 @@ class _FilesViewState extends State : _shareSelected(context, selected), ), SelectionAction( + kind: SelectionActionKind.download, icon: LucideIcons.download, label: 'Download', onTap: () => _downloadSelected(context, selected), ), SelectionAction( + kind: SelectionActionKind.delete, icon: LucideIcons.trash2, label: 'Delete', onTap: () => _confirmDeleteSelected(context, selected), ), SelectionAction( + kind: SelectionActionKind.copy, icon: LucideIcons.copy, label: 'Copy', onTap: () => _moveOrCopySelected(context, selected, copy: true), ), SelectionAction( + kind: SelectionActionKind.move, icon: LucideIcons.folderInput, label: 'Move', onTap: () => _moveOrCopySelected(context, selected, copy: false), ), if (selected.length == 1) SelectionAction( + kind: SelectionActionKind.rename, icon: LucideIcons.filePen, label: 'Rename', onTap: () => _renameItem(selected.single), ), SelectionAction( + kind: SelectionActionKind.sync, icon: LucideIcons.hardDriveDownload, label: allSynced ? 'Stop syncing to device' : 'Sync to device', onTap: () => _toggleSyncSelected(context, sync, selected), ), if (selected.length == 1) SelectionAction( + kind: SelectionActionKind.details, icon: LucideIcons.info, label: 'Details', onTap: () => DetailsSheet.show(context, selected.single), ), ]; + return orderSelectionActions( + actions, + context.read().selectionActionOrder, + ); } Future _renameItem(NextcloudItem item) async { diff --git a/lib/views/photos_view.dart b/lib/views/photos_view.dart index cf5e9b3..40dcc67 100644 --- a/lib/views/photos_view.dart +++ b/lib/views/photos_view.dart @@ -11,6 +11,7 @@ import '../providers/item_operations.dart'; import '../providers/photos_controller.dart'; import '../providers/pick_controller.dart'; import '../providers/session_controller.dart'; +import '../providers/settings_controller.dart'; import '../services/download_service.dart'; import '../theme/design_tokens.dart'; import '../widgets/details/details_sheet.dart'; @@ -113,6 +114,7 @@ class _PhotosViewState extends State { if (pick.isPicking) { return [ SelectionAction( + kind: SelectionActionKind.favorite, icon: LucideIcons.check, label: 'Use ${selected.length} item(s)', onTap: () => pick.confirmPick(selected), @@ -123,13 +125,15 @@ class _PhotosViewState extends State { // uses for favorite/unfavorite (see `files_view.dart`) rather than a // filled heart. final allFavorited = selected.every((i) => i.isFavorite); - return [ + final actions = [ SelectionAction( + kind: SelectionActionKind.favorite, icon: allFavorited ? LucideIcons.starOff : LucideIcons.star, label: allFavorited ? 'Remove from favorites' : 'Favorite', onTap: () => _favoriteSelected(selected), ), SelectionAction( + kind: SelectionActionKind.share, icon: LucideIcons.share2, label: 'Share', onTap: () => selected.length == 1 @@ -137,32 +141,41 @@ class _PhotosViewState extends State { : _shareSelected(context, selected), ), SelectionAction( + kind: SelectionActionKind.download, icon: LucideIcons.download, label: 'Download', onTap: () => _downloadSelected(context, selected), ), SelectionAction( + kind: SelectionActionKind.delete, icon: LucideIcons.trash2, label: 'Delete', onTap: () => _confirmDeleteSelected(context, selected), ), SelectionAction( + kind: SelectionActionKind.copy, icon: LucideIcons.copy, label: 'Copy', onTap: () => _moveOrCopySelected(selected, copy: true), ), SelectionAction( + kind: SelectionActionKind.move, icon: LucideIcons.folderInput, label: 'Move', onTap: () => _moveOrCopySelected(selected, copy: false), ), if (selected.length == 1) SelectionAction( + kind: SelectionActionKind.details, icon: LucideIcons.info, label: 'Details', onTap: () => DetailsSheet.show(context, selected.single), ), ]; + return orderSelectionActions( + actions, + context.read().selectionActionOrder, + ); } /// Splits an already-filtered/sorted list into contiguous month runs diff --git a/lib/widgets/noo/lists/noo_tab_order_row.dart b/lib/widgets/noo/lists/noo_tab_order_row.dart index d1f276e..9a74740 100644 --- a/lib/widgets/noo/lists/noo_tab_order_row.dart +++ b/lib/widgets/noo/lists/noo_tab_order_row.dart @@ -11,6 +11,11 @@ class NooTabOrderRow extends StatelessWidget { final bool pinned; final VoidCallback? onTogglePin; + /// False hides the trailing pin toggle entirely - for reorder-only lists + /// (Settings' Action bar section) that have no "pinned" concept, just an + /// order. + final bool showPin; + /// This row's index inside its `ReorderableListView` - when set, only the /// grip icon starts a drag (`ReorderableDragStartListener` wraps just the /// icon, not the whole row), so a vertical drag/scroll starting anywhere @@ -25,6 +30,7 @@ class NooTabOrderRow extends StatelessWidget { required this.label, this.pinned = false, this.onTogglePin, + this.showPin = true, this.dragIndex, }); @@ -50,23 +56,24 @@ class NooTabOrderRow extends StatelessWidget { style: NooText.bodyL.copyWith(height: 1, color: colors.fg1), ), ), - GestureDetector( - onTap: onTogglePin, - child: Container( - width: 36, - height: 36, - alignment: Alignment.center, - decoration: BoxDecoration( - color: pinned ? colors.accentSoft : colors.surface2, - shape: BoxShape.circle, - ), - child: Icon( - pinned ? LucideIcons.pin : LucideIcons.pinOff, - size: 16, - color: pinned ? colors.accentText : colors.fg3, + if (showPin) + GestureDetector( + onTap: onTogglePin, + child: Container( + width: 36, + height: 36, + alignment: Alignment.center, + decoration: BoxDecoration( + color: pinned ? colors.accentSoft : colors.surface2, + shape: BoxShape.circle, + ), + child: Icon( + pinned ? LucideIcons.pin : LucideIcons.pinOff, + size: 16, + color: pinned ? colors.accentText : colors.fg3, + ), ), ), - ), ], ), ); diff --git a/lib/widgets/settings/settings_action_bar.dart b/lib/widgets/settings/settings_action_bar.dart new file mode 100644 index 0000000..46e9225 --- /dev/null +++ b/lib/widgets/settings/settings_action_bar.dart @@ -0,0 +1,132 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../../models/selection_action.dart'; +import '../../providers/settings_controller.dart'; +import '../../theme/design_tokens.dart'; +import '../noo/lists/noo_tab_order_row.dart'; +import '../noo/noo_layout.dart'; + +/// Settings section: the priority order bulk actions (favorite, share, +/// download, ...) show in on the Files/Photos/Favorites multi-select +/// action bar. Every action stays available everywhere - this only decides +/// which ones land in `NooSelectionBar`'s fixed inline slots (3 mobile / 4 +/// desktop, see its own doc comment) vs. behind its trailing "More" - +/// there's no pin/hide concept here the way Tabs has, so this is just a +/// reorder list, not a whole section of its own option rows. +class SettingsActionBarSection extends StatelessWidget { + const SettingsActionBarSection({super.key}); + + static const _subtitle = + 'Drag to reorder - the first ones shown appear directly in the bar, the rest sit behind "More"'; + + void _handleReorder(SettingsController settings, int oldIndex, int newIndex) { + final order = List.from(settings.selectionActionOrder); + final kind = order.removeAt(oldIndex); + order.insert(newIndex, kind); + settings.setSelectionActionOrder(order); + } + + @override + Widget build(BuildContext context) { + final settings = context.watch(); + final desktop = NooLayout.isDesktop(context); + + final orderList = _ActionOrderList( + order: settings.selectionActionOrder, + onReorder: (oldIndex, newIndex) => + _handleReorder(settings, oldIndex, newIndex), + ); + + final colors = context.nooColors; + + if (!desktop) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Text( + 'Action bar', + style: NooText.label.copyWith(color: colors.fg2), + ), + ), + const SizedBox(height: 4), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Text( + _subtitle, + style: NooText.meta.copyWith(color: colors.fg3), + ), + ), + const SizedBox(height: 8), + orderList, + ], + ); + } + + return Container( + decoration: BoxDecoration( + color: colors.surface, + border: Border.all(color: colors.line), + borderRadius: BorderRadius.circular(NooRadii.card), + ), + padding: const EdgeInsets.all(NooSpace.lg), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Action bar', + style: NooText.cardTitle.copyWith(color: colors.fg1), + ), + const SizedBox(height: 4), + Text(_subtitle, style: NooText.meta.copyWith(color: colors.fg3)), + const SizedBox(height: NooSpace.md), + orderList, + ], + ), + ); + } +} + +/// The reorderable [NooTabOrderRow] stack for [SelectionActionKind]s - same +/// radius-20, line-divided card `settings_tabs.dart`'s `_TabOrderList` +/// draws, minus the pin toggle (`showPin: false`): there's nothing to +/// show/hide here, only an order. +class _ActionOrderList extends StatelessWidget { + final List order; + final void Function(int oldIndex, int newIndex) onReorder; + + const _ActionOrderList({required this.order, required this.onReorder}); + + @override + Widget build(BuildContext context) { + final colors = context.nooColors; + final list = ReorderableListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + buildDefaultDragHandles: false, + itemCount: order.length, + onReorderItem: onReorder, + itemBuilder: (context, index) { + final kind = order[index]; + return Padding( + key: ValueKey(kind), + padding: EdgeInsets.only(top: index > 0 ? 1 : 0), + child: NooTabOrderRow( + icon: kind.icon, + label: kind.label, + showPin: false, + dragIndex: index, + ), + ); + }, + ); + return ClipRRect( + borderRadius: BorderRadius.circular(NooRadii.card), + child: DecoratedBox( + decoration: BoxDecoration(color: colors.line), + child: list, + ), + ); + } +} diff --git a/test/widgets/noo/lists_test.dart b/test/widgets/noo/lists_test.dart index 233b93f..1966eca 100644 --- a/test/widgets/noo/lists_test.dart +++ b/test/widgets/noo/lists_test.dart @@ -14,12 +14,15 @@ import 'package:noo/widgets/noo/lists/noo_tab_order_row.dart'; import 'noo_test_utils.dart'; /// [n] actions labelled A, B, C, ... each incrementing an entry in [taps] -/// keyed by its own label when tapped. +/// keyed by its own label when tapped. [n] must be at most +/// `SelectionActionKind.values.length` - each generated action needs its +/// own distinct kind. List _actions(int n, Map taps) => List.generate(n, (i) { final label = String.fromCharCode(65 + i); taps[label] = 0; return SelectionAction( + kind: SelectionActionKind.values[i], icon: LucideIcons.star, label: label, onTap: () => taps[label] = taps[label]! + 1, From c9889c69b80e9a92df3bdf0bf93212552aa0d39d Mon Sep 17 00:00:00 2001 From: ayushya Date: Mon, 28 Sep 2026 13:02:31 -0400 Subject: [PATCH 06/29] 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. --- .claude/context/styling.md | 2 +- lib/views/shares_view.dart | 52 ++++++--- lib/views/trash_view.dart | 78 ++++++++----- lib/widgets/files_controls_row.dart | 170 ++++++++++++++++------------ 4 files changed, 187 insertions(+), 115 deletions(-) diff --git a/.claude/context/styling.md b/.claude/context/styling.md index a13fe00..37116af 100644 --- a/.claude/context/styling.md +++ b/.claude/context/styling.md @@ -78,7 +78,7 @@ Catalog: | Folder | Components | |---|---| | `core/` | `NooButton`, `NooFab`, `NooChip`, `NooSegmentedControl`, `NooToggle`, `NooSearchField`, `NooAvatar`, `NooBadge`, `NooProgressBar` | -| `lists/` | `NooGroupedList`, `NooSettingsRow`, `NooTabOrderRow`, `NooBanner`, `NooSummaryCard` | +| `lists/` | `NooGroupedList`, `NooSettingsRow`, `NooTabOrderRow`, `NooBanner`, `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` | diff --git a/lib/views/shares_view.dart b/lib/views/shares_view.dart index f7d4e60..3d398a4 100644 --- a/lib/views/shares_view.dart +++ b/lib/views/shares_view.dart @@ -11,7 +11,6 @@ import '../widgets/noo/core/noo_segmented_control.dart'; import '../widgets/noo/files/noo_file_kind.dart'; import '../widgets/noo/files/noo_file_row.dart'; import '../widgets/noo/files/noo_file_table.dart'; -import '../widgets/noo/lists/noo_grouped_list.dart'; import '../widgets/noo/noo_layout.dart'; import '../widgets/tabs/tab_state_slivers.dart'; @@ -162,24 +161,47 @@ class _SharesViewState extends State { c.setSharedWithMe(scope == _ShareScope.withYou); } + // A lazily-built `SliverList`, not `NooGroupedList` (its own `Column` + // isn't lazy - see files_view.dart's `_buildMobileRow` doc comment, and + // trash_view.dart's `_buildMobileList`, which had the same bug: a heavy + // account's full share list built eagerly up front). Each row still + // reads as one continuous radius-20 card via per-row corner rounding + + // a 1px `line` divider. Widget _buildMobileList(BuildContext context, List shares) { + final colors = context.nooColors; return SliverPadding( padding: const EdgeInsets.symmetric(horizontal: NooSpace.sm), - sliver: SliverToBoxAdapter( - child: NooGroupedList( - children: [ - for (final share in shares) - NooFileRow( - kind: NooFileKind.from(name: share.name, isDirectory: share.isFolder), - name: share.name, - meta: '${share.ownerDisplayName} · ${_permissionLabel(share.permissions)}', - iosStyle: NooLayout.iosStyle(context), - onTap: share.isFolder ? () => _openFolder(context, share) : null, - trailing: Icon(_shareTypeIcon(share.shareType), size: 16), - onMore: () => _confirmUnshare(context, share), + sliver: SliverList( + delegate: SliverChildBuilderDelegate((context, index) { + final share = shares[index]; + final row = NooFileRow( + kind: NooFileKind.from(name: share.name, isDirectory: share.isFolder), + name: share.name, + meta: '${share.ownerDisplayName} · ${_permissionLabel(share.permissions)}', + iosStyle: NooLayout.iosStyle(context), + onTap: share.isFolder ? () => _openFolder(context, share) : null, + trailing: Icon(_shareTypeIcon(share.shareType), size: 16), + onMore: () => _confirmUnshare(context, share), + ); + final isFirst = index == 0; + final isLast = index == shares.length - 1; + return Column( + children: [ + ClipRRect( + borderRadius: BorderRadius.vertical( + top: isFirst + ? const Radius.circular(NooRadii.card) + : Radius.zero, + bottom: isLast + ? const Radius.circular(NooRadii.card) + : Radius.zero, + ), + child: row, ), - ], - ), + if (!isLast) Container(height: 1, color: colors.line), + ], + ); + }, childCount: shares.length), ), ); } diff --git a/lib/views/trash_view.dart b/lib/views/trash_view.dart index 339f01a..666c8f9 100644 --- a/lib/views/trash_view.dart +++ b/lib/views/trash_view.dart @@ -11,7 +11,6 @@ import '../widgets/noo/files/noo_file_row.dart'; import '../widgets/noo/files/noo_file_table.dart'; import '../widgets/noo/files/noo_file_tile.dart'; import '../widgets/noo/lists/noo_banner.dart'; -import '../widgets/noo/lists/noo_grouped_list.dart'; import '../widgets/noo/noo_layout.dart'; import '../widgets/tabs/tab_location.dart'; import '../widgets/tabs/tab_state_slivers.dart'; @@ -103,38 +102,61 @@ class _TrashViewState extends State { ); } + // A lazily-built `SliverList`, not `NooGroupedList` (its own `Column` + // isn't lazy - see files_view.dart's `_buildMobileRow` doc comment for + // the same reasoning): Trash can hold hundreds of items, and building + // every row eagerly up front is what made this tab laggy. Each row still + // reads as one continuous radius-20 card via per-row corner rounding + + // a 1px `line` divider, matching `NooGroupedList`'s look. Widget _buildMobileList(BuildContext context, List trash) { + final colors = context.nooColors; return SliverPadding( padding: const EdgeInsets.symmetric(horizontal: NooSpace.sm), - sliver: SliverToBoxAdapter( - child: NooGroupedList( - children: [ - for (final item in trash) - NooFileRow( - kind: NooFileKind.from( - name: item.name, - mimeType: item.mimeType, - isDirectory: item.isFolder, + sliver: SliverList( + delegate: SliverChildBuilderDelegate((context, index) { + final item = trash[index]; + final row = NooFileRow( + kind: NooFileKind.from( + name: item.name, + mimeType: item.mimeType, + isDirectory: item.isFolder, + ), + name: item.name, + meta: _meta(item), + iosStyle: NooLayout.iosStyle(context), + trailing: SizedBox.square( + dimension: 40, + child: Semantics( + button: true, + label: 'Restore', + child: InkResponse( + onTap: () => _restore(context, item), + radius: 20, + child: const Icon(LucideIcons.rotateCcw), ), - name: item.name, - meta: _meta(item), - iosStyle: NooLayout.iosStyle(context), - trailing: SizedBox.square( - dimension: 40, - child: Semantics( - button: true, - label: 'Restore', - child: InkResponse( - onTap: () => _restore(context, item), - radius: 20, - child: const Icon(LucideIcons.rotateCcw), - ), - ), - ), - onMore: () => _confirmDeleteForever(context, item), ), - ], - ), + ), + onMore: () => _confirmDeleteForever(context, item), + ); + final isFirst = index == 0; + final isLast = index == trash.length - 1; + return Column( + children: [ + ClipRRect( + borderRadius: BorderRadius.vertical( + top: isFirst + ? const Radius.circular(NooRadii.card) + : Radius.zero, + bottom: isLast + ? const Radius.circular(NooRadii.card) + : Radius.zero, + ), + child: row, + ), + if (!isLast) Container(height: 1, color: colors.line), + ], + ); + }, childCount: trash.length), ), ); } diff --git a/lib/widgets/files_controls_row.dart b/lib/widgets/files_controls_row.dart index 553724c..3d90c22 100644 --- a/lib/widgets/files_controls_row.dart +++ b/lib/widgets/files_controls_row.dart @@ -93,33 +93,49 @@ class FilesControlsRow extends StatelessWidget { showNooSheet( context, children: [ - NooSegmentedControl( - fill: true, - value: files.sortAscendingFor(folderPath), - onChanged: (asc) { - if (asc != files.sortAscendingFor(folderPath)) { - files.toggleSortOrderFor(folderPath); - } - }, - options: const [ - NooSegmentOption(value: true, icon: LucideIcons.arrowUp, label: 'Ascending'), - NooSegmentOption(value: false, icon: LucideIcons.arrowDown, label: 'Descending'), - ], - ), - NooGroupedList( - children: [ - for (final field in FileSortField.values) - NooSettingsRow( - label: Text(sortFieldLabel(field)), - trailing: field == files.sortFieldFor(folderPath) - ? Icon(LucideIcons.check, size: 18, color: context.nooColors.accentText) - : null, - onTap: () { - files.setSortFieldFor(folderPath, field); - Navigator.pop(context); + // `showNooSheet`'s `children` are built once, up front - a bare + // checkmark/value here would freeze at whatever it was when the + // sheet opened, since tapping a row calls `files.set...` and + // notifies the controller, not this already-built widget tree. + // `ListenableBuilder` re-runs its `builder` on every + // `FilesController.notifyListeners()` instead, so the selection + // updates live - see the identical fix in `_showFilterSheet`. + ListenableBuilder( + listenable: files, + builder: (context, _) => Column( + mainAxisSize: MainAxisSize.min, + children: [ + NooSegmentedControl( + fill: true, + value: files.sortAscendingFor(folderPath), + onChanged: (asc) { + if (asc != files.sortAscendingFor(folderPath)) { + files.toggleSortOrderFor(folderPath); + } }, + options: const [ + NooSegmentOption(value: true, icon: LucideIcons.arrowUp, label: 'Ascending'), + NooSegmentOption(value: false, icon: LucideIcons.arrowDown, label: 'Descending'), + ], ), - ], + const SizedBox(height: 22), + NooGroupedList( + children: [ + for (final field in FileSortField.values) + NooSettingsRow( + label: Text(sortFieldLabel(field)), + trailing: field == files.sortFieldFor(folderPath) + ? Icon(LucideIcons.check, size: 18, color: context.nooColors.accentText) + : null, + onTap: () { + files.setSortFieldFor(folderPath, field); + Navigator.pop(context); + }, + ), + ], + ), + ], + ), ), ], ); @@ -129,54 +145,66 @@ class FilesControlsRow extends StatelessWidget { showNooSheet( context, children: [ - NooGroupedList( - label: 'Show', - children: [ - NooSettingsRow( - label: const Text('Files and folders'), - trailing: files.filesTypeFilter == FilesTypeFilter.all - ? Icon(LucideIcons.check, size: 18, color: context.nooColors.accentText) - : null, - onTap: () => files.setFilesTypeFilter(FilesTypeFilter.all), - ), - NooSettingsRow( - label: const Text('Files only'), - trailing: files.filesTypeFilter == FilesTypeFilter.filesOnly - ? Icon(LucideIcons.check, size: 18, color: context.nooColors.accentText) - : null, - onTap: () => files.setFilesTypeFilter(FilesTypeFilter.filesOnly), - ), - NooSettingsRow( - label: const Text('Folders only'), - trailing: files.filesTypeFilter == FilesTypeFilter.foldersOnly - ? Icon(LucideIcons.check, size: 18, color: context.nooColors.accentText) - : null, - onTap: () => files.setFilesTypeFilter(FilesTypeFilter.foldersOnly), - ), - ], - ), - NooGroupedList( - children: [ - NooSettingsRow( - icon: LucideIcons.eye, - label: const Text('Show hidden files'), - trailing: NooToggle( - checked: files.showHiddenFiles, - onChanged: (_) => files.toggleShowHiddenFiles(), - ), - ), - if (showStorageScope) - NooSettingsRow( - icon: LucideIcons.hardDrive, - label: const Text('External storage'), - trailing: NooToggle( - checked: files.storageScope == StorageScope.external, - onChanged: (external) => files.setStorageScope( - external ? StorageScope.external : StorageScope.cloud, + // See `_showSortSheet`'s comment: without this, none of the + // checkmarks/toggles below would move until the sheet is closed + // and reopened. + ListenableBuilder( + listenable: files, + builder: (context, _) => Column( + mainAxisSize: MainAxisSize.min, + children: [ + NooGroupedList( + label: 'Show', + children: [ + NooSettingsRow( + label: const Text('Files and folders'), + trailing: files.filesTypeFilter == FilesTypeFilter.all + ? Icon(LucideIcons.check, size: 18, color: context.nooColors.accentText) + : null, + onTap: () => files.setFilesTypeFilter(FilesTypeFilter.all), ), - ), + NooSettingsRow( + label: const Text('Files only'), + trailing: files.filesTypeFilter == FilesTypeFilter.filesOnly + ? Icon(LucideIcons.check, size: 18, color: context.nooColors.accentText) + : null, + onTap: () => files.setFilesTypeFilter(FilesTypeFilter.filesOnly), + ), + NooSettingsRow( + label: const Text('Folders only'), + trailing: files.filesTypeFilter == FilesTypeFilter.foldersOnly + ? Icon(LucideIcons.check, size: 18, color: context.nooColors.accentText) + : null, + onTap: () => files.setFilesTypeFilter(FilesTypeFilter.foldersOnly), + ), + ], ), - ], + const SizedBox(height: 22), + NooGroupedList( + children: [ + NooSettingsRow( + icon: LucideIcons.eye, + label: const Text('Show hidden files'), + trailing: NooToggle( + checked: files.showHiddenFiles, + onChanged: (_) => files.toggleShowHiddenFiles(), + ), + ), + if (showStorageScope) + NooSettingsRow( + icon: LucideIcons.hardDrive, + label: const Text('External storage'), + trailing: NooToggle( + checked: files.storageScope == StorageScope.external, + onChanged: (external) => files.setStorageScope( + external ? StorageScope.external : StorageScope.cloud, + ), + ), + ), + ], + ), + ], + ), ), ], ); From 72472c771a4e2441539893c332e2ac46bf3f8ce1 Mon Sep 17 00:00:00 2001 From: ayushya Date: Mon, 28 Sep 2026 13:11:11 -0400 Subject: [PATCH 07/29] 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. --- lib/providers/settings_controller.dart | 2 +- lib/theme/app_theme.dart | 14 ++++- lib/theme/design_tokens.dart | 48 ++++++++++---- lib/widgets/settings/settings_appearance.dart | 62 ++++++++++++++----- test/widgets/noo/core_test.dart | 40 ++++++++++++ test/widgets/noo/noo_test_utils.dart | 17 +++-- 6 files changed, 149 insertions(+), 34 deletions(-) diff --git a/lib/providers/settings_controller.dart b/lib/providers/settings_controller.dart index 8517fa1..d5d247a 100644 --- a/lib/providers/settings_controller.dart +++ b/lib/providers/settings_controller.dart @@ -37,7 +37,7 @@ class SettingsController extends ChangeNotifier { final Future _prefsFuture = SharedPreferences.getInstance(); - Color _seedColor = AppTheme.defaultNextcloudBlue; + Color _seedColor = AppTheme.defaultAccent; ThemeMode _themeMode = ThemeMode.system; bool _useDynamicColor = true; bool _amoledDark = false; diff --git a/lib/theme/app_theme.dart b/lib/theme/app_theme.dart index 5db2bb6..749fcfb 100644 --- a/lib/theme/app_theme.dart +++ b/lib/theme/app_theme.dart @@ -5,6 +5,15 @@ import 'design_tokens.dart'; class AppTheme { static const Color defaultNextcloudBlue = Color(0xFF0082C9); + /// The design system's own fixed violet - [NooColors.light]/`.dark`'s + /// `accent`, and the accent picker's "Default" swatch. Also + /// [SettingsController]'s initial [SettingsController.seedColor], so a + /// fresh install (or any device without Material You dynamic color) keeps + /// today's violet look rather than picking up the leftover + /// [defaultNextcloudBlue] default from before seed colors actually did + /// anything to the Noo-styled UI (see [NooColors.fromSeed]). + static const Color defaultAccent = Color(0xFF8D0DE3); + // Flutter's Material 3 default (ZoomPageTransitionsBuilder) doesn't // implement Android's predictive-back gesture at all. Opting into // PredictiveBackPageTransitionsBuilder here (Android only) is required — @@ -24,6 +33,7 @@ class AppTheme { static const _sliderTheme = SliderThemeData(year2023: false); static const List seedColors = [ + defaultAccent, // Default (Noo Violet) Color(0xFF0082C9), // Nextcloud Blue Color(0xFF009688), // Ocean Teal Color(0xFF6750A4), // Deep Purple @@ -87,7 +97,7 @@ class AppTheme { ); final nooColors = (useDynamicColor && dynamicScheme != null) ? NooColors.fromDynamicScheme(dynamicScheme) - : NooColors.light; + : NooColors.fromSeed(seedColor, Brightness.light); return ThemeData( useMaterial3: true, @@ -131,7 +141,7 @@ class AppTheme { ); var nooColors = (useDynamicColor && dynamicScheme != null) ? NooColors.fromDynamicScheme(dynamicScheme) - : NooColors.dark; + : NooColors.fromSeed(seedColor, Brightness.dark); if (amoled) { // True OLED black across every surface tone Material 3 hands out - diff --git a/lib/theme/design_tokens.dart b/lib/theme/design_tokens.dart index 58c9d2c..1cc7b1c 100644 --- a/lib/theme/design_tokens.dart +++ b/lib/theme/design_tokens.dart @@ -7,13 +7,13 @@ import 'package:google_fonts/google_fonts.dart'; /// `Theme.of(context).extension()!` (or a `context.nooColors` /// getter, once one exists) instead of the Material [ColorScheme] roles. /// -/// Two independent sources feed this, matching the app's color settings: -/// the fixed [light]/[dark] palettes below (the design system's own -/// default look), or [NooColors.fromDynamicScheme] when the user has -/// Material You dynamic color turned on - there's no third option (no -/// custom accent picker), since the design system explicitly rules out -/// "add new colors". [withAmoled] layers on top of either source when the -/// user's AMOLED toggle is on. +/// Three sources feed this, matching the app's color settings: the fixed +/// [light]/[dark] palettes below (the design system's own default look, +/// also what the accent picker's "Default" violet swatch selects), +/// [NooColors.fromDynamicScheme] when the user has Material You dynamic +/// color turned on, or [NooColors.fromSeed] for a custom accent-picker +/// swatch. [withAmoled] layers on top of any of the three when the user's +/// AMOLED toggle is on. @immutable class NooColors extends ThemeExtension { final Color bg; @@ -161,6 +161,34 @@ class NooColors extends ThemeExtension { ); } + /// Derives an accent-only variant of the fixed [light]/[dark] palette for + /// a user-chosen custom seed color (Settings' accent-color picker) - + /// unlike [fromDynamicScheme], only the accent roles change; bg/surface/ + /// fg etc. stay the Noo warm-neutral palette, since picking a named + /// accent ("Ocean Teal") is meant to retint the brand color, not reshape + /// the whole surface to match a wallpaper the way dynamic color does. + /// [seedColor] equal to the fixed palette's own accent (the picker's + /// "Default" violet swatch) returns the untouched fixed palette, so the + /// out-of-box look stays pixel-identical rather than going through + /// [ColorScheme.fromSeed]'s tonal-palette math for no reason. + factory NooColors.fromSeed(Color seedColor, Brightness brightness) { + final fixed = brightness == Brightness.dark ? dark : light; + if (seedColor == light.accent) return fixed; + final cs = ColorScheme.fromSeed( + seedColor: seedColor, + brightness: brightness, + ); + return fixed.copyWith( + accent: seedColor, + // Light accentText matches accent exactly, same as the fixed + // palette - dark needs a lighter, desaturated tint for contrast + // against a dark surface, which is exactly what Material's tonal + // primary (tone ~80 in dark mode) is designed to give. + accentText: brightness == Brightness.dark ? cs.primary : seedColor, + accentSoft: cs.primaryContainer, + ); + } + /// True OLED black across every surface tone - not just [bg] - so cards, /// bars and sheets all go black too instead of the dark palette's warm /// dark-grey tones. Mirrors the AMOLED handling this replaces in @@ -391,10 +419,8 @@ class NooText { fontWeight: FontWeight.w400, ); - static TextStyle get label => GoogleFonts.instrumentSans( - fontSize: 13, - fontWeight: FontWeight.w600, - ); + static TextStyle get label => + GoogleFonts.instrumentSans(fontSize: 13, fontWeight: FontWeight.w600); static TextStyle get meta => GoogleFonts.instrumentSans( fontSize: 13, diff --git a/lib/widgets/settings/settings_appearance.dart b/lib/widgets/settings/settings_appearance.dart index 8c24209..26d333e 100644 --- a/lib/widgets/settings/settings_appearance.dart +++ b/lib/widgets/settings/settings_appearance.dart @@ -28,12 +28,13 @@ String _seekBarStyleLabel(MediaProgressBarStyle style) { /// Settings section 6: appearance. Only the theme mode (System/Light/Dark) /// is in the design system's own recipe - the accent-color picker, dynamic -/// color, AMOLED and the video seek-bar style are all settings that predate -/// the design system and have no place in it (a single fixed accent, no -/// per-user color choice). They still work, just demoted into a separate -/// "Advanced appearance" group beneath the main card per the rebuild brief, -/// rather than dropped - see the handoff report for whether to remove them -/// outright. +/// color, AMOLED and the video seek-bar style predate the design system, so +/// they're demoted into a separate "Advanced appearance" group beneath the +/// main card rather than folded into it. The accent picker itself picks a +/// [NooColors] variant (see [NooColors.fromSeed]/[NooColors.fromDynamicScheme]): +/// "Default" is the fixed violet palette, the named swatches retint just +/// the accent roles, and "Match wallpaper" (the `wallpaper`-icon swatch) +/// follows Material You dynamic color instead. class SettingsAppearanceSection extends StatelessWidget { const SettingsAppearanceSection({super.key}); @@ -55,9 +56,21 @@ class SettingsAppearanceSection extends StatelessWidget { value: settings.themeMode, onChanged: settings.setThemeMode, options: const [ - NooSegmentOption(value: ThemeMode.system, icon: LucideIcons.monitor, label: 'System'), - NooSegmentOption(value: ThemeMode.light, icon: LucideIcons.sun, label: 'Light'), - NooSegmentOption(value: ThemeMode.dark, icon: LucideIcons.moon, label: 'Dark'), + NooSegmentOption( + value: ThemeMode.system, + icon: LucideIcons.monitor, + label: 'System', + ), + NooSegmentOption( + value: ThemeMode.light, + icon: LucideIcons.sun, + label: 'Light', + ), + NooSegmentOption( + value: ThemeMode.dark, + icon: LucideIcons.moon, + label: 'Dark', + ), ], ), ), @@ -70,20 +83,33 @@ class SettingsAppearanceSection extends StatelessWidget { NooSettingsRow( icon: LucideIcons.palette, label: const Text('Accent color'), - subtitle: Text(settings.useDynamicColor ? 'Matching your wallpaper' : 'Custom color'), - trailing: _AccentSwatchDot(color: settings.useDynamicColor ? null : settings.seedColor), + subtitle: Text( + settings.useDynamicColor + ? 'Matching your wallpaper' + : settings.seedColor == AppTheme.defaultAccent + ? 'Default' + : 'Custom color', + ), + trailing: _AccentSwatchDot( + color: settings.useDynamicColor ? null : settings.seedColor, + ), onTap: () => _openAccentPicker(context, settings), ), NooSettingsRow( icon: LucideIcons.sparkles, label: const Text('AMOLED black'), subtitle: const Text('Use pure black backgrounds in dark mode'), - trailing: NooToggle(checked: settings.amoledDark, onChanged: settings.setAmoledDark), + trailing: NooToggle( + checked: settings.amoledDark, + onChanged: settings.setAmoledDark, + ), ), NooSettingsRow( icon: LucideIcons.waves, label: const Text('Seek bar style'), - subtitle: const Text('The progress bar style used when playing videos'), + subtitle: const Text( + 'The progress bar style used when playing videos', + ), value: _seekBarStyleLabel(settings.mediaProgressBarStyle), onTap: () => _openSeekBarPicker(context, settings), ), @@ -112,7 +138,9 @@ class _AccentSwatchDot extends StatelessWidget { color: color ?? colors.surface3, shape: BoxShape.circle, ), - child: color == null ? Icon(LucideIcons.wallpaper, size: 12, color: colors.fg2) : null, + child: color == null + ? Icon(LucideIcons.wallpaper, size: 12, color: colors.fg2) + : null, ); } } @@ -219,7 +247,11 @@ class _SeekBarStyleOption extends StatelessWidget { final bool isSelected; final VoidCallback onTap; - const _SeekBarStyleOption({required this.style, required this.isSelected, required this.onTap}); + const _SeekBarStyleOption({ + required this.style, + required this.isSelected, + required this.onTap, + }); @override Widget build(BuildContext context) { diff --git a/test/widgets/noo/core_test.dart b/test/widgets/noo/core_test.dart index dc718f1..564b9c4 100644 --- a/test/widgets/noo/core_test.dart +++ b/test/widgets/noo/core_test.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:noo/theme/app_theme.dart'; import 'package:noo/theme/design_tokens.dart'; import 'package:noo/widgets/noo/core/noo_avatar.dart'; import 'package:noo/widgets/noo/core/noo_badge.dart'; @@ -17,6 +18,45 @@ import 'noo_test_utils.dart'; void main() { setUpNooTests(); + group('NooColors.fromSeed', () { + test('the default accent returns the untouched fixed palette', () { + expect( + identical( + NooColors.fromSeed(AppTheme.defaultAccent, Brightness.light), + NooColors.light, + ), + isTrue, + ); + expect( + identical( + NooColors.fromSeed(AppTheme.defaultAccent, Brightness.dark), + NooColors.dark, + ), + isTrue, + ); + }); + + test('a custom seed retints only the accent roles', () { + const seed = Color(0xFF009688); // Ocean Teal + final light = NooColors.fromSeed(seed, Brightness.light); + expect(light.accent, seed); + expect(light.accentText, seed); + expect(light.accentSoft, isNot(NooColors.light.accentSoft)); + // The rest of the palette is untouched - a named accent choice + // retints the brand color, it doesn't reshape the surface. + expect(light.bg, NooColors.light.bg); + expect(light.surface, NooColors.light.surface); + expect(light.fg1, NooColors.light.fg1); + + final dark = NooColors.fromSeed(seed, Brightness.dark); + expect(dark.accent, seed); + // Dark accentText is a lighter/desaturated tint for contrast, not + // the raw seed itself. + expect(dark.accentText, isNot(seed)); + expect(dark.bg, NooColors.dark.bg); + }); + }); + group('NooAvatar', () { testNooWidgets('current user uses accent-soft/accent-text', ( tester, diff --git a/test/widgets/noo/noo_test_utils.dart b/test/widgets/noo/noo_test_utils.dart index 574776c..184e5d0 100644 --- a/test/widgets/noo/noo_test_utils.dart +++ b/test/widgets/noo/noo_test_utils.dart @@ -15,10 +15,13 @@ void setUpNooTests() { /// Themes built through the real [AppTheme] (non-dynamic), so the /// [NooColors] extension is registered exactly as in the app. Built lazily /// inside test bodies - AppTheme touches Google Fonts, which needs the test -/// binding to exist first. +/// binding to exist first. Seeded with [AppTheme.defaultAccent] (not just +/// any color) so component tests exercise the exact fixed [NooColors.light]/ +/// `.dark` palette their color assertions are written against, rather than +/// a [NooColors.fromSeed] retint - see its doc comment. ThemeData nooTheme(Brightness brightness) => brightness == Brightness.light - ? AppTheme.light(AppTheme.defaultNextcloudBlue, useDynamicColor: false) - : AppTheme.dark(AppTheme.defaultNextcloudBlue, useDynamicColor: false); + ? AppTheme.light(AppTheme.defaultAccent, useDynamicColor: false) + : AppTheme.dark(AppTheme.defaultAccent, useDynamicColor: false); Future pumpNoo( WidgetTester tester, @@ -43,7 +46,8 @@ Future pumpNoo( /// Runs [body] once per theme, each as its own named test. void testNooWidgets( String description, - Future Function(WidgetTester tester, ThemeData theme, NooColors colors) body, + Future Function(WidgetTester tester, ThemeData theme, NooColors colors) + body, ) { for (final brightness in Brightness.values) { testWidgets('$description (${brightness.name})', (tester) async { @@ -59,7 +63,10 @@ BoxDecoration decorationOf(WidgetTester tester, Finder finder) { final boxes = find.ancestor( of: finder, matching: find.byWidgetPredicate( - (w) => w is DecoratedBox && w.decoration is BoxDecoration && (w.decoration as BoxDecoration).color != null, + (w) => + w is DecoratedBox && + w.decoration is BoxDecoration && + (w.decoration as BoxDecoration).color != null, ), matchRoot: true, ); From 1b8bbaa22f35ac34183c7cc50b29a0151dcd66ac Mon Sep 17 00:00:00 2001 From: ayushya Date: Mon, 28 Sep 2026 17:37:03 -0400 Subject: [PATCH 08/29] 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). --- .claude/context/server.md | 9 +++++++++ lib/services/nextcloud_service.dart | 21 +++++++++++++++++---- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/.claude/context/server.md b/.claude/context/server.md index 5887f71..8fb0f78 100644 --- a/.claude/context/server.md +++ b/.claude/context/server.md @@ -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. diff --git a/lib/services/nextcloud_service.dart b/lib/services/nextcloud_service.dart index ed3b0fe..3d673f3 100644 --- a/lib/services/nextcloud_service.dart +++ b/lib/services/nextcloud_service.dart @@ -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. @@ -356,7 +369,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 +684,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 +848,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 +1013,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; From 8d17ae1f4b0f73a9232b22c3db25b00f12860b87 Mon Sep 17 00:00:00 2001 From: ayushya Date: Mon, 28 Sep 2026 17:49:26 -0400 Subject: [PATCH 09/29] 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. --- lib/theme/design_tokens.dart | 9 +++++++-- lib/widgets/details/details_sheet.dart | 20 ++++++++++++++------ test/widgets/noo/core_test.dart | 16 ++++++++++++++++ 3 files changed, 37 insertions(+), 8 deletions(-) diff --git a/lib/theme/design_tokens.dart b/lib/theme/design_tokens.dart index 1cc7b1c..6cb33e8 100644 --- a/lib/theme/design_tokens.dart +++ b/lib/theme/design_tokens.dart @@ -133,7 +133,12 @@ class NooColors extends ThemeExtension { /// palettes. `success`/`successSoft`/`warning`/`warningSoft`/`info`/ /// `infoSoft` have no Material dynamic equivalent and are content-identity /// colors regardless (file-type tiles) rather than brand colors, so they - /// always come from the fixed palette matching [cs]'s brightness. + /// always come from the fixed palette matching [cs]'s brightness. Same + /// for `scrim`: Material 3's `ColorScheme.scrim` role is fully *opaque* + /// black (`0xFF000000`), meant to be used at reduced opacity by whoever + /// paints it, not painted directly - using it as-is for a sheet/dialog's + /// `barrierColor` hid the screen behind completely instead of dimming it, + /// so this uses the fixed palette's already-translucent scrim instead. factory NooColors.fromDynamicScheme(ColorScheme cs) { final fixed = cs.brightness == Brightness.dark ? dark : light; return NooColors( @@ -157,7 +162,7 @@ class NooColors extends ThemeExtension { warningSoft: fixed.warningSoft, info: fixed.info, infoSoft: fixed.infoSoft, - scrim: cs.scrim, + scrim: fixed.scrim, ); } diff --git a/lib/widgets/details/details_sheet.dart b/lib/widgets/details/details_sheet.dart index 7ffa9e9..740f1d0 100644 --- a/lib/widgets/details/details_sheet.dart +++ b/lib/widgets/details/details_sheet.dart @@ -96,7 +96,12 @@ class DetailsSheet extends StatelessWidget { /// [StatefulWidget] rather than three fields on [DetailsSheet] so the /// selection survives independently of how the header/container around it /// is built (mobile embeds this once, desktop hands it to [showNooDialog] -/// directly). +/// directly). All three tabs are built once and kept mounted in an +/// [IndexedStack] rather than swapped in/out of the tree - Versions and +/// Activity each fetch on their own first build (see their `_requested` +/// guard), so switching tabs via a bare `switch` on the selected type would +/// tear down and rebuild whichever tab isn't showing, discarding its +/// fetched data and re-requesting it every time the user switched back. class _DetailsBody extends StatefulWidget { final NextcloudItem item; @@ -138,11 +143,14 @@ class _DetailsBodyState extends State<_DetailsBody> { ], ), const SizedBox(height: NooSpace.lg), - switch (_tab) { - _DetailsTab.info => DetailsInfoTab(item: widget.item), - _DetailsTab.versions => DetailsVersionsTab(item: widget.item), - _DetailsTab.activity => DetailsActivityTab(item: widget.item), - }, + IndexedStack( + index: _tab.index, + children: [ + DetailsInfoTab(item: widget.item), + DetailsVersionsTab(item: widget.item), + DetailsActivityTab(item: widget.item), + ], + ), ], ); } diff --git a/test/widgets/noo/core_test.dart b/test/widgets/noo/core_test.dart index 564b9c4..30f04d2 100644 --- a/test/widgets/noo/core_test.dart +++ b/test/widgets/noo/core_test.dart @@ -57,6 +57,22 @@ void main() { }); }); + group('NooColors.fromDynamicScheme', () { + test('scrim comes from the fixed palette, not the opaque dynamic role', () { + // Material 3's ColorScheme.scrim is fully opaque black - using it + // directly as a sheet/dialog barrierColor hid the screen behind + // completely instead of dimming it. + final dynamicScheme = ColorScheme.fromSeed( + seedColor: const Color(0xFF009688), + brightness: Brightness.light, + ); + expect(dynamicScheme.scrim.a, 1.0); + final nooColors = NooColors.fromDynamicScheme(dynamicScheme); + expect(nooColors.scrim, NooColors.light.scrim); + expect(nooColors.scrim.a, lessThan(1.0)); + }); + }); + group('NooAvatar', () { testNooWidgets('current user uses accent-soft/accent-text', ( tester, From c0f702b00e5162916657b9c8eaca039eeffc5a47 Mon Sep 17 00:00:00 2001 From: ayushya Date: Mon, 28 Sep 2026 18:16:44 -0400 Subject: [PATCH 10/29] 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. --- .../context/design-system/DESIGN_SYSTEM.md | 16 +- lib/views/file_viewer_screen.dart | 32 +-- lib/widgets/noo/overlays/noo_sheet.dart | 146 +++++++++++--- lib/widgets/viewer/media_action_bar.dart | 11 +- test/widgets/noo/overlays_test.dart | 186 +++++++++++++++--- 5 files changed, 310 insertions(+), 81 deletions(-) diff --git a/.claude/context/design-system/DESIGN_SYSTEM.md b/.claude/context/design-system/DESIGN_SYSTEM.md index 1ab5159..55f3de6 100644 --- a/.claude/context/design-system/DESIGN_SYSTEM.md +++ b/.claude/context/design-system/DESIGN_SYSTEM.md @@ -340,12 +340,16 @@ Sidebar items are 38px tall with radius 12, an 18px icon and a 14/500 label. The 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): a black stage - regardless of theme, like a native photo/video viewer - not `bg`. A - translucent, blurred top bar (back, filename, meta) and bottom bar float - over the media; 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. Back is a plain arrow (`arrow-left`), not the iOS +- **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 fixed + dark panel regardless of theme (it needs to read over arbitrary photo/ + video brightness, not over `bg`); 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. 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 diff --git a/lib/views/file_viewer_screen.dart b/lib/views/file_viewer_screen.dart index 1dc4df4..66ec85b 100644 --- a/lib/views/file_viewer_screen.dart +++ b/lib/views/file_viewer_screen.dart @@ -376,14 +376,13 @@ class _FileViewerScreenState extends State { final session = context.read(); final ops = context.read(); final colors = context.nooColors; - // The stage behind the media itself is pure black rather than `bg` - - // the near-universal "letterbox" convention of photo/video viewers - // (matting an image/video in the app's warm neutral background reads - // as unfinished, and black also hides any letterboxing from - // `BoxFit.contain`/`AspectRatio` at the screen edges). PDFs/text/ - // unsupported-file previews aren't "viewed" the same way - they're - // read, so they stay on the normal `bg` like any other screen. - final stageColor = _isSwipeable ? Colors.black : colors.bg; + // The stage follows the app's own theme (`bg`) rather than being pinned + // to black - a black stage in light mode read as jarringly out of place + // next to the rest of the light-themed app. The floating top/action + // bars stay a fixed dark frosted panel regardless (see + // FrostedGlassContainer's doc comment) since they need to read over + // arbitrary photo/video brightness, not over this background. + final stageColor = colors.bg; return Scaffold( backgroundColor: stageColor, @@ -437,9 +436,13 @@ class _FileViewerScreenState extends State { offset: _controlsVisible ? Offset.zero : const Offset(0, -1.4), child: IgnorePointer( ignoring: !_controlsVisible, - child: SafeArea( - bottom: false, - child: FrostedGlassContainer( + // The blurred/tinted background lives outside the SafeArea + // (not inside it) so it extends up behind the status bar + // instead of stopping short beneath it - see the matching + // fix/comment on MediaActionBar's own SafeArea. + child: FrostedGlassContainer( + child: SafeArea( + bottom: false, child: Padding( padding: const EdgeInsets.symmetric( horizontal: 6, @@ -447,10 +450,9 @@ class _FileViewerScreenState extends State { ), child: IconTheme.merge( // Fixed white, not `colors.fg1`: this bar always - // sits on the dark translucent panel over a black - // media stage (see `FrostedGlassContainer`'s doc - // comment), regardless of the app's light/dark - // theme. + // sits on the dark translucent panel over the media + // stage (see `FrostedGlassContainer`'s doc comment), + // regardless of the app's light/dark theme. data: const IconThemeData( color: Colors.white, size: 24, diff --git a/lib/widgets/noo/overlays/noo_sheet.dart b/lib/widgets/noo/overlays/noo_sheet.dart index 78fbfb1..f23c9eb 100644 --- a/lib/widgets/noo/overlays/noo_sheet.dart +++ b/lib/widgets/noo/overlays/noo_sheet.dart @@ -1,13 +1,23 @@ import 'package:flutter/material.dart'; import '../../../theme/design_tokens.dart'; +/// Fraction of the available height a sheet can reach before it switches +/// from sizing to its content to a draggable peek (see [_NooSheetBody]). +const double _kNooSheetPeekFraction = 0.6; +const double _kNooSheetMinFraction = 0.3; +const double _kNooSheetMaxFraction = 0.95; + /// Mobile bottom sheet on a scrim: radius-28 top, grabber, 22px gap between /// sections (Noo Design System project, `components/overlays/Sheet.jsx`). /// `showModalBottomSheet` already supplies the scrim/backdrop-dismiss /// machinery, so this only standardizes the shape/padding - use it instead -/// of a bare `showModalBottomSheet` for any new sheet. Scroll-controlled so a -/// tall sheet (e.g. share) can grow past half the screen; content scrolls -/// once it hits the top. +/// of a bare `showModalBottomSheet` for any new sheet. Sizes to its content +/// like a plain scroll view for anything that fits comfortably on screen +/// (most sheets: a menu, a handful of settings rows); content long enough to +/// need scrolling (Details' Versions/Activity tabs with many rows, a long +/// accounts list, ...) instead opens at a native-style peek height the user +/// can drag up to reveal more of, rather than snapping straight to nearly +/// the full screen with nothing left to adjust - see [_NooSheetBody]. Future showNooSheet( BuildContext context, { required List children, @@ -20,31 +30,111 @@ Future showNooSheet( isScrollControlled: true, useSafeArea: true, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(NooRadii.sheetTop)), + borderRadius: BorderRadius.vertical( + top: Radius.circular(NooRadii.sheetTop), + ), ), - builder: (context) { - return SafeArea( - child: SingleChildScrollView( - padding: const EdgeInsets.fromLTRB(20, 10, 20, 20), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: 36, - height: 5, - decoration: BoxDecoration( - color: colors.surface3, - borderRadius: BorderRadius.circular(NooRadii.pill), - ), - ), - for (final child in children) ...[ - const SizedBox(height: 22), - child, - ], - ], - ), - ), - ); - }, + builder: (context) => _NooSheetBody(children: children), ); } + +/// Renders [children] once, unconstrained, to measure their natural height +/// against the sheet's available height. Content that fits within +/// [_kNooSheetPeekFraction] just stays as that first (plain, content-sized) +/// render - the common case for a short menu/option list. Content taller +/// than that switches to a [DraggableScrollableSheet] opened at the peek +/// fraction instead, so it never jumps straight to (near) full height with +/// no room left to adjust. The one extra layout pass for tall content +/// happens within the sheet's own entrance animation, so it isn't visible +/// as a jump. +class _NooSheetBody extends StatefulWidget { + final List children; + + const _NooSheetBody({required this.children}); + + @override + State<_NooSheetBody> createState() => _NooSheetBodyState(); +} + +class _NooSheetBodyState extends State<_NooSheetBody> { + final _contentKey = GlobalKey(); + bool _measured = false; + bool _needsPeek = false; + + void _measureAfterFrame(double availableHeight) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || _measured) return; + final box = _contentKey.currentContext?.findRenderObject() as RenderBox?; + final contentHeight = box?.size.height ?? 0; + setState(() { + _measured = true; + _needsPeek = contentHeight > availableHeight * _kNooSheetPeekFraction; + }); + }); + } + + Widget _buildContent(ScrollController? scrollController) { + final colors = context.nooColors; + return SingleChildScrollView( + controller: scrollController, + padding: const EdgeInsets.fromLTRB(20, 10, 20, 20), + child: Column( + key: _measured ? null : _contentKey, + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 36, + height: 5, + decoration: BoxDecoration( + color: colors.surface3, + borderRadius: BorderRadius.circular(NooRadii.pill), + ), + ), + for (final child in widget.children) ...[ + const SizedBox(height: 22), + child, + ], + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + return SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + if (!_measured) { + _measureAfterFrame(constraints.maxHeight); + return _buildContent(null); + } + if (!_needsPeek) { + return _buildContent(null); + } + return DraggableScrollableSheet( + initialChildSize: _kNooSheetPeekFraction, + minChildSize: _kNooSheetMinFraction, + maxChildSize: _kNooSheetMaxFraction, + expand: false, + builder: (context, scrollController) { + // Plain `DraggableScrollableSheet` doesn't dismiss the modal + // route on its own when dragged down to its floor - it just + // stops resizing there - so pop explicitly once it's been + // dragged (near) all the way down, matching a native + // peek sheet's swipe-to-dismiss. + return NotificationListener( + onNotification: (notification) { + if (notification.extent <= notification.minExtent + 0.01) { + Navigator.of(context).maybePop(); + } + return false; + }, + child: _buildContent(scrollController), + ); + }, + ); + }, + ), + ); + } +} diff --git a/lib/widgets/viewer/media_action_bar.dart b/lib/widgets/viewer/media_action_bar.dart index 208c055..e5ae8bb 100644 --- a/lib/widgets/viewer/media_action_bar.dart +++ b/lib/widgets/viewer/media_action_bar.dart @@ -46,9 +46,14 @@ class MediaActionBar extends StatelessWidget { // favorite/delete stay legible even when the ambient theme is light. const dark = NooColors.dark; - return SafeArea( - top: false, - child: FrostedGlassContainer( + // The blurred/tinted background lives outside the SafeArea (not inside + // it) so it extends all the way to the physical bottom edge, behind the + // gesture bar/home indicator, instead of the panel itself stopping + // short and leaving that strip unstyled - only the actual row content + // needs padding up and away from the gesture area. + return FrostedGlassContainer( + child: SafeArea( + top: false, child: Column( mainAxisSize: MainAxisSize.min, children: [ diff --git a/test/widgets/noo/overlays_test.dart b/test/widgets/noo/overlays_test.dart index d2a6a85..222e75b 100644 --- a/test/widgets/noo/overlays_test.dart +++ b/test/widgets/noo/overlays_test.dart @@ -14,18 +14,26 @@ import 'noo_test_utils.dart'; /// A button that opens an overlay from a context below MaterialApp. Widget _launcher(void Function(BuildContext) open) => Builder( - builder: (context) => TextButton(onPressed: () => open(context), child: const Text('open')), + builder: (context) => + TextButton(onPressed: () => open(context), child: const Text('open')), ); Finder _dialogCard() => find.byWidgetPredicate( - (w) => w is Container && w.decoration is BoxDecoration && (w.decoration as BoxDecoration).boxShadow != null, + (w) => + w is Container && + w.decoration is BoxDecoration && + (w.decoration as BoxDecoration).boxShadow != null, ); void main() { setUpNooTests(); group('NooOverlayHeader', () { - testNooWidgets('title, subtitle, leading and close', (tester, theme, c) async { + testNooWidgets('title, subtitle, leading and close', ( + tester, + theme, + c, + ) async { var closed = 0; await pumpNoo( tester, @@ -41,23 +49,41 @@ void main() { theme: theme, ); expect(find.text('Report.pdf'), findsOneWidget); - expect(tester.widget(find.text('2 MB · Documents')).style!.color, c.fg3); - expect(decorationOf(tester, find.byIcon(LucideIcons.x)).color, c.surface2); + expect( + tester.widget(find.text('2 MB · Documents')).style!.color, + c.fg3, + ); + expect( + decorationOf(tester, find.byIcon(LucideIcons.x)).color, + c.surface2, + ); await tester.tap(find.byIcon(LucideIcons.x)); expect(closed, 1); }); testWidgets('no close button without onClose', (tester) async { - await pumpNoo(tester, const SizedBox(width: 360, child: NooOverlayHeader(title: 'T'))); + await pumpNoo( + tester, + const SizedBox(width: 360, child: NooOverlayHeader(title: 'T')), + ); expect(find.byIcon(LucideIcons.x), findsNothing); }); }); group('showNooSheet', () { - testNooWidgets('shows children on a surface sheet and dismisses', (tester, theme, c) async { + testNooWidgets('shows children on a surface sheet and dismisses', ( + tester, + theme, + c, + ) async { await pumpNoo( tester, - _launcher((ctx) => showNooSheet(ctx, children: const [Text('Section A'), Text('Section B')])), + _launcher( + (ctx) => showNooSheet( + ctx, + children: const [Text('Section A'), Text('Section B')], + ), + ), theme: theme, ); await tester.tap(find.text('open')); @@ -66,7 +92,8 @@ void main() { expect(find.text('Section B'), findsOneWidget); final sheet = tester.widget(find.byType(BottomSheet)); expect(sheet.backgroundColor, c.surface); - final gap = tester.getTopLeft(find.text('Section B')).dy - + final gap = + tester.getTopLeft(find.text('Section B')).dy - tester.getBottomLeft(find.text('Section A')).dy; expect(gap, 22); @@ -74,13 +101,56 @@ void main() { await tester.pumpAndSettle(); expect(find.text('Section A'), findsNothing); }); + + testNooWidgets('long content opens as a draggable peek, not full height', ( + tester, + theme, + c, + ) async { + await pumpNoo( + tester, + _launcher( + (ctx) => showNooSheet( + ctx, + children: [ + for (var i = 0; i < 30; i++) + SizedBox(height: 40, child: Text('Row $i')), + ], + ), + ), + theme: theme, + surfaceSize: const Size(400, 700), + ); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + + // Content (30 * (40 + 22) =~ 1860px) far exceeds the peek fraction of + // the 700px-tall surface, so it should switch to a draggable sheet + // instead of the plain content-sized scroll view, opened at a peek + // (well under half the content height) rather than snapping to + // (near) the full 700px available. + expect(find.text('Row 0'), findsOneWidget); + final sheetHeight = tester + .getSize(find.byType(DraggableScrollableSheet)) + .height; + expect(sheetHeight, lessThan(500)); + expect(sheetHeight, greaterThan(150)); + }); }); group('NooDialog', () { - testNooWidgets('card is 540 wide, r24, single dialog shadow', (tester, theme, c) async { + testNooWidgets('card is 540 wide, r24, single dialog shadow', ( + tester, + theme, + c, + ) async { await pumpNoo( tester, - NooDialog(title: 'Share "Report.pdf"', onClose: () {}, children: const [Text('body')]), + NooDialog( + title: 'Share "Report.pdf"', + onClose: () {}, + children: const [Text('body')], + ), theme: theme, surfaceSize: const Size(1200, 900), ); @@ -92,7 +162,9 @@ void main() { expect(tester.getSize(card).width, 540); }); - testWidgets('narrow screens shrink the card to fit with a 24px margin', (tester) async { + testWidgets('narrow screens shrink the card to fit with a 24px margin', ( + tester, + ) async { await tester.binding.setSurfaceSize(const Size(400, 800)); addTearDown(() => tester.binding.setSurfaceSize(null)); await tester.pumpWidget( @@ -105,7 +177,11 @@ void main() { expect(tester.getSize(card).width, 400 - 48); }); - testNooWidgets('header close is a 32px surface-2 circle', (tester, theme, c) async { + testNooWidgets('header close is a 32px surface-2 circle', ( + tester, + theme, + c, + ) async { var closed = 0; await pumpNoo( tester, @@ -118,12 +194,19 @@ void main() { theme: theme, ); expect(tester.getSize(find.byType(NooCloseButton)), const Size(32, 32)); - expect(decorationOf(tester, find.byIcon(LucideIcons.x)).color, c.surface2); + expect( + decorationOf(tester, find.byIcon(LucideIcons.x)).color, + c.surface2, + ); await tester.tap(find.byType(NooCloseButton)); expect(closed, 1); }); - testNooWidgets('showNooDialog uses the scrim and closes via the button', (tester, theme, c) async { + testNooWidgets('showNooDialog uses the scrim and closes via the button', ( + tester, + theme, + c, + ) async { Future? result; await pumpNoo( tester, @@ -140,7 +223,9 @@ void main() { await tester.tap(find.text('open')); await tester.pumpAndSettle(); expect(find.text('dialog body'), findsOneWidget); - final barrier = tester.widgetList(find.byType(ModalBarrier)).last; + final barrier = tester + .widgetList(find.byType(ModalBarrier)) + .last; expect(barrier.color, c.scrim); await tester.tap(find.byType(NooCloseButton)); @@ -176,7 +261,10 @@ void main() { (ctx) => showNooDialog( ctx, title: 'T', - children: [for (var i = 0; i < 40; i++) SizedBox(height: 60, child: Text('row $i'))], + children: [ + for (var i = 0; i < 40; i++) + SizedBox(height: 60, child: Text('row $i')), + ], ), ), surfaceSize: const Size(1200, 600), @@ -189,13 +277,20 @@ void main() { }); group('NooTextField', () { - testNooWidgets('surface-2, r14, fg-3 placeholder, typing', (tester, theme, c) async { + testNooWidgets('surface-2, r14, fg-3 placeholder, typing', ( + tester, + theme, + c, + ) async { String? typed; await pumpNoo( tester, SizedBox( width: 400, - child: NooTextField(placeholder: 'Name, email or group', onChanged: (v) => typed = v), + child: NooTextField( + placeholder: 'Name, email or group', + onChanged: (v) => typed = v, + ), ), theme: theme, ); @@ -209,14 +304,20 @@ void main() { expect(typed, 'ali'); }); - testNooWidgets('mono URL field with inline Copy link button', (tester, theme, c) async { + testNooWidgets('mono URL field with inline Copy link button', ( + tester, + theme, + c, + ) async { var copied = 0; await pumpNoo( tester, SizedBox( width: 480, child: NooTextField( - controller: TextEditingController(text: 'https://cloud.example/s/abc'), + controller: TextEditingController( + text: 'https://cloud.example/s/abc', + ), mono: true, readOnly: true, onSurface: false, @@ -233,7 +334,10 @@ void main() { expect(field.style!.fontFamily, NooText.mono.fontFamily); expect(field.readOnly, isTrue); expect(decorationOf(tester, find.byType(TextField)).color, c.surface); - expect(tester.getSize(find.byType(NooButton)).height, NooSizes.buttonField); + expect( + tester.getSize(find.byType(NooButton)).height, + NooSizes.buttonField, + ); await tester.tap(find.text('Copy link')); await tester.pumpAndSettle(); expect(copied, 1); @@ -241,7 +345,11 @@ void main() { }); group('Share parts', () { - testNooWidgets('NooShareSection title, trailing and caption', (tester, theme, c) async { + testNooWidgets('NooShareSection title, trailing and caption', ( + tester, + theme, + c, + ) async { await pumpNoo( tester, const SizedBox( @@ -255,13 +363,26 @@ void main() { ), theme: theme, ); - expect(tester.widget(find.text('Send file directly')).style!.color, c.fg1); - expect(tester.widget(find.text("Link settings don't apply")).style!.color, c.fg3); + expect( + tester.widget(find.text('Send file directly')).style!.color, + c.fg1, + ); + expect( + tester + .widget(find.text("Link settings don't apply")) + .style! + .color, + c.fg3, + ); expect(find.byIcon(LucideIcons.link), findsOneWidget); expect(find.text('content'), findsOneWidget); }); - testNooWidgets('owner row is static, others get a tappable pill', (tester, theme, c) async { + testNooWidgets('owner row is static, others get a tappable pill', ( + tester, + theme, + c, + ) async { var taps = 0; await pumpNoo( tester, @@ -296,13 +417,19 @@ void main() { expect(taps, 1); }); - testWidgets('same parts lay out inside the sheet and the dialog', (tester) async { + testWidgets('same parts lay out inside the sheet and the dialog', ( + tester, + ) async { List parts() => [ NooShareSection( title: 'Share with people', child: const NooTextField(placeholder: 'Name, email or group'), ), - const NooPersonAccessRow(avatar: NooAvatar(initials: 'JD'), name: 'Jane', permission: 'Can view'), + const NooPersonAccessRow( + avatar: NooAvatar(initials: 'JD'), + name: 'Jane', + permission: 'Can view', + ), ]; await pumpNoo( tester, @@ -312,7 +439,8 @@ void main() { _launcher((ctx) => showNooSheet(ctx, children: parts())), Builder( builder: (ctx) => TextButton( - onPressed: () => showNooDialog(ctx, title: 'Share', children: parts()), + onPressed: () => + showNooDialog(ctx, title: 'Share', children: parts()), child: const Text('dialog'), ), ), From 0abea46b1ca42b2fef1c37efcaf653eb8ceb51fd Mon Sep 17 00:00:00 2001 From: ayushya Date: Mon, 28 Sep 2026 19:26:18 -0400 Subject: [PATCH 11/29] 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. --- .../context/design-system/DESIGN_SYSTEM.md | 28 ++-- lib/views/file_viewer_screen.dart | 38 ++--- lib/views/move_copy_destination_picker.dart | 78 +++++++++-- lib/views/share_upload_view.dart | 132 +++++++++++------- lib/views/trash_view.dart | 93 ++++++++---- lib/widgets/files_controls_row.dart | 50 +++++-- lib/widgets/frosted_glass_container.dart | 27 ++-- lib/widgets/noo/overlays/noo_sheet.dart | 94 +++++++------ lib/widgets/viewer/media_action_bar.dart | 12 +- lib/widgets/viewer/media_video_preview.dart | 2 +- lib/widgets/viewer/viewer_icon_button.dart | 25 ++-- test/widgets/noo/overlays_test.dart | 69 +++++++++ 12 files changed, 441 insertions(+), 207 deletions(-) diff --git a/.claude/context/design-system/DESIGN_SYSTEM.md b/.claude/context/design-system/DESIGN_SYSTEM.md index 55f3de6..77b4eee 100644 --- a/.claude/context/design-system/DESIGN_SYSTEM.md +++ b/.claude/context/design-system/DESIGN_SYSTEM.md @@ -343,13 +343,16 @@ Sidebar items are 38px tall with radius 12, an 18px icon and a 14/500 label. The - **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 fixed - dark panel regardless of theme (it needs to read over arbitrary photo/ - video brightness, not over `bg`); 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. 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 + 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 @@ -369,10 +372,13 @@ Sidebar items are 38px tall with radius 12, an 18px icon and a 14/500 label. The - **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. Breadcrumb row, then a folder-only list (no files: this - screen only browses folders) using the standard file row/tile at the - folder kind. Bottom bar: a meta line ("Moving 3 items") above a full-width - 52px primary CTA ("Upload here" / "Move here" / "Copy here"). + 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. --- diff --git a/lib/views/file_viewer_screen.dart b/lib/views/file_viewer_screen.dart index 66ec85b..65f5f16 100644 --- a/lib/views/file_viewer_screen.dart +++ b/lib/views/file_viewer_screen.dart @@ -379,9 +379,9 @@ class _FileViewerScreenState extends State { // The stage follows the app's own theme (`bg`) rather than being pinned // to black - a black stage in light mode read as jarringly out of place // next to the rest of the light-themed app. The floating top/action - // bars stay a fixed dark frosted panel regardless (see - // FrostedGlassContainer's doc comment) since they need to read over - // arbitrary photo/video brightness, not over this background. + // bars now follow the theme too (FrostedGlassContainer's own `surface` + // default), at a higher blur opacity to stay legible over arbitrary + // photo/video brightness underneath. final stageColor = colors.bg; return Scaffold( @@ -413,6 +413,10 @@ class _FileViewerScreenState extends State { // gradient fade, not `FrostedGlassContainer`'s blur - just // enough to keep the back button/title legible over bright // media without another blurred layer stacked on the bar itself. + // Fades toward `surface` (the same tint the panel itself uses), + // not a fixed black - a black scrim would fight the panel's own + // theme-matched icons/text (dark in light theme) instead of + // extending its contrast strategy past the panel's hard edge. if (_isSwipeable) IgnorePointer( child: AnimatedOpacity( @@ -420,11 +424,14 @@ class _FileViewerScreenState extends State { opacity: _controlsVisible ? 1 : 0, child: Container( height: 150, - decoration: const BoxDecoration( + decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, - colors: [Color(0x8C000000), Colors.transparent], + colors: [ + colors.surface.withValues(alpha: 0.7), + Colors.transparent, + ], ), ), ), @@ -449,14 +456,10 @@ class _FileViewerScreenState extends State { vertical: 6, ), child: IconTheme.merge( - // Fixed white, not `colors.fg1`: this bar always - // sits on the dark translucent panel over the media - // stage (see `FrostedGlassContainer`'s doc comment), - // regardless of the app's light/dark theme. - data: const IconThemeData( - color: Colors.white, - size: 24, - ), + // `fg1`, matching FrostedGlassContainer's own + // theme-following panel underneath - both used to + // be a fixed white-on-black regardless of theme. + data: IconThemeData(color: colors.fg1, size: 24), child: Row( children: [ NooTopBarButton( @@ -469,7 +472,7 @@ class _FileViewerScreenState extends State { child: MarqueeTitle( text: _currentItem.name, style: NooText.label.copyWith( - color: Colors.white, + color: colors.fg1, ), ), ), @@ -491,11 +494,14 @@ class _FileViewerScreenState extends State { opacity: _controlsVisible ? 1 : 0, child: Container( height: 260, - decoration: const BoxDecoration( + decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.bottomCenter, end: Alignment.topCenter, - colors: [Color(0x99000000), Colors.transparent], + colors: [ + colors.surface.withValues(alpha: 0.75), + Colors.transparent, + ], ), ), ), diff --git a/lib/views/move_copy_destination_picker.dart b/lib/views/move_copy_destination_picker.dart index fab4d22..ecdc279 100644 --- a/lib/views/move_copy_destination_picker.dart +++ b/lib/views/move_copy_destination_picker.dart @@ -7,12 +7,14 @@ import '../providers/files_controller.dart'; import '../providers/item_operations.dart'; import '../theme/design_tokens.dart'; import '../widgets/breadcrumbs.dart'; +import '../widgets/files_controls_row.dart'; import '../widgets/more_tabs_button.dart'; import '../widgets/move_copy_conflict_sheet.dart'; import '../widgets/noo/core/noo_button.dart'; import '../widgets/noo/files/noo_file_kind.dart'; import '../widgets/noo/files/noo_file_row.dart'; import '../widgets/noo/files/noo_file_table.dart'; +import '../widgets/noo/media/noo_grid_card.dart'; import '../widgets/noo/nav/noo_top_bar.dart'; import '../widgets/noo/nav/noo_toolbar.dart'; import '../widgets/noo/noo_layout.dart'; @@ -86,9 +88,7 @@ class _MoveCopyDestinationPickerState extends State { Future _fetch(String path) async { setState(() => _isLoading = true); - final raw = await context.read().fetchFolderListing( - path, - ); + final raw = await context.read().fetchFolderListing(path); if (!mounted) return; setState(() { _rawItems = raw; @@ -212,6 +212,19 @@ class _MoveCopyDestinationPickerState extends State { ); } + Widget _buildGridCard(NextcloudItem item) { + final colors = context.nooColors; + return NooGridCard( + name: item.name, + meta: 'Folder', + placeholderColor: colors.accentSoft, + icon: LucideIcons.folder, + iconColor: colors.accentText, + thumbnailHeight: NooLayout.isDesktop(context) ? 118 : 104, + onTap: () => _navigateToFolder(item.path), + ); + } + @override Widget build(BuildContext context) { final colors = context.nooColors; @@ -231,16 +244,34 @@ class _MoveCopyDestinationPickerState extends State { final slivers = [ SliverToBoxAdapter( child: Padding( - padding: EdgeInsets.fromLTRB(gutter, NooSpace.md, gutter, NooSpace.sm), - child: hasBreadcrumbs - ? SizedBox( + padding: EdgeInsets.fromLTRB( + gutter, + NooSpace.md, + gutter, + NooSpace.sm, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + height: 44, + child: FilesControlsRow( + folderPath: _currentPath, + showStorageScope: false, + ), + ), + if (hasBreadcrumbs) ...[ + const SizedBox(height: 10), + SizedBox( height: 32, child: Breadcrumbs( pathStack: _pathStack, onTap: _navigateToPathIndex, ), - ) - : const SizedBox.shrink(), + ), + ], + ], + ), ), ), if (_isLoading) @@ -251,6 +282,22 @@ class _MoveCopyDestinationPickerState extends State { icon: LucideIcons.folder, message: 'No folders here', ) + else if (files.isGridView) + SliverPadding( + padding: EdgeInsets.symmetric(horizontal: gutter), + sliver: SliverGrid( + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: desktop ? 5 : 2, + childAspectRatio: desktop ? 1.05 : 0.92, + crossAxisSpacing: desktop ? 16 : 10, + mainAxisSpacing: desktop ? 16 : 10, + ), + delegate: SliverChildBuilderDelegate( + (context, index) => _buildGridCard(folders[index]), + childCount: folders.length, + ), + ), + ) else if (desktop) SliverPadding( padding: EdgeInsets.symmetric(horizontal: gutter), @@ -266,7 +313,8 @@ class _MoveCopyDestinationPickerState extends State { padding: EdgeInsets.symmetric(horizontal: gutter), sliver: SliverList( delegate: SliverChildBuilderDelegate( - (context, index) => _buildRow(folders[index], index, folders.length), + (context, index) => + _buildRow(folders[index], index, folders.length), childCount: folders.length, ), ), @@ -312,7 +360,10 @@ class _MoveCopyDestinationPickerState extends State { color: colors.accent, backgroundColor: colors.surface, onRefresh: () => _fetch(_currentPath), - child: CustomScrollView(controller: _scrollController, slivers: slivers), + child: CustomScrollView( + controller: _scrollController, + slivers: slivers, + ), ), ), bottomNavigationBar: DecoratedBox( @@ -331,13 +382,14 @@ class _MoveCopyDestinationPickerState extends State { ), child: Column( mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, children: [ Text( invalidDestination ? "Can't ${widget.copy ? 'copy' : 'move'} a folder into " "itself or one of its own subfolders" : '$verb $itemCount item${itemCount == 1 ? '' : 's'}', + textAlign: TextAlign.center, style: NooText.meta.copyWith( color: invalidDestination ? colors.danger : colors.fg3, ), @@ -350,7 +402,9 @@ class _MoveCopyDestinationPickerState extends State { disabled: invalidDestination || _isSubmitting, icon: _isSubmitting ? null - : (widget.copy ? LucideIcons.copy : LucideIcons.folderInput), + : (widget.copy + ? LucideIcons.copy + : LucideIcons.folderInput), onTap: invalidDestination || _isSubmitting ? null : () => _confirm(ops), diff --git a/lib/views/share_upload_view.dart b/lib/views/share_upload_view.dart index 57ee2af..c3b1661 100644 --- a/lib/views/share_upload_view.dart +++ b/lib/views/share_upload_view.dart @@ -10,11 +10,12 @@ import '../services/share_intent_service.dart'; import '../services/upload_service.dart'; import '../theme/design_tokens.dart'; import '../widgets/breadcrumbs.dart'; -import '../widgets/marquee_title.dart'; +import '../widgets/files_controls_row.dart'; import '../widgets/noo/core/noo_button.dart'; import '../widgets/noo/files/noo_file_kind.dart'; import '../widgets/noo/files/noo_file_row.dart'; import '../widgets/noo/files/noo_file_table.dart'; +import '../widgets/noo/media/noo_grid_card.dart'; import '../widgets/noo/nav/noo_top_bar.dart'; import '../widgets/noo/nav/noo_toolbar.dart'; import '../widgets/noo/noo_layout.dart'; @@ -51,7 +52,9 @@ class _ShareUploadViewState extends State { // Shared files have no relationship to wherever the user was last // browsing, so start the destination picker fresh at the root. WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) context.read().navigateToAbsoluteFolder('/'); + if (mounted) { + context.read().navigateToAbsoluteFolder('/'); + } }); } @@ -94,7 +97,12 @@ class _ShareUploadViewState extends State { // Only folders are valid upload destinations, so the listing - unlike // Files' own - never shows plain files at all. - Widget _buildRow(BuildContext context, NextcloudItem item, int index, int count) { + Widget _buildRow( + BuildContext context, + NextcloudItem item, + int index, + int count, + ) { final colors = context.nooColors; final files = context.read(); final row = NooFileRow( @@ -130,6 +138,20 @@ class _ShareUploadViewState extends State { ); } + Widget _buildGridCard(BuildContext context, NextcloudItem item) { + final colors = context.nooColors; + final files = context.read(); + return NooGridCard( + name: item.name, + meta: 'Folder', + placeholderColor: colors.accentSoft, + icon: LucideIcons.folder, + iconColor: colors.accentText, + thumbnailHeight: NooLayout.isDesktop(context) ? 118 : 104, + onTap: () => files.navigateToFolder(item.path), + ); + } + @override Widget build(BuildContext context) { final colors = context.nooColors; @@ -147,16 +169,34 @@ class _ShareUploadViewState extends State { final slivers = [ SliverToBoxAdapter( child: Padding( - padding: EdgeInsets.fromLTRB(gutter, NooSpace.md, gutter, NooSpace.sm), - child: hasBreadcrumbs - ? SizedBox( + padding: EdgeInsets.fromLTRB( + gutter, + NooSpace.md, + gutter, + NooSpace.sm, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + height: 44, + child: FilesControlsRow( + folderPath: currentPath, + showStorageScope: false, + ), + ), + if (hasBreadcrumbs) ...[ + const SizedBox(height: 10), + SizedBox( height: 32, child: Breadcrumbs( pathStack: files.pathStack, onTap: (index) => files.navigateToPathIndex(index), ), - ) - : const SizedBox.shrink(), + ), + ], + ], + ), ), ), if (files.isLoading) @@ -167,6 +207,22 @@ class _ShareUploadViewState extends State { icon: LucideIcons.folder, message: 'No folders here', ) + else if (files.isGridView) + SliverPadding( + padding: EdgeInsets.symmetric(horizontal: gutter), + sliver: SliverGrid( + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: desktop ? 5 : 2, + childAspectRatio: desktop ? 1.05 : 0.92, + crossAxisSpacing: desktop ? 16 : 10, + mainAxisSpacing: desktop ? 16 : 10, + ), + delegate: SliverChildBuilderDelegate( + (context, index) => _buildGridCard(context, folders[index]), + childCount: folders.length, + ), + ), + ) else if (desktop) SliverPadding( padding: EdgeInsets.symmetric(horizontal: gutter), @@ -223,12 +279,20 @@ class _ShareUploadViewState extends State { color: colors.accent, backgroundColor: colors.surface, onRefresh: files.refreshData, - child: CustomScrollView(controller: _scrollController, slivers: slivers), + child: CustomScrollView( + controller: _scrollController, + slivers: slivers, + ), ), ), // A flat surface bar with a top line, peeking up from the bottom edge - - // the uploading-file summary (marqueed if it doesn't fit on one line) - // sits directly above the destination button. + // a centered item-count meta line sits directly above the destination + // button (DESIGN_SYSTEM.md's "Upload / Move / Copy destination + // picker" recipe: "a meta line ... above a full-width ... primary + // CTA" - matches MoveCopyDestinationPicker's identical bar). A count, + // not a filename: this is the one-file case just as much as the + // many-files case, and a long filename has no good fixed-width + // treatment the way a short count always does. bottomNavigationBar: DecoratedBox( decoration: BoxDecoration( color: colors.surface, @@ -245,48 +309,12 @@ class _ShareUploadViewState extends State { ), child: Column( mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, children: [ - // Sized to the text itself (like a real chip) up to the - // row's available width - MarqueeTitle needs a concrete - // (not just loose) width to know whether/how far to scroll, - // so this measures the text once up front rather than - // leaving the chip unconstrained. - LayoutBuilder( - builder: (context, constraints) { - final uploadingText = widget.files.length == 1 - ? 'Uploading ${widget.files.first.name}' - : 'Uploading ${widget.files.length} files'; - final chipTextStyle = NooText.label.copyWith( - color: colors.fg2, - ); - const horizontalPadding = 28.0; - final painter = TextPainter( - text: TextSpan(text: uploadingText, style: chipTextStyle), - maxLines: 1, - textDirection: Directionality.of(context), - )..layout(maxWidth: double.infinity); - final chipWidth = (painter.width + horizontalPadding).clamp( - 0.0, - constraints.maxWidth, - ); - - return Container( - width: chipWidth, - padding: const EdgeInsets.symmetric( - horizontal: NooSpace.smd, - vertical: NooSpace.xs, - ), - decoration: BoxDecoration( - color: colors.surface2, - borderRadius: BorderRadius.circular(NooRadii.pill), - ), - child: SizedBox( - height: 20, - child: MarqueeTitle(text: uploadingText, style: chipTextStyle), - ), - ); - }, + Text( + 'Uploading ${widget.files.length} item${widget.files.length == 1 ? '' : 's'}', + textAlign: TextAlign.center, + style: NooText.meta.copyWith(color: colors.fg3), ), const SizedBox(height: NooSpace.sm), NooButton( diff --git a/lib/views/trash_view.dart b/lib/views/trash_view.dart index 666c8f9..2f919ce 100644 --- a/lib/views/trash_view.dart +++ b/lib/views/trash_view.dart @@ -60,8 +60,12 @@ class _TrashViewState extends State { sliver: SliverToBoxAdapter( child: NooBanner( actionLabel: trash.isEmpty ? null : 'Empty trash', - onAction: trash.isEmpty ? null : () => _confirmEmptyTrash(context, trash), - child: const Text('Deleted items are kept for 30 days, then removed automatically.'), + onAction: trash.isEmpty + ? null + : () => _confirmEmptyTrash(context, trash), + child: const Text( + 'Deleted items are kept for 30 days, then removed automatically.', + ), ), ), ), @@ -115,6 +119,10 @@ class _TrashViewState extends State { sliver: SliverList( delegate: SliverChildBuilderDelegate((context, index) { final item = trash[index]; + // The overflow menu's only action here is "Delete forever" - a + // bare ellipsis obscures that behind an extra tap to discover it, + // so this row shows the delete icon directly instead of going + // through `NooFileRow.onMore`. final row = NooFileRow( kind: NooFileKind.from( name: item.name, @@ -124,19 +132,30 @@ class _TrashViewState extends State { name: item.name, meta: _meta(item), iosStyle: NooLayout.iosStyle(context), - trailing: SizedBox.square( - dimension: 40, - child: Semantics( - button: true, - label: 'Restore', - child: InkResponse( - onTap: () => _restore(context, item), - radius: 20, - child: const Icon(LucideIcons.rotateCcw), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Semantics( + button: true, + label: 'Restore', + child: InkResponse( + onTap: () => _restore(context, item), + radius: 20, + child: const Icon(LucideIcons.rotateCcw), + ), ), - ), + const SizedBox(width: NooSpace.xs), + Semantics( + button: true, + label: 'Delete forever', + child: InkResponse( + onTap: () => _confirmDeleteForever(context, item), + radius: 20, + child: Icon(LucideIcons.trash2, color: colors.danger), + ), + ), + ], ), - onMore: () => _confirmDeleteForever(context, item), ); final isFirst = index == 0; final isLast = index == trash.length - 1; @@ -186,7 +205,7 @@ class _TrashViewState extends State { col2: _deletedLabel(item), col3: item.originalLocation ?? 'Unknown location', onRestore: () => _restore(context, item), - onMore: () => _confirmDeleteForever(context, item), + onDelete: () => _confirmDeleteForever(context, item), ); }, childCount: trash.length + 1), ), @@ -196,8 +215,9 @@ class _TrashViewState extends State { String _meta(NextcloudItem item) => 'Deleted ${_deletedLabel(item)} · ${tabLocationLabel(item.originalLocation ?? item.path, item.name)}'; - String _deletedLabel(NextcloudItem item) => - item.deletedAt != null ? DateFormat.yMMMd().format(item.deletedAt!) : 'recently'; + String _deletedLabel(NextcloudItem item) => item.deletedAt != null + ? DateFormat.yMMMd().format(item.deletedAt!) + : 'recently'; Future _restore(BuildContext context, NextcloudItem item) async { final messenger = ScaffoldMessenger.of(context); @@ -301,19 +321,23 @@ class _TrashViewState extends State { } /// Desktop trash row: like `NooFileTableRow`, but its 120px last column -/// holds a tonal "Restore" button plus the overflow button (permanent -/// delete) instead of status icons - `NooFileTableRow` has no slot for -/// screen-specific actions there. Column widths (180/160/120) are matched -/// by hand to `NooFileTableHeader`'s (private in `noo_file_table.dart`) so -/// this still lines up under it; promoting an optional `actions` slot onto -/// `NooFileTableRow` would let this fold back into the shared component. +/// holds a tonal "Restore" button plus a danger-tonal "Delete" button +/// (permanent delete) instead of status icons - `NooFileTableRow` has no +/// slot for screen-specific actions there. A labelled pill, not a bare +/// overflow icon: it's the row's only destructive action, not a menu of +/// several, and desktop already prefers labelled buttons to bare icons +/// (see `NooSelectionBar`'s own desktop actions). Column widths +/// (180/160/120) are matched by hand to `NooFileTableHeader`'s (private in +/// `noo_file_table.dart`) so this still lines up under it; promoting an +/// optional `actions` slot onto `NooFileTableRow` would let this fold back +/// into the shared component. class _TrashDesktopRow extends StatelessWidget { final NooFileKind kind; final String name; final String col2; final String col3; final VoidCallback onRestore; - final VoidCallback onMore; + final VoidCallback onDelete; const _TrashDesktopRow({ required this.kind, @@ -321,7 +345,7 @@ class _TrashDesktopRow extends StatelessWidget { required this.col2, required this.col3, required this.onRestore, - required this.onMore, + required this.onDelete, }); @override @@ -358,11 +382,21 @@ class _TrashDesktopRow extends StatelessWidget { ), SizedBox( width: 180, - child: Text(col2, maxLines: 1, overflow: TextOverflow.ellipsis, style: metaStyle), + child: Text( + col2, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: metaStyle, + ), ), SizedBox( width: 160, - child: Text(col3, maxLines: 1, overflow: TextOverflow.ellipsis, style: metaStyle), + child: Text( + col3, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: metaStyle, + ), ), SizedBox( width: 120, @@ -379,7 +413,12 @@ class _TrashDesktopRow extends StatelessWidget { child: const Text('Restore'), ), const SizedBox(width: 4), - NooOverflowButton(size: 24, onTap: onMore), + NooButton( + variant: NooButtonVariant.danger, + size: NooButtonSize.xs, + onTap: onDelete, + child: const Text('Delete'), + ), ], ), ), diff --git a/lib/widgets/files_controls_row.dart b/lib/widgets/files_controls_row.dart index 3d90c22..d47c5b3 100644 --- a/lib/widgets/files_controls_row.dart +++ b/lib/widgets/files_controls_row.dart @@ -14,7 +14,7 @@ import 'sort_menu_button.dart' show sortFieldLabel; String _typeFilterLabel(FilesTypeFilter filter) { switch (filter) { case FilesTypeFilter.all: - return 'All types'; + return 'Filters'; case FilesTypeFilter.filesOnly: return 'Files only'; case FilesTypeFilter.foldersOnly: @@ -47,7 +47,8 @@ class FilesControlsRow extends StatelessWidget { @override Widget build(BuildContext context) { final files = context.watch(); - final filtersActive = files.filesTypeFilter != FilesTypeFilter.all || + final filtersActive = + files.filesTypeFilter != FilesTypeFilter.all || files.showHiddenFiles || (showStorageScope && files.storageScope != StorageScope.cloud); @@ -114,8 +115,16 @@ class FilesControlsRow extends StatelessWidget { } }, options: const [ - NooSegmentOption(value: true, icon: LucideIcons.arrowUp, label: 'Ascending'), - NooSegmentOption(value: false, icon: LucideIcons.arrowDown, label: 'Descending'), + NooSegmentOption( + value: true, + icon: LucideIcons.arrowUp, + label: 'Ascending', + ), + NooSegmentOption( + value: false, + icon: LucideIcons.arrowDown, + label: 'Descending', + ), ], ), const SizedBox(height: 22), @@ -125,7 +134,11 @@ class FilesControlsRow extends StatelessWidget { NooSettingsRow( label: Text(sortFieldLabel(field)), trailing: field == files.sortFieldFor(folderPath) - ? Icon(LucideIcons.check, size: 18, color: context.nooColors.accentText) + ? Icon( + LucideIcons.check, + size: 18, + color: context.nooColors.accentText, + ) : null, onTap: () { files.setSortFieldFor(folderPath, field); @@ -159,23 +172,38 @@ class FilesControlsRow extends StatelessWidget { NooSettingsRow( label: const Text('Files and folders'), trailing: files.filesTypeFilter == FilesTypeFilter.all - ? Icon(LucideIcons.check, size: 18, color: context.nooColors.accentText) + ? Icon( + LucideIcons.check, + size: 18, + color: context.nooColors.accentText, + ) : null, onTap: () => files.setFilesTypeFilter(FilesTypeFilter.all), ), NooSettingsRow( label: const Text('Files only'), trailing: files.filesTypeFilter == FilesTypeFilter.filesOnly - ? Icon(LucideIcons.check, size: 18, color: context.nooColors.accentText) + ? Icon( + LucideIcons.check, + size: 18, + color: context.nooColors.accentText, + ) : null, - onTap: () => files.setFilesTypeFilter(FilesTypeFilter.filesOnly), + onTap: () => + files.setFilesTypeFilter(FilesTypeFilter.filesOnly), ), NooSettingsRow( label: const Text('Folders only'), - trailing: files.filesTypeFilter == FilesTypeFilter.foldersOnly - ? Icon(LucideIcons.check, size: 18, color: context.nooColors.accentText) + trailing: + files.filesTypeFilter == FilesTypeFilter.foldersOnly + ? Icon( + LucideIcons.check, + size: 18, + color: context.nooColors.accentText, + ) : null, - onTap: () => files.setFilesTypeFilter(FilesTypeFilter.foldersOnly), + onTap: () => + files.setFilesTypeFilter(FilesTypeFilter.foldersOnly), ), ], ), diff --git a/lib/widgets/frosted_glass_container.dart b/lib/widgets/frosted_glass_container.dart index 4015a9d..a67c8dd 100644 --- a/lib/widgets/frosted_glass_container.dart +++ b/lib/widgets/frosted_glass_container.dart @@ -1,15 +1,18 @@ import 'dart:ui'; import 'package:flutter/material.dart'; +import '../theme/design_tokens.dart'; /// The blurred/translucent chrome for the media viewer's overlay bars /// (`file_viewer_screen.dart`'s top bar, `MediaActionBar`, the video /// transport row) - the one deliberate exception to the design system's /// flat, no-shadow product UI (DESIGN_SYSTEM.md 1.4), since this chrome /// floats over photo/video content rather than over the app's own -/// surfaces. No shadow, no outline, no `ColorScheme` tint: a fixed dark -/// translucent fill (`color`) that reads correctly over any media, -/// regardless of the app's light/dark theme - matching the canvas at -/// https://claude.ai/artifact/3AGPqqMdkLSC2ypCh2CQs4. +/// surfaces. No shadow, no outline: [color] defaults to the app's own +/// `surface` token (so this panel reads as light or dark to match the +/// active theme, like the rest of the media viewer, instead of a fixed +/// dark tone regardless of theme) at a higher-than-usual [opacity], since +/// a light tint needs denser coverage than a near-black one did to stay +/// legible over arbitrary photo/video brightness underneath. class FrostedGlassContainer extends StatelessWidget { final Widget child; final double borderRadius; @@ -23,30 +26,30 @@ class FrostedGlassContainer extends StatelessWidget { /// adjustable. final double opacity; - /// The tint under the blur. Defaults to the design's fixed dark tone - /// (`#141311`, the spec's `fg-1` dark value) - deliberately not a - /// `ColorScheme`/`NooColors` lookup, since this chrome always sits over a - /// black media stage, not over a themed surface. - final Color color; + /// The tint under the blur. Defaults to [NooColors.surface] (resolved at + /// build time, so it always matches the active theme) - pass an explicit + /// color only to override that, e.g. for a fixed-color panel elsewhere. + final Color? color; const FrostedGlassContainer({ super.key, required this.child, this.borderRadius = 0, this.blurSigma = 20, - this.opacity = 0.55, - this.color = const Color(0xFF141311), + this.opacity = 0.8, + this.color, }); @override Widget build(BuildContext context) { + final tint = color ?? context.nooColors.surface; return ClipRRect( borderRadius: BorderRadius.circular(borderRadius), child: BackdropFilter( filter: ImageFilter.blur(sigmaX: blurSigma, sigmaY: blurSigma), child: Container( decoration: BoxDecoration( - color: color.withValues(alpha: opacity), + color: tint.withValues(alpha: opacity), borderRadius: BorderRadius.circular(borderRadius), ), child: child, diff --git a/lib/widgets/noo/overlays/noo_sheet.dart b/lib/widgets/noo/overlays/noo_sheet.dart index f23c9eb..eb1eafa 100644 --- a/lib/widgets/noo/overlays/noo_sheet.dart +++ b/lib/widgets/noo/overlays/noo_sheet.dart @@ -38,15 +38,20 @@ Future showNooSheet( ); } -/// Renders [children] once, unconstrained, to measure their natural height -/// against the sheet's available height. Content that fits within -/// [_kNooSheetPeekFraction] just stays as that first (plain, content-sized) -/// render - the common case for a short menu/option list. Content taller -/// than that switches to a [DraggableScrollableSheet] opened at the peek -/// fraction instead, so it never jumps straight to (near) full height with -/// no room left to adjust. The one extra layout pass for tall content -/// happens within the sheet's own entrance animation, so it isn't visible -/// as a jump. +/// Caps content at [_kNooSheetPeekFraction] of the available height from the +/// very first frame - never a one-shot "measure, then decide" pass, since +/// some sheets (Details' Versions/Activity tabs) fetch their content +/// asynchronously and only reach their real (long) size well after that +/// first frame, which a one-shot measurement would miss entirely. Content +/// that never needs more than that cap just sizes to itself as a plain +/// scroll view, at whatever height that is - the common case for a short +/// menu/option list, and visually identical to before. [_needsPeek] flips +/// true - upgrading to a draggable [DraggableScrollableSheet], opened at the +/// exact same peek height so nothing visibly jumps - the moment a +/// [ScrollMetricsNotification] reports the content actually overflowing +/// that cap, however and whenever that happens (immediately for a long +/// static list, or later, once async content finishes loading and grows +/// past it). class _NooSheetBody extends StatefulWidget { final List children; @@ -57,44 +62,44 @@ class _NooSheetBody extends StatefulWidget { } class _NooSheetBodyState extends State<_NooSheetBody> { - final _contentKey = GlobalKey(); - bool _measured = false; bool _needsPeek = false; - void _measureAfterFrame(double availableHeight) { + void _handleOverflow(ScrollMetrics metrics) { + if (_needsPeek || metrics.maxScrollExtent <= 0) return; + // `ScrollMetricsNotification` is dispatched mid-layout; deferring avoids + // a "setState during build" error. WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted || _measured) return; - final box = _contentKey.currentContext?.findRenderObject() as RenderBox?; - final contentHeight = box?.size.height ?? 0; - setState(() { - _measured = true; - _needsPeek = contentHeight > availableHeight * _kNooSheetPeekFraction; - }); + if (mounted && !_needsPeek) setState(() => _needsPeek = true); }); } - Widget _buildContent(ScrollController? scrollController) { + Widget _buildScrollable(ScrollController? scrollController) { final colors = context.nooColors; - return SingleChildScrollView( - controller: scrollController, - padding: const EdgeInsets.fromLTRB(20, 10, 20, 20), - child: Column( - key: _measured ? null : _contentKey, - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: 36, - height: 5, - decoration: BoxDecoration( - color: colors.surface3, - borderRadius: BorderRadius.circular(NooRadii.pill), + return NotificationListener( + onNotification: (notification) { + _handleOverflow(notification.metrics); + return false; + }, + child: SingleChildScrollView( + controller: scrollController, + padding: const EdgeInsets.fromLTRB(20, 10, 20, 20), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 36, + height: 5, + decoration: BoxDecoration( + color: colors.surface3, + borderRadius: BorderRadius.circular(NooRadii.pill), + ), ), - ), - for (final child in widget.children) ...[ - const SizedBox(height: 22), - child, + for (final child in widget.children) ...[ + const SizedBox(height: 22), + child, + ], ], - ], + ), ), ); } @@ -104,12 +109,13 @@ class _NooSheetBodyState extends State<_NooSheetBody> { return SafeArea( child: LayoutBuilder( builder: (context, constraints) { - if (!_measured) { - _measureAfterFrame(constraints.maxHeight); - return _buildContent(null); - } if (!_needsPeek) { - return _buildContent(null); + return ConstrainedBox( + constraints: BoxConstraints( + maxHeight: constraints.maxHeight * _kNooSheetPeekFraction, + ), + child: _buildScrollable(null), + ); } return DraggableScrollableSheet( initialChildSize: _kNooSheetPeekFraction, @@ -129,7 +135,7 @@ class _NooSheetBodyState extends State<_NooSheetBody> { } return false; }, - child: _buildContent(scrollController), + child: _buildScrollable(scrollController), ); }, ); diff --git a/lib/widgets/viewer/media_action_bar.dart b/lib/widgets/viewer/media_action_bar.dart index e5ae8bb..b12c9e2 100644 --- a/lib/widgets/viewer/media_action_bar.dart +++ b/lib/widgets/viewer/media_action_bar.dart @@ -40,11 +40,7 @@ class MediaActionBar extends StatelessWidget { @override Widget build(BuildContext context) { - // This panel always sits on a black media stage regardless of the - // app's own light/dark theme (see FrostedGlassContainer's doc - // comment) - fixed dark-palette values, not `context.nooColors`, so - // favorite/delete stay legible even when the ambient theme is light. - const dark = NooColors.dark; + final colors = context.nooColors; // The blurred/tinted background lives outside the SafeArea (not inside // it) so it extends all the way to the physical bottom edge, behind the @@ -59,7 +55,7 @@ class MediaActionBar extends StatelessWidget { children: [ if (transportControls != null) ...[ transportControls!, - Divider(height: 1, color: Colors.white.withValues(alpha: 0.14)), + Divider(height: 1, color: colors.fg1.withValues(alpha: 0.14)), ], Padding( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 6), @@ -80,7 +76,7 @@ class MediaActionBar extends StatelessWidget { tooltip: isFavorite ? 'Remove from favorites' : 'Favorite', - color: isFavorite ? dark.accentText : null, + color: isFavorite ? colors.accentText : null, onTap: onFavorite, ), ViewerIconButton( @@ -98,7 +94,7 @@ class MediaActionBar extends StatelessWidget { ViewerIconButton( icon: LucideIcons.trash2, tooltip: 'Delete', - color: dark.danger, + color: colors.danger, onTap: isBusy ? null : onDelete, ), ViewerIconButton( diff --git a/lib/widgets/viewer/media_video_preview.dart b/lib/widgets/viewer/media_video_preview.dart index 51576ae..879bd97 100644 --- a/lib/widgets/viewer/media_video_preview.dart +++ b/lib/widgets/viewer/media_video_preview.dart @@ -161,7 +161,7 @@ class VideoTransportControls extends StatelessWidget { return AnimatedBuilder( animation: controller, builder: (context, _) { - const fg = Colors.white; + final fg = context.nooColors.fg1; return Padding( padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4), child: Column( diff --git a/lib/widgets/viewer/viewer_icon_button.dart b/lib/widgets/viewer/viewer_icon_button.dart index 1505b4e..65cfcc0 100644 --- a/lib/widgets/viewer/viewer_icon_button.dart +++ b/lib/widgets/viewer/viewer_icon_button.dart @@ -1,22 +1,22 @@ import 'package:flutter/material.dart'; +import '../../theme/design_tokens.dart'; /// Icon-only action button for the media viewer's floating chrome (top bar, /// bottom action bar, video transport controls) - a fixed 22px icon in a -/// round hit box. Colored white by default, not from [NooColors]/ -/// [ColorScheme]: this chrome always sits on the dark translucent panel -/// over a black media stage (see `FrostedGlassContainer`'s doc comment), -/// regardless of the app's light/dark theme, so a theme-derived color would -/// go near-invisible in light mode. Kept as one small widget so every -/// floating control in this screen looks the same instead of drifting -/// apart across the three call sites that need one. +/// round hit box. Colored from [NooColors.fg1] by default, matching +/// [FrostedGlassContainer]'s own theme-following panel underneath it (both +/// used to be fixed white-on-black regardless of theme - see their git +/// history). Kept as one small widget so every floating control in this +/// screen looks the same instead of drifting apart across the three call +/// sites that need one. class ViewerIconButton extends StatelessWidget { final IconData icon; final String tooltip; final VoidCallback? onTap; - /// Overrides the default white (e.g. `danger` for delete, `accent-text` - /// for a favorited state). Ignored while [onTap] is null - a disabled - /// button always dims to faded white regardless of its normal color. + /// Overrides the default [NooColors.fg1] (e.g. `danger` for delete, + /// `accent-text` for a favorited state). Ignored while [onTap] is null - + /// a disabled button always dims regardless of its normal color. final Color? color; const ViewerIconButton({ @@ -29,9 +29,8 @@ class ViewerIconButton extends StatelessWidget { @override Widget build(BuildContext context) { - final fg = onTap == null - ? Colors.white.withValues(alpha: 0.4) - : (color ?? Colors.white); + final fg1 = context.nooColors.fg1; + final fg = onTap == null ? fg1.withValues(alpha: 0.4) : (color ?? fg1); return Tooltip( message: tooltip, diff --git a/test/widgets/noo/overlays_test.dart b/test/widgets/noo/overlays_test.dart index 222e75b..f0e4a53 100644 --- a/test/widgets/noo/overlays_test.dart +++ b/test/widgets/noo/overlays_test.dart @@ -18,6 +18,43 @@ Widget _launcher(void Function(BuildContext) open) => Builder( TextButton(onPressed: () => open(context), child: const Text('open')), ); +/// A short placeholder that swaps in a long list one microtask later - +/// standing in for a sheet tab that fetches its content on first build +/// (`_requested`, as `DetailsVersionsTab`/`DetailsActivityTab` do) rather +/// than having it all synchronously on the very first frame. +class _AsyncGrowingContent extends StatefulWidget { + const _AsyncGrowingContent(); + + @override + State<_AsyncGrowingContent> createState() => _AsyncGrowingContentState(); +} + +class _AsyncGrowingContentState extends State<_AsyncGrowingContent> { + bool _loaded = false; + + @override + void initState() { + super.initState(); + Future.delayed(Duration.zero, () { + if (mounted) setState(() => _loaded = true); + }); + } + + @override + Widget build(BuildContext context) { + if (!_loaded) { + return const SizedBox(height: 40, child: Center(child: Text('Loading'))); + } + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (var i = 0; i < 30; i++) + SizedBox(height: 40, child: Text('Row $i')), + ], + ); + } +} + Finder _dialogCard() => find.byWidgetPredicate( (w) => w is Container && @@ -136,6 +173,38 @@ void main() { expect(sheetHeight, lessThan(500)); expect(sheetHeight, greaterThan(150)); }); + + testNooWidgets( + 'content that grows after an async load still switches to peek', + (tester, theme, c) async { + // Mirrors DetailsVersionsTab/DetailsActivityTab: a small placeholder + // first, then a long list once a fetch "completes" - the sheet has + // to catch the overflow whenever it happens, not just on its first + // frame (that one-shot approach is what shipped the original bug). + await pumpNoo( + tester, + _launcher( + (ctx) => showNooSheet( + ctx, + children: [const _AsyncGrowingContent()], + ), + ), + theme: theme, + surfaceSize: const Size(400, 700), + ); + await tester.tap(find.text('open')); + await tester.pump(); + expect(find.byType(DraggableScrollableSheet), findsNothing); + + await tester.pumpAndSettle(); + expect(find.byType(DraggableScrollableSheet), findsOneWidget); + final sheetHeight = tester + .getSize(find.byType(DraggableScrollableSheet)) + .height; + expect(sheetHeight, lessThan(500)); + expect(sheetHeight, greaterThan(150)); + }, + ); }); group('NooDialog', () { From a127d72d5660f06237e2631a71f550655d614d7d Mon Sep 17 00:00:00 2001 From: ayushya Date: Mon, 28 Sep 2026 22:55:31 -0400 Subject: [PATCH 12/29] 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. --- .../context/design-system/DESIGN_SYSTEM.md | 39 ++++- lib/providers/shares_controller.dart | 45 +++++- lib/views/account_view.dart | 147 ++++++++++++++---- lib/views/file_viewer_screen.dart | 20 +++ lib/views/files_view.dart | 86 +--------- lib/views/photos_view.dart | 139 +++++++++-------- lib/views/shares_view.dart | 94 +++++++++-- lib/widgets/files_controls_row.dart | 69 ++++---- lib/widgets/noo/lists/noo_banner.dart | 52 ++++--- lib/widgets/noo/lists/noo_summary_card.dart | 50 +++--- lib/widgets/settings/settings_appearance.dart | 92 +++++++---- lib/widgets/viewer/media_image_preview.dart | 17 ++ lib/widgets/viewer/media_pdf_preview.dart | 20 ++- 13 files changed, 556 insertions(+), 314 deletions(-) diff --git a/.claude/context/design-system/DESIGN_SYSTEM.md b/.claude/context/design-system/DESIGN_SYSTEM.md index 77b4eee..05d61cf 100644 --- a/.claude/context/design-system/DESIGN_SYSTEM.md +++ b/.claude/context/design-system/DESIGN_SYSTEM.md @@ -207,8 +207,13 @@ multi-select) **Banner / summary card** - A surface card with radius 20 (surface-2 on desktop) and padding of 14–18. -- Trash: an info icon, the retention text, and "Empty trash" as a danger text button. -- Offline: the stat, a 6px progress bar, the last-sync meta, and a "Sync now" tonal button. +- 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. @@ -224,6 +229,13 @@ multi-select) - 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. @@ -304,7 +316,13 @@ Sidebar items are 38px tall with radius 12, an 18px icon and a 14/500 label. The - 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:** type chips (All, Photos, Videos, Camera), then the photo grid grouped by month. The grid view is the only view. +- **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. 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". @@ -326,7 +344,12 @@ Sidebar items are 38px tall with radius 12, an 18px icon and a 14/500 label. The slots, the rest sit behind "More". See §2 "Selection action bar". 9. Swipe on a file - Mobile uses one column of grouped lists. Desktop uses a 2-column grid of cards with a 1px line and radius 20. + Mobile uses one column of grouped lists, with a trailing jump rail (one + small icon per section, pinned where the scrollbar would sit) since the + full list runs long enough that scrolling to a specific section by hand + is slow - tapping an icon scrolls straight to that section. Desktop uses + a 2-column grid of cards with a 1px line and radius 20, wide enough to + see most sections without scrolling, so it doesn't get the rail. - **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 ▾"). @@ -358,7 +381,13 @@ Sidebar items are 38px tall with radius 12, an 18px icon and a 14/500 label. The 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. + 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 diff --git a/lib/providers/shares_controller.dart b/lib/providers/shares_controller.dart index fe6c5aa..ab5b31b 100644 --- a/lib/providers/shares_controller.dart +++ b/lib/providers/shares_controller.dart @@ -8,7 +8,16 @@ import 'session_controller.dart'; class SharesController extends ChangeNotifier { final SessionController session; - List _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 _withMeShares = []; + List _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 get shares => _shares; + List 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 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 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; diff --git a/lib/views/account_view.dart b/lib/views/account_view.dart index 5861d5e..1058de8 100644 --- a/lib/views/account_view.dart +++ b/lib/views/account_view.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../theme/design_tokens.dart'; import '../widgets/noo/nav/noo_top_bar.dart'; import '../widgets/noo/nav/noo_toolbar.dart'; @@ -13,27 +14,35 @@ import '../widgets/settings/settings_security.dart'; import '../widgets/settings/settings_swipe.dart'; import '../widgets/settings/settings_tabs.dart'; +/// One entry in [AccountView._sections] - [icon] and [label] are only used +/// by [_SectionJumpRail]'s tap targets, not shown next to the section +/// itself (each section already titles itself). +typedef _Section = (String label, IconData icon, Widget child); + /// Settings, pushed on top of the shell (DESIGN_SYSTEM.md 4's 9-section /// order: account card, accounts, security, file sync, files cache, /// appearance, tabs, action bar, swipe on a file). One column of /// [SettingsSection]s (and [SettingsActionBarSection], which has no option /// rows of its own to put in one - just the reorder list) on -/// mobile; a 2-column grid of cards on desktop - see each +/// mobile, with a trailing jump rail (see [_SectionJumpRail]) since the +/// list runs long enough that finding a specific section by scrolling +/// alone is slow; a 2-column grid of cards on desktop, wide enough to see +/// most sections without scrolling, so it doesn't get one. See each /// `widgets/settings/*.dart` file for a section's own content and any /// setting that had to be slotted in or grouped under "Advanced appearance". class AccountView extends StatelessWidget { const AccountView({super.key}); - static const _sections = [ - SettingsAccountCard(), - SettingsAccountsSection(), - SettingsSecuritySection(), - SettingsFileSyncSection(), - SettingsFilesCacheSection(), - SettingsAppearanceSection(), - SettingsTabsSection(), - SettingsActionBarSection(), - SettingsSwipeSection(), + static const _sections = <_Section>[ + ('Account', LucideIcons.circleUser, SettingsAccountCard()), + ('Accounts', LucideIcons.users, SettingsAccountsSection()), + ('Security', LucideIcons.shield, SettingsSecuritySection()), + ('File sync', LucideIcons.folderSync, SettingsFileSyncSection()), + ('Files cache', LucideIcons.hardDrive, SettingsFilesCacheSection()), + ('Appearance', LucideIcons.sunMoon, SettingsAppearanceSection()), + ('Tabs', LucideIcons.layoutGrid, SettingsTabsSection()), + ('Action bar', LucideIcons.slidersHorizontal, SettingsActionBarSection()), + ('Swipe', LucideIcons.moveHorizontal, SettingsSwipeSection()), ]; @override @@ -53,37 +62,123 @@ class AccountView extends StatelessWidget { body: SafeArea( top: false, child: desktop - ? _DesktopGrid(sections: _sections) + ? _DesktopGrid(sections: [for (final s in _sections) s.$3]) : _MobileList(sections: _sections), ), ); } } -class _MobileList extends StatelessWidget { - final List sections; +/// A stable per-section identity, shared between the [KeyedSubtree] each +/// section is wrapped in and [_SectionJumpRail]'s lookup - [GlobalObjectKey] +/// compares by [String] equality, not instance identity, so building a new +/// one from the same label on each side still resolves to the same key. +GlobalKey _sectionKey(String label) => GlobalObjectKey(label); + +class _MobileList extends StatefulWidget { + final List<_Section> sections; const _MobileList({required this.sections}); + @override + State<_MobileList> createState() => _MobileListState(); +} + +class _MobileListState extends State<_MobileList> { + final _scrollController = ScrollController(); + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + void _jumpTo(String label) { + final target = _sectionKey(label).currentContext; + if (target == null) return; + Scrollable.ensureVisible( + target, + duration: NooMotion.slow, + curve: NooMotion.ease, + alignment: 0.05, + ); + } + @override Widget build(BuildContext context) { - return ListView( - padding: const EdgeInsets.fromLTRB( - NooSpace.sm, - NooSpace.sm, - NooSpace.sm, - NooSpace.xxl, - ), - physics: const BouncingScrollPhysics(), + return Stack( children: [ - for (final section in sections) ...[ - section, - const SizedBox(height: NooSpace.xl), - ], + ListView( + controller: _scrollController, + padding: const EdgeInsets.fromLTRB( + NooSpace.sm, + NooSpace.sm, + NooSpace.xxl, + NooSpace.xxl, + ), + physics: const BouncingScrollPhysics(), + children: [ + for (final (label, _, child) in widget.sections) ...[ + KeyedSubtree(key: _sectionKey(label), child: child), + const SizedBox(height: NooSpace.xl), + ], + ], + ), + Positioned( + top: 0, + bottom: 0, + right: 2, + child: _SectionJumpRail(sections: widget.sections, onTap: _jumpTo), + ), ], ); } } +/// A vertical rail of small per-section icons pinned to the trailing edge, +/// sitting where the scrollbar would otherwise be - tapping one jumps +/// straight to that section instead of scrolling the whole (fairly long) +/// Settings list by hand. Each icon is a generous 32px tap target even +/// though the rail itself is slim, so this stays usable one-handed. +class _SectionJumpRail extends StatelessWidget { + final List<_Section> sections; + final ValueChanged onTap; + + const _SectionJumpRail({required this.sections, required this.onTap}); + + @override + Widget build(BuildContext context) { + final colors = context.nooColors; + return Center( + child: Container( + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 2), + decoration: BoxDecoration( + color: colors.surface, + borderRadius: BorderRadius.circular(NooRadii.pill), + border: Border.all(color: colors.line), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final (label, icon, _) in sections) + Semantics( + button: true, + label: 'Jump to $label', + child: InkResponse( + onTap: () => onTap(label), + radius: 18, + child: Padding( + padding: const EdgeInsets.all(6), + child: Icon(icon, size: 16, color: colors.fg3), + ), + ), + ), + ], + ), + ), + ); + } +} + /// Splits the sections between two columns rather than a strict grid, since /// each card's content height varies a lot (the tab reorder list and the /// accounts list can both run much taller than, say, Security) - a fixed diff --git a/lib/views/file_viewer_screen.dart b/lib/views/file_viewer_screen.dart index 65f5f16..7da59c8 100644 --- a/lib/views/file_viewer_screen.dart +++ b/lib/views/file_viewer_screen.dart @@ -148,6 +148,12 @@ class _FileViewerScreenState extends State { /// `VideoTransportControls` inside the bottom `MediaActionBar` panel. VideoPlayerController? _videoController; + /// Whether the *current* page's `MediaImagePreview` is zoomed in - see + /// `_buildBody`'s `onZoomChanged`. Disables the `PageView`'s own + /// left/right swipe while true, so panning around a zoomed photo doesn't + /// also drag the gallery to the next item. + bool _isZoomed = false; + NextcloudItem get _currentItem => _mediaItems[_currentIndex]; bool get _isOffline => widget.localPathResolver != null; @@ -552,6 +558,10 @@ class _FileViewerScreenState extends State { return PageView.builder( controller: _pageController, + // Zoomed into the current photo: let InteractiveViewer's own pan + // claim horizontal drags instead of the PageView swiping to the next + // item out from under it. + physics: _isZoomed ? const NeverScrollableScrollPhysics() : null, itemCount: _mediaItems.length, onPageChanged: (index) => setState(() { _currentIndex = index; @@ -560,6 +570,10 @@ class _FileViewerScreenState extends State { // but that can lag a frame or two behind this rebuild - clear // eagerly so the transport row never briefly shows the old video. _videoController = null; + // The new page always starts unzoomed - and the old page's own + // listener is now scoped away from `_isZoomed` (see `isActive` + // below), so nothing else would reset this. + _isZoomed = false; }), itemBuilder: (context, index) { final mediaItem = _mediaItems[index]; @@ -570,6 +584,9 @@ class _FileViewerScreenState extends State { key: ValueKey(mediaItem.id), url: session.service!.fileUrl(mediaItem.path), headers: session.service!.authHeaders, + onZoomChanged: isActive + ? (zoomed) => setState(() => _isZoomed = zoomed) + : null, ); } return MediaVideoPreview( @@ -613,6 +630,9 @@ class _FileViewerScreenState extends State { url: null, headers: const {}, localPath: localPath, + onZoomChanged: isActive + ? (zoomed) => setState(() => _isZoomed = zoomed) + : null, ); } return MediaVideoPreview( diff --git a/lib/views/files_view.dart b/lib/views/files_view.dart index a8cf266..4ef84e9 100644 --- a/lib/views/files_view.dart +++ b/lib/views/files_view.dart @@ -227,12 +227,9 @@ class _GridThumbnail extends StatelessWidget { } } -class _FilesViewState extends State - with SingleTickerProviderStateMixin { +class _FilesViewState extends State { final Set _selectedIds = {}; int _lastPathDepth = 1; - final ScrollController _controlsScrollController = ScrollController(); - final List _scrollHintControllers = []; bool get _isSelecting => _selectedIds.isNotEmpty; @@ -264,84 +261,6 @@ class _FilesViewState extends State // that just never got its initial fetch. bool _requestedInitialLoad = false; - @override - void initState() { - super.initState(); - WidgetsBinding.instance.addPostFrameCallback( - (_) => _playScrollHint(_controlsScrollController), - ); - } - - /// A one-shot hint that a horizontally-scrollable row actually scrolls: - /// nudges it a little to the right and back, once - a motion cue instead - /// of a persistent widget (a chevron badge, an edge fade) sitting on top - /// of the actual controls the whole time. Shared by the controls row - /// (played once it first appears) and the selection actions row (played - /// the first time a selection starts, see `_toggleSelection`). No-ops if - /// there's nothing to scroll (row already fits). - Future _playScrollHint(ScrollController scrollController) async { - // The delay lets the row's first frame (and its actual layout/max - // scroll extent) settle before nudging it, and reads more like a - // deliberate hint than something that happens to fire on load. - await Future.delayed(const Duration(milliseconds: 500)); - // `hasClients` only means a position is attached, not that it's - // finished its first layout - reading maxScrollExtent before that - // throws (min/maxScrollExtent are still null internally). - if (!mounted || - !scrollController.hasClients || - !scrollController.position.hasContentDimensions) { - return; - } - final maxExtent = scrollController.position.maxScrollExtent; - if (maxExtent <= 0) return; - final double peak = maxExtent < 36 ? maxExtent : 36; - // Driven as a single controller (rather than two chained animateTo - // calls) with mirrored ease-in-out halves, so the motion decelerates - // smoothly into the peak and back out instead of visibly changing - // pace where the two legs meet. - final controller = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 900), - ); - _scrollHintControllers.add(controller); - final hint = TweenSequence([ - TweenSequenceItem( - tween: Tween( - begin: 0.0, - end: peak, - ).chain(CurveTween(curve: Curves.easeInOutSine)), - weight: 50, - ), - TweenSequenceItem( - tween: Tween( - begin: peak, - end: 0.0, - ).chain(CurveTween(curve: Curves.easeInOutSine)), - weight: 50, - ), - ]).animate(controller); - void onTick() { - if (scrollController.hasClients) { - scrollController.jumpTo(hint.value); - } - } - - hint.addListener(onTick); - await controller.forward(); - hint.removeListener(onTick); - _scrollHintControllers.remove(controller); - controller.dispose(); - } - - @override - void dispose() { - for (final controller in _scrollHintControllers) { - controller.dispose(); - } - _controlsScrollController.dispose(); - super.dispose(); - } - void _toggleSelection(NextcloudItem item) { HapticFeedback.selectionClick(); setState(() { @@ -703,13 +622,10 @@ class _FilesViewState extends State children: [ Row( children: [ - // The scroll-hint nudge (see _playScrollHint) plays on - // this row once, right after it first appears. Expanded( child: FilesControlsRow( folderPath: browser.currentFolderPath, showStorageScope: !_offline, - scrollController: _controlsScrollController, ), ), if (_offline) ...[ diff --git a/lib/views/photos_view.dart b/lib/views/photos_view.dart index 40dcc67..eae28ef 100644 --- a/lib/views/photos_view.dart +++ b/lib/views/photos_view.dart @@ -381,51 +381,36 @@ class _PhotosViewState extends State { FilesController files, ) { final filtersActive = + _typeFilter != _PhotoTypeFilter.all || photos.showFavoritesOnly || photos.showHidden || files.storageScope != StorageScope.cloud; + // A plain Row, not a horizontally-scrolling one - see + // `FilesControlsRow`'s identical fix/doc comment: a `SingleChildScrollView` + // gives its child unbounded width for no benefit here (two chips never + // need to scroll), and it's actively harmful for a row with a trailing + // `Spacer`/flex child. return SizedBox( height: 44, - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: [ - NooChip( - icon: photos.sortAscending - ? LucideIcons.arrowUp - : LucideIcons.arrowDown, - onTap: () => _showSortSheet(context, photos), - child: Text(sortFieldLabel(photos.sortField)), - ), - const SizedBox(width: 8), - NooChip( - selected: _typeFilter == _PhotoTypeFilter.all, - onTap: () => setState(() => _typeFilter = _PhotoTypeFilter.all), - child: const Text('All'), - ), - const SizedBox(width: 8), - NooChip( - selected: _typeFilter == _PhotoTypeFilter.image, - onTap: () => setState(() => _typeFilter = _PhotoTypeFilter.image), - child: const Text('Photos'), - ), - const SizedBox(width: 8), - NooChip( - selected: _typeFilter == _PhotoTypeFilter.video, - onTap: () => setState(() => _typeFilter = _PhotoTypeFilter.video), - child: const Text('Videos'), - ), - const SizedBox(width: 8), - NooChip( - icon: LucideIcons.filter, - trailing: NooChipTrailing.menu, - selected: filtersActive, - onTap: () => _showFilterSheet(context, photos, files), - child: const Text('Filter'), - ), - ], - ), + child: Row( + children: [ + NooChip( + icon: photos.sortAscending + ? LucideIcons.arrowUp + : LucideIcons.arrowDown, + onTap: () => _showSortSheet(context, photos), + child: Text(sortFieldLabel(photos.sortField)), + ), + const SizedBox(width: 8), + NooChip( + icon: LucideIcons.filter, + trailing: NooChipTrailing.menu, + selected: filtersActive, + onTap: () => _showFilterSheet(context, photos, files), + child: const Text('Filters'), + ), + ], ), ); } @@ -503,33 +488,59 @@ class _PhotosViewState extends State { // toggles span two controllers. ListenableBuilder( listenable: Listenable.merge([photos, files]), - builder: (context, _) => NooGroupedList( + builder: (context, _) => Column( + mainAxisSize: MainAxisSize.min, children: [ - NooSettingsRow( - icon: LucideIcons.heart, - label: const Text('Favorites only'), - trailing: NooToggle( - checked: photos.showFavoritesOnly, - onChanged: (_) => photos.toggleFavoritesFilter(), - ), - ), - NooSettingsRow( - icon: LucideIcons.eye, - label: const Text('Show hidden files'), - trailing: NooToggle( - checked: photos.showHidden, - onChanged: (_) => photos.toggleShowHidden(), - ), - ), - NooSettingsRow( - icon: LucideIcons.hardDrive, - label: const Text('External storage'), - trailing: NooToggle( - checked: files.storageScope == StorageScope.external, - onChanged: (external) => files.setStorageScope( - external ? StorageScope.external : StorageScope.cloud, + // Same segmented-control design as Settings' Theme row - a + // full-width pill track, not chips, and its own row rather + // than a settings row's trailing slot (matches the + // Ascending/Descending control above, in `_showSortSheet`). + NooSegmentedControl<_PhotoTypeFilter>( + fill: true, + value: _typeFilter, + onChanged: (filter) => setState(() => _typeFilter = filter), + options: const [ + NooSegmentOption(value: _PhotoTypeFilter.all, label: 'All'), + NooSegmentOption( + value: _PhotoTypeFilter.image, + label: 'Photos', ), - ), + NooSegmentOption( + value: _PhotoTypeFilter.video, + label: 'Videos', + ), + ], + ), + const SizedBox(height: 22), + NooGroupedList( + children: [ + NooSettingsRow( + icon: LucideIcons.heart, + label: const Text('Favorites only'), + trailing: NooToggle( + checked: photos.showFavoritesOnly, + onChanged: (_) => photos.toggleFavoritesFilter(), + ), + ), + NooSettingsRow( + icon: LucideIcons.eye, + label: const Text('Show hidden files'), + trailing: NooToggle( + checked: photos.showHidden, + onChanged: (_) => photos.toggleShowHidden(), + ), + ), + NooSettingsRow( + icon: LucideIcons.hardDrive, + label: const Text('External storage'), + trailing: NooToggle( + checked: files.storageScope == StorageScope.external, + onChanged: (external) => files.setStorageScope( + external ? StorageScope.external : StorageScope.cloud, + ), + ), + ), + ], ), ], ), diff --git a/lib/views/shares_view.dart b/lib/views/shares_view.dart index 3d398a4..acb342a 100644 --- a/lib/views/shares_view.dart +++ b/lib/views/shares_view.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../models/app_tab.dart'; @@ -11,16 +12,21 @@ import '../widgets/noo/core/noo_segmented_control.dart'; import '../widgets/noo/files/noo_file_kind.dart'; import '../widgets/noo/files/noo_file_row.dart'; import '../widgets/noo/files/noo_file_table.dart'; +import '../widgets/noo/lists/noo_grouped_list.dart'; +import '../widgets/noo/lists/noo_settings_row.dart'; import '../widgets/noo/noo_layout.dart'; +import '../widgets/noo/overlays/noo_sheet.dart'; import '../widgets/tabs/tab_state_slivers.dart'; /// Which slice of [SharesController.shares] is on screen - `With you` maps /// straight onto the controller's own `sharedWithMe: true` fetch, but the /// controller only ever exposes one boolean scope, so `By you` and `Links` /// both use its `sharedWithMe: false` fetch and are told apart client-side -/// by [NextcloudShare.shareType] (see `_visibleShares`) - `Links` and -/// `By you` never trigger a re-fetch when switching between each other, -/// only when crossing to/from `With you`. +/// by [NextcloudShare.shareType] (see `_visibleShares`). `SharesController` +/// caches each of its two scopes after its first fetch, so switching +/// between any of the three - including back to `With you` after leaving +/// it - only re-fetches a scope that's never been loaded yet, not on every +/// single switch. enum _ShareScope { withYou, byYou, links } IconData _shareTypeIcon(ShareType type) { @@ -110,7 +116,11 @@ class _SharesViewState extends State { onRetry: sharesController.fetchAll, ) else if (shares.isEmpty) - tabEmptySliver(context, icon: LucideIcons.share2, message: _emptyMessage()) + tabEmptySliver( + context, + icon: LucideIcons.share2, + message: _emptyMessage(), + ) else if (isDesktop) _buildDesktopTable(context, shares) else @@ -137,9 +147,13 @@ class _SharesViewState extends State { case _ShareScope.withYou: return c.shares; case _ShareScope.byYou: - return c.shares.where((s) => s.shareType != ShareType.publicLink).toList(); + return c.shares + .where((s) => s.shareType != ShareType.publicLink) + .toList(); case _ShareScope.links: - return c.shares.where((s) => s.shareType == ShareType.publicLink).toList(); + return c.shares + .where((s) => s.shareType == ShareType.publicLink) + .toList(); } } @@ -175,13 +189,17 @@ class _SharesViewState extends State { delegate: SliverChildBuilderDelegate((context, index) { final share = shares[index]; final row = NooFileRow( - kind: NooFileKind.from(name: share.name, isDirectory: share.isFolder), + kind: NooFileKind.from( + name: share.name, + isDirectory: share.isFolder, + ), name: share.name, - meta: '${share.ownerDisplayName} · ${_permissionLabel(share.permissions)}', + meta: + '${share.ownerDisplayName} · ${_permissionLabel(share.permissions)}', iosStyle: NooLayout.iosStyle(context), onTap: share.isFolder ? () => _openFolder(context, share) : null, trailing: Icon(_shareTypeIcon(share.shareType), size: 16), - onMore: () => _confirmUnshare(context, share), + onMore: () => _showShareActions(context, share), ); final isFirst = index == 0; final isLast = index == shares.length - 1; @@ -215,16 +233,22 @@ class _SharesViewState extends State { sliver: SliverList( delegate: SliverChildBuilderDelegate((context, index) { if (index == 0) { - return const NooFileTableHeader(col2Label: 'Owner', col3Label: 'Permission'); + return const NooFileTableHeader( + col2Label: 'Owner', + col3Label: 'Permission', + ); } final share = shares[index - 1]; return NooFileTableRow( - kind: NooFileKind.from(name: share.name, isDirectory: share.isFolder), + kind: NooFileKind.from( + name: share.name, + isDirectory: share.isFolder, + ), name: share.name, col2: share.ownerDisplayName, col3: _permissionLabel(share.permissions), onTap: share.isFolder ? () => _openFolder(context, share) : null, - onMore: () => _confirmUnshare(context, share), + onMore: () => _showShareActions(context, share), ); }, childCount: shares.length + 1), ), @@ -240,6 +264,52 @@ class _SharesViewState extends State { context.read().requestTab(AppTab.files); } + /// The row/table overflow menu - used to jump straight to the unshare + /// confirmation with no menu at all, so a link share had no way to copy + /// its own URL short of opening it and finding "Copy link" inside the + /// full Share sheet. A link share gets that shortcut here too now; + /// everything else just gets the one remove/unshare action. + void _showShareActions(BuildContext context, NextcloudShare share) { + final isLink = share.shareType == ShareType.publicLink && share.url != null; + showNooSheet( + context, + children: [ + NooGroupedList( + children: [ + if (isLink) + NooSettingsRow( + icon: LucideIcons.copy, + label: const Text('Copy link'), + onTap: () { + Navigator.pop(context); + _copyShareLink(context, share); + }, + ), + NooSettingsRow( + icon: LucideIcons.trash2, + label: Text(share.sharedWithMe ? 'Remove share' : 'Unshare'), + danger: true, + onTap: () { + Navigator.pop(context); + _confirmUnshare(context, share); + }, + ), + ], + ), + ], + ); + } + + void _copyShareLink(BuildContext context, NextcloudShare share) { + Clipboard.setData(ClipboardData(text: share.url!)); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Link copied'), + behavior: SnackBarBehavior.floating, + ), + ); + } + Future _confirmUnshare( BuildContext context, NextcloudShare share, diff --git a/lib/widgets/files_controls_row.dart b/lib/widgets/files_controls_row.dart index d47c5b3..1af9bec 100644 --- a/lib/widgets/files_controls_row.dart +++ b/lib/widgets/files_controls_row.dart @@ -32,16 +32,21 @@ String _typeFilterLabel(FilesTypeFilter filter) { /// Hidden-files and storage-scope, which have no home in the mockup's /// simple two-chip row, live inside the filter chip's sheet instead of as /// their own row controls. +/// +/// A plain `Row`, not a horizontally-scrolling one - it used to wrap in a +/// `SingleChildScrollView`, which gives its child unbounded width, silently +/// breaking the trailing `Spacer` (a flex child needs a bounded width to +/// size against) and leaving the List/Grid toggle sitting right after the +/// filter chip instead of pinned to the row's right edge. The two chips +/// plus an icon-only toggle never come close to needing to scroll anyway. class FilesControlsRow extends StatelessWidget { final String folderPath; final bool showStorageScope; - final ScrollController? scrollController; const FilesControlsRow({ super.key, required this.folderPath, this.showStorageScope = true, - this.scrollController, }); @override @@ -54,38 +59,34 @@ class FilesControlsRow extends StatelessWidget { return SizedBox( height: 44, - child: SingleChildScrollView( - controller: scrollController, - scrollDirection: Axis.horizontal, - child: Row( - children: [ - NooChip( - icon: files.sortAscendingFor(folderPath) - ? LucideIcons.arrowUp - : LucideIcons.arrowDown, - onTap: () => _showSortSheet(context, files), - child: Text(sortFieldLabel(files.sortFieldFor(folderPath))), - ), - const SizedBox(width: 8), - NooChip( - icon: LucideIcons.filter, - trailing: NooChipTrailing.menu, - selected: filtersActive, - onTap: () => _showFilterSheet(context, files), - child: Text(_typeFilterLabel(files.filesTypeFilter)), - ), - const Spacer(), - NooSegmentedControl( - iconOnly: true, - value: files.isGridView, - onChanged: files.setGridView, - options: const [ - NooSegmentOption(value: false, icon: LucideIcons.list), - NooSegmentOption(value: true, icon: LucideIcons.grid), - ], - ), - ], - ), + child: Row( + children: [ + NooChip( + icon: files.sortAscendingFor(folderPath) + ? LucideIcons.arrowUp + : LucideIcons.arrowDown, + onTap: () => _showSortSheet(context, files), + child: Text(sortFieldLabel(files.sortFieldFor(folderPath))), + ), + const SizedBox(width: 8), + NooChip( + icon: LucideIcons.filter, + trailing: NooChipTrailing.menu, + selected: filtersActive, + onTap: () => _showFilterSheet(context, files), + child: Text(_typeFilterLabel(files.filesTypeFilter)), + ), + const Spacer(), + NooSegmentedControl( + iconOnly: true, + value: files.isGridView, + onChanged: files.setGridView, + options: const [ + NooSegmentOption(value: false, icon: LucideIcons.list), + NooSegmentOption(value: true, icon: LucideIcons.grid), + ], + ), + ], ), ); } diff --git a/lib/widgets/noo/lists/noo_banner.dart b/lib/widgets/noo/lists/noo_banner.dart index 06365f1..aa778f9 100644 --- a/lib/widgets/noo/lists/noo_banner.dart +++ b/lib/widgets/noo/lists/noo_banner.dart @@ -26,29 +26,45 @@ class NooBanner extends StatelessWidget { final colors = context.nooColors; return Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), - decoration: BoxDecoration(color: colors.surface, borderRadius: BorderRadius.circular(NooRadii.card)), - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, + decoration: BoxDecoration( + color: colors.surface, + borderRadius: BorderRadius.circular(NooRadii.card), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Icon(icon, size: 20, color: colors.fg2), - const SizedBox(width: 12), - Expanded( - child: DefaultTextStyle( - style: NooText.body.copyWith(color: colors.fg2), - child: child, - ), + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Icon(icon, size: 20, color: colors.fg2), + const SizedBox(width: 12), + Expanded( + child: DefaultTextStyle( + style: NooText.body.copyWith(color: colors.fg2), + child: child, + ), + ), + ], ), - if (actionLabel != null) - GestureDetector( - onTap: onAction, - child: Text( - actionLabel!, - style: NooText.buttonSm.copyWith( - fontSize: 14, - color: actionIsDanger ? colors.danger : colors.accentText, + // Its own row below the description text, not squeezed onto the + // same line - a long retention message and "Empty trash" used to + // fight for the same row's width. + if (actionLabel != null) ...[ + const SizedBox(height: 10), + Align( + alignment: Alignment.centerRight, + child: GestureDetector( + onTap: onAction, + child: Text( + actionLabel!, + style: NooText.buttonSm.copyWith( + fontSize: 14, + color: actionIsDanger ? colors.danger : colors.accentText, + ), ), ), ), + ], ], ), ); diff --git a/lib/widgets/noo/lists/noo_summary_card.dart b/lib/widgets/noo/lists/noo_summary_card.dart index 27ab47e..62ce3f3 100644 --- a/lib/widgets/noo/lists/noo_summary_card.dart +++ b/lib/widgets/noo/lists/noo_summary_card.dart @@ -10,6 +10,7 @@ enum NooSummaryCardTone { normal, danger, accent } class NooSummaryCard extends StatelessWidget { final String stat; final String? caption; + /// 0-1; renders a [NooProgressBar]. final double? progress; final String? meta; @@ -29,41 +30,44 @@ class NooSummaryCard extends StatelessWidget { @override Widget build(BuildContext context) { final colors = context.nooColors; - final bg = tone == NooSummaryCardTone.danger ? colors.dangerSoft : colors.surface; + final bg = tone == NooSummaryCardTone.danger + ? colors.dangerSoft + : colors.surface; final statColor = switch (tone) { NooSummaryCardTone.danger => colors.danger, NooSummaryCardTone.accent => colors.accentText, NooSummaryCardTone.normal => colors.fg1, }; - final capColor = tone == NooSummaryCardTone.danger ? colors.danger : colors.fg2; + final capColor = tone == NooSummaryCardTone.danger + ? colors.danger + : colors.fg2; return Container( padding: const EdgeInsets.all(18), - decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(NooRadii.card)), + decoration: BoxDecoration( + color: bg, + borderRadius: BorderRadius.circular(NooRadii.card), + ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(stat, style: NooText.stat.copyWith(color: statColor)), - if (caption != null) ...[ - const SizedBox(height: 6), - Text( - caption!, - style: NooText.body.copyWith(fontSize: 14, height: 1.2, color: capColor), - ), - ], - ], - ), + Text(stat, style: NooText.stat.copyWith(color: statColor)), + if (caption != null) ...[ + const SizedBox(height: 6), + Text( + caption!, + style: NooText.body.copyWith( + fontSize: 14, + height: 1.2, + color: capColor, ), - ?action, - ], - ), + ), + ], + // Its own row below the description, not squeezed beside the + // stat/caption - there wasn't room to grow the action (e.g. a + // labelled "Sync now" button) without it colliding with a long + // caption. + if (action != null) ...[const SizedBox(height: 14), action!], if (progress != null) ...[ const SizedBox(height: 14), NooProgressBar(value: progress!), diff --git a/lib/widgets/settings/settings_appearance.dart b/lib/widgets/settings/settings_appearance.dart index 26d333e..8ec884a 100644 --- a/lib/widgets/settings/settings_appearance.dart +++ b/lib/widgets/settings/settings_appearance.dart @@ -47,34 +47,7 @@ class SettingsAppearanceSection extends StatelessWidget { children: [ SettingsSection( title: 'Appearance', - children: [ - NooSettingsRow( - icon: LucideIcons.sunMoon, - label: const Text('Theme'), - trailing: NooSegmentedControl( - size: NooSegmentedSize.sm, - value: settings.themeMode, - onChanged: settings.setThemeMode, - options: const [ - NooSegmentOption( - value: ThemeMode.system, - icon: LucideIcons.monitor, - label: 'System', - ), - NooSegmentOption( - value: ThemeMode.light, - icon: LucideIcons.sun, - label: 'Light', - ), - NooSegmentOption( - value: ThemeMode.dark, - icon: LucideIcons.moon, - label: 'Dark', - ), - ], - ), - ), - ], + children: [_ThemeRow(settings: settings)], ), const SizedBox(height: NooSpace.xl), SettingsSection( @@ -120,6 +93,69 @@ class SettingsAppearanceSection extends StatelessWidget { } } +/// The "Theme" row: label on its own line, then the System/Light/Dark +/// segmented control full-width on the line below - not the usual +/// label-plus-trailing-control `NooSettingsRow` layout, since three +/// icon+label segments crammed into a trailing slot next to the row's own +/// label left every segment too cramped to read comfortably. Matches the +/// same "label, then a full-width segmented control below it" shape +/// `_showSortSheet` (Ascending/Descending) and Photos' own filter sheet +/// (All/Photos/Videos) already use. +class _ThemeRow extends StatelessWidget { + final SettingsController settings; + + const _ThemeRow({required this.settings}); + + @override + Widget build(BuildContext context) { + final colors = context.nooColors; + return Material( + color: colors.surface, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: NooSpace.md, + vertical: 12, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(LucideIcons.sunMoon, size: 20, color: colors.fg2), + const SizedBox(width: 14), + Text('Theme', style: NooText.bodyL.copyWith(color: colors.fg1)), + ], + ), + const SizedBox(height: 12), + NooSegmentedControl( + fill: true, + value: settings.themeMode, + onChanged: settings.setThemeMode, + options: const [ + NooSegmentOption( + value: ThemeMode.system, + icon: LucideIcons.monitor, + label: 'System', + ), + NooSegmentOption( + value: ThemeMode.light, + icon: LucideIcons.sun, + label: 'Light', + ), + NooSegmentOption( + value: ThemeMode.dark, + icon: LucideIcons.moon, + label: 'Dark', + ), + ], + ), + ], + ), + ), + ); + } +} + /// The current accent-color choice, shown as a small filled circle in the /// "Accent color" row - a wallpaper icon when following the OS's dynamic /// color instead of a fixed swatch. diff --git a/lib/widgets/viewer/media_image_preview.dart b/lib/widgets/viewer/media_image_preview.dart index 79f2af1..0f268f7 100644 --- a/lib/widgets/viewer/media_image_preview.dart +++ b/lib/widgets/viewer/media_image_preview.dart @@ -15,11 +15,18 @@ class MediaImagePreview extends StatefulWidget { /// `FileViewerScreen.localPathResolver`'s doc comment). final String? localPath; + /// Reports whenever this image crosses the zoomed-in/out threshold (not + /// on every micro-change in scale) - lets the parent `PageView` disable + /// its own left/right swipe while zoomed in, so panning around a zoomed + /// photo doesn't fight with swiping to the next one. + final ValueChanged? onZoomChanged; + const MediaImagePreview({ super.key, required this.url, required this.headers, this.localPath, + this.onZoomChanged, }); @override @@ -35,6 +42,7 @@ class _MediaImagePreviewState extends State late final AnimationController _animController; Animation? _animation; Offset _doubleTapPosition = Offset.zero; + bool _isZoomed = false; @override void initState() { @@ -48,10 +56,19 @@ class _MediaImagePreviewState extends State _transformController.value = _animation!.value; } }); + _transformController.addListener(_handleTransformChanged); + } + + void _handleTransformChanged() { + final isZoomed = _transformController.value.getMaxScaleOnAxis() > 1.01; + if (isZoomed == _isZoomed) return; + _isZoomed = isZoomed; + widget.onZoomChanged?.call(isZoomed); } @override void dispose() { + _transformController.removeListener(_handleTransformChanged); _animController.dispose(); _transformController.dispose(); super.dispose(); diff --git a/lib/widgets/viewer/media_pdf_preview.dart b/lib/widgets/viewer/media_pdf_preview.dart index 16df16d..6a2b690 100644 --- a/lib/widgets/viewer/media_pdf_preview.dart +++ b/lib/widgets/viewer/media_pdf_preview.dart @@ -57,9 +57,7 @@ class _MediaPdfPreviewState extends State { } if (!mounted) return; setState(() { - _controller = PdfControllerPinch( - document: PdfDocument.openData(bytes), - ); + _controller = PdfControllerPinch(document: PdfDocument.openData(bytes)); }); } catch (e) { if (mounted) setState(() => _error = e.toString()); @@ -86,13 +84,13 @@ class _MediaPdfPreviewState extends State { if (_controller == null) { return Center(child: CircularProgressIndicator(color: colors.accent)); } - // pdfx's own default minScale (1.0 = the page's true/100% size) is - // often *larger* than the fit-to-width size a wide page first renders - // at, since that initial render is just normal box layout, not the - // InteractiveViewer transform pinching engages on first touch - so the - // moment you touch the page it snaps up to 1.0 and, with the default - // floor, can never pinch back down past it. A low floor here lets you - // zoom back out past that to the fit-width view you started at. - return PdfViewPinch(controller: _controller!, minScale: 0.3); + // Keep minScale at (not below) 1.0: pdfx's PdfViewPinch hard-codes an + // *infinite* boundaryMargin whenever minScale < 1 (its own + // pdf_view_pinch.dart), meaning the page could be panned arbitrarily + // far off-screen with no way back short of reopening the viewer, even + // without ever pinching to zoom. There's no way to override that + // margin from here - it isn't an exposed parameter - so this avoids + // the branch that sets it instead of fighting it. + return PdfViewPinch(controller: _controller!); } } From 4e6477a3ae26273fd87708468fafcd6043c99b2d Mon Sep 17 00:00:00 2001 From: ayushya Date: Mon, 28 Sep 2026 23:51:16 -0400 Subject: [PATCH 13/29] 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 --- .claude/context/architecture.md | 36 ++++++ lib/providers/session_controller.dart | 37 ++++-- test/providers/session_controller_test.dart | 120 ++++++++++++++++++++ 3 files changed, 184 insertions(+), 9 deletions(-) create mode 100644 test/providers/session_controller_test.dart diff --git a/.claude/context/architecture.md b/.claude/context/architecture.md index 7743f8b..2c0da71 100644 --- a/.claude/context/architecture.md +++ b/.claude/context/architecture.md @@ -153,6 +153,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 diff --git a/lib/providers/session_controller.dart b/lib/providers/session_controller.dart index e22d08e..2504d80 100644 --- a/lib/providers/session_controller.dart +++ b/lib/providers/session_controller.dart @@ -27,8 +27,7 @@ enum LoginFlowStatus { idle, initiating, awaitingBrowser, error } /// full rationale. class SessionController extends ChangeNotifier with WidgetsBindingObserver { final ConnectivityController connectivity; - final Future prefsFuture = - SharedPreferences.getInstance(); + final Future 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 _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(); } diff --git a/test/providers/session_controller_test.dart b/test/providers/session_controller_test.dart new file mode 100644 index 0000000..459977d --- /dev/null +++ b/test/providers/session_controller_test.dart @@ -0,0 +1,120 @@ +import 'dart:convert'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:noo/models/saved_account.dart'; +import 'package:noo/providers/connectivity_controller.dart'; +import 'package:noo/providers/session_controller.dart'; + +/// Regression coverage for the "empty list on first load" bug class: a +/// controller built by a *lazy* `ChangeNotifierProvider` (FilesController, +/// PhotosController, ...) can be constructed after +/// [SessionController]'s one-shot account-activated/ready event already +/// fired - e.g. `ConnectivityController` misreporting offline for a couple +/// of seconds right after a cold Android start hides the Files tab from +/// `main.dart`'s bottom nav, delaying `FilesController`'s construction +/// until after login already finished. `addAccountActivatedListener`/ +/// `addAccountReadyListener` must call back immediately for a listener +/// that registers after the fact, not only fire for the *next* login. +void main() { + const secureStorageChannel = MethodChannel( + 'plugins.it_nomads.com/flutter_secure_storage', + ); + const connectivityChannel = MethodChannel( + 'dev.fluttercommunity.plus/connectivity', + ); + const connectivityStatusChannel = EventChannel( + 'dev.fluttercommunity.plus/connectivity_status', + ); + + const accountId = 'server_example_com__alice'; + const passwordKey = 'nc_app_password_$accountId'; + const password = 'app-password'; + + TestWidgetsFlutterBinding.ensureInitialized(); + + setUpAll(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(secureStorageChannel, (call) async { + if (call.method == 'readAll') { + return {passwordKey: password}; + } + if (call.method == 'read') { + final key = (call.arguments as Map)['key'] as String?; + return key == passwordKey ? password : null; + } + return null; + }); + // Reports no network on every `checkConnectivity()` call, so + // `ConnectivityController` deterministically settles into `isOffline` + // and `SessionController` never attempts a real (and, in this + // sandboxed test environment, unreachable) HTTP call. + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(connectivityChannel, (call) async { + if (call.method == 'check') return ['none']; + return null; + }); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockStreamHandler( + connectivityStatusChannel, + MockStreamHandler.inline(onListen: (arguments, events) {}), + ); + }); + + Future pumpUntil(bool Function() condition) async { + for (var i = 0; i < 50 && !condition(); i++) { + await Future.delayed(Duration.zero); + } + } + + test('addAccountReadyListener calls back immediately for a listener that ' + 'registers after a provisional/offline login already happened', () async { + SharedPreferences.setMockInitialValues({ + 'account_migration_v1_done': true, + 'accounts_list': jsonEncode([ + const SavedAccount( + id: accountId, + serverUrl: 'https://server.example.com', + username: 'alice', + ).toJson(), + ]), + 'active_account_id': accountId, + }); + + final connectivity = ConnectivityController(); + await pumpUntil(() => connectivity.isOffline); + expect(connectivity.isOffline, true); + + final session = SessionController(connectivity); + await pumpUntil(() => session.isLoggedIn); + expect(session.isLoggedIn, true); + + // The fix under test: this mirrors what FilesController's/ + // OfflineController's own constructor does, but simulated as + // happening *after* the event above already fired - exactly the + // lazy-Provider race this test guards against. + var readyCalls = 0; + session.addAccountReadyListener(() => readyCalls++); + expect(readyCalls, 1); + + connectivity.dispose(); + session.dispose(); + }); + + test('addAccountReadyListener does not call back before any account is ' + 'active', () async { + SharedPreferences.setMockInitialValues({'account_migration_v1_done': true}); + + final connectivity = ConnectivityController(); + final session = SessionController(connectivity); + await pumpUntil(() => !session.isRestoringSession); + expect(session.isLoggedIn, false); + + var readyCalls = 0; + session.addAccountReadyListener(() => readyCalls++); + expect(readyCalls, 0); + + connectivity.dispose(); + session.dispose(); + }); +} From f9653bc0f2a3a29419773454a09c8b70986b4a92 Mon Sep 17 00:00:00 2001 From: ayushya Date: Tue, 29 Sep 2026 03:49:24 -0400 Subject: [PATCH 14/29] 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 --- .../context/design-system/DESIGN_SYSTEM.md | 24 ++- .claude/context/styling.md | 4 +- lib/services/nextcloud_service.dart | 33 ++-- lib/views/account_view.dart | 159 ++++-------------- lib/views/move_copy_destination_picker.dart | 5 +- lib/views/photos_view.dart | 125 ++++++++------ lib/views/share_upload_view.dart | 5 +- lib/widgets/files_controls_row.dart | 63 +++---- .../noo/core/noo_segmented_control.dart | 32 +++- lib/widgets/noo/lists/noo_grouped_list.dart | 148 +++++++++++----- lib/widgets/settings/settings_action_bar.dart | 26 +-- lib/widgets/settings/settings_section.dart | 16 +- test/widgets/noo/core_test.dart | 40 +++++ test/widgets/noo/lists_test.dart | 63 +++++++ 14 files changed, 417 insertions(+), 326 deletions(-) diff --git a/.claude/context/design-system/DESIGN_SYSTEM.md b/.claude/context/design-system/DESIGN_SYSTEM.md index 05d61cf..48bfceb 100644 --- a/.claude/context/design-system/DESIGN_SYSTEM.md +++ b/.claude/context/design-system/DESIGN_SYSTEM.md @@ -321,8 +321,13 @@ Sidebar items are 38px tall with radius 12, an 18px icon and a 14/500 label. The (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. Then the photo grid - grouped by month. + 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". @@ -344,12 +349,15 @@ Sidebar items are 38px tall with radius 12, an 18px icon and a 14/500 label. The slots, the rest sit behind "More". See §2 "Selection action bar". 9. Swipe on a file - Mobile uses one column of grouped lists, with a trailing jump rail (one - small icon per section, pinned where the scrollbar would sit) since the - full list runs long enough that scrolling to a specific section by hand - is slow - tapping an icon scrolls straight to that section. Desktop uses - a 2-column grid of cards with a 1px line and radius 20, wide enough to - see most sections without scrolling, so it doesn't get the rail. + Mobile uses one column of grouped lists, each individually collapsible + (tap its label, expanded by default - `NooGroupedList`'s `collapsible` + param) so the full list can be collapsed down instead of needing a + separate way to navigate it; an earlier version had a trailing jump rail + (one small icon per section, pinned where the scrollbar would sit) + instead, dropped for adding a second, redundant navigation method without + shortening the page. Desktop uses 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. - **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 ▾"). diff --git a/.claude/context/styling.md b/.claude/context/styling.md index 37116af..c40a2bf 100644 --- a/.claude/context/styling.md +++ b/.claude/context/styling.md @@ -89,7 +89,9 @@ Gotchas: `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). + do). Its `collapsible`/`initiallyExpanded` params (off by default) make + `label` a tap target that shows/hides the card - `SettingsSection` is the + only caller that opts in, for Settings' mobile sections. - `NooSwipeAction` only reveals its action. The user has to tap the block to trigger it; a full swipe never deletes. - Window chrome (macOS traffic lights, the Windows 40px title bar) isn't diff --git a/lib/services/nextcloud_service.dart b/lib/services/nextcloud_service.dart index 3d673f3..f7fe332 100644 --- a/lib/services/nextcloud_service.dart +++ b/lib/services/nextcloud_service.dart @@ -1471,28 +1471,17 @@ class NextcloudService { final rawList = data['ocs']?['data']; if (rawList is! List) return []; - return rawList.map((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( + (raw) => _shareFromJson(raw, sharedWithMe: sharedWithMe), + ) + .toList(); } /// Creates a public link share for [path] and returns its share URL. diff --git a/lib/views/account_view.dart b/lib/views/account_view.dart index 1058de8..d0c3def 100644 --- a/lib/views/account_view.dart +++ b/lib/views/account_view.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../theme/design_tokens.dart'; import '../widgets/noo/nav/noo_top_bar.dart'; import '../widgets/noo/nav/noo_toolbar.dart'; @@ -14,35 +13,33 @@ import '../widgets/settings/settings_security.dart'; import '../widgets/settings/settings_swipe.dart'; import '../widgets/settings/settings_tabs.dart'; -/// One entry in [AccountView._sections] - [icon] and [label] are only used -/// by [_SectionJumpRail]'s tap targets, not shown next to the section -/// itself (each section already titles itself). -typedef _Section = (String label, IconData icon, Widget child); - /// Settings, pushed on top of the shell (DESIGN_SYSTEM.md 4's 9-section /// order: account card, accounts, security, file sync, files cache, /// appearance, tabs, action bar, swipe on a file). One column of /// [SettingsSection]s (and [SettingsActionBarSection], which has no option -/// rows of its own to put in one - just the reorder list) on -/// mobile, with a trailing jump rail (see [_SectionJumpRail]) since the -/// list runs long enough that finding a specific section by scrolling -/// alone is slow; a 2-column grid of cards on desktop, wide enough to see -/// most sections without scrolling, so it doesn't get one. See each -/// `widgets/settings/*.dart` file for a section's own content and any +/// rows of its own to put in one - just the reorder list) on mobile, each +/// individually collapsible (expanded by default, tap its label to +/// collapse - see `SettingsSection`/`NooGroupedList`'s `collapsible` param) +/// so a long Settings screen can be collapsed down rather than needing a +/// separate jump rail (an earlier version had one; it added a second, +/// redundant way to navigate on top of plain scrolling and still didn't +/// shorten the page). Desktop uses a 2-column grid of cards instead, wide +/// enough to see most sections without scrolling, so it gets neither. See +/// each `widgets/settings/*.dart` file for a section's own content and any /// setting that had to be slotted in or grouped under "Advanced appearance". class AccountView extends StatelessWidget { const AccountView({super.key}); - static const _sections = <_Section>[ - ('Account', LucideIcons.circleUser, SettingsAccountCard()), - ('Accounts', LucideIcons.users, SettingsAccountsSection()), - ('Security', LucideIcons.shield, SettingsSecuritySection()), - ('File sync', LucideIcons.folderSync, SettingsFileSyncSection()), - ('Files cache', LucideIcons.hardDrive, SettingsFilesCacheSection()), - ('Appearance', LucideIcons.sunMoon, SettingsAppearanceSection()), - ('Tabs', LucideIcons.layoutGrid, SettingsTabsSection()), - ('Action bar', LucideIcons.slidersHorizontal, SettingsActionBarSection()), - ('Swipe', LucideIcons.moveHorizontal, SettingsSwipeSection()), + static const _sections = [ + SettingsAccountCard(), + SettingsAccountsSection(), + SettingsSecuritySection(), + SettingsFileSyncSection(), + SettingsFilesCacheSection(), + SettingsAppearanceSection(), + SettingsTabsSection(), + SettingsActionBarSection(), + SettingsSwipeSection(), ]; @override @@ -62,119 +59,33 @@ class AccountView extends StatelessWidget { body: SafeArea( top: false, child: desktop - ? _DesktopGrid(sections: [for (final s in _sections) s.$3]) + ? _DesktopGrid(sections: _sections) : _MobileList(sections: _sections), ), ); } } -/// A stable per-section identity, shared between the [KeyedSubtree] each -/// section is wrapped in and [_SectionJumpRail]'s lookup - [GlobalObjectKey] -/// compares by [String] equality, not instance identity, so building a new -/// one from the same label on each side still resolves to the same key. -GlobalKey _sectionKey(String label) => GlobalObjectKey(label); - -class _MobileList extends StatefulWidget { - final List<_Section> sections; +class _MobileList extends StatelessWidget { + final List sections; const _MobileList({required this.sections}); - @override - State<_MobileList> createState() => _MobileListState(); -} - -class _MobileListState extends State<_MobileList> { - final _scrollController = ScrollController(); - - @override - void dispose() { - _scrollController.dispose(); - super.dispose(); - } - - void _jumpTo(String label) { - final target = _sectionKey(label).currentContext; - if (target == null) return; - Scrollable.ensureVisible( - target, - duration: NooMotion.slow, - curve: NooMotion.ease, - alignment: 0.05, - ); - } - @override Widget build(BuildContext context) { - return Stack( - children: [ - ListView( - controller: _scrollController, - padding: const EdgeInsets.fromLTRB( - NooSpace.sm, - NooSpace.sm, - NooSpace.xxl, - NooSpace.xxl, - ), - physics: const BouncingScrollPhysics(), - children: [ - for (final (label, _, child) in widget.sections) ...[ - KeyedSubtree(key: _sectionKey(label), child: child), - const SizedBox(height: NooSpace.xl), - ], - ], - ), - Positioned( - top: 0, - bottom: 0, - right: 2, - child: _SectionJumpRail(sections: widget.sections, onTap: _jumpTo), - ), - ], - ); - } -} - -/// A vertical rail of small per-section icons pinned to the trailing edge, -/// sitting where the scrollbar would otherwise be - tapping one jumps -/// straight to that section instead of scrolling the whole (fairly long) -/// Settings list by hand. Each icon is a generous 32px tap target even -/// though the rail itself is slim, so this stays usable one-handed. -class _SectionJumpRail extends StatelessWidget { - final List<_Section> sections; - final ValueChanged onTap; - - const _SectionJumpRail({required this.sections, required this.onTap}); - - @override - Widget build(BuildContext context) { - final colors = context.nooColors; - return Center( - child: Container( - padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 2), - decoration: BoxDecoration( - color: colors.surface, - borderRadius: BorderRadius.circular(NooRadii.pill), - border: Border.all(color: colors.line), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - for (final (label, icon, _) in sections) - Semantics( - button: true, - label: 'Jump to $label', - child: InkResponse( - onTap: () => onTap(label), - radius: 18, - child: Padding( - padding: const EdgeInsets.all(6), - child: Icon(icon, size: 16, color: colors.fg3), - ), - ), - ), - ], - ), + return ListView( + padding: const EdgeInsets.fromLTRB( + NooSpace.sm, + NooSpace.sm, + NooSpace.sm, + NooSpace.xxl, ), + physics: const BouncingScrollPhysics(), + children: [ + for (final section in sections) ...[ + section, + const SizedBox(height: NooSpace.xl), + ], + ], ); } } diff --git a/lib/views/move_copy_destination_picker.dart b/lib/views/move_copy_destination_picker.dart index ecdc279..78fd460 100644 --- a/lib/views/move_copy_destination_picker.dart +++ b/lib/views/move_copy_destination_picker.dart @@ -255,10 +255,7 @@ class _MoveCopyDestinationPickerState extends State { children: [ SizedBox( height: 44, - child: FilesControlsRow( - folderPath: _currentPath, - showStorageScope: false, - ), + child: FilesControlsRow(folderPath: _currentPath), ), if (hasBreadcrumbs) ...[ const SizedBox(height: 10), diff --git a/lib/views/photos_view.dart b/lib/views/photos_view.dart index eae28ef..2a3122a 100644 --- a/lib/views/photos_view.dart +++ b/lib/views/photos_view.dart @@ -484,65 +484,80 @@ class _PhotosViewState extends State { showNooSheet( context, children: [ - // See `_showSortSheet`'s comment - `Listenable.merge` since these - // toggles span two controllers. - ListenableBuilder( - listenable: Listenable.merge([photos, files]), - builder: (context, _) => Column( - mainAxisSize: MainAxisSize.min, - children: [ - // Same segmented-control design as Settings' Theme row - a - // full-width pill track, not chips, and its own row rather - // than a settings row's trailing slot (matches the - // Ascending/Descending control above, in `_showSortSheet`). - NooSegmentedControl<_PhotoTypeFilter>( - fill: true, - value: _typeFilter, - onChanged: (filter) => setState(() => _typeFilter = filter), - options: const [ - NooSegmentOption(value: _PhotoTypeFilter.all, label: 'All'), - NooSegmentOption( - value: _PhotoTypeFilter.image, - label: 'Photos', - ), - NooSegmentOption( - value: _PhotoTypeFilter.video, - label: 'Videos', - ), - ], - ), - const SizedBox(height: 22), - NooGroupedList( - children: [ - NooSettingsRow( - icon: LucideIcons.heart, - label: const Text('Favorites only'), - trailing: NooToggle( - checked: photos.showFavoritesOnly, - onChanged: (_) => photos.toggleFavoritesFilter(), + // `_typeFilter` lives on this State, not a ChangeNotifier, so + // `Listenable.merge([photos, files])` alone doesn't rebuild this + // sheet when it changes - it used to only visibly move once the + // sheet was closed and reopened. `StatefulBuilder` gives it a + // rebuild trigger of its own; see `_showSortSheet`'s comment for + // why the two controllers still need `ListenableBuilder`. + StatefulBuilder( + builder: (context, setSheetState) => ListenableBuilder( + listenable: Listenable.merge([photos, files]), + builder: (context, _) => Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Same "icon always, label only when selected" pill as + // Files' own type filter - not a fully-labelled track, so + // the two screens' filter sheets look and behave the same. + NooSegmentedControl<_PhotoTypeFilter>( + fill: true, + labelOnlySelected: true, + value: _typeFilter, + onChanged: (filter) => setState(() { + _typeFilter = filter; + setSheetState(() {}); + }), + options: const [ + NooSegmentOption( + value: _PhotoTypeFilter.all, + icon: LucideIcons.layoutGrid, + label: 'All', ), - ), - NooSettingsRow( - icon: LucideIcons.eye, - label: const Text('Show hidden files'), - trailing: NooToggle( - checked: photos.showHidden, - onChanged: (_) => photos.toggleShowHidden(), + NooSegmentOption( + value: _PhotoTypeFilter.image, + icon: LucideIcons.image, + label: 'Photos', ), - ), - NooSettingsRow( - icon: LucideIcons.hardDrive, - label: const Text('External storage'), - trailing: NooToggle( - checked: files.storageScope == StorageScope.external, - onChanged: (external) => files.setStorageScope( - external ? StorageScope.external : StorageScope.cloud, + NooSegmentOption( + value: _PhotoTypeFilter.video, + icon: LucideIcons.film, + label: 'Videos', + ), + ], + ), + const SizedBox(height: 22), + NooGroupedList( + children: [ + NooSettingsRow( + icon: LucideIcons.heart, + label: const Text('Favorites only'), + trailing: NooToggle( + checked: photos.showFavoritesOnly, + onChanged: (_) => photos.toggleFavoritesFilter(), ), ), - ), - ], - ), - ], + NooSettingsRow( + icon: LucideIcons.eye, + label: const Text('Show hidden files'), + trailing: NooToggle( + checked: photos.showHidden, + onChanged: (_) => photos.toggleShowHidden(), + ), + ), + NooSettingsRow( + icon: LucideIcons.hardDrive, + label: const Text('External storage'), + trailing: NooToggle( + checked: files.storageScope == StorageScope.external, + onChanged: (external) => files.setStorageScope( + external ? StorageScope.external : StorageScope.cloud, + ), + ), + ), + ], + ), + ], + ), ), ), ], diff --git a/lib/views/share_upload_view.dart b/lib/views/share_upload_view.dart index c3b1661..35e6178 100644 --- a/lib/views/share_upload_view.dart +++ b/lib/views/share_upload_view.dart @@ -180,10 +180,7 @@ class _ShareUploadViewState extends State { children: [ SizedBox( height: 44, - child: FilesControlsRow( - folderPath: currentPath, - showStorageScope: false, - ), + child: FilesControlsRow(folderPath: currentPath), ), if (hasBreadcrumbs) ...[ const SizedBox(height: 10), diff --git a/lib/widgets/files_controls_row.dart b/lib/widgets/files_controls_row.dart index 1af9bec..faeb5e3 100644 --- a/lib/widgets/files_controls_row.dart +++ b/lib/widgets/files_controls_row.dart @@ -159,52 +159,37 @@ class FilesControlsRow extends StatelessWidget { showNooSheet( context, children: [ - // See `_showSortSheet`'s comment: without this, none of the - // checkmarks/toggles below would move until the sheet is closed - // and reopened. + // See `_showSortSheet`'s comment: without this, neither the type + // filter's selected segment nor the toggles below would move until + // the sheet is closed and reopened. ListenableBuilder( listenable: files, builder: (context, _) => Column( mainAxisSize: MainAxisSize.min, children: [ - NooGroupedList( - label: 'Show', - children: [ - NooSettingsRow( - label: const Text('Files and folders'), - trailing: files.filesTypeFilter == FilesTypeFilter.all - ? Icon( - LucideIcons.check, - size: 18, - color: context.nooColors.accentText, - ) - : null, - onTap: () => files.setFilesTypeFilter(FilesTypeFilter.all), + // Same "icon always, label only when selected" pill as + // Photos' own type filter below - not a checkmark list, so + // the two screens' filter sheets look and behave the same. + NooSegmentedControl( + fill: true, + labelOnlySelected: true, + value: files.filesTypeFilter, + onChanged: files.setFilesTypeFilter, + options: const [ + NooSegmentOption( + value: FilesTypeFilter.all, + icon: LucideIcons.layoutGrid, + label: 'All', ), - NooSettingsRow( - label: const Text('Files only'), - trailing: files.filesTypeFilter == FilesTypeFilter.filesOnly - ? Icon( - LucideIcons.check, - size: 18, - color: context.nooColors.accentText, - ) - : null, - onTap: () => - files.setFilesTypeFilter(FilesTypeFilter.filesOnly), + NooSegmentOption( + value: FilesTypeFilter.filesOnly, + icon: LucideIcons.file, + label: 'Files', ), - NooSettingsRow( - label: const Text('Folders only'), - trailing: - files.filesTypeFilter == FilesTypeFilter.foldersOnly - ? Icon( - LucideIcons.check, - size: 18, - color: context.nooColors.accentText, - ) - : null, - onTap: () => - files.setFilesTypeFilter(FilesTypeFilter.foldersOnly), + NooSegmentOption( + value: FilesTypeFilter.foldersOnly, + icon: LucideIcons.folder, + label: 'Folders', ), ], ), diff --git a/lib/widgets/noo/core/noo_segmented_control.dart b/lib/widgets/noo/core/noo_segmented_control.dart index 5158abb..2738c50 100644 --- a/lib/widgets/noo/core/noo_segmented_control.dart +++ b/lib/widgets/noo/core/noo_segmented_control.dart @@ -11,7 +11,11 @@ class NooSegmentOption { enum NooSegmentedSize { md, sm, xs } -const _heights = {NooSegmentedSize.md: 34.0, NooSegmentedSize.sm: 30.0, NooSegmentedSize.xs: 28.0}; +const _heights = { + NooSegmentedSize.md: 34.0, + NooSegmentedSize.sm: 30.0, + NooSegmentedSize.xs: 28.0, +}; /// Pill track with an accent-soft active segment - List/Grid, Shares scope, /// Theme (Noo Design System project, `components/core/SegmentedControl.jsx`). @@ -21,12 +25,22 @@ class NooSegmentedControl extends StatelessWidget { final List> options; final T value; final ValueChanged? onChanged; + /// Use surface on bg, surface-2 on surface. final bool onSurface; final NooSegmentedSize size; final bool fill; final bool iconOnly; + /// Every segment always shows its icon, but only the *selected* one also + /// shows its label - a middle ground between `iconOnly` (no segment is + /// ever distinguishable without a label) and the default (every segment's + /// label always visible, crowding a 3+-option control). Used for Files'/ + /// Photos' type filter (All/Files/Folders, All/Photos/Videos) so both use + /// the same List/Grid-toggle-style pill instead of Files' old checkmark + /// list and Photos' own always-labelled control. + final bool labelOnlySelected; + const NooSegmentedControl({ super.key, required this.options, @@ -36,6 +50,7 @@ class NooSegmentedControl extends StatelessWidget { this.size = NooSegmentedSize.md, this.fill = false, this.iconOnly = false, + this.labelOnlySelected = false, }); @override @@ -44,10 +59,13 @@ class NooSegmentedControl extends StatelessWidget { final h = iconOnly ? 28.0 : (_heights[size] ?? 34.0); final segments = options.map((o) { final on = o.value == value; + final showLabel = labelOnlySelected ? on : !iconOnly; final child = Container( height: h, width: iconOnly ? 36 : null, - padding: iconOnly || fill ? EdgeInsets.zero : const EdgeInsets.symmetric(horizontal: 12), + padding: iconOnly || fill + ? EdgeInsets.zero + : const EdgeInsets.symmetric(horizontal: 12), alignment: Alignment.center, decoration: BoxDecoration( color: on ? colors.accentSoft : Colors.transparent, @@ -62,8 +80,9 @@ class NooSegmentedControl extends StatelessWidget { size: iconOnly ? (size == NooSegmentedSize.md ? 18 : 16) : 14, color: on ? colors.accentText : colors.fg2, ), - if (o.icon != null && !iconOnly && o.label != null) const SizedBox(width: 6), - if (!iconOnly && o.label != null) + if (o.icon != null && showLabel && o.label != null) + const SizedBox(width: 6), + if (showLabel && o.label != null) Text( o.label!, style: NooText.body.copyWith( @@ -89,7 +108,10 @@ class NooSegmentedControl extends StatelessWidget { color: onSurface ? colors.surface2 : colors.surface, borderRadius: BorderRadius.circular(NooRadii.pill), ), - child: Row(mainAxisSize: fill ? MainAxisSize.max : MainAxisSize.min, children: segments), + child: Row( + mainAxisSize: fill ? MainAxisSize.max : MainAxisSize.min, + children: segments, + ), ); } } diff --git a/lib/widgets/noo/lists/noo_grouped_list.dart b/lib/widgets/noo/lists/noo_grouped_list.dart index 99c949f..cc4ccdc 100644 --- a/lib/widgets/noo/lists/noo_grouped_list.dart +++ b/lib/widgets/noo/lists/noo_grouped_list.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../../theme/design_tokens.dart'; /// Radius-20 surface card of rows with 1px line dividers and an optional @@ -7,12 +8,22 @@ import '../../../theme/design_tokens.dart'; /// titled card isn't built yet). Each child (`SettingsRow`, `FileRow`, ...) /// paints its own `surface` background; this only supplies the 1px `line` /// gaps between them and the rounded clip. Stack groups 18-20px apart. -class NooGroupedList extends StatelessWidget { +/// +/// [collapsible] makes [label] (required alongside it) a tap target that +/// shows/hides the card and [footer] below it, with a trailing chevron - +/// used by `SettingsSection` for Settings' mobile sections so a long +/// Settings screen can be collapsed section by section instead of needing a +/// separate jump rail. Every other call site leaves this false and renders +/// exactly as before, always expanded and non-interactive. +class NooGroupedList extends StatefulWidget { final String? label; + /// Right-aligned secondary text next to [label]. final String? aside; final List children; final Widget? footer; + final bool collapsible; + final bool initiallyExpanded; const NooGroupedList({ super.key, @@ -20,60 +31,115 @@ class NooGroupedList extends StatelessWidget { this.aside, required this.children, this.footer, + this.collapsible = false, + this.initiallyExpanded = true, }); + @override + State createState() => _NooGroupedListState(); +} + +class _NooGroupedListState extends State { + late bool _expanded = widget.initiallyExpanded; + @override Widget build(BuildContext context) { final colors = context.nooColors; + final expanded = !widget.collapsible || _expanded; + final label = widget.label; + return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (label != null) + if (label != null) ...[ Padding( padding: const EdgeInsets.symmetric(horizontal: 8), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text(label!, style: NooText.label.copyWith(color: colors.fg2)), - if (aside != null) - Text( - aside!, - style: NooText.body.copyWith( - fontSize: 13, - height: 1, - fontWeight: FontWeight.w500, - color: colors.fg3, - ), - ), - ], - ), + child: _buildLabelRow(label, colors, expanded), ), - if (label != null) const SizedBox(height: 8), - ClipRRect( - borderRadius: BorderRadius.circular(NooRadii.card), - child: DecoratedBox( - decoration: BoxDecoration(color: colors.line), - child: Column( - children: [ - for (var i = 0; i < children.length; i++) ...[ - if (i > 0) const SizedBox(height: 1), - children[i], - ], - ], - ), - ), - ), - if (footer != null) ...[ const SizedBox(height: 8), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: DefaultTextStyle( - style: NooText.meta.copyWith(color: colors.fg3), - child: footer!, - ), - ), ], + AnimatedCrossFade( + duration: NooMotion.fast, + sizeCurve: NooMotion.ease, + crossFadeState: expanded + ? CrossFadeState.showFirst + : CrossFadeState.showSecond, + firstChild: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(NooRadii.card), + child: DecoratedBox( + decoration: BoxDecoration(color: colors.line), + child: Column( + children: [ + for (var i = 0; i < widget.children.length; i++) ...[ + if (i > 0) const SizedBox(height: 1), + widget.children[i], + ], + ], + ), + ), + ), + if (widget.footer != null) ...[ + const SizedBox(height: 8), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: DefaultTextStyle( + style: NooText.meta.copyWith(color: colors.fg3), + child: widget.footer!, + ), + ), + ], + ], + ), + secondChild: const SizedBox(width: double.infinity), + ), ], ); } + + Widget _buildLabelRow(String label, NooColors colors, bool expanded) { + final row = Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label, style: NooText.label.copyWith(color: colors.fg2)), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (widget.aside != null) + Padding( + padding: EdgeInsets.only(right: widget.collapsible ? 8 : 0), + child: Text( + widget.aside!, + style: NooText.body.copyWith( + fontSize: 13, + height: 1, + fontWeight: FontWeight.w500, + color: colors.fg3, + ), + ), + ), + if (widget.collapsible) + AnimatedRotation( + turns: expanded ? 0.5 : 0, + duration: NooMotion.fast, + curve: NooMotion.ease, + child: Icon( + LucideIcons.chevronDown, + size: 16, + color: colors.fg3, + ), + ), + ], + ), + ], + ); + if (!widget.collapsible) return row; + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => setState(() => _expanded = !_expanded), + child: row, + ); + } } diff --git a/lib/widgets/settings/settings_action_bar.dart b/lib/widgets/settings/settings_action_bar.dart index 46e9225..0fef176 100644 --- a/lib/widgets/settings/settings_action_bar.dart +++ b/lib/widgets/settings/settings_action_bar.dart @@ -5,6 +5,7 @@ import '../../providers/settings_controller.dart'; import '../../theme/design_tokens.dart'; import '../noo/lists/noo_tab_order_row.dart'; import '../noo/noo_layout.dart'; +import 'settings_section.dart'; /// Settings section: the priority order bulk actions (favorite, share, /// download, ...) show in on the Files/Photos/Favorites multi-select @@ -40,27 +41,10 @@ class SettingsActionBarSection extends StatelessWidget { final colors = context.nooColors; if (!desktop) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: Text( - 'Action bar', - style: NooText.label.copyWith(color: colors.fg2), - ), - ), - const SizedBox(height: 4), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: Text( - _subtitle, - style: NooText.meta.copyWith(color: colors.fg3), - ), - ), - const SizedBox(height: 8), - orderList, - ], + return SettingsSection( + title: 'Action bar', + subtitle: _subtitle, + children: [orderList], ); } diff --git a/lib/widgets/settings/settings_section.dart b/lib/widgets/settings/settings_section.dart index 5c086e0..7ceae70 100644 --- a/lib/widgets/settings/settings_section.dart +++ b/lib/widgets/settings/settings_section.dart @@ -31,6 +31,11 @@ class SettingsSection extends StatelessWidget { return NooGroupedList( label: title, footer: subtitle != null ? Text(subtitle!) : null, + // Every mobile Settings section is individually collapsible, + // expanded by default - replaces the old trailing jump rail (see + // `account_view.dart`'s doc comment) as the way to navigate a long + // Settings screen quickly. + collapsible: true, children: children, ); } @@ -99,8 +104,15 @@ void showSettingsPicker( required List options, }) { if (NooLayout.isDesktop(context)) { - showNooDialog(context, title: title, children: [Column(children: options)]); + showNooDialog( + context, + title: title, + children: [Column(children: options)], + ); } else { - showNooSheet(context, children: [NooGroupedList(label: title, children: options)]); + showNooSheet( + context, + children: [NooGroupedList(label: title, children: options)], + ); } } diff --git a/test/widgets/noo/core_test.dart b/test/widgets/noo/core_test.dart index 30f04d2..cbb6059 100644 --- a/test/widgets/noo/core_test.dart +++ b/test/widgets/noo/core_test.dart @@ -369,6 +369,46 @@ void main() { const Size(78, 34), ); }); + + testNooWidgets( + 'labelOnlySelected shows every icon but only the selected label', + (tester, theme, c) async { + var value = 'all'; + await pumpNoo( + tester, + StatefulBuilder( + builder: (context, setState) => NooSegmentedControl( + fill: true, + labelOnlySelected: true, + value: value, + onChanged: (v) => setState(() => value = v), + options: const [ + NooSegmentOption( + value: 'all', + icon: LucideIcons.layoutGrid, + label: 'All', + ), + NooSegmentOption( + value: 'files', + icon: LucideIcons.file, + label: 'Files', + ), + ], + ), + ), + theme: theme, + ); + expect(find.byIcon(LucideIcons.layoutGrid), findsOneWidget); + expect(find.byIcon(LucideIcons.file), findsOneWidget); + expect(find.text('All'), findsOneWidget); + expect(find.text('Files'), findsNothing); + + await tester.tap(find.byIcon(LucideIcons.file)); + await tester.pump(); + expect(find.text('Files'), findsOneWidget); + expect(find.text('All'), findsNothing); + }, + ); }); group('NooToggle', () { diff --git a/test/widgets/noo/lists_test.dart b/test/widgets/noo/lists_test.dart index 1966eca..f9e081f 100644 --- a/test/widgets/noo/lists_test.dart +++ b/test/widgets/noo/lists_test.dart @@ -105,6 +105,69 @@ void main() { // Rows paint their own surface; the gaps show the list's line fill. expect(decorationOf(tester, find.byKey(const Key('r1'))).color, c.line); }); + + testNooWidgets('collapsible starts expanded, tapping the label hides ' + 'and re-shows the card', (tester, theme, c) async { + await pumpNoo( + tester, + SizedBox( + width: 360, + child: NooGroupedList( + label: 'Appearance', + collapsible: true, + children: [ + Container(key: const Key('r1'), height: 52, color: c.surface), + ], + ), + ), + theme: theme, + ); + expect(find.byIcon(LucideIcons.chevronDown), findsOneWidget); + expect(tester.getSize(find.byKey(const Key('r1'))).height, 52); + AnimatedCrossFade crossFade() => + tester.widget(find.byType(AnimatedCrossFade)); + expect(crossFade().crossFadeState, CrossFadeState.showFirst); + + await tester.tap(find.text('Appearance')); + await tester.pump(); + expect(crossFade().crossFadeState, CrossFadeState.showSecond); + expect( + tester.widget(find.byType(AnimatedRotation)).turns, + 0, + ); + + await tester.tap(find.text('Appearance')); + await tester.pump(); + expect(crossFade().crossFadeState, CrossFadeState.showFirst); + expect( + tester.widget(find.byType(AnimatedRotation)).turns, + 0.5, + ); + }); + + testNooWidgets('non-collapsible has no chevron and ignores label taps', ( + tester, + theme, + c, + ) async { + await pumpNoo( + tester, + SizedBox( + width: 360, + child: NooGroupedList( + label: 'Accounts', + children: [ + Container(key: const Key('r1'), height: 52, color: c.surface), + ], + ), + ), + theme: theme, + ); + expect(find.byIcon(LucideIcons.chevronDown), findsNothing); + await tester.tap(find.text('Accounts')); + await tester.pumpAndSettle(); + expect(tester.getSize(find.byKey(const Key('r1'))).height, 52); + }); }); group('NooSelectionBar', () { From b73f451fff7f9975711535213acde612a38dc7db Mon Sep 17 00:00:00 2001 From: ayushya Date: Tue, 29 Sep 2026 10:45:05 -0400 Subject: [PATCH 15/29] 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 --- .../context/design-system/DESIGN_SYSTEM.md | 9 +- .claude/context/styling.md | 18 +++ lib/providers/files_controller.dart | 25 +++- lib/views/activity_view.dart | 10 +- lib/views/favorites_view.dart | 3 + lib/views/files_view.dart | 5 + lib/views/move_copy_destination_picker.dart | 4 + lib/views/photos_view.dart | 5 + lib/views/recent_view.dart | 12 +- lib/views/share_upload_view.dart | 4 + lib/views/shares_view.dart | 134 ++++-------------- lib/views/trash_view.dart | 3 + lib/widgets/files_controls_row.dart | 2 + lib/widgets/sticky_header_delegate.dart | 19 ++- 14 files changed, 140 insertions(+), 113 deletions(-) diff --git a/.claude/context/design-system/DESIGN_SYSTEM.md b/.claude/context/design-system/DESIGN_SYSTEM.md index 48bfceb..276c04b 100644 --- a/.claude/context/design-system/DESIGN_SYSTEM.md +++ b/.claude/context/design-system/DESIGN_SYSTEM.md @@ -330,7 +330,14 @@ Sidebar items are 38px tall with radius 12, an 18px icon and a 14/500 label. 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". +- **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. diff --git a/.claude/context/styling.md b/.claude/context/styling.md index c40a2bf..dc75393 100644 --- a/.claude/context/styling.md +++ b/.claude/context/styling.md @@ -96,6 +96,24 @@ Gotchas: trigger it; a full swipe never deletes. - Window chrome (macOS traffic lights, the Windows 40px title bar) isn't built yet. `NooSidebar.windowControls` is the slot for it. +- Always read colors through `context.nooColors`, never + `Theme.of(context).colorScheme` - the latter is Flutter's own Material 3 + scheme, reseeded by the user's accent color choice (`AppTheme.light`/ + `.dark`), so anything painted from it carries a faint hue of whatever + accent is picked instead of the app's neutral palette. `sticky_header_delegate.dart` + shipped with this bug once already (a pinned controls-row header tinted + by the accent instead of matching its screen's plain `colors.bg`). +- `NooSegmentedControl` defaults to `onSurface: false` (`colors.surface` + fill) - right when it's sitting directly on `colors.bg` (List/Grid + toggle, Shares' scope switcher), but pass `onSurface: true` for one + placed inside a sheet or dialog (already `colors.surface`), or its pill + track blends invisibly into the sheet instead of reading as a grouped + control (Files'/Photos' sort and type-filter sheets do this). +- Any `RefreshIndicator` needs `physics: const AlwaysScrollableScrollPhysics()` + on its scrollable child, or pull-to-refresh silently can't be triggered + once the list is short enough to fit the viewport (empty, or one item) - + every tab's `CustomScrollView` sets this explicitly for exactly that + reason. ## Reusable chrome diff --git a/lib/providers/files_controller.dart b/lib/providers/files_controller.dart index 71d2f78..af0013d 100644 --- a/lib/providers/files_controller.dart +++ b/lib/providers/files_controller.dart @@ -266,7 +266,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 _restoreDisplayPrefs() => _prefsRestore ??= _doRestoreDisplayPrefs(); + Future _restoreDisplayPrefs() => + _prefsRestore ??= _doRestoreDisplayPrefs(); /// Completes once this account's cache policy/display prefs have been /// loaded from storage - lets `SyncStatusController` schedule background @@ -334,7 +335,9 @@ class FilesController extends ChangeNotifier defaultCacheIntervalMinutes; notifyListeners(); } catch (e) { - debugPrint('[FilesController] Account-activation prefs restore failed: $e'); + debugPrint( + '[FilesController] Account-activation prefs restore failed: $e', + ); } } } @@ -401,7 +404,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 +554,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 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__`-namespaced diff --git a/lib/views/activity_view.dart b/lib/views/activity_view.dart index 8121ee0..90f693e 100644 --- a/lib/views/activity_view.dart +++ b/lib/views/activity_view.dart @@ -73,9 +73,12 @@ class ActivityView extends StatelessWidget { ? act.subject : null, time: DateFormat.jm().format(act.timestamp), - avatarColor: NooPhotoTile.paletteColor(act.author.hashCode), + avatarColor: NooPhotoTile.paletteColor( + act.author.hashCode, + ), currentUser: - act.author.toLowerCase() == session.username.toLowerCase(), + act.author.toLowerCase() == + session.username.toLowerCase(), ), ), ], @@ -94,6 +97,9 @@ class ActivityView extends StatelessWidget { final scrollView = CustomScrollView( controller: scrollController, + // See files_view.dart's identical fix - without this, pull-to- + // refresh can't be triggered on an empty or single-item feed. + physics: const AlwaysScrollableScrollPhysics(), slivers: contentSlivers, ); diff --git a/lib/views/favorites_view.dart b/lib/views/favorites_view.dart index e27eab2..cae784c 100644 --- a/lib/views/favorites_view.dart +++ b/lib/views/favorites_view.dart @@ -654,6 +654,9 @@ class _FavoritesViewState extends State { onRefresh: favoritesController.fetchAll, child: CustomScrollView( controller: widget.scrollController, + // See files_view.dart's identical fix - without this, pull-to- + // refresh can't be triggered on an empty or single-item list. + physics: const AlwaysScrollableScrollPhysics(), slivers: contentSlivers, ), ), diff --git a/lib/views/files_view.dart b/lib/views/files_view.dart index 4ef84e9..a64f1bc 100644 --- a/lib/views/files_view.dart +++ b/lib/views/files_view.dart @@ -838,6 +838,11 @@ class _FilesViewState extends State { }, child: CustomScrollView( controller: widget.scrollController, + // Pull-to-refresh needs a scroll physics that allows dragging + // past the edge even when content doesn't fill the viewport - + // an empty or single-item list otherwise can't be pulled at all + // under the platform default physics. + physics: const AlwaysScrollableScrollPhysics(), slivers: contentSlivers, ), ), diff --git a/lib/views/move_copy_destination_picker.dart b/lib/views/move_copy_destination_picker.dart index 78fd460..b07022b 100644 --- a/lib/views/move_copy_destination_picker.dart +++ b/lib/views/move_copy_destination_picker.dart @@ -359,6 +359,10 @@ class _MoveCopyDestinationPickerState extends State { onRefresh: () => _fetch(_currentPath), child: CustomScrollView( controller: _scrollController, + // See files_view.dart's identical fix - without this, pull- + // to-refresh can't be triggered on an empty or single-folder + // listing. + physics: const AlwaysScrollableScrollPhysics(), slivers: slivers, ), ), diff --git a/lib/views/photos_view.dart b/lib/views/photos_view.dart index 2a3122a..4fa9152 100644 --- a/lib/views/photos_view.dart +++ b/lib/views/photos_view.dart @@ -364,6 +364,9 @@ class _PhotosViewState extends State { onRefresh: photosController.fetchAllMedia, child: CustomScrollView( controller: widget.scrollController, + // See files_view.dart's identical fix - without this, pull-to- + // refresh can't be triggered on an empty or single-item list. + physics: const AlwaysScrollableScrollPhysics(), slivers: contentSlivers, ), ), @@ -432,6 +435,7 @@ class _PhotosViewState extends State { children: [ NooSegmentedControl( fill: true, + onSurface: true, value: photos.sortAscending, onChanged: (asc) { if (asc != photos.sortAscending) photos.toggleSortOrder(); @@ -501,6 +505,7 @@ class _PhotosViewState extends State { // the two screens' filter sheets look and behave the same. NooSegmentedControl<_PhotoTypeFilter>( fill: true, + onSurface: true, labelOnlySelected: true, value: _typeFilter, onChanged: (filter) => setState(() { diff --git a/lib/views/recent_view.dart b/lib/views/recent_view.dart index e8484f6..02af7e3 100644 --- a/lib/views/recent_view.dart +++ b/lib/views/recent_view.dart @@ -85,6 +85,9 @@ class _RecentViewState extends State { onRefresh: recent.fetchAll, child: CustomScrollView( controller: widget.scrollController, + // See files_view.dart's identical fix - without this, pull-to- + // refresh can't be triggered on an empty or single-item list. + physics: const AlwaysScrollableScrollPhysics(), slivers: contentSlivers, ), ), @@ -101,7 +104,9 @@ class _RecentViewState extends State { delegate: SliverChildBuilderDelegate((context, index) { final group = groups[index]; return Padding( - padding: EdgeInsets.only(bottom: index == groups.length - 1 ? 0 : 18), + padding: EdgeInsets.only( + bottom: index == groups.length - 1 ? 0 : 18, + ), child: NooGroupedList( label: group.label, children: [ @@ -132,7 +137,10 @@ class _RecentViewState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ TabGroupLabel(group.label), - const NooFileTableHeader(col2Label: 'Modified', col3Label: 'Location'), + const NooFileTableHeader( + col2Label: 'Modified', + col3Label: 'Location', + ), for (final item in group.items) _buildDesktopRow(context, item), ], ), diff --git a/lib/views/share_upload_view.dart b/lib/views/share_upload_view.dart index 35e6178..c7181e2 100644 --- a/lib/views/share_upload_view.dart +++ b/lib/views/share_upload_view.dart @@ -278,6 +278,10 @@ class _ShareUploadViewState extends State { onRefresh: files.refreshData, child: CustomScrollView( controller: _scrollController, + // See files_view.dart's identical fix - without this, pull-to- + // refresh can't be triggered on an empty or single-folder + // listing. + physics: const AlwaysScrollableScrollPhysics(), slivers: slivers, ), ), diff --git a/lib/views/shares_view.dart b/lib/views/shares_view.dart index acb342a..e833975 100644 --- a/lib/views/shares_view.dart +++ b/lib/views/shares_view.dart @@ -1,21 +1,16 @@ import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; -import '../models/app_tab.dart'; import '../models/nextcloud_share.dart'; import '../providers/files_controller.dart'; -import '../providers/settings_controller.dart'; import '../providers/shares_controller.dart'; import '../theme/design_tokens.dart'; import '../widgets/noo/core/noo_segmented_control.dart'; import '../widgets/noo/files/noo_file_kind.dart'; import '../widgets/noo/files/noo_file_row.dart'; import '../widgets/noo/files/noo_file_table.dart'; -import '../widgets/noo/lists/noo_grouped_list.dart'; -import '../widgets/noo/lists/noo_settings_row.dart'; import '../widgets/noo/noo_layout.dart'; -import '../widgets/noo/overlays/noo_sheet.dart'; +import '../widgets/share_sheet.dart'; import '../widgets/tabs/tab_state_slivers.dart'; /// Which slice of [SharesController.shares] is on screen - `With you` maps @@ -136,6 +131,9 @@ class _SharesViewState extends State { onRefresh: sharesController.fetchAll, child: CustomScrollView( controller: widget.scrollController, + // See files_view.dart's identical fix - without this, pull-to- + // refresh can't be triggered on an empty or single-item list. + physics: const AlwaysScrollableScrollPhysics(), slivers: contentSlivers, ), ), @@ -197,9 +195,8 @@ class _SharesViewState extends State { meta: '${share.ownerDisplayName} · ${_permissionLabel(share.permissions)}', iosStyle: NooLayout.iosStyle(context), - onTap: share.isFolder ? () => _openFolder(context, share) : null, + onTap: () => _openShareSheet(context, share), trailing: Icon(_shareTypeIcon(share.shareType), size: 16), - onMore: () => _showShareActions(context, share), ); final isFirst = index == 0; final isLast = index == shares.length - 1; @@ -247,112 +244,41 @@ class _SharesViewState extends State { name: share.name, col2: share.ownerDisplayName, col3: _permissionLabel(share.permissions), - onTap: share.isFolder ? () => _openFolder(context, share) : null, - onMore: () => _showShareActions(context, share), + onTap: () => _openShareSheet(context, share), ); }, childCount: shares.length + 1), ), ); } - /// Only folders are safely tappable - unlike Recent/Favorites, a share - /// doesn't carry the size/mime-type `FileViewerScreen` needs, so opening - /// a file share here would have to synthesize that data instead of - /// reading it, same as the original screen (no tap action at all). - void _openFolder(BuildContext context, NextcloudShare share) { - context.read().navigateToAbsoluteFolder(share.path); - context.read().requestTab(AppTab.files); - } - - /// The row/table overflow menu - used to jump straight to the unshare - /// confirmation with no menu at all, so a link share had no way to copy - /// its own URL short of opening it and finding "Copy link" inside the - /// full Share sheet. A link share gets that shortcut here too now; - /// everything else just gets the one remove/unshare action. - void _showShareActions(BuildContext context, NextcloudShare share) { - final isLink = share.shareType == ShareType.publicLink && share.url != null; - showNooSheet( - context, - children: [ - NooGroupedList( - children: [ - if (isLink) - NooSettingsRow( - icon: LucideIcons.copy, - label: const Text('Copy link'), - onTap: () { - Navigator.pop(context); - _copyShareLink(context, share); - }, - ), - NooSettingsRow( - icon: LucideIcons.trash2, - label: Text(share.sharedWithMe ? 'Remove share' : 'Unshare'), - danger: true, - onTap: () { - Navigator.pop(context); - _confirmUnshare(context, share); - }, - ), - ], - ), - ], - ); - } - - void _copyShareLink(BuildContext context, NextcloudShare share) { - Clipboard.setData(ClipboardData(text: share.url!)); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Link copied'), - behavior: SnackBarBehavior.floating, - ), - ); - } - - Future _confirmUnshare( + /// Every row's whole-row tap - a share only carries enough metadata for + /// its own row, not the size/dates the Share sheet's header needs, so + /// this fetches the real item first (see `FilesController.fetchItemAtPath`) + /// rather than opening the sheet straight from [share]. Reused by every + /// scope (With you/By you/Links) - the Share sheet is the one place that + /// already knows how to copy a link or remove access, so there's no + /// separate overflow menu here any more. + Future _openShareSheet( BuildContext context, NextcloudShare share, ) async { - final confirmed = await showDialog( - context: context, - builder: (dialogContext) { - return AlertDialog( - title: Text(share.sharedWithMe ? 'Remove share' : 'Unshare'), - content: Text( - share.sharedWithMe - ? 'Remove "${share.name}" shared with you by ${share.ownerDisplayName}?' - : 'Stop sharing "${share.name}"?', - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(dialogContext, false), - child: const Text('Cancel'), - ), - FilledButton( - style: FilledButton.styleFrom( - backgroundColor: Theme.of(dialogContext).colorScheme.error, - ), - onPressed: () => Navigator.pop(dialogContext, true), - child: const Text('Remove'), - ), - ], - ); - }, - ); - if (confirmed != true || !context.mounted) return; - + final files = context.read(); final messenger = ScaffoldMessenger.of(context); - final success = await context.read().deleteShare(share); - messenger.showSnackBar( - SnackBar( - content: Text( - success - ? 'Removed share for ${share.name}' - : 'Failed to remove share for ${share.name}', + final item = await files.fetchItemAtPath(share.path); + if (!context.mounted) return; + if (item == null) { + messenger.showSnackBar( + const SnackBar( + content: Text('Could not find this item - it may have moved'), + behavior: SnackBarBehavior.floating, ), - behavior: SnackBarBehavior.floating, - ), - ); + ); + return; + } + await ShareSheet.show(context, item); + // The sheet manages shares through `ItemOperations`, not this tab's own + // `SharesController` (see its `deleteShare`'s doc comment) - refetch so + // a share added/removed from inside it doesn't leave this list stale. + if (context.mounted) context.read().fetchAll(); } } diff --git a/lib/views/trash_view.dart b/lib/views/trash_view.dart index 2f919ce..6a23610 100644 --- a/lib/views/trash_view.dart +++ b/lib/views/trash_view.dart @@ -100,6 +100,9 @@ class _TrashViewState extends State { onRefresh: trashController.fetchAll, child: CustomScrollView( controller: widget.scrollController, + // See files_view.dart's identical fix - without this, pull-to- + // refresh can't be triggered on an empty or single-item list. + physics: const AlwaysScrollableScrollPhysics(), slivers: contentSlivers, ), ), diff --git a/lib/widgets/files_controls_row.dart b/lib/widgets/files_controls_row.dart index faeb5e3..abde3ee 100644 --- a/lib/widgets/files_controls_row.dart +++ b/lib/widgets/files_controls_row.dart @@ -109,6 +109,7 @@ class FilesControlsRow extends StatelessWidget { children: [ NooSegmentedControl( fill: true, + onSurface: true, value: files.sortAscendingFor(folderPath), onChanged: (asc) { if (asc != files.sortAscendingFor(folderPath)) { @@ -172,6 +173,7 @@ class FilesControlsRow extends StatelessWidget { // the two screens' filter sheets look and behave the same. NooSegmentedControl( fill: true, + onSurface: true, labelOnlySelected: true, value: files.filesTypeFilter, onChanged: files.setFilesTypeFilter, diff --git a/lib/widgets/sticky_header_delegate.dart b/lib/widgets/sticky_header_delegate.dart index 4559422..342386e 100644 --- a/lib/widgets/sticky_header_delegate.dart +++ b/lib/widgets/sticky_header_delegate.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import '../theme/design_tokens.dart'; /// Wraps [child] as a sliver that pins to the top of the scroll view when /// used with `SliverPersistentHeader(pinned: true, ...)`, or scrolls away @@ -24,7 +25,23 @@ class StickyHeaderDelegate extends SliverPersistentHeaderDelegate { double shrinkOffset, bool overlapsContent, ) { - return Material(color: Theme.of(context).colorScheme.surface, child: child); + // `context.nooColors.bg`, not `Theme.of(context).colorScheme.surface` - + // the latter is Flutter's own Material 3 scheme, seeded from the user's + // accent color choice (see AppTheme.light/dark), so it carried a faint + // hue of whatever accent is picked, and didn't match the plain + // `colors.bg` every one of these screens' own `ColoredBox` background + // uses. `bg`, not `surface`, so the sort/filter chips and List/Grid + // toggle riding on top of this (each already `colors.surface`-filled) + // still pop against it, the same as they do everywhere else in the app + // - matching `surface` here would make this pinned header the one place + // they'd flatten into their background instead. `surfaceTintColor: + // Colors.transparent` guards against the same M3 elevation-tint + // behavior even if this ever gets a non-zero elevation. + return Material( + color: context.nooColors.bg, + surfaceTintColor: Colors.transparent, + child: child, + ); } @override From c18fc98dc38d7f754b33493c26ede6c780bb8048 Mon Sep 17 00:00:00 2001 From: ayushya Date: Tue, 29 Sep 2026 13:09:08 -0400 Subject: [PATCH 16/29] Refresh Files list automatically when an upload finishes 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 --- .claude/context/server.md | 27 +++++++++---- .../kotlin/dev/ayushya/noo/MainActivity.kt | 25 ++++++++++++ .../dev/ayushya/noo/ShareUploadService.kt | 7 ++++ .../kotlin/dev/ayushya/noo/UploadEventBus.kt | 39 +++++++++++++++++++ lib/providers/files_controller.dart | 18 +++++++++ lib/services/upload_service.dart | 38 +++++++++++++++++- 6 files changed, 146 insertions(+), 8 deletions(-) create mode 100644 android/app/src/main/kotlin/dev/ayushya/noo/UploadEventBus.kt diff --git a/.claude/context/server.md b/.claude/context/server.md index 8fb0f78..555dd17 100644 --- a/.claude/context/server.md +++ b/.claude/context/server.md @@ -206,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) diff --git a/android/app/src/main/kotlin/dev/ayushya/noo/MainActivity.kt b/android/app/src/main/kotlin/dev/ayushya/noo/MainActivity.kt index cd51686..5acca1a 100644 --- a/android/app/src/main/kotlin/dev/ayushya/noo/MainActivity.kt +++ b/android/app/src/main/kotlin/dev/ayushya/noo/MainActivity.kt @@ -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 diff --git a/android/app/src/main/kotlin/dev/ayushya/noo/ShareUploadService.kt b/android/app/src/main/kotlin/dev/ayushya/noo/ShareUploadService.kt index 569aebd..5e6b682 100644 --- a/android/app/src/main/kotlin/dev/ayushya/noo/ShareUploadService.kt +++ b/android/app/src/main/kotlin/dev/ayushya/noo/ShareUploadService.kt @@ -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) diff --git a/android/app/src/main/kotlin/dev/ayushya/noo/UploadEventBus.kt b/android/app/src/main/kotlin/dev/ayushya/noo/UploadEventBus.kt new file mode 100644 index 0000000..34835ff --- /dev/null +++ b/android/app/src/main/kotlin/dev/ayushya/noo/UploadEventBus.kt @@ -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) } + } +} diff --git a/lib/providers/files_controller.dart b/lib/providers/files_controller.dart index af0013d..29fbe55 100644 --- a/lib/providers/files_controller.dart +++ b/lib/providers/files_controller.dart @@ -3,6 +3,7 @@ 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'; @@ -107,6 +108,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? _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 @@ -711,6 +728,7 @@ class FilesController extends ChangeNotifier void dispose() { WidgetsBinding.instance.removeObserver(this); _cacheRefreshTimer?.cancel(); + _uploadSub?.cancel(); super.dispose(); } } diff --git a/lib/services/upload_service.dart b/lib/services/upload_service.dart index f047ce6..ebda2c2 100644 --- a/lib/services/upload_service.dart +++ b/lib/services/upload_service.dart @@ -5,6 +5,27 @@ import '../providers/session_controller.dart'; import 'native_channel.dart'; import 'share_intent_service.dart'; +/// One upload batch finishing - see [UploadService.completions]. +class UploadCompletion { + final String remoteFolder; + final int succeeded; + final int failed; + + const UploadCompletion({ + required this.remoteFolder, + required this.succeeded, + required this.failed, + }); + + factory UploadCompletion.fromMap(Map map) { + return UploadCompletion( + remoteFolder: map['remoteFolder'] as String? ?? '/', + succeeded: (map['succeeded'] as num?)?.toInt() ?? 0, + failed: (map['failed'] as num?)?.toInt() ?? 0, + ); + } +} + /// Hands a "Share to Noo" upload off to `ShareUploadService.kt`'s Android /// foreground service, which does the actual prepare+upload entirely on its /// own from there - including surviving the app being closed - with a @@ -14,11 +35,15 @@ import 'share_intent_service.dart'; /// real Android Service does. class UploadService { static const _channel = MethodChannel('dev.ayushya.noo/upload_service'); + static const _statusChannel = EventChannel( + 'dev.ayushya.noo/upload_service/status', + ); /// Starts uploading [files] into the active account's current folder. /// Fire-and-forget from Dart's perspective - once this returns, the /// service owns the rest and reports progress/completion/cancellation - /// through its own notification, not back to the app. + /// through its own notification, not back to the app. [completions] is + /// the one thing it does report back, for `FilesController` to act on. static Future startUpload( SessionController session, FilesController files, @@ -37,4 +62,15 @@ class UploadService { 'remoteFolder': files.currentFolderPath, }); } + + /// Fires once per finished batch (at least one file uploaded) from + /// `UploadEventBus` (Kotlin) - lets `FilesController` refresh the + /// destination folder if it's the one currently on screen, instead of the + /// list only updating on a manual pull-to-refresh. One shared stream, + /// deliberately: see `SyncService.statusStream`'s identical doc comment - + /// `EventChannel.receiveBroadcastStream()` opens its own native + /// subscription per call, and the native side only keeps the latest one. + static final Stream completions = _statusChannel + .receiveBroadcastStream() + .map((event) => UploadCompletion.fromMap(event as Map)); } From a70be19f809600a11cfc37a645a8835752e3a777 Mon Sep 17 00:00:00 2001 From: ayushya Date: Tue, 29 Sep 2026 16:21:13 -0400 Subject: [PATCH 17/29] 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 --- .../context/design-system/DESIGN_SYSTEM.md | 1 + .claude/context/styling.md | 7 +- lib/main.dart | 6 + lib/providers/settings_controller.dart | 18 +++ lib/widgets/bottom_nav_bar.dart | 3 + lib/widgets/noo/nav/noo_bottom_bar.dart | 140 ++++++++++++------ lib/widgets/noo/nav/noo_nav_style.dart | 12 ++ lib/widgets/settings/settings_appearance.dart | 64 +++++++- test/widgets/noo/nav_test.dart | 75 ++++++++++ 9 files changed, 280 insertions(+), 46 deletions(-) diff --git a/.claude/context/design-system/DESIGN_SYSTEM.md b/.claude/context/design-system/DESIGN_SYSTEM.md index 276c04b..2ddead3 100644 --- a/.claude/context/design-system/DESIGN_SYSTEM.md +++ b/.claude/context/design-system/DESIGN_SYSTEM.md @@ -273,6 +273,7 @@ Mobile always has **5 pinned tabs in the bottom bar**. The rest go in the drawer | Status bar | 54px, Dynamic Island | 40px | | Top bar | 44px row: `menu` on the left; `plus` and avatar on the right (all accent-text). Then a 34px large title, then the search field. | 64px: `menu`, 22px title, `search`, avatar | | 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. No shadow (product UI stays flat - see 1.4); a 1px `line` border stands in for elevation instead. Row height drops slightly (64/72 vs 50/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 (27 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. | | Upload | `plus` in the nav bar | Extended FAB | | 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. | diff --git a/.claude/context/styling.md b/.claude/context/styling.md index dc75393..b541e87 100644 --- a/.claude/context/styling.md +++ b/.claude/context/styling.md @@ -191,7 +191,12 @@ blocks are noted where they matter: 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. + 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 flat/non-blurred, just inset + with a `line` border instead of edge to edge (no opacity/blur knob + either). The host `Scaffold` needs `extendBody: true` while it's active + (`main.dart` already wires this off `SettingsController.bottomBarStyle`). - [`SyncedHeaderScaffold`](../../lib/widgets/synced_header_scaffold.dart) — the pull-to-sync `CustomScrollView` header with the persistent sync-status chip and pull-to-refresh gesture/spinner. Every screen (including diff --git a/lib/main.dart b/lib/main.dart index 5adeb99..256ba94 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -472,6 +472,7 @@ class _MainShellViewState extends State { ); } else { final navStyle = NooLayout.navStyle(context); + final bottomBarStyle = settings.bottomBarStyle; scaffold = Scaffold( backgroundColor: colors.bg, drawerScrimColor: colors.scrim, @@ -479,6 +480,10 @@ class _MainShellViewState extends State { ? AppTopBar(style: navStyle, tab: selectedTab) : null, drawer: pickRequest == null ? const AppDrawer() : null, + // Floating needs the body to draw behind the bar's own transparent + // margin (see NooBottomBarStyle's doc comment) instead of stopping + // short of it like attached does. + extendBody: bottomBarStyle == NooBottomBarStyle.floating, // Android-only extended Upload FAB - iOS uses the top bar's `plus` // instead (see AppTopBar). Stays mounted across every tab (picking // aside) and collapses to an icon-only circle off Files/Photos, @@ -494,6 +499,7 @@ class _MainShellViewState extends State { body: tabStack, bottomNavigationBar: BottomNavBar( style: navStyle, + barStyle: bottomBarStyle, tabs: pinnedTabs, selectedIndex: pinnedIndex, onDestinationSelected: (index) { diff --git a/lib/providers/settings_controller.dart b/lib/providers/settings_controller.dart index d5d247a..b7bfec4 100644 --- a/lib/providers/settings_controller.dart +++ b/lib/providers/settings_controller.dart @@ -3,6 +3,7 @@ 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. @@ -30,6 +31,7 @@ class SettingsController extends ChangeNotifier { static const _prefDefaultTab = 'ui_default_tab'; static const _prefSwipeLeftAction = 'ui_swipe_left_action'; static const _prefSwipeRightAction = 'ui_swipe_right_action'; + static const _prefBottomBarStyle = 'ui_bottom_bar_style'; static const _prefAmoledDark = 'ui_amoled_dark'; static const _prefMediaProgressBarStyle = 'ui_media_progress_bar_style'; static const _prefTapTabToScrollTop = 'ui_tap_tab_to_scroll_top'; @@ -39,6 +41,7 @@ class SettingsController extends ChangeNotifier { Color _seedColor = AppTheme.defaultAccent; ThemeMode _themeMode = ThemeMode.system; + NooBottomBarStyle _bottomBarStyle = NooBottomBarStyle.attached; bool _useDynamicColor = true; bool _amoledDark = false; MediaProgressBarStyle _mediaProgressBarStyle = MediaProgressBarStyle.wavy; @@ -80,6 +83,7 @@ class SettingsController extends ChangeNotifier { Color get seedColor => _seedColor; ThemeMode get themeMode => _themeMode; + NooBottomBarStyle get bottomBarStyle => _bottomBarStyle; bool get useDynamicColor => _useDynamicColor; bool get amoledDark => _amoledDark; MediaProgressBarStyle get mediaProgressBarStyle => _mediaProgressBarStyle; @@ -115,6 +119,13 @@ class SettingsController extends ChangeNotifier { orElse: () => ThemeMode.system, ); } + final bottomBarStyleName = prefs.getString(_prefBottomBarStyle); + if (bottomBarStyleName != null) { + _bottomBarStyle = NooBottomBarStyle.values.firstWhere( + (s) => s.name == bottomBarStyleName, + orElse: () => _bottomBarStyle, + ); + } _useDynamicColor = prefs.getBool(_prefUseDynamicColor) ?? _useDynamicColor; _amoledDark = prefs.getBool(_prefAmoledDark) ?? _amoledDark; @@ -256,6 +267,13 @@ class SettingsController extends ChangeNotifier { _prefsFuture.then((p) => p.setString(_prefThemeMode, mode.name)); } + void setBottomBarStyle(NooBottomBarStyle style) { + if (_bottomBarStyle == style) return; + _bottomBarStyle = style; + notifyListeners(); + _prefsFuture.then((p) => p.setString(_prefBottomBarStyle, style.name)); + } + void setTabOrder(List order) { _tabOrder = order; notifyListeners(); diff --git a/lib/widgets/bottom_nav_bar.dart b/lib/widgets/bottom_nav_bar.dart index 9407d53..1da95de 100644 --- a/lib/widgets/bottom_nav_bar.dart +++ b/lib/widgets/bottom_nav_bar.dart @@ -9,6 +9,7 @@ import 'noo/nav/noo_bottom_bar.dart'; /// drawer's "More" list that isn't one of the pinned [tabs]. class BottomNavBar extends StatelessWidget { final NooNavStyle style; + final NooBottomBarStyle barStyle; final List tabs; final int selectedIndex; final ValueChanged onDestinationSelected; @@ -16,6 +17,7 @@ class BottomNavBar extends StatelessWidget { const BottomNavBar({ super.key, required this.style, + this.barStyle = NooBottomBarStyle.attached, required this.tabs, required this.selectedIndex, required this.onDestinationSelected, @@ -25,6 +27,7 @@ class BottomNavBar extends StatelessWidget { Widget build(BuildContext context) { return NooBottomBar( style: style, + barStyle: barStyle, destinations: [ for (final tab in tabs) NooNavDestination(icon: tab.icon, label: tab.label), diff --git a/lib/widgets/noo/nav/noo_bottom_bar.dart b/lib/widgets/noo/nav/noo_bottom_bar.dart index eafe993..807365f 100644 --- a/lib/widgets/noo/nav/noo_bottom_bar.dart +++ b/lib/widgets/noo/nav/noo_bottom_bar.dart @@ -5,8 +5,11 @@ import 'noo_nav_style.dart'; export 'noo_nav_style.dart'; /// Android indicator geometry, shared between the sliding pill and each -/// item's own icon box so they line up exactly. -const double _kAndroidPillTop = 14; +/// item's own icon box so they line up exactly. Floating's own top offset +/// is shorter than attached's - its row is 8px shorter overall (72 vs 80) +/// and there's no edge-to-edge safe-area strip inside it eating into that. +const double _kAttachedPillTop = 14; +const double _kFloatingPillTop = 10; const double _kAndroidPillWidth = 56; const double _kAndroidPillHeight = 32; @@ -23,11 +26,21 @@ const double _kAndroidPillHeight = 32; /// 56x32 accent-soft pill that slides between tabs as selection moves, /// rather than popping in/out on the destination item itself. /// +/// [barStyle] (user-configurable in Settings, Appearance) picks between +/// that edge-to-edge [NooBottomBarStyle.attached] bar and +/// [NooBottomBarStyle.floating] - inset 16px from both side edges, 28px +/// corners, a 1px `line` border instead of a shadow (see that enum's own +/// doc comment for why). Floating's Android row is 8px shorter (72 vs 80) +/// and drops its idle tabs' reserved label space - with no label to leave +/// room for, an idle icon just centers in the whole button and renders a +/// touch bigger (27 vs 24px) instead of sitting high with a gap under it. /// The home indicator / gesture bar area below the row comes from the /// bottom safe-area inset rather than a fixed 34/20px spacer, so it's -/// right on every device. Meant for `Scaffold.bottomNavigationBar`. +/// right on every device. Meant for `Scaffold.bottomNavigationBar` - the +/// host `Scaffold` needs `extendBody: true` while floating. class NooBottomBar extends StatelessWidget { final NooNavStyle style; + final NooBottomBarStyle barStyle; final List destinations; final int selectedIndex; final ValueChanged onSelected; @@ -35,6 +48,7 @@ class NooBottomBar extends StatelessWidget { const NooBottomBar({ super.key, required this.style, + this.barStyle = NooBottomBarStyle.attached, required this.destinations, required this.selectedIndex, required this.onSelected, @@ -44,8 +58,31 @@ class NooBottomBar extends StatelessWidget { Widget build(BuildContext context) { final colors = context.nooColors; final ios = style == NooNavStyle.ios; + final floating = barStyle == NooBottomBarStyle.floating; - final row = ios ? _buildIosRow() : _buildAndroidRow(colors); + final row = ios + ? _buildIosRow() + : _buildAndroidRow(colors, floating: floating); + final barHeight = ios ? (floating ? 64.0 : 50.0) : (floating ? 72.0 : 80.0); + + if (floating) { + return SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), + child: Container( + height: barHeight, + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: colors.surface, + border: Border.all(color: colors.line), + borderRadius: BorderRadius.circular(28), + ), + child: row, + ), + ), + ); + } return Container( decoration: BoxDecoration( @@ -54,7 +91,7 @@ class NooBottomBar extends StatelessWidget { ), child: SafeArea( top: false, - child: SizedBox(height: ios ? 50 : 80, child: row), + child: SizedBox(height: barHeight, child: row), ), ); } @@ -75,7 +112,8 @@ class NooBottomBar extends StatelessWidget { ); } - Widget _buildAndroidRow(NooColors colors) { + Widget _buildAndroidRow(NooColors colors, {required bool floating}) { + final pillTop = floating ? _kFloatingPillTop : _kAttachedPillTop; return LayoutBuilder( builder: (context, constraints) { final itemWidth = constraints.maxWidth / destinations.length; @@ -84,7 +122,7 @@ class NooBottomBar extends StatelessWidget { AnimatedPositioned( duration: NooMotion.base, curve: NooMotion.ease, - top: _kAndroidPillTop, + top: pillTop, left: itemWidth * selectedIndex + (itemWidth - _kAndroidPillWidth) / 2, @@ -105,6 +143,7 @@ class NooBottomBar extends StatelessWidget { child: _AndroidItem( destination: destinations[i], selected: i == selectedIndex, + floating: floating, onTap: () => onSelected(i), ), ), @@ -165,24 +204,69 @@ class _IosItem extends StatelessWidget { } /// Icon (over the shared sliding pill, drawn separately by [NooBottomBar]) -/// with its label always below it - the label's space is reserved -/// whether or not it's showing (only its opacity changes), so the icon -/// never shifts vertically as selection changes, and stays aligned with -/// the pill's fixed [_kAndroidPillTop]/[_kAndroidPillHeight]. +/// with its label below it. Attached always reserves the label's space +/// (just invisible when idle, per [_kAttachedPillTop]'s doc comment on +/// [NooBottomBar]) so the icon never shifts vertically as selection +/// changes. Floating drops that reserved space when idle instead: there's +/// no label to leave room for, so the icon centers in the whole button and +/// renders a touch bigger. class _AndroidItem extends StatelessWidget { final NooNavDestination destination; final bool selected; + final bool floating; final VoidCallback onTap; const _AndroidItem({ required this.destination, required this.selected, + required this.floating, required this.onTap, }); @override Widget build(BuildContext context) { final colors = context.nooColors; + final iconColor = selected ? colors.accentText : colors.fg2; + + final Widget content; + if (floating && !selected) { + content = Center( + child: Icon(destination.icon, size: 27, color: iconColor), + ); + } else { + content = Padding( + padding: EdgeInsets.only( + top: floating ? _kFloatingPillTop : _kAttachedPillTop, + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + SizedBox( + height: _kAndroidPillHeight, + child: Center( + child: Icon(destination.icon, size: 24, color: iconColor), + ), + ), + const SizedBox(height: 4), + AnimatedOpacity( + duration: NooMotion.base, + curve: NooMotion.ease, + opacity: selected ? 1 : 0, + child: Text( + destination.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: NooText.navLabelActive.copyWith( + height: 1, + color: colors.fg1, + ), + ), + ), + ], + ), + ); + } + return Semantics( button: true, selected: selected, @@ -190,39 +274,7 @@ class _AndroidItem extends StatelessWidget { child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: onTap, - child: Padding( - padding: const EdgeInsets.only(top: _kAndroidPillTop), - child: Column( - mainAxisAlignment: MainAxisAlignment.start, - children: [ - SizedBox( - height: _kAndroidPillHeight, - child: Center( - child: Icon( - destination.icon, - size: 24, - color: selected ? colors.accentText : colors.fg2, - ), - ), - ), - const SizedBox(height: 4), - AnimatedOpacity( - duration: NooMotion.base, - curve: NooMotion.ease, - opacity: selected ? 1 : 0, - child: Text( - destination.label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: NooText.navLabelActive.copyWith( - height: 1, - color: colors.fg1, - ), - ), - ), - ], - ), - ), + child: content, ), ); } diff --git a/lib/widgets/noo/nav/noo_nav_style.dart b/lib/widgets/noo/nav/noo_nav_style.dart index fae76e8..ca35d6b 100644 --- a/lib/widgets/noo/nav/noo_nav_style.dart +++ b/lib/widgets/noo/nav/noo_nav_style.dart @@ -17,6 +17,18 @@ enum NooNavStyle { }; } +/// How [NooBottomBar] sits on the screen - user-configurable in Settings +/// (Appearance). [attached] is the original edge-to-edge bar with no +/// side/bottom margin. [floating] insets it 16px from both side edges and +/// clear of the bottom safe area, rounded (28px - the same radius the +/// sheet top/drawer edge use) rather than square, with a 1px `line` border +/// standing in for elevation instead of a shadow (product UI stays flat - +/// DESIGN_SYSTEM.md 1.4 - so this is the one place that border does that +/// job). The host `Scaffold` needs `extendBody: true` while floating, so +/// its body scrolls behind the bar's transparent margin instead of +/// stopping short of it like [attached] does. +enum NooBottomBarStyle { attached, floating } + /// One destination in a [NooBottomBar]. Data-agnostic on purpose - the app /// maps its own tab model (e.g. `AppTab`) onto these. @immutable diff --git a/lib/widgets/settings/settings_appearance.dart b/lib/widgets/settings/settings_appearance.dart index 8ec884a..da02663 100644 --- a/lib/widgets/settings/settings_appearance.dart +++ b/lib/widgets/settings/settings_appearance.dart @@ -7,6 +7,7 @@ import '../../theme/design_tokens.dart'; import '../noo/core/noo_segmented_control.dart'; import '../noo/core/noo_toggle.dart'; import '../noo/lists/noo_settings_row.dart'; +import '../noo/nav/noo_nav_style.dart'; import '../noo/noo_layout.dart'; import '../noo/overlays/noo_dialog.dart'; import '../noo/overlays/noo_sheet.dart'; @@ -47,7 +48,10 @@ class SettingsAppearanceSection extends StatelessWidget { children: [ SettingsSection( title: 'Appearance', - children: [_ThemeRow(settings: settings)], + children: [ + _ThemeRow(settings: settings), + _BottomBarStyleRow(settings: settings), + ], ), const SizedBox(height: NooSpace.xl), SettingsSection( @@ -156,6 +160,64 @@ class _ThemeRow extends StatelessWidget { } } +/// The "Bottom bar" row: same "label, then a full-width segmented control +/// below it" shape as [_ThemeRow] right above it. Attached is the original +/// edge-to-edge bar; floating insets it from both side/bottom edges with +/// rounded corners - see [NooBottomBarStyle]'s own doc comment for the +/// full visual rationale. +class _BottomBarStyleRow extends StatelessWidget { + final SettingsController settings; + + const _BottomBarStyleRow({required this.settings}); + + @override + Widget build(BuildContext context) { + final colors = context.nooColors; + return Material( + color: colors.surface, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: NooSpace.md, + vertical: 12, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(LucideIcons.panelBottom, size: 20, color: colors.fg2), + const SizedBox(width: 14), + Text( + 'Bottom bar', + style: NooText.bodyL.copyWith(color: colors.fg1), + ), + ], + ), + const SizedBox(height: 12), + NooSegmentedControl( + fill: true, + value: settings.bottomBarStyle, + onChanged: settings.setBottomBarStyle, + options: const [ + NooSegmentOption( + value: NooBottomBarStyle.attached, + icon: LucideIcons.panelBottom, + label: 'Attached', + ), + NooSegmentOption( + value: NooBottomBarStyle.floating, + icon: LucideIcons.panelBottomOpen, + label: 'Floating', + ), + ], + ), + ], + ), + ), + ); + } +} + /// The current accent-color choice, shown as a small filled circle in the /// "Accent color" row - a wallpaper icon when following the OS's dynamic /// color instead of a fixed swatch. diff --git a/test/widgets/noo/nav_test.dart b/test/widgets/noo/nav_test.dart index 3b5df93..d2e8bf6 100644 --- a/test/widgets/noo/nav_test.dart +++ b/test/widgets/noo/nav_test.dart @@ -137,5 +137,80 @@ void main() { final labelCenter = tester.getCenter(find.text('Photos')); expect(labelCenter.dy, greaterThan(iconCenter.dy)); }); + + testNooWidgets('floating: inset, rounded, bordered - not edge to edge', ( + tester, + theme, + c, + ) async { + await pumpNoo( + tester, + SizedBox( + width: 360, + height: 96, + child: NooBottomBar( + style: NooNavStyle.android, + barStyle: NooBottomBarStyle.floating, + destinations: _destinations, + selectedIndex: 0, + onSelected: (_) {}, + ), + ), + theme: theme, + ); + final pillFinder = find.descendant( + of: find.byType(NooBottomBar), + matching: find.byWidgetPredicate( + (w) => w is Container && w.decoration != null, + ), + ); + final container = tester.widget(pillFinder); + final decoration = container.decoration as BoxDecoration; + expect(decoration.borderRadius, BorderRadius.circular(28)); + expect(decoration.border, Border.all(color: c.line)); + // Inset from both side edges of the bar's own host box, not flush + // against it - both rects are in the same (global) coordinate space, + // so comparing them directly still holds regardless of where + // `pumpNoo`'s own harness centers that host box on screen. + final hostRect = tester.getRect(find.byType(NooBottomBar)); + final rect = tester.getRect(pillFinder); + expect(rect.left, greaterThan(hostRect.left)); + expect(rect.right, lessThan(hostRect.right)); + }); + + testNooWidgets( + 'floating android: idle icon has no reserved label space and is ' + 'bigger than the active one', + (tester, theme, c) async { + await pumpNoo( + tester, + SizedBox( + width: 360, + height: 96, + child: NooBottomBar( + style: NooNavStyle.android, + barStyle: NooBottomBarStyle.floating, + destinations: _destinations, + selectedIndex: 0, + onSelected: (_) {}, + ), + ), + theme: theme, + ); + // Unlike attached, an idle floating tab has no label at all - not + // just an invisible one - so there's nothing to find here. + expect(find.text('Photos'), findsNothing); + expect( + find.ancestor( + of: find.byIcon(LucideIcons.images), + matching: find.byType(AnimatedOpacity), + ), + findsNothing, + ); + final activeIcon = tester.widget(find.byIcon(LucideIcons.folder)); + final idleIcon = tester.widget(find.byIcon(LucideIcons.images)); + expect(idleIcon.size, greaterThan(activeIcon.size!)); + }, + ); }); } From 58312511c54b4060ef480d771232fc82cbf4041d Mon Sep 17 00:00:00 2001 From: ayushya Date: Tue, 29 Sep 2026 22:19:13 -0400 Subject: [PATCH 18/29] Add "Search in bottom bar" to Settings 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 --- .../context/design-system/DESIGN_SYSTEM.md | 1 + .claude/context/styling.md | 6 + lib/main.dart | 12 +- lib/models/app_tab.dart | 9 +- lib/providers/settings_controller.dart | 25 ++++ lib/widgets/app_top_bar.dart | 19 ++- lib/widgets/bottom_nav_bar.dart | 12 ++ lib/widgets/noo/nav/noo_bottom_bar.dart | 125 +++++++++++++++--- lib/widgets/settings/settings_appearance.dart | 12 ++ lib/widgets/settings/settings_tabs.dart | 3 +- test/widgets/noo/nav_test.dart | 91 +++++++++++++ 11 files changed, 292 insertions(+), 23 deletions(-) diff --git a/.claude/context/design-system/DESIGN_SYSTEM.md b/.claude/context/design-system/DESIGN_SYSTEM.md index 2ddead3..3ddbc59 100644 --- a/.claude/context/design-system/DESIGN_SYSTEM.md +++ b/.claude/context/design-system/DESIGN_SYSTEM.md @@ -274,6 +274,7 @@ Mobile always has **5 pinned tabs in the bottom bar**. The rest go in the drawer | Top bar | 44px row: `menu` on the left; `plus` and avatar on the right (all accent-text). Then a 34px large title, then the search field. | 64px: `menu`, 22px title, `search`, avatar | | 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. No shadow (product UI stays flat - see 1.4); a 1px `line` border stands in for elevation instead. Row height drops slightly (64/72 vs 50/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 (27 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. | +| 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. | | Upload | `plus` in the nav bar | Extended FAB | | 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. | diff --git a/.claude/context/styling.md b/.claude/context/styling.md index b541e87..0220b14 100644 --- a/.claude/context/styling.md +++ b/.claude/context/styling.md @@ -197,6 +197,12 @@ blocks are noted where they matter: with a `line` border instead of edge to edge (no opacity/blur knob either). The host `Scaffold` needs `extendBody: true` while it's active (`main.dart` already wires this off `SettingsController.bottomBarStyle`). + 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. - [`SyncedHeaderScaffold`](../../lib/widgets/synced_header_scaffold.dart) — the pull-to-sync `CustomScrollView` header with the persistent sync-status chip and pull-to-refresh gesture/spinner. Every screen (including diff --git a/lib/main.dart b/lib/main.dart index 256ba94..0b44578 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -473,11 +473,20 @@ class _MainShellViewState extends State { } else { final navStyle = NooLayout.navStyle(context); final bottomBarStyle = settings.bottomBarStyle; + // Picking/offline already override the tab set itself (see + // pinnedTabs above) - Search doesn't belong in either: there's + // nothing to search for a file-picking flow, and Search needs the + // network Offline mode doesn't have. + final showBottomBarSearch = !overrideActive && settings.searchInBottomBar; scaffold = Scaffold( backgroundColor: colors.bg, drawerScrimColor: colors.scrim, appBar: pickRequest == null - ? AppTopBar(style: navStyle, tab: selectedTab) + ? AppTopBar( + style: navStyle, + tab: selectedTab, + searchInBottomBar: showBottomBarSearch, + ) : null, drawer: pickRequest == null ? const AppDrawer() : null, // Floating needs the body to draw behind the bar's own transparent @@ -502,6 +511,7 @@ class _MainShellViewState extends State { barStyle: bottomBarStyle, tabs: pinnedTabs, selectedIndex: pinnedIndex, + onSearchTap: showBottomBarSearch ? () => openSearch(context) : null, onDestinationSelected: (index) { final tappedTab = pinnedTabs[index]; if (tappedTab == _currentTab) { diff --git a/lib/models/app_tab.dart b/lib/models/app_tab.dart index 8fc5181..b34ca41 100644 --- a/lib/models/app_tab.dart +++ b/lib/models/app_tab.dart @@ -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 { diff --git a/lib/providers/settings_controller.dart b/lib/providers/settings_controller.dart index b7bfec4..0220cc8 100644 --- a/lib/providers/settings_controller.dart +++ b/lib/providers/settings_controller.dart @@ -32,6 +32,7 @@ class SettingsController extends ChangeNotifier { static const _prefSwipeLeftAction = 'ui_swipe_left_action'; static const _prefSwipeRightAction = 'ui_swipe_right_action'; static const _prefBottomBarStyle = 'ui_bottom_bar_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'; @@ -42,6 +43,7 @@ class SettingsController extends ChangeNotifier { Color _seedColor = AppTheme.defaultAccent; ThemeMode _themeMode = ThemeMode.system; NooBottomBarStyle _bottomBarStyle = NooBottomBarStyle.attached; + bool _searchInBottomBar = false; bool _useDynamicColor = true; bool _amoledDark = false; MediaProgressBarStyle _mediaProgressBarStyle = MediaProgressBarStyle.wavy; @@ -84,11 +86,20 @@ class SettingsController extends ChangeNotifier { Color get seedColor => _seedColor; ThemeMode get themeMode => _themeMode; NooBottomBarStyle get bottomBarStyle => _bottomBarStyle; + bool get searchInBottomBar => _searchInBottomBar; bool get useDynamicColor => _useDynamicColor; bool get amoledDark => _amoledDark; MediaProgressBarStyle get mediaProgressBarStyle => _mediaProgressBarStyle; bool get tapTabToScrollTop => _tapTabToScrollTop; + /// The cap a screen should actually enforce for the *regular*, user- + /// reorderable tabs - one below [defaultMaxVisibleTabs] while + /// [searchInBottomBar] is on, since Search then takes that freed-up slot + /// itself (the row's last tab when attached, its own satellite circle + /// when floating - see `NooBottomBar`) rather than counting against it. + int get maxVisibleTabs => + defaultMaxVisibleTabs - (_searchInBottomBar ? 1 : 0); + /// Every tab in the user's configured order, including hidden ones — used /// by the reorder/visibility settings UI. List get tabOrder => _tabOrder; @@ -126,6 +137,8 @@ class SettingsController extends ChangeNotifier { orElse: () => _bottomBarStyle, ); } + _searchInBottomBar = + prefs.getBool(_prefSearchInBottomBar) ?? _searchInBottomBar; _useDynamicColor = prefs.getBool(_prefUseDynamicColor) ?? _useDynamicColor; _amoledDark = prefs.getBool(_prefAmoledDark) ?? _amoledDark; @@ -274,6 +287,18 @@ class SettingsController extends ChangeNotifier { _prefsFuture.then((p) => p.setString(_prefBottomBarStyle, 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 order) { _tabOrder = order; notifyListeners(); diff --git a/lib/widgets/app_top_bar.dart b/lib/widgets/app_top_bar.dart index 70d329c..0c46fa7 100644 --- a/lib/widgets/app_top_bar.dart +++ b/lib/widgets/app_top_bar.dart @@ -19,13 +19,24 @@ import 'shell/shell_common.dart'; /// [style] is resolved by the caller (`NooLayout.navStyle`) rather than /// read from context here, since [preferredSize] is a plain getter with no /// BuildContext - the same reason [NooTopBar] itself takes it as a field. +/// [searchInBottomBar] is [SettingsController.searchInBottomBar], passed in +/// for the same reason: it hides this bar's own search field/icon so +/// there's only ever one search entry point on screen once the bottom bar +/// has taken it over. class AppTopBar extends StatelessWidget implements PreferredSizeWidget { final NooNavStyle style; final AppTab tab; + final bool searchInBottomBar; - const AppTopBar({super.key, required this.style, required this.tab}); + const AppTopBar({ + super.key, + required this.style, + required this.tab, + this.searchInBottomBar = false, + }); bool get _ios => style == NooNavStyle.ios; + bool get _showSearch => !searchInBottomBar; @override Size get preferredSize => NooTopBar( @@ -34,7 +45,7 @@ class AppTopBar extends StatelessWidget implements PreferredSizeWidget { // A throwaway placeholder purely to make preferredSize's "is there a // search slot" check match what build() actually shows - never // inserted into the tree, so its content doesn't matter. - search: _ios ? const SizedBox.shrink() : null, + search: _ios && _showSearch ? const SizedBox.shrink() : null, ).preferredSize; @override @@ -44,7 +55,7 @@ class AppTopBar extends StatelessWidget implements PreferredSizeWidget { title: tab.label, onMenu: () => Scaffold.of(context).openDrawer(), actions: [ - if (!_ios) + if (!_ios && _showSearch) NooTopBarButton( icon: LucideIcons.search, tooltip: 'Search', @@ -58,7 +69,7 @@ class AppTopBar extends StatelessWidget implements PreferredSizeWidget { ), ShellAvatarButton(hitBox: _ios ? 44 : 48), ], - search: _ios ? const ShellSearchLauncher(ios: true) : null, + search: _ios && _showSearch ? const ShellSearchLauncher(ios: true) : null, ); } } diff --git a/lib/widgets/bottom_nav_bar.dart b/lib/widgets/bottom_nav_bar.dart index 1da95de..f116a38 100644 --- a/lib/widgets/bottom_nav_bar.dart +++ b/lib/widgets/bottom_nav_bar.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../models/app_tab.dart'; import 'noo/nav/noo_bottom_bar.dart'; @@ -7,12 +8,18 @@ import 'noo/nav/noo_bottom_bar.dart'; /// doesn't need to import `noo/nav` itself. [selectedIndex] may be -1 (no /// destination highlighted) when the shell is showing a tab opened from the /// drawer's "More" list that isn't one of the pinned [tabs]. +/// +/// [onSearchTap] (from Settings' "Search in bottom bar") adds the Search +/// entry [NooBottomBar] itself knows how to place (row's last item when +/// attached, its own satellite when floating) - null leaves the bar +/// exactly as before. class BottomNavBar extends StatelessWidget { final NooNavStyle style; final NooBottomBarStyle barStyle; final List tabs; final int selectedIndex; final ValueChanged onDestinationSelected; + final VoidCallback? onSearchTap; const BottomNavBar({ super.key, @@ -21,6 +28,7 @@ class BottomNavBar extends StatelessWidget { required this.tabs, required this.selectedIndex, required this.onDestinationSelected, + this.onSearchTap, }); @override @@ -34,6 +42,10 @@ class BottomNavBar extends StatelessWidget { ], selectedIndex: selectedIndex, onSelected: onDestinationSelected, + searchDestination: onSearchTap == null + ? null + : const NooNavDestination(icon: LucideIcons.search, label: 'Search'), + onSearchTap: onSearchTap, ); } } diff --git a/lib/widgets/noo/nav/noo_bottom_bar.dart b/lib/widgets/noo/nav/noo_bottom_bar.dart index 807365f..ef19ba7 100644 --- a/lib/widgets/noo/nav/noo_bottom_bar.dart +++ b/lib/widgets/noo/nav/noo_bottom_bar.dart @@ -38,12 +38,21 @@ const double _kAndroidPillHeight = 32; /// bottom safe-area inset rather than a fixed 34/20px spacer, so it's /// right on every device. Meant for `Scaffold.bottomNavigationBar` - the /// host `Scaffold` needs `extendBody: true` while floating. +/// +/// [searchDestination]/[onSearchTap] (set together, from Settings' +/// "Search in bottom bar" - see `DESIGN_SYSTEM.md`'s floating bottom bar +/// entry) add a Search entry that's never highlighted (tapping it pushes +/// `SearchView`, it doesn't select anything) - the row's last item when +/// attached, or its own satellite circle beside the bar when floating, +/// always fully round regardless of the bar's own corner radius. class NooBottomBar extends StatelessWidget { final NooNavStyle style; final NooBottomBarStyle barStyle; final List destinations; final int selectedIndex; final ValueChanged onSelected; + final NooNavDestination? searchDestination; + final VoidCallback? onSearchTap; const NooBottomBar({ super.key, @@ -52,6 +61,8 @@ class NooBottomBar extends StatelessWidget { required this.destinations, required this.selectedIndex, required this.onSelected, + this.searchDestination, + this.onSearchTap, }); @override @@ -59,27 +70,50 @@ class NooBottomBar extends StatelessWidget { final colors = context.nooColors; final ios = style == NooNavStyle.ios; final floating = barStyle == NooBottomBarStyle.floating; + final hasSearch = searchDestination != null && onSearchTap != null; + // Attached folds Search into the row itself (last item); floating + // gives it a separate satellite circle instead (built below), so the + // row builders only ever see it as a trailing item in the former case. + final rowSearch = hasSearch && !floating ? searchDestination : null; final row = ios - ? _buildIosRow() - : _buildAndroidRow(colors, floating: floating); + ? _buildIosRow(trailingSearch: rowSearch) + : _buildAndroidRow( + colors, + floating: floating, + trailingSearch: rowSearch, + ); final barHeight = ios ? (floating ? 64.0 : 50.0) : (floating ? 72.0 : 80.0); if (floating) { + final pill = Container( + height: barHeight, + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: colors.surface, + border: Border.all(color: colors.line), + borderRadius: BorderRadius.circular(28), + ), + child: row, + ); return SafeArea( top: false, child: Padding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), - child: Container( - height: barHeight, - clipBehavior: Clip.antiAlias, - decoration: BoxDecoration( - color: colors.surface, - border: Border.all(color: colors.line), - borderRadius: BorderRadius.circular(28), - ), - child: row, - ), + child: hasSearch + ? Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded(child: pill), + const SizedBox(width: 8), + _SearchSatellite( + destination: searchDestination!, + onTap: onSearchTap!, + size: barHeight, + ), + ], + ) + : pill, ), ); } @@ -96,7 +130,7 @@ class NooBottomBar extends StatelessWidget { ); } - Widget _buildIosRow() { + Widget _buildIosRow({NooNavDestination? trailingSearch}) { return Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -108,15 +142,28 @@ class NooBottomBar extends StatelessWidget { onTap: () => onSelected(i), ), ), + if (trailingSearch != null) + Expanded( + child: _IosItem( + destination: trailingSearch, + selected: false, + onTap: onSearchTap!, + ), + ), ], ); } - Widget _buildAndroidRow(NooColors colors, {required bool floating}) { + Widget _buildAndroidRow( + NooColors colors, { + required bool floating, + NooNavDestination? trailingSearch, + }) { final pillTop = floating ? _kFloatingPillTop : _kAttachedPillTop; + final itemCount = destinations.length + (trailingSearch != null ? 1 : 0); return LayoutBuilder( builder: (context, constraints) { - final itemWidth = constraints.maxWidth / destinations.length; + final itemWidth = constraints.maxWidth / itemCount; return Stack( children: [ AnimatedPositioned( @@ -147,6 +194,15 @@ class NooBottomBar extends StatelessWidget { onTap: () => onSelected(i), ), ), + if (trailingSearch != null) + Expanded( + child: _AndroidItem( + destination: trailingSearch, + selected: false, + floating: floating, + onTap: onSearchTap!, + ), + ), ], ), ], @@ -156,6 +212,45 @@ class NooBottomBar extends StatelessWidget { } } +/// The floating bar's own Search entry point, next to the pill rather than +/// inside it - always fully round (see [NooBottomBar]'s doc comment), +/// same fill/border as the pill so the two still read as one family. +class _SearchSatellite extends StatelessWidget { + final NooNavDestination destination; + final VoidCallback onTap; + final double size; + + const _SearchSatellite({ + required this.destination, + required this.onTap, + required this.size, + }); + + @override + Widget build(BuildContext context) { + final colors = context.nooColors; + return Semantics( + button: true, + label: destination.label, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onTap, + child: Container( + width: size, + height: size, + alignment: Alignment.center, + decoration: BoxDecoration( + color: colors.surface, + border: Border.all(color: colors.line), + shape: BoxShape.circle, + ), + child: Icon(destination.icon, size: 24, color: colors.fg1), + ), + ), + ); + } +} + class _IosItem extends StatelessWidget { final NooNavDestination destination; final bool selected; diff --git a/lib/widgets/settings/settings_appearance.dart b/lib/widgets/settings/settings_appearance.dart index da02663..a2c8a79 100644 --- a/lib/widgets/settings/settings_appearance.dart +++ b/lib/widgets/settings/settings_appearance.dart @@ -51,6 +51,18 @@ class SettingsAppearanceSection extends StatelessWidget { children: [ _ThemeRow(settings: settings), _BottomBarStyleRow(settings: settings), + NooSettingsRow( + icon: LucideIcons.search, + label: const Text('Search in bottom bar'), + subtitle: const Text( + 'Adds Search to the bottom bar and removes it from the top ' + 'bar - leaves room for one fewer regular tab', + ), + trailing: NooToggle( + checked: settings.searchInBottomBar, + onChanged: settings.setSearchInBottomBar, + ), + ), ], ), const SizedBox(height: NooSpace.xl), diff --git a/lib/widgets/settings/settings_tabs.dart b/lib/widgets/settings/settings_tabs.dart index 94ef147..494a76c 100644 --- a/lib/widgets/settings/settings_tabs.dart +++ b/lib/widgets/settings/settings_tabs.dart @@ -11,7 +11,8 @@ import '../noo/noo_layout.dart'; import 'settings_section.dart'; /// Settings section 7: bottom-nav tab order, visibility (pinned = shown, up -/// to [maxVisibleTabs]) and default tab. +/// to [SettingsController.maxVisibleTabs] - one fewer while Appearance's +/// "Search in bottom bar" is on) and default tab. class SettingsTabsSection extends StatelessWidget { const SettingsTabsSection({super.key}); diff --git a/test/widgets/noo/nav_test.dart b/test/widgets/noo/nav_test.dart index d2e8bf6..ac3d53f 100644 --- a/test/widgets/noo/nav_test.dart +++ b/test/widgets/noo/nav_test.dart @@ -212,5 +212,96 @@ void main() { expect(idleIcon.size, greaterThan(activeIcon.size!)); }, ); + + testNooWidgets( + 'attached: search is the row\'s last item and never highlights', + (tester, theme, c) async { + var searchTaps = 0; + var selected = 0; + await pumpNoo( + tester, + StatefulBuilder( + builder: (context, setState) => SizedBox( + width: 360, + height: 80, + child: NooBottomBar( + style: NooNavStyle.android, + destinations: _destinations, + selectedIndex: selected, + onSelected: (i) => setState(() => selected = i), + searchDestination: const NooNavDestination( + icon: LucideIcons.search, + label: 'Search', + ), + onSearchTap: () => searchTaps++, + ), + ), + ), + theme: theme, + ); + expect(find.byIcon(LucideIcons.search), findsOneWidget); + final iconCenters = [ + tester.getCenter(find.byIcon(LucideIcons.folder)), + tester.getCenter(find.byIcon(LucideIcons.images)), + tester.getCenter(find.byIcon(LucideIcons.star)), + tester.getCenter(find.byIcon(LucideIcons.search)), + ]; + // Evenly spaced, search landing after every real destination. + for (var i = 1; i < iconCenters.length; i++) { + expect(iconCenters[i].dx, greaterThan(iconCenters[i - 1].dx)); + } + + await tester.tap(find.byIcon(LucideIcons.search)); + await tester.pump(); + expect(searchTaps, 1); + // Tapping search never selects it or moves the indicator. + expect(selected, 0); + expect(_indicator(tester, c), findsOneWidget); + final indicatorLeft = tester.getTopLeft(_indicator(tester, c)).dx; + expect(indicatorLeft, lessThan(iconCenters[3].dx)); + }, + ); + + testNooWidgets('floating: search is a separate round satellite', ( + tester, + theme, + c, + ) async { + var searchTaps = 0; + await pumpNoo( + tester, + SizedBox( + width: 360, + height: 96, + child: NooBottomBar( + style: NooNavStyle.ios, + barStyle: NooBottomBarStyle.floating, + destinations: _destinations, + selectedIndex: 0, + onSelected: (_) {}, + searchDestination: const NooNavDestination( + icon: LucideIcons.search, + label: 'Search', + ), + onSearchTap: () => searchTaps++, + ), + ), + theme: theme, + ); + // Not part of the main pill - its own separately-tappable icon. + expect(find.text('Search'), findsNothing); + final satellite = tester.widget( + find.ancestor( + of: find.byIcon(LucideIcons.search), + matching: find.byType(Container), + ), + ); + final decoration = satellite.decoration as BoxDecoration; + expect(decoration.shape, BoxShape.circle); + + await tester.tap(find.byIcon(LucideIcons.search)); + await tester.pump(); + expect(searchTaps, 1); + }); }); } From 4927416277245be9000b95f6393b0b20bb8f4888 Mon Sep 17 00:00:00 2001 From: ayushya Date: Tue, 29 Sep 2026 22:25:18 -0400 Subject: [PATCH 19/29] 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 --- website/README.md | 38 +++ website/favicon.svg | 4 + website/index.html | 307 ++++++++++++++++++ website/script.js | 141 ++++++++ website/styles.css | 758 ++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 1248 insertions(+) create mode 100644 website/README.md create mode 100644 website/favicon.svg create mode 100644 website/index.html create mode 100644 website/script.js create mode 100644 website/styles.css diff --git a/website/README.md b/website/README.md new file mode 100644 index 0000000..3d98088 --- /dev/null +++ b/website/README.md @@ -0,0 +1,38 @@ +# Noo website + +The product site for Noo, meant to be deployed at `noo.ayushya.dev`. + +Static HTML/CSS/vanilla JS, no build step and no dependencies beyond the +two Google Fonts already used by the app (Schibsted Grotesk, Instrument +Sans). Colors, radii, type scale and motion timing are copied from +`lib/theme/design_tokens.dart` so the site and the app read as the same +product. + +## Files + +- `index.html` — the whole page (hero, features, customize, privacy, CTA). +- `styles.css` — all styling, incl. the design tokens as CSS custom + properties and a dark-mode variant (`prefers-color-scheme` and an + explicit `data-theme` override for a future toggle). +- `script.js` — mobile nav, the hero's exploded-layer parallax + (scroll + pointer), the two interactive Customize demos (accent color, + bottom bar style), and scroll-reveal. +- `favicon.svg`. + +## Local preview + +Any static file server works, e.g.: + +```bash +cd website +python3 -m http.server 8000 +``` + +Then open `http://localhost:8000`. + +## Deploying + +Point any static host (Cloudflare Pages, Netlify, GitHub Pages, or a +plain nginx `root`) at this folder - there's nothing to build. Update the +`https://github.com` placeholder links and the two "Download for..." +buttons in `index.html` once real release links exist. diff --git a/website/favicon.svg b/website/favicon.svg new file mode 100644 index 0000000..93211a9 --- /dev/null +++ b/website/favicon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/website/index.html b/website/index.html new file mode 100644 index 0000000..81baef7 --- /dev/null +++ b/website/index.html @@ -0,0 +1,307 @@ + + + + + +Noo — a Nextcloud client that feels like it belongs on your phone + + + + + + + + + + + + + + + + + + +
+ + +
+
+
+

For Nextcloud, unofficial

+

Your cloud.
Looking like yours.

+

+ Noo is a fast, private Nextcloud client that browses files and + photos the way a native app should — and lets you rebuild its + whole shell around how you actually use your phone. +

+ +
    +
  • No password stored — Login Flow v2
  • +
  • Offline device sync
  • +
  • Open source
  • +
+
+ + +
+
+ + +
+
+

Features

+

Everything your Nextcloud does. Nothing it doesn't.

+

+ Files, photos, sharing and sync — built as a real mobile app, not a + browser tab pinned to your home screen. +

+ +
+
+ +

Files, browsed properly

+

Grid or list, sorted your way, with the same filters and + controls whether you're picking a file, uploading, or moving + one into place.

+
+
+ +

Photos, grouped by month

+

Every image and video across your account in one gallery — + filter to just photos, just videos, or just favorites.

+
+
+ +

Offline, for real

+

Pick folders to mirror locally and keep browsing — reading, + opening, previewing — with no connection at all.

+
+
+ +

Sharing, without the detour

+

Share with people or a public link, see who has access, and + copy that link — all from one sheet, not three menus.

+
+
+ +

Activity & recent

+

What changed, and where — grouped into today, yesterday and + this week, so catching up takes seconds.

+
+
+ +

Multiple accounts

+

Switch between personal and work Nextcloud instances with a + swipe — each with its own sync, cache and lock.

+
+
+
+
+ + +
+
+

Customize

+

Shape it around your thumb, not the other way round.

+

+ Every screen bends to a handful of real settings — try a few + right here. +

+ +
+ +
+
+
+ + +
+
Active tab
+
+
+
+
+

Pick your accent

+

Six built-in colors, or match your wallpaper with dynamic + color on Android.

+
+ + + + + + +
+
+
+ +
+
+
+
+ + + + +
+
+
+

Floating or attached

+

The bottom bar can sit flush with the edge, or float free + with rounded corners — your call.

+
+ + +
+
+
+ +
+

And the rest

+
    +
  • Reorder tabs, hide the ones you never open
  • +
  • Move Search into the bottom bar
  • +
  • Light, dark, or match the system
  • +
  • Pure black backgrounds in dark mode
  • +
  • Choose what swiping a file left or right does
  • +
  • Reorder the bulk-action bar for multi-select
  • +
+
+ +
+
+
+ + +
+
+
+

Privacy

+

It's your server. Noo just visits.

+

+ Noo never asks for your password. Signing in hands off to your + Nextcloud server's own login page (Login Flow v2), which returns + a revocable app token — not your credentials. Nothing but that + token, and whatever you choose to sync, ever leaves your device. +

+
+
    +
  • No password stored on device
  • +
  • Talks only to your own server
  • +
  • Open source, auditable
  • +
  • No analytics, no trackers
  • +
+
+
+ + +
+
+

Bring your Nextcloud with you.

+

Free and open source. Point it at your server and go.

+ +

Or build it from source — it's just Flutter.

+
+
+ +
+ +
+ +
+ + + + diff --git a/website/script.js b/website/script.js new file mode 100644 index 0000000..337149e --- /dev/null +++ b/website/script.js @@ -0,0 +1,141 @@ +(() => { + "use strict"; + + /* ---------- mobile nav ---------- */ + + const navToggle = document.getElementById("navToggle"); + const mobileNav = document.getElementById("mobileNav"); + if (navToggle && mobileNav) { + navToggle.addEventListener("click", () => { + const open = mobileNav.classList.toggle("is-open"); + navToggle.setAttribute("aria-expanded", String(open)); + navToggle.setAttribute("aria-label", open ? "Close menu" : "Open menu"); + }); + mobileNav.querySelectorAll("a").forEach((a) => + a.addEventListener("click", () => { + mobileNav.classList.remove("is-open"); + navToggle.setAttribute("aria-expanded", "false"); + }) + ); + } + + /* ---------- exploded hero parallax ---------- */ + /* Each [data-depth] layer drifts at its own rate on scroll, and tilts + gently toward the pointer - the "exploded" pieces read as separate + depths rather than a flat collage. Skipped entirely under + prefers-reduced-motion. */ + + const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; + const heroVisual = document.getElementById("heroVisual"); + + if (heroVisual && !reduceMotion) { + const layers = Array.from(heroVisual.querySelectorAll("[data-depth]")).map((el) => ({ + el, + depth: parseFloat(el.dataset.depth) || 0.3, + })); + + let scrollOffset = 0; + let pointer = { x: 0, y: 0 }; // -1..1 + let ticking = false; + + const apply = () => { + const rect = heroVisual.getBoundingClientRect(); + const inView = rect.bottom > 0 && rect.top < window.innerHeight; + if (inView) { + for (const layer of layers) { + const scrollShift = scrollOffset * layer.depth * 40; + const px = pointer.x * layer.depth * 14; + const py = pointer.y * layer.depth * 14; + layer.el.style.transform = + `translate3d(${px}px, ${scrollShift + py}px, 0)`; + } + } + ticking = false; + }; + + const requestTick = () => { + if (!ticking) { + ticking = true; + requestAnimationFrame(apply); + } + }; + + const onScroll = () => { + const rect = heroVisual.getBoundingClientRect(); + // -1 (scrolled well past) .. 1 (below viewport) - 0 while centered. + scrollOffset = (window.innerHeight / 2 - (rect.top + rect.height / 2)) / window.innerHeight; + requestTick(); + }; + + const onPointerMove = (e) => { + const rect = heroVisual.getBoundingClientRect(); + pointer.x = ((e.clientX - rect.left) / rect.width) * 2 - 1; + pointer.y = ((e.clientY - rect.top) / rect.height) * 2 - 1; + requestTick(); + }; + + const onPointerLeave = () => { + pointer = { x: 0, y: 0 }; + requestTick(); + }; + + window.addEventListener("scroll", onScroll, { passive: true }); + heroVisual.addEventListener("pointermove", onPointerMove); + heroVisual.addEventListener("pointerleave", onPointerLeave); + onScroll(); + } + + /* ---------- accent color demo ---------- */ + + const accentDemo = document.getElementById("accentDemo"); + if (accentDemo) { + const buttons = accentDemo.querySelectorAll(".swatch-btn"); + buttons.forEach((btn) => { + btn.addEventListener("click", () => { + buttons.forEach((b) => b.classList.remove("is-active")); + btn.classList.add("is-active"); + const color = btn.dataset.accent; + accentDemo.style.setProperty("--demo-accent", color); + }); + }); + } + + /* ---------- bottom bar style demo ---------- */ + + const barDemo = document.getElementById("barDemo"); + const demoBar = document.getElementById("demoBar"); + if (barDemo && demoBar) { + const buttons = barDemo.querySelectorAll(".segmented-btn"); + buttons.forEach((btn) => { + btn.addEventListener("click", () => { + buttons.forEach((b) => b.classList.remove("is-active")); + btn.classList.add("is-active"); + demoBar.classList.toggle("is-attached", btn.dataset.bar === "attached"); + }); + }); + } + + /* ---------- scroll reveal ---------- */ + + const revealTargets = document.querySelectorAll( + ".card, .customize-demo, .customize-list, .privacy-points li" + ); + revealTargets.forEach((el) => el.classList.add("reveal")); + + if ("IntersectionObserver" in window && !reduceMotion) { + const io = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + if (entry.isIntersecting) { + entry.target.classList.add("is-visible"); + io.unobserve(entry.target); + } + } + }, + { threshold: 0.15, rootMargin: "0px 0px -40px 0px" } + ); + revealTargets.forEach((el) => io.observe(el)); + } else { + revealTargets.forEach((el) => el.classList.add("is-visible")); + } +})(); diff --git a/website/styles.css b/website/styles.css new file mode 100644 index 0000000..f6c1bbd --- /dev/null +++ b/website/styles.css @@ -0,0 +1,758 @@ +/* Noo marketing site — tokens mirror lib/theme/design_tokens.dart exactly, + so this reads as the same product, not a separate marketing skin. */ + +:root { + --bg: #F3EFE6; + --surface: #FFFFFF; + --surface2: #F6F5F2; + --surface3: #EAE8E2; + --line: #EAE8E2; + --fg1: #0E0D0B; + --fg2: #5A5852; + --fg3: #7C7A72; + --accent: #8D0DE3; + --accent-text: #8D0DE3; + --accent-soft: #ECDFFF; + --danger: #A8202A; + + --demo-accent: #8D0DE3; + + --font-display: 'Schibsted Grotesk', system-ui, sans-serif; + --font-body: 'Instrument Sans', system-ui, sans-serif; + + --ease: cubic-bezier(0.7, 0, 0.15, 1); + --motion-fast: 180ms; + --motion-base: 280ms; + + --radius-pill: 999px; + --radius-card: 20px; + --radius-input: 14px; + --radius-panel: 28px; + + --gutter: clamp(20px, 5vw, 64px); + --content-max: 1120px; + + color-scheme: light; +} + +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) { + --bg: #0E0D0B; + --surface: #25241F; + --surface2: #3D3C38; + --surface3: #5A5852; + --line: #3D3C38; + --fg1: #F3EFE6; + --fg2: #BDBAB1; + --fg3: #9E9B92; + --accent: #8D0DE3; + --accent-text: #CBA5FD; + --accent-soft: #2F0454; + --danger: #F49E9E; + color-scheme: dark; + } +} + +:root[data-theme="dark"] { + --bg: #0E0D0B; + --surface: #25241F; + --surface2: #3D3C38; + --surface3: #5A5852; + --line: #3D3C38; + --fg1: #F3EFE6; + --fg2: #BDBAB1; + --fg3: #9E9B92; + --accent: #8D0DE3; + --accent-text: #CBA5FD; + --accent-soft: #2F0454; + --danger: #F49E9E; + color-scheme: dark; +} + +* { box-sizing: border-box; } + +html { scroll-behavior: smooth; } + +body { + margin: 0; + background: var(--bg); + color: var(--fg1); + font-family: var(--font-body); + font-size: 16px; + line-height: 1.5; + -webkit-font-smoothing: antialiased; +} + +img, svg { display: block; max-width: 100%; } + +a { color: inherit; } + +h1, h2, h3 { + font-family: var(--font-display); + font-weight: 600; + margin: 0; + letter-spacing: -0.02em; +} + +.wrap { + max-width: var(--content-max); + margin: 0 auto; + padding-inline: var(--gutter); +} + +.skip-link { + position: absolute; + left: -9999px; + top: 0; + background: var(--accent); + color: #fff; + padding: 10px 16px; + border-radius: var(--radius-input); + z-index: 100; +} +.skip-link:focus { left: 16px; top: 16px; } + +.eyebrow { + font-family: var(--font-body); + font-size: 13px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--accent-text); + margin: 0 0 12px; +} + +/* ---------- buttons ---------- */ + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + font-family: var(--font-body); + font-weight: 600; + text-decoration: none; + border-radius: var(--radius-pill); + border: 1px solid transparent; + cursor: pointer; + transition: transform var(--motion-fast) var(--ease), opacity var(--motion-fast) var(--ease), background var(--motion-fast) var(--ease); + white-space: nowrap; +} +.btn:active { transform: scale(0.98); } + +.btn-primary { background: var(--accent); color: #fff; } +.btn-primary:hover { opacity: 0.92; } + +.btn-outline { background: transparent; color: var(--fg1); border-color: var(--line); } +.btn-outline:hover { background: var(--surface2); } + +.btn-lg { padding: 14px 26px; font-size: 16px; } +.btn-sm { padding: 9px 18px; font-size: 14px; } + +/* ---------- header ---------- */ + +.site-header { + position: sticky; + top: 0; + z-index: 40; + background: var(--bg); + border-bottom: 1px solid var(--line); +} + +.header-row { + display: flex; + align-items: center; + gap: 24px; + height: 72px; +} + +.wordmark { + display: flex; + align-items: center; + gap: 10px; + font-family: var(--font-display); + font-weight: 700; + font-size: 20px; + text-decoration: none; + color: var(--fg1); + margin-right: auto; +} +.wordmark-sm { font-size: 17px; } + +.wordmark-mark { + width: 26px; + height: 26px; + border-radius: 8px; + background: var(--accent); + position: relative; + flex: none; +} +.wordmark-mark::after { + content: ""; + position: absolute; + inset: 7px; + border-radius: 4px; + background: #fff; + opacity: 0.92; +} + +.site-nav { + display: flex; + gap: 28px; +} +.site-nav a { + font-size: 15px; + font-weight: 500; + text-decoration: none; + color: var(--fg2); + transition: color var(--motion-fast) var(--ease); +} +.site-nav a:hover { color: var(--fg1); } + +.nav-toggle { + display: none; + flex-direction: column; + justify-content: center; + gap: 4px; + width: 40px; + height: 40px; + border: none; + background: none; + cursor: pointer; + margin-left: -6px; +} +.nav-toggle span { + display: block; + height: 2px; + border-radius: 2px; + background: var(--fg1); + transition: transform var(--motion-base) var(--ease), opacity var(--motion-base) var(--ease); +} +.nav-toggle[aria-expanded="true"] span:nth-child(1) { transform: translateY(6px) rotate(45deg); } +.nav-toggle[aria-expanded="true"] span:nth-child(2) { opacity: 0; } +.nav-toggle[aria-expanded="true"] span:nth-child(3) { transform: translateY(-6px) rotate(-45deg); } + +.mobile-nav { + display: none; + flex-direction: column; + gap: 4px; + padding: 8px var(--gutter) 20px; + border-top: 1px solid var(--line); +} +.mobile-nav a { + padding: 12px 4px; + text-decoration: none; + color: var(--fg1); + font-weight: 500; + border-bottom: 1px solid var(--line); +} +.mobile-nav a:last-child { border-bottom: none; margin-top: 8px; text-align: center; } +.mobile-nav.is-open { display: flex; } + +@media (max-width: 760px) { + .site-nav, .header-row > .btn { display: none; } + .nav-toggle { display: flex; } +} + +/* ---------- hero ---------- */ + +.hero { padding: clamp(48px, 8vw, 96px) 0 clamp(40px, 6vw, 72px); overflow: clip; } + +.hero-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: clamp(32px, 6vw, 64px); + align-items: center; +} + +.hero-copy h1 { + font-size: clamp(38px, 5.2vw, 60px); + line-height: 1.02; + margin-bottom: 20px; +} +.hero-copy h1 em { + font-style: normal; + color: var(--accent-text); +} + +.hero-sub { + font-size: clamp(16px, 1.6vw, 18px); + color: var(--fg2); + max-width: 46ch; + margin: 0 0 28px; +} + +.hero-actions { display: flex; gap: 14px; flex-wrap: wrap; } + +.hero-proof { + list-style: none; + margin: 28px 0 0; + padding: 0; + display: flex; + gap: 20px; + flex-wrap: wrap; + font-size: 13px; + color: var(--fg3); +} +.hero-proof li { display: flex; align-items: center; gap: 7px; } +.hero-proof li::before { + content: ""; + width: 6px; height: 6px; + border-radius: 50%; + background: var(--accent); +} + +/* ---------- hero exploded visual ---------- */ + +.hero-visual { + position: relative; + height: clamp(360px, 46vw, 520px); + display: flex; + align-items: center; + justify-content: center; +} + +.glow { + position: absolute; + inset: -10%; + background: radial-gradient(closest-side, color-mix(in srgb, var(--accent) 16%, transparent), transparent 70%); + pointer-events: none; +} + +.phone { + position: relative; + width: clamp(180px, 19vw, 220px); + aspect-ratio: 9 / 18.5; + background: var(--fg1); + border-radius: 34px; + padding: 10px; + will-change: transform; +} + +.phone-notch { + position: absolute; + top: 10px; left: 50%; + transform: translateX(-50%); + width: 34%; height: 16px; + background: var(--fg1); + border-radius: 0 0 12px 12px; + z-index: 2; +} + +.phone-screen { + position: relative; + height: 100%; + background: var(--bg); + border-radius: 24px; + overflow: hidden; + display: flex; + flex-direction: column; +} + +.mini-topbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 22px 14px 8px; +} +.mini-title { font-family: var(--font-display); font-weight: 600; font-size: 15px; color: var(--fg1); } +.mini-dot { width: 16px; height: 16px; border-radius: 50%; background: var(--surface2); } + +.mini-list { flex: 1; padding: 4px 10px; display: flex; flex-direction: column; gap: 6px; } +.mini-row { + display: flex; + align-items: center; + gap: 8px; + background: var(--surface); + border-radius: 10px; + padding: 7px 8px; +} +.mini-row-active { background: var(--accent-soft); } + +.mini-ic { width: 16px; height: 16px; border-radius: 5px; flex: none; } +.ic-folder { background: var(--accent); opacity: 0.85; } +.ic-image { background: #2940A8; opacity: 0.8; } +.ic-file { background: var(--fg2); opacity: 0.6; } +.ic-share { background: #3E7A23; opacity: 0.85; } +.ic-lock { background: var(--fg1); opacity: 0.9; } + +.mini-lines { display: flex; flex-direction: column; gap: 4px; flex: 1; min-width: 0; } +.mini-lines b, .mini-lines i { + display: block; + height: 5px; + border-radius: 3px; + background: var(--fg2); + opacity: 0.35; + font-style: normal; +} +.mini-lines b { width: 70%; opacity: 0.55; } +.mini-lines i { width: 40%; } + +.mini-bar { + display: flex; + align-items: center; + justify-content: space-around; + padding: 10px 8px; + border-top: 1px solid var(--line); + position: relative; +} +.mini-bar-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--fg3); opacity: 0.4; } +.mini-bar-pill { + position: absolute; + left: 10px; + width: 20px; height: 10px; + border-radius: var(--radius-pill); + background: var(--accent-soft); +} + +.chip { + position: absolute; + background: var(--surface); + border: 1px solid var(--line); + border-radius: var(--radius-card); + will-change: transform; +} + +.chip-photo { + top: 6%; right: 2%; + width: 92px; height: 92px; + padding: 8px; + transform: rotate(-6deg); +} +.chip-photo-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; height: 100%; } +.chip-photo-grid span:nth-child(1) { background: #8DCDE2; border-radius: 8px; } +.chip-photo-grid span:nth-child(2) { background: #E5D8BD; border-radius: 8px; } +.chip-photo-grid span:nth-child(3) { background: #DA9AC7; border-radius: 8px; } +.chip-photo-grid span:nth-child(4) { background: #A7D296; border-radius: 8px; } + +.chip-row { + bottom: 20%; right: -2%; + display: flex; align-items: center; gap: 10px; + padding: 12px 16px; + transform: rotate(4deg); + width: 160px; +} + +.chip-swatches { + bottom: 6%; left: 4%; + display: flex; gap: 8px; + padding: 12px; + transform: rotate(-4deg); +} +.chip-swatches .swatch { width: 18px; height: 18px; border-radius: 50%; background: var(--c); } + +.chip-toggle { + top: 16%; left: -4%; + display: flex; align-items: center; gap: 14px; + padding: 12px 14px; + width: 150px; + transform: rotate(3deg); +} +.mini-lines-toggle { flex: 1; } +.mini-toggle { width: 30px; height: 18px; border-radius: var(--radius-pill); background: var(--surface3); position: relative; flex: none; } +.mini-toggle i { position: absolute; top: 2px; left: 2px; width: 14px; height: 14px; border-radius: 50%; background: var(--fg1); opacity: 0.3; } +.mini-toggle-on { background: var(--accent); } +.mini-toggle-on i { left: 14px; background: #fff; opacity: 1; } + +.chip-badge { + bottom: -2%; left: 30%; + display: flex; align-items: center; gap: 8px; + padding: 9px 14px; + font-size: 13px; + font-weight: 500; + border-radius: var(--radius-pill); + transform: rotate(-2deg); +} +.chip-badge .mini-ic { width: 14px; height: 14px; } + +@media (max-width: 980px) { + .hero-grid { grid-template-columns: 1fr; text-align: center; } + .hero-copy { order: 2; } + .hero-copy .hero-sub { margin-inline: auto; } + .hero-actions, .hero-proof { justify-content: center; } + .hero-visual { order: 1; } +} + +/* ---------- sections ---------- */ + +.section { padding: clamp(56px, 8vw, 104px) 0; } +.section-alt { background: var(--surface); } + +.section h2 { + font-size: clamp(28px, 3.4vw, 42px); + line-height: 1.08; + max-width: 20ch; +} +.section-sub { + font-size: clamp(15px, 1.4vw, 17px); + color: var(--fg2); + max-width: 56ch; + margin: 16px 0 0; +} + +.grid { display: grid; gap: 20px; margin-top: 44px; } +.grid-features { grid-template-columns: repeat(3, minmax(0, 1fr)); } + +.card { + background: var(--surface); + border-radius: var(--radius-card); + padding: 28px; +} +.section-alt .card { background: var(--bg); } + +.card h3 { + font-size: 19px; + margin: 16px 0 8px; +} +.card p { margin: 0; color: var(--fg2); font-size: 15px; } + +.card-ic, .pt-ic { + display: inline-flex; + align-items: center; + justify-content: center; + width: 44px; height: 44px; + border-radius: var(--radius-input); + background: var(--accent-soft); + position: relative; +} +.card-ic::after, .pt-ic::after { + content: ""; + width: 20px; height: 20px; + background: var(--accent-text); + -webkit-mask-size: contain; + mask-size: contain; + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; + -webkit-mask-position: center; + mask-position: center; +} + +/* Minimal inline icon set (stroke-style, Lucide-ish) via CSS mask so the + whole page stays dependency-free - no icon font, no SVG sprite fetch. */ +.ic-folder-lg::after { -webkit-mask-image: var(--i-folder); mask-image: var(--i-folder); } +.ic-image-lg::after { -webkit-mask-image: var(--i-image); mask-image: var(--i-image); } +.ic-download-lg::after { -webkit-mask-image: var(--i-download); mask-image: var(--i-download); } +.ic-share-lg::after { -webkit-mask-image: var(--i-share); mask-image: var(--i-share); } +.ic-clock-lg::after { -webkit-mask-image: var(--i-clock); mask-image: var(--i-clock); } +.ic-users-lg::after { -webkit-mask-image: var(--i-users); mask-image: var(--i-users); } +.pt-ic.ic-lock::after { -webkit-mask-image: var(--i-lock); mask-image: var(--i-lock); } +.pt-ic.ic-server::after { -webkit-mask-image: var(--i-server); mask-image: var(--i-server); } +.pt-ic.ic-code::after { -webkit-mask-image: var(--i-code); mask-image: var(--i-code); } +.pt-ic.ic-off::after { -webkit-mask-image: var(--i-off); mask-image: var(--i-off); } + +:root { + --i-folder: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M4 20h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13c0 1.1.9 2 2 2Z'/%3E%3C/svg%3E"); + --i-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect width='18' height='18' x='3' y='3' rx='2'/%3E%3Ccircle cx='9' cy='9' r='2'/%3E%3Cpath d='m21 15-3.1-3.1a2 2 0 0 0-2.8 0L6 21'/%3E%3C/svg%3E"); + --i-download: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4'/%3E%3Cpolyline points='7 10 12 15 17 10'/%3E%3Cline x1='12' y1='15' x2='12' y2='3'/%3E%3C/svg%3E"); + --i-share: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='18' cy='5' r='3'/%3E%3Ccircle cx='6' cy='12' r='3'/%3E%3Ccircle cx='18' cy='19' r='3'/%3E%3Cline x1='8.6' y1='10.5' x2='15.4' y2='6.5'/%3E%3Cline x1='8.6' y1='13.5' x2='15.4' y2='17.5'/%3E%3C/svg%3E"); + --i-clock: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='9'/%3E%3Cpolyline points='12 7 12 12 15.5 14.5'/%3E%3C/svg%3E"); + --i-users: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2'/%3E%3Ccircle cx='9' cy='7' r='4'/%3E%3Cpath d='M22 21v-2a4 4 0 0 0-3-3.87'/%3E%3Cpath d='M16 3.13a4 4 0 0 1 0 7.75'/%3E%3C/svg%3E"); + --i-lock: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='3' y='11' width='18' height='11' rx='2'/%3E%3Cpath d='M7 11V7a5 5 0 0 1 10 0v4'/%3E%3C/svg%3E"); + --i-server: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='2' y='3' width='20' height='7' rx='1.5'/%3E%3Crect x='2' y='14' width='20' height='7' rx='1.5'/%3E%3Cline x1='6' y1='6.5' x2='6.01' y2='6.5'/%3E%3Cline x1='6' y1='17.5' x2='6.01' y2='17.5'/%3E%3C/svg%3E"); + --i-code: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='16 18 22 12 16 6'/%3E%3Cpolyline points='8 6 2 12 8 18'/%3E%3C/svg%3E"); + --i-off: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='2' y1='2' x2='22' y2='22'/%3E%3Cpath d='M16.9 16.9A9 9 0 0 1 4 12a9 9 0 0 1 2.6-6.3'/%3E%3Cpath d='M9.9 4.24A9 9 0 0 1 20 12a9 9 0 0 1-.8 3.7'/%3E%3C/svg%3E"); +} + +@media (max-width: 900px) { .grid-features { grid-template-columns: repeat(2, minmax(0, 1fr)); } } +@media (max-width: 600px) { .grid-features { grid-template-columns: 1fr; } } + +/* ---------- customize ---------- */ + +.customize-grid { + margin-top: 44px; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 20px; +} + +.customize-demo { + background: var(--bg); + border-radius: var(--radius-card); + padding: 24px; + display: flex; + flex-direction: column; + gap: 22px; +} + +.customize-demo h3 { font-size: 18px; margin: 0 0 8px; } +.customize-demo p { margin: 0; color: var(--fg2); font-size: 14px; } + +.demo-preview { + background: var(--surface); + border: 1px solid var(--line); + border-radius: var(--radius-input); + padding: 16px; + display: flex; + flex-direction: column; + gap: 10px; +} +.demo-topbar { display: flex; align-items: center; gap: 10px; } +.demo-dot { width: 12px; height: 12px; border-radius: 50%; transition: background var(--motion-base) var(--ease); } +.demo-line { display: block; height: 8px; border-radius: 4px; background: var(--surface3); } +.demo-pill { + align-self: flex-start; + padding: 6px 14px; + border-radius: var(--radius-pill); + font-size: 13px; + font-weight: 600; + transition: background var(--motion-base) var(--ease), color var(--motion-base) var(--ease); +} +.demo-row { padding: 6px 0; } + +.swatch-row { display: flex; gap: 10px; } +.swatch-btn { + width: 32px; height: 32px; + border-radius: 50%; + border: 2px solid transparent; + background: var(--c); + cursor: pointer; + padding: 0; + transition: transform var(--motion-fast) var(--ease), border-color var(--motion-fast) var(--ease); +} +.swatch-btn:last-child { + background: linear-gradient(135deg, #8DCDE2, #DA9AC7 50%, #E5D8BD); +} +.swatch-btn.is-active { border-color: var(--fg1); transform: scale(1.08); } +.swatch-btn:hover { transform: scale(1.08); } + +.demo-preview-bar { position: relative; height: 128px; padding: 0; overflow: hidden; } +.demo-screen-fill { + position: absolute; inset: 0; + background: repeating-linear-gradient(180deg, var(--surface2) 0 34px, var(--surface) 34px 68px); + opacity: 0.6; +} +.demo-bar { + position: absolute; + left: 12px; right: 12px; bottom: 12px; + height: 44px; + background: var(--surface); + border: 1px solid var(--line); + border-radius: var(--radius-pill); + display: flex; + align-items: center; + justify-content: space-around; + transition: left var(--motion-base) var(--ease), right var(--motion-base) var(--ease), bottom var(--motion-base) var(--ease), border-radius var(--motion-base) var(--ease); +} +.demo-bar.is-attached { left: 0; right: 0; bottom: 0; border-radius: 0; border-width: 1px 0 0; } +.demo-bar-item { width: 18px; height: 18px; border-radius: 6px; background: var(--surface3); transition: background var(--motion-fast) var(--ease); } +.demo-bar-item.is-on { background: var(--accent); } + +.segmented { + display: inline-flex; + padding: 3px; + background: var(--surface); + border-radius: var(--radius-pill); + gap: 2px; + align-self: flex-start; +} +.segmented-btn { + border: none; + background: transparent; + padding: 8px 16px; + border-radius: var(--radius-pill); + font-family: var(--font-body); + font-size: 13px; + font-weight: 600; + color: var(--fg2); + cursor: pointer; + transition: background var(--motion-fast) var(--ease), color var(--motion-fast) var(--ease); +} +.segmented-btn.is-active { background: var(--accent-soft); color: var(--accent-text); } + +.customize-list { + grid-column: 1 / -1; + background: var(--bg); + border-radius: var(--radius-card); + padding: 28px 32px; +} +.customize-list h3 { font-size: 18px; margin: 0 0 16px; } +.feature-list { + margin: 0; + padding: 0; + list-style: none; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px 32px; + color: var(--fg2); + font-size: 15px; +} +.feature-list li { display: flex; align-items: center; gap: 10px; } +.feature-list li::before { + content: ""; + width: 6px; height: 6px; + border-radius: 50%; + background: var(--accent); + flex: none; +} + +@media (max-width: 760px) { + .customize-grid { grid-template-columns: 1fr; } + .feature-list { grid-template-columns: 1fr; } +} + +/* ---------- privacy ---------- */ + +.privacy-grid { + display: grid; + grid-template-columns: minmax(0, 1.1fr) minmax(0, 0.9fr); + gap: clamp(32px, 6vw, 64px); + align-items: center; +} + +.privacy-points { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: 14px; +} +.privacy-points li { + display: flex; + align-items: center; + gap: 14px; + background: var(--surface); + border-radius: var(--radius-input); + padding: 16px 18px; + font-weight: 500; +} + +@media (max-width: 820px) { + .privacy-grid { grid-template-columns: 1fr; } +} + +/* ---------- cta ---------- */ + +.section-cta { text-align: center; background: var(--surface); } +.cta-inner { display: flex; flex-direction: column; align-items: center; } +.cta-inner .section-sub { margin-bottom: 32px; } +.cta-actions { justify-content: center; } +.cta-fine { margin-top: 20px; font-size: 14px; color: var(--fg3); } +.cta-fine a { color: var(--accent-text); font-weight: 600; text-decoration: none; } + +/* ---------- footer ---------- */ + +.site-footer { border-top: 1px solid var(--line); padding: 32px 0; } +.footer-row { + display: flex; + align-items: center; + gap: 24px; + flex-wrap: wrap; +} +.footer-note { color: var(--fg3); font-size: 13px; margin: 0; flex: 1; min-width: 200px; } +.footer-links { display: flex; gap: 20px; } +.footer-links a { font-size: 14px; text-decoration: none; color: var(--fg2); } +.footer-links a:hover { color: var(--fg1); } + +/* ---------- motion ---------- */ + +@media (prefers-reduced-motion: reduce) { + html { scroll-behavior: auto; } + .phone, .chip, .btn { transition: none !important; } +} + +.reveal { + opacity: 0; + transform: translateY(16px); + transition: opacity 600ms var(--ease), transform 600ms var(--ease); +} +.reveal.is-visible { opacity: 1; transform: none; } From 5f4caa2a3b25fd32478fd5388c074a87df76f6cc Mon Sep 17 00:00:00 2001 From: ayushya Date: Tue, 29 Sep 2026 22:59:52 -0400 Subject: [PATCH 20/29] Fix floating bottom bar filling the screen when search is enabled 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 --- lib/widgets/noo/nav/noo_bottom_bar.dart | 34 +++++++++++++++++-------- test/widgets/noo/nav_test.dart | 34 +++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 11 deletions(-) diff --git a/lib/widgets/noo/nav/noo_bottom_bar.dart b/lib/widgets/noo/nav/noo_bottom_bar.dart index ef19ba7..380f922 100644 --- a/lib/widgets/noo/nav/noo_bottom_bar.dart +++ b/lib/widgets/noo/nav/noo_bottom_bar.dart @@ -101,17 +101,29 @@ class NooBottomBar extends StatelessWidget { child: Padding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), child: hasSearch - ? Row( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Expanded(child: pill), - const SizedBox(width: 8), - _SearchSatellite( - destination: searchDestination!, - onTap: onSearchTap!, - size: barHeight, - ), - ], + // A fixed-height SizedBox, not just a Row with + // crossAxisAlignment.stretch - the bottomNavigationBar slot + // gives this widget a *loose* (unbounded-max) height + // constraint, and stretch on an unbounded cross axis blows up + // to fill the screen in profile/release (the assertion that + // would catch it in debug is stripped there) - the same + // silent-oversizing bug files_controls_row.dart hit earlier + // from a different cause. Both children already size + // themselves explicitly (pill's own `height`, the + // satellite's `size`), so stretch was never actually needed. + ? SizedBox( + height: barHeight, + child: Row( + children: [ + Expanded(child: pill), + const SizedBox(width: 8), + _SearchSatellite( + destination: searchDestination!, + onTap: onSearchTap!, + size: barHeight, + ), + ], + ), ) : pill, ), diff --git a/test/widgets/noo/nav_test.dart b/test/widgets/noo/nav_test.dart index ac3d53f..9a0ccf5 100644 --- a/test/widgets/noo/nav_test.dart +++ b/test/widgets/noo/nav_test.dart @@ -303,5 +303,39 @@ void main() { await tester.pump(); expect(searchTaps, 1); }); + + testWidgets('floating with search sizes correctly in a real Scaffold ' + '(regression: unbounded stretch used to fill the whole screen)', ( + tester, + ) async { + // A bare SizedBox host (as the other tests use) gives this widget a + // bounded height, masking this exact bug - Scaffold.bottomNavigationBar + // gives its child a *loose*, unbounded-max height instead, which is + // what a Row with crossAxisAlignment.stretch blows up against in + // profile/release (the debug assertion that would catch it is + // stripped there). Only a real Scaffold reproduces that. + await tester.pumpWidget( + MaterialApp( + theme: nooTheme(Brightness.light), + home: Scaffold( + body: const SizedBox.expand(), + bottomNavigationBar: NooBottomBar( + style: NooNavStyle.android, + barStyle: NooBottomBarStyle.floating, + destinations: _destinations, + selectedIndex: 0, + onSelected: (_) {}, + searchDestination: const NooNavDestination( + icon: LucideIcons.search, + label: 'Search', + ), + onSearchTap: () {}, + ), + ), + ), + ); + final size = tester.getSize(find.byType(NooBottomBar)); + expect(size.height, lessThan(150)); + }); }); } From a8b14b8744d3898a644da873b9c2f1e54ad1abfc Mon Sep 17 00:00:00 2001 From: ayushya Date: Wed, 30 Sep 2026 16:15:32 -0400 Subject: [PATCH 21/29] 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 --- .../context/design-system/DESIGN_SYSTEM.md | 26 ++- .claude/context/styling.md | 8 +- lib/views/account_view.dart | 182 ++++++++++++++-- lib/widgets/settings/settings_section.dart | 14 +- test/views/account_view_test.dart | 198 ++++++++++++++++++ 5 files changed, 391 insertions(+), 37 deletions(-) create mode 100644 test/views/account_view_test.dart diff --git a/.claude/context/design-system/DESIGN_SYSTEM.md b/.claude/context/design-system/DESIGN_SYSTEM.md index 3ddbc59..7a4c738 100644 --- a/.claude/context/design-system/DESIGN_SYSTEM.md +++ b/.claude/context/design-system/DESIGN_SYSTEM.md @@ -358,15 +358,23 @@ Sidebar items are 38px tall with radius 12, an 18px icon and a 14/500 label. The slots, the rest sit behind "More". See §2 "Selection action bar". 9. Swipe on a file - Mobile uses one column of grouped lists, each individually collapsible - (tap its label, expanded by default - `NooGroupedList`'s `collapsible` - param) so the full list can be collapsed down instead of needing a - separate way to navigate it; an earlier version had a trailing jump rail - (one small icon per section, pinned where the scrollbar would sit) - instead, dropped for adding a second, redundant navigation method without - shortening the page. Desktop uses 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. + 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 ▾"). diff --git a/.claude/context/styling.md b/.claude/context/styling.md index 0220b14..316ccf1 100644 --- a/.claude/context/styling.md +++ b/.claude/context/styling.md @@ -90,8 +90,12 @@ Gotchas: - `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 - `SettingsSection` is the - only caller that opts in, for Settings' mobile sections. + `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` only reveals its action. The user has to tap the block to trigger it; a full swipe never deletes. - Window chrome (macOS traffic lights, the Windows 40px title bar) isn't diff --git a/lib/views/account_view.dart b/lib/views/account_view.dart index d0c3def..cdb1a0e 100644 --- a/lib/views/account_view.dart +++ b/lib/views/account_view.dart @@ -1,5 +1,8 @@ import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../theme/design_tokens.dart'; +import '../widgets/noo/lists/noo_grouped_list.dart'; +import '../widgets/noo/lists/noo_settings_row.dart'; import '../widgets/noo/nav/noo_top_bar.dart'; import '../widgets/noo/nav/noo_toolbar.dart'; import '../widgets/noo/noo_layout.dart'; @@ -15,18 +18,32 @@ import '../widgets/settings/settings_tabs.dart'; /// Settings, pushed on top of the shell (DESIGN_SYSTEM.md 4's 9-section /// order: account card, accounts, security, file sync, files cache, -/// appearance, tabs, action bar, swipe on a file). One column of -/// [SettingsSection]s (and [SettingsActionBarSection], which has no option -/// rows of its own to put in one - just the reorder list) on mobile, each -/// individually collapsible (expanded by default, tap its label to -/// collapse - see `SettingsSection`/`NooGroupedList`'s `collapsible` param) -/// so a long Settings screen can be collapsed down rather than needing a -/// separate jump rail (an earlier version had one; it added a second, -/// redundant way to navigate on top of plain scrolling and still didn't -/// shorten the page). Desktop uses a 2-column grid of cards instead, wide -/// enough to see most sections without scrolling, so it gets neither. See -/// each `widgets/settings/*.dart` file for a section's own content and any -/// setting that had to be slotted in or grouped under "Advanced appearance". +/// appearance, tabs, action bar, swipe on a file). +/// +/// Desktop is unchanged: a 2-column grid of cards ([_DesktopGrid]) wide +/// enough to see every section at once, so it has no scroll-depth problem +/// and needs no menu. +/// +/// Mobile is a two-level menu, the way native iOS/Android Settings apps +/// work: [SettingsAccountCard] (the account summary, not a settings picker) +/// stays pinned at the top of a single top-level list ([_MobileMenu]), and +/// every other section becomes one tappable [NooSettingsRow] - icon, title, +/// chevron - in a [NooGroupedList] below it. Tapping a row pushes a new +/// [_SettingsCategoryScreen] with its own [NooTopBar]/[NooTopBarBack], +/// containing just that section's content full-screen. This replaces an +/// earlier design where every section rendered inline in one long +/// collapsible-sections column (`_MobileList`, since removed) - and before +/// that, a trailing jump rail (an even earlier version) that scrolled that +/// *same* page to an anchor. Both were rejected: the jump rail added a +/// second, redundant way to navigate on top of plain scrolling without +/// shortening the page, and the collapsible-sections column still left a +/// long page to scroll past even collapsed. A genuinely separate pushed +/// screen per category removes the scroll-depth problem outright, so +/// neither a jump rail nor per-section collapsing is needed any more - see +/// [SettingsSection]'s doc comment for how that reflects in its mobile +/// layout. See each `widgets/settings/*.dart` file for a section's own +/// content and any setting that had to be slotted in or grouped under +/// "Advanced appearance". class AccountView extends StatelessWidget { const AccountView({super.key}); @@ -42,6 +59,49 @@ class AccountView extends StatelessWidget { SettingsSwipeSection(), ]; + static final _categories = <_SettingsCategory>[ + _SettingsCategory( + title: 'Accounts', + icon: LucideIcons.users, + builder: (_) => const SettingsAccountsSection(), + ), + _SettingsCategory( + title: 'Security', + icon: LucideIcons.lock, + builder: (_) => const SettingsSecuritySection(), + ), + _SettingsCategory( + title: 'File sync', + icon: LucideIcons.cloud, + builder: (_) => const SettingsFileSyncSection(), + ), + _SettingsCategory( + title: 'Files cache', + icon: LucideIcons.database, + builder: (_) => const SettingsFilesCacheSection(), + ), + _SettingsCategory( + title: 'Appearance', + icon: LucideIcons.sunMoon, + builder: (_) => const SettingsAppearanceSection(), + ), + _SettingsCategory( + title: 'Tabs', + icon: LucideIcons.layoutGrid, + builder: (_) => const SettingsTabsSection(), + ), + _SettingsCategory( + title: 'Action bar', + icon: LucideIcons.slidersHorizontal, + builder: (_) => const SettingsActionBarSection(), + ), + _SettingsCategory( + title: 'Swipe on a file', + icon: LucideIcons.chevronsLeftRight, + builder: (_) => const SettingsSwipeSection(), + ), + ]; + @override Widget build(BuildContext context) { final colors = context.nooColors; @@ -60,18 +120,49 @@ class AccountView extends StatelessWidget { top: false, child: desktop ? _DesktopGrid(sections: _sections) - : _MobileList(sections: _sections), + : _MobileMenu(categories: _categories), ), ); } } -class _MobileList extends StatelessWidget { - final List sections; - const _MobileList({required this.sections}); +/// One row of [AccountView]'s mobile top-level menu: a title, a leading +/// icon (reused from that section's own first/most-representative row, so +/// the menu icon and the content the user lands on agree), and a builder +/// for the section content shown on [_SettingsCategoryScreen]. +class _SettingsCategory { + final String title; + final IconData icon; + final WidgetBuilder builder; + + _SettingsCategory({ + required this.title, + required this.icon, + required this.builder, + }); +} + +/// The mobile top-level Settings list: [SettingsAccountCard] pinned above a +/// single [NooGroupedList] of category rows, one per [AccountView._categories] +/// entry - the menu half of the menu-then-pushed-screen pattern described on +/// [AccountView]'s own doc comment. +class _MobileMenu extends StatelessWidget { + final List<_SettingsCategory> categories; + const _MobileMenu({required this.categories}); + + void _open(BuildContext context, _SettingsCategory category) { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => _SettingsCategoryScreen(category: category), + ), + ); + } @override Widget build(BuildContext context) { + final colors = context.nooColors; + return ListView( padding: const EdgeInsets.fromLTRB( NooSpace.sm, @@ -81,15 +172,66 @@ class _MobileList extends StatelessWidget { ), physics: const BouncingScrollPhysics(), children: [ - for (final section in sections) ...[ - section, - const SizedBox(height: NooSpace.xl), - ], + const SettingsAccountCard(), + const SizedBox(height: NooSpace.xl), + NooGroupedList( + children: [ + for (final category in categories) + NooSettingsRow( + icon: category.icon, + label: Text(category.title), + trailing: Icon( + LucideIcons.chevronRight, + size: 18, + color: colors.fg3, + ), + onTap: () => _open(context, category), + ), + ], + ), ], ); } } +/// A pushed, single-category Settings screen: [NooTopBar] titled with the +/// category, a back button, and just that section's own content - the +/// pushed half of [AccountView]'s mobile menu-then-screen pattern. Always +/// built in a mobile-width context (desktop never opens this screen; it +/// shows every section inline in its own grid instead), so the section +/// widgets inside render their normal mobile layout unchanged. +class _SettingsCategoryScreen extends StatelessWidget { + final _SettingsCategory category; + const _SettingsCategoryScreen({required this.category}); + + @override + Widget build(BuildContext context) { + final colors = context.nooColors; + + return Scaffold( + backgroundColor: colors.bg, + appBar: NooTopBar( + style: NooLayout.navStyle(context), + title: category.title, + leading: const NooTopBarBack(), + ), + body: SafeArea( + top: false, + child: ListView( + padding: const EdgeInsets.fromLTRB( + NooSpace.sm, + NooSpace.sm, + NooSpace.sm, + NooSpace.xxl, + ), + physics: const BouncingScrollPhysics(), + children: [category.builder(context)], + ), + ), + ); + } +} + /// Splits the sections between two columns rather than a strict grid, since /// each card's content height varies a lot (the tab reorder list and the /// accounts list can both run much taller than, say, Security) - a fixed diff --git a/lib/widgets/settings/settings_section.dart b/lib/widgets/settings/settings_section.dart index 7ceae70..d2a8407 100644 --- a/lib/widgets/settings/settings_section.dart +++ b/lib/widgets/settings/settings_section.dart @@ -5,7 +5,7 @@ import '../noo/noo_layout.dart'; import '../noo/overlays/noo_dialog.dart'; import '../noo/overlays/noo_sheet.dart'; -/// One block of Settings (DESIGN_SYSTEM.md 4's 8-part order): a +/// One block of Settings (DESIGN_SYSTEM.md 4's 9-part order): a /// [NooGroupedList] on mobile (label above a radius-20 card), or a titled, /// bordered radius-20 card holding a flat row group on desktop /// ("Mobile uses one column of grouped lists. Desktop uses a 2-column grid @@ -31,11 +31,13 @@ class SettingsSection extends StatelessWidget { return NooGroupedList( label: title, footer: subtitle != null ? Text(subtitle!) : null, - // Every mobile Settings section is individually collapsible, - // expanded by default - replaces the old trailing jump rail (see - // `account_view.dart`'s doc comment) as the way to navigate a long - // Settings screen quickly. - collapsible: true, + // Not collapsible: each mobile Settings section now renders on its + // own pushed screen (see `account_view.dart`'s doc comment for the + // menu-then-pushed-screen pattern), so there's no long single-scroll + // page left to collapse sections *within* - an earlier design had + // every section inline in one column and made them individually + // collapsible for exactly that reason; that's gone now that each + // one is already isolated on its own screen. children: children, ); } diff --git a/test/views/account_view_test.dart b/test/views/account_view_test.dart new file mode 100644 index 0000000..fe3d76b --- /dev/null +++ b/test/views/account_view_test.dart @@ -0,0 +1,198 @@ +import 'dart:convert'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:noo/models/saved_account.dart'; +import 'package:noo/providers/connectivity_controller.dart'; +import 'package:noo/providers/files_controller.dart'; +import 'package:noo/providers/session_controller.dart'; +import 'package:noo/providers/settings_controller.dart'; +import 'package:noo/providers/sync_status_controller.dart'; +import 'package:noo/theme/app_theme.dart'; +import 'package:noo/views/account_view.dart'; +import 'package:noo/widgets/noo/nav/noo_top_bar.dart'; + +/// Covers the mobile Settings navigation restructure: a top-level menu of +/// category rows (`account_view.dart`'s `_MobileMenu`) that pushes a +/// single-section screen per row, replacing the old design where every +/// section rendered inline in one long scrolling column. This only covers +/// the menu/push/back mechanics - each section's own content already has +/// (or doesn't need) its own coverage elsewhere. +void main() { + const secureStorageChannel = MethodChannel( + 'plugins.it_nomads.com/flutter_secure_storage', + ); + const connectivityChannel = MethodChannel( + 'dev.fluttercommunity.plus/connectivity', + ); + const connectivityStatusChannel = EventChannel( + 'dev.fluttercommunity.plus/connectivity_status', + ); + + const accountId = 'server_example_com__alice'; + const passwordKey = 'nc_app_password_$accountId'; + const password = 'app-password'; + + setUpAll(() { + GoogleFonts.config.allowRuntimeFetching = false; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(secureStorageChannel, (call) async { + if (call.method == 'readAll') { + return {passwordKey: password}; + } + if (call.method == 'read') { + final key = (call.arguments as Map)['key'] as String?; + return key == passwordKey ? password : null; + } + return null; + }); + // Offline, like `session_controller_test.dart`'s setup: keeps + // SessionController in a provisional login (no real HTTP calls) and + // stops FilesController/SyncStatusController from starting their + // network-fetch/periodic-refresh machinery, which would otherwise + // leave timers pending forever in a test. + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(connectivityChannel, (call) async { + if (call.method == 'check') return ['none']; + return null; + }); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockStreamHandler( + connectivityStatusChannel, + MockStreamHandler.inline(onListen: (arguments, events) {}), + ); + }); + + setUp(() { + SharedPreferences.setMockInitialValues({ + 'account_migration_v1_done': true, + 'accounts_list': jsonEncode([ + const SavedAccount( + id: accountId, + serverUrl: 'https://server.example.com', + username: 'alice', + ).toJson(), + ]), + 'active_account_id': accountId, + }); + }); + + Future pumpSettings(WidgetTester tester) async { + await tester.binding.setSurfaceSize(const Size(400, 800)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider(create: (_) => ConnectivityController()), + ChangeNotifierProvider( + create: (context) => SessionController(context.read()), + ), + ChangeNotifierProvider(create: (_) => SettingsController()), + ChangeNotifierProvider( + create: (context) => FilesController(context.read()), + ), + ChangeNotifierProvider( + create: (context) => + SyncStatusController(context.read(), context.read()), + ), + ], + child: MaterialApp( + theme: AppTheme.light(AppTheme.defaultAccent, useDynamicColor: false), + home: const AccountView(), + ), + ), + ); + + // Not `pumpAndSettle`: once the saved account is ready, + // FilesController/SyncStatusController start their own periodic + // refresh timers, which `pumpAndSettle` would spin on forever. A + // bounded number of small pumps is enough to flush the async gaps in + // SessionController's prefs restore and the dependent controllers' + // one-shot account-ready reactions (mirrors + // `session_controller_test.dart`'s `pumpUntil` loop). + for (var i = 0; i < 30; i++) { + await tester.pump(const Duration(milliseconds: 10)); + } + } + + /// Advances exactly far enough to finish a push/pop transition + /// (`MaterialPageRoute`'s default is 300ms) without risking + /// `pumpAndSettle` picking up a pending periodic timer. + Future settleNav(WidgetTester tester) async { + await tester.pump(); + for (var i = 0; i < 10; i++) { + await tester.pump(const Duration(milliseconds: 50)); + } + } + + const categoryTitles = [ + 'Accounts', + 'Security', + 'File sync', + 'Files cache', + 'Appearance', + 'Tabs', + 'Action bar', + 'Swipe on a file', + ]; + + testWidgets( + 'shows a top-level menu of category rows, not every section inline', + (tester) async { + await pumpSettings(tester); + + // The account card (pinned, not a category row of its own) ... + expect(find.text('alice'), findsOneWidget); + // ... plus exactly one row per section. + for (final title in categoryTitles) { + expect(find.text(title), findsOneWidget); + } + // A section's own content isn't rendered until its row is tapped - + // this is a menu, not the old all-sections-inline column. + expect( + find.text("Require this device's PIN or biometric to open Noo"), + findsNothing, + ); + }, + ); + + testWidgets( + 'tapping a category row pushes just that section, with a way back', + (tester) async { + await pumpSettings(tester); + + await tester.tap(find.text('Security')); + await settleNav(tester); + + // Landed on a separate, pushed Security screen: its own content - + // not shown anywhere on the top-level menu - is now visible. (The + // menu screen below it may stay mounted per `PageRoute.maintainState`, + // so this checks for the pushed screen's content rather than the + // menu's absence.) + expect( + find.text("Require this device's PIN or biometric to open Noo"), + findsOneWidget, + ); + // At least one `NooTopBarBack` now leads back - the pushed screen's + // own, on top of `AccountView`'s own (for returning to the shell). + expect(find.byType(NooTopBarBack), findsAtLeastNWidgets(1)); + + // Tapping the topmost back button pops the pushed screen back off, + // taking its content with it - unlike the underlying menu, a popped + // route is actually removed, so this absence check is meaningful. + await tester.tap(find.byType(NooTopBarBack).last); + await settleNav(tester); + + expect( + find.text("Require this device's PIN or biometric to open Noo"), + findsNothing, + ); + expect(find.text('Security'), findsOneWidget); + expect(find.text('alice'), findsOneWidget); + }, + ); +} From 5dea9038f77c84e347388f05ed203f3292f15d78 Mon Sep 17 00:00:00 2001 From: ayushya Date: Wed, 30 Sep 2026 16:28:23 -0400 Subject: [PATCH 22/29] 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 --- .claude/context/architecture.md | 16 +- .claude/context/styling.md | 6 + lib/main.dart | 54 +++++-- lib/views/activity_view.dart | 7 +- lib/views/favorites_view.dart | 8 +- lib/views/files_view.dart | 7 + lib/views/photos_view.dart | 8 +- lib/views/recent_view.dart | 7 +- lib/views/shares_view.dart | 7 +- lib/views/trash_view.dart | 7 +- lib/widgets/app_tab_view_builder.dart | 29 +++- lib/widgets/tabs/tab_state_slivers.dart | 26 ++++ test/widgets/tabs/tab_state_slivers_test.dart | 143 ++++++++++++++++++ 13 files changed, 296 insertions(+), 29 deletions(-) create mode 100644 test/widgets/tabs/tab_state_slivers_test.dart diff --git a/.claude/context/architecture.md b/.claude/context/architecture.md index 2c0da71..bc60a55 100644 --- a/.claude/context/architecture.md +++ b/.claude/context/architecture.md @@ -284,7 +284,21 @@ bare system prompt gives no context on its own. other tab has a create/upload flow), and the account avatar; Android gets a compact title row with `search`/avatar actions, relying on an extended `NooFab` ("Upload", Files/Photos only) instead of a top-bar - icon for upload. `BottomNavBar` (`widgets/bottom_nav_bar.dart`) adapts + icon for upload. Rather than one shared instance in `Scaffold.appBar`, + `MainShellView` builds a separate `AppTopBar` per tab (labelled for that + tab) and each tab plants its own as the first sliver in its own + `CustomScrollView` (`topBarSliver` in `widgets/tabs/tab_state_slivers.dart`, + wrapping it in the framework's `SliverFloatingHeader`) instead of passing + it to `Scaffold.appBar` - see that file's doc comment for why (Material's + native "floating" app bar behavior, tied to that tab's own + `ScrollController`: scrolls away as the list scrolls down and reappears + the moment the drag reverses, not only once scrolled back to the top). + `topBar` is null (no top bar rendered) on desktop and while picking, + matching `Scaffold.appBar`'s old `pickRequest == null` guard - see + `buildAppTabView`'s doc comment. It sits above each tab's own pinned + in-content header (the sort/filter controls row, or Files/Photos' + selection bar - see below); the two float/scroll independently. + `BottomNavBar` (`widgets/bottom_nav_bar.dart`) adapts the pinned `AppTab`s onto `NooBottomBar`. `AppDrawer` (`widgets/app_drawer.dart`) builds a `NooDrawer`: account block, storage meter, a "More" list of the hidden tabs, Settings, and an "Edit tabs" diff --git a/.claude/context/styling.md b/.claude/context/styling.md index 0220b14..cd788b3 100644 --- a/.claude/context/styling.md +++ b/.claude/context/styling.md @@ -137,6 +137,12 @@ blocks are noted where they matter: `lib/widgets/tabs/` (`tab_state_slivers.dart`, `tab_day_groups.dart`, `tab_location.dart`) — the loading/error/empty-state slivers and day/month grouping helpers shared by Recent/Activity/Trash/Shares. + `tab_state_slivers.dart` also has `topBarSliver`, which every regular + tab's view uses to plant its own `AppTopBar` instance as the first sliver + in its `CustomScrollView` (a thin wrapper around the framework's + `SliverFloatingHeader`) instead of the shell passing one shared instance + to `Scaffold.appBar` - see its doc comment and `architecture.md`'s + "Mobile" bullet for why. `lib/widgets/settings/` — Settings' 8 section widgets plus `settings_section.dart`'s `SettingsSection`/`showSettingsPicker` and `settings_dialogs.dart`'s `confirmRemoveAccount`. diff --git a/lib/main.dart b/lib/main.dart index 0b44578..d50398d 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -381,15 +381,51 @@ class _MainShellViewState extends State { String? trashBadge(AppTab tab) => tab == AppTab.trash && trashCount > 0 ? '$trashCount' : null; + final isDesktop = NooLayout.isDesktop(context); + final navStyle = NooLayout.navStyle(context); + // Picking/offline already override the tab set itself (see pinnedTabs + // above) - Search doesn't belong in either: there's nothing to search + // for a file-picking flow, and Search needs the network Offline mode + // doesn't have. + final showBottomBarSearch = !overrideActive && settings.searchInBottomBar; + // Each tab renders its own sticky selection toolbar inline (right under // its sort/filter row) instead of the shell swapping in a shared one, // so the nav chrome stays put and usable regardless of selection state. + // + // Each tab also builds its own [AppTopBar] (labelled for that tab, not + // just whichever is currently selected) and plants it as that tab's own + // first sliver - see `buildAppTabView`'s doc comment and `topBarSliver` + // in `tabs/tab_state_slivers.dart` - instead of one shared instance + // living in `Scaffold.appBar`. That's what gives each tab's top bar + // Material's native floating-away-on-scroll-down/reappear-on-scroll-up + // behavior, tied to that tab's own `ScrollController`: a fixed + // `Scaffold.appBar` can't do that (no per-tab scroll signal reaches + // it), and a shared single instance can't show 7 different tab labels + // at once now that every tab keeps its own independent scroll state. + // Null on desktop (which shows `NooToolbar` instead - embedding it here + // too, since `tabStack` is shared by both layouts below, would double + // up the top chrome there) and while picking (no top bar at all, same + // as this screen's old `Scaffold.appBar: pickRequest == null ? ... : + // null`). final tabStack = Stack( children: [ IndexedStack( index: selectedIndex, children: displayTabs - .map((tab) => buildAppTabView(tab, _scrollControllers[tab]!)) + .map( + (tab) => buildAppTabView( + tab, + _scrollControllers[tab]!, + topBar: isDesktop || pickRequest != null + ? null + : AppTopBar( + style: navStyle, + tab: tab, + searchInBottomBar: showBottomBarSearch, + ), + ), + ) .toList(), ), if (pick.isDownloadingForPick) const _PickingProgressOverlay(), @@ -401,7 +437,7 @@ class _MainShellViewState extends State { (selectedTab == AppTab.files || selectedTab == AppTab.photos); Widget scaffold; - if (NooLayout.isDesktop(context)) { + if (isDesktop) { scaffold = Scaffold( backgroundColor: colors.bg, body: Row( @@ -471,23 +507,11 @@ class _MainShellViewState extends State { ), ); } else { - final navStyle = NooLayout.navStyle(context); final bottomBarStyle = settings.bottomBarStyle; - // Picking/offline already override the tab set itself (see - // pinnedTabs above) - Search doesn't belong in either: there's - // nothing to search for a file-picking flow, and Search needs the - // network Offline mode doesn't have. - final showBottomBarSearch = !overrideActive && settings.searchInBottomBar; scaffold = Scaffold( backgroundColor: colors.bg, drawerScrimColor: colors.scrim, - appBar: pickRequest == null - ? AppTopBar( - style: navStyle, - tab: selectedTab, - searchInBottomBar: showBottomBarSearch, - ) - : null, + // The top bar itself no longer lives here - see `tabStack` above. drawer: pickRequest == null ? const AppDrawer() : null, // Floating needs the body to draw behind the bar's own transparent // margin (see NooBottomBarStyle's doc comment) instead of stopping diff --git a/lib/views/activity_view.dart b/lib/views/activity_view.dart index 90f693e..e2ac073 100644 --- a/lib/views/activity_view.dart +++ b/lib/views/activity_view.dart @@ -29,7 +29,11 @@ import '../widgets/tabs/tab_state_slivers.dart'; class ActivityView extends StatelessWidget { final ScrollController scrollController; - const ActivityView({super.key, required this.scrollController}); + /// This tab's own shell top bar, planted as its first sliver - see + /// `buildAppTabView`'s doc comment. Null on desktop and while picking. + final PreferredSizeWidget? topBar; + + const ActivityView({super.key, required this.scrollController, this.topBar}); @override Widget build(BuildContext context) { @@ -90,6 +94,7 @@ class ActivityView extends StatelessWidget { } final List contentSlivers = [ + if (topBar != null) topBarSliver(topBar!), const SliverToBoxAdapter(child: SizedBox(height: NooSpace.md)), feed, ...tabBottomInsetSlivers, diff --git a/lib/views/favorites_view.dart b/lib/views/favorites_view.dart index cae784c..b4d236c 100644 --- a/lib/views/favorites_view.dart +++ b/lib/views/favorites_view.dart @@ -30,6 +30,7 @@ import '../widgets/noo/core/noo_button.dart'; import '../widgets/share_sheet.dart'; import '../widgets/sticky_header_delegate.dart'; import '../widgets/synced_header_scaffold.dart' show formatBytes; +import '../widgets/tabs/tab_state_slivers.dart'; import 'file_viewer_screen.dart'; import 'move_copy_destination_picker.dart'; @@ -50,7 +51,11 @@ import 'move_copy_destination_picker.dart'; class FavoritesView extends StatefulWidget { final ScrollController scrollController; - const FavoritesView({super.key, required this.scrollController}); + /// This tab's own shell top bar, planted as its first sliver - see + /// `buildAppTabView`'s doc comment. Null on desktop and while picking. + final PreferredSizeWidget? topBar; + + const FavoritesView({super.key, required this.scrollController, this.topBar}); @override State createState() => _FavoritesViewState(); @@ -507,6 +512,7 @@ class _FavoritesViewState extends State { ); final contentSlivers = [ + if (widget.topBar != null) topBarSliver(widget.topBar!), SliverPersistentHeader( pinned: !_isSelecting, delegate: StickyHeaderDelegate( diff --git a/lib/views/files_view.dart b/lib/views/files_view.dart index a64f1bc..e947b47 100644 --- a/lib/views/files_view.dart +++ b/lib/views/files_view.dart @@ -43,6 +43,7 @@ import '../widgets/noo/noo_layout.dart'; import '../widgets/noo/overlays/noo_sheet.dart'; import '../widgets/share_sheet.dart'; import '../widgets/sticky_header_delegate.dart'; +import '../widgets/tabs/tab_state_slivers.dart'; import '../widgets/synced_header_scaffold.dart' show formatBytes; import 'file_viewer_screen.dart'; import 'move_copy_destination_picker.dart'; @@ -59,10 +60,15 @@ class FilesView extends StatefulWidget { final ScrollController scrollController; final bool offline; + /// This tab's own shell top bar, planted as its first sliver - see + /// `buildAppTabView`'s doc comment. Null on desktop and while picking. + final PreferredSizeWidget? topBar; + const FilesView({ super.key, required this.scrollController, this.offline = false, + this.topBar, }); @override @@ -653,6 +659,7 @@ class _FilesViewState extends State { ); final List contentSlivers = [ + if (widget.topBar != null) topBarSliver(widget.topBar!), // Pinned in both states - while browsing this is the controls row // (+ breadcrumbs), while selecting it's the selection bar (see // `topRow` above): either way it's the one thing that always stays diff --git a/lib/views/photos_view.dart b/lib/views/photos_view.dart index 4fa9152..1d72d3c 100644 --- a/lib/views/photos_view.dart +++ b/lib/views/photos_view.dart @@ -30,6 +30,7 @@ import '../widgets/noo/overlays/noo_sheet.dart'; import '../widgets/share_sheet.dart'; import '../widgets/sort_menu_button.dart' show sortFieldLabel; import '../widgets/sticky_header_delegate.dart'; +import '../widgets/tabs/tab_state_slivers.dart'; import 'file_viewer_screen.dart'; import 'move_copy_destination_picker.dart'; @@ -53,7 +54,11 @@ class _MonthGroup { class PhotosView extends StatefulWidget { final ScrollController scrollController; - const PhotosView({super.key, required this.scrollController}); + /// This tab's own shell top bar, planted as its first sliver - see + /// `buildAppTabView`'s doc comment. Null on desktop and while picking. + final PreferredSizeWidget? topBar; + + const PhotosView({super.key, required this.scrollController, this.topBar}); @override State createState() => _PhotosViewState(); @@ -239,6 +244,7 @@ class _PhotosViewState extends State { ); final List contentSlivers = [ + if (widget.topBar != null) topBarSliver(widget.topBar!), // Sticky while browsing; once selecting, the selection bar takes over // the same slot instead. SliverPersistentHeader( diff --git a/lib/views/recent_view.dart b/lib/views/recent_view.dart index 02af7e3..0ecd184 100644 --- a/lib/views/recent_view.dart +++ b/lib/views/recent_view.dart @@ -27,7 +27,11 @@ import 'file_viewer_screen.dart'; class RecentView extends StatefulWidget { final ScrollController scrollController; - const RecentView({super.key, required this.scrollController}); + /// This tab's own shell top bar, planted as its first sliver - see + /// `buildAppTabView`'s doc comment. Null on desktop and while picking. + final PreferredSizeWidget? topBar; + + const RecentView({super.key, required this.scrollController, this.topBar}); @override State createState() => _RecentViewState(); @@ -54,6 +58,7 @@ class _RecentViewState extends State { ); final List contentSlivers = [ + if (widget.topBar != null) topBarSliver(widget.topBar!), const SliverToBoxAdapter(child: SizedBox(height: NooSpace.md)), if (recent.isLoading && items.isEmpty) tabLoadingSliver diff --git a/lib/views/shares_view.dart b/lib/views/shares_view.dart index e833975..707d9b6 100644 --- a/lib/views/shares_view.dart +++ b/lib/views/shares_view.dart @@ -53,7 +53,11 @@ String _permissionLabel(int permissions) { class SharesView extends StatefulWidget { final ScrollController scrollController; - const SharesView({super.key, required this.scrollController}); + /// This tab's own shell top bar, planted as its first sliver - see + /// `buildAppTabView`'s doc comment. Null on desktop and while picking. + final PreferredSizeWidget? topBar; + + const SharesView({super.key, required this.scrollController, this.topBar}); @override State createState() => _SharesViewState(); @@ -81,6 +85,7 @@ class _SharesViewState extends State { final isDesktop = NooLayout.isDesktop(context); final List contentSlivers = [ + if (widget.topBar != null) topBarSliver(widget.topBar!), SliverPadding( padding: EdgeInsets.fromLTRB( NooLayout.gutter(context), diff --git a/lib/views/trash_view.dart b/lib/views/trash_view.dart index 6a23610..33d20f7 100644 --- a/lib/views/trash_view.dart +++ b/lib/views/trash_view.dart @@ -25,7 +25,11 @@ import '../widgets/tabs/tab_state_slivers.dart'; class TrashView extends StatefulWidget { final ScrollController scrollController; - const TrashView({super.key, required this.scrollController}); + /// This tab's own shell top bar, planted as its first sliver - see + /// `buildAppTabView`'s doc comment. Null on desktop and while picking. + final PreferredSizeWidget? topBar; + + const TrashView({super.key, required this.scrollController, this.topBar}); @override State createState() => _TrashViewState(); @@ -50,6 +54,7 @@ class _TrashViewState extends State { final isDesktop = NooLayout.isDesktop(context); final List contentSlivers = [ + if (widget.topBar != null) topBarSliver(widget.topBar!), SliverPadding( padding: EdgeInsets.fromLTRB( NooLayout.gutter(context), diff --git a/lib/widgets/app_tab_view_builder.dart b/lib/widgets/app_tab_view_builder.dart index f170cd8..9be3456 100644 --- a/lib/widgets/app_tab_view_builder.dart +++ b/lib/widgets/app_tab_view_builder.dart @@ -11,7 +11,20 @@ import '../views/trash_view.dart'; /// Builds the view widget for a given [AppTab]. Shared by the main shell /// (for visible tabs) and the "more tabs" dropdown (for launching a tab /// that's currently hidden from the bottom nav bar). -Widget buildAppTabView(AppTab tab, ScrollController controller) { +/// +/// [topBar], when given, is that tab's own [AppTopBar] instance (built by +/// the caller, which owns `NooLayout.navStyle`/pick-mode/search-in-bottom- +/// bar state) - each view plants it as its own first sliver (see +/// `topBarSliver` in `tabs/tab_state_slivers.dart`) so it scrolls away and +/// reappears independently, tied to that tab's own `ScrollController` +/// rather than living in the shared `Scaffold.appBar`. Null on desktop +/// (which shows `NooToolbar` instead) and while picking (no top bar at +/// all), matching `Scaffold.appBar`'s old `pickRequest == null` guard. +Widget buildAppTabView( + AppTab tab, + ScrollController controller, { + PreferredSizeWidget? topBar, +}) { switch (tab) { case AppTab.files: // Keyed so the Files and Offline tabs (same widget type) never share @@ -19,24 +32,26 @@ Widget buildAppTabView(AppTab tab, ScrollController controller) { return FilesView( key: const ValueKey('files'), scrollController: controller, + topBar: topBar, ); case AppTab.photos: - return PhotosView(scrollController: controller); + return PhotosView(scrollController: controller, topBar: topBar); case AppTab.favorites: - return FavoritesView(scrollController: controller); + return FavoritesView(scrollController: controller, topBar: topBar); case AppTab.activity: - return ActivityView(scrollController: controller); + return ActivityView(scrollController: controller, topBar: topBar); case AppTab.trash: - return TrashView(scrollController: controller); + return TrashView(scrollController: controller, topBar: topBar); case AppTab.shares: - return SharesView(scrollController: controller); + return SharesView(scrollController: controller, topBar: topBar); case AppTab.recent: - return RecentView(scrollController: controller); + return RecentView(scrollController: controller, topBar: topBar); case AppTab.offline: return FilesView( key: const ValueKey('offline'), scrollController: controller, offline: true, + topBar: topBar, ); } } diff --git a/lib/widgets/tabs/tab_state_slivers.dart b/lib/widgets/tabs/tab_state_slivers.dart index 97c5b6f..e611087 100644 --- a/lib/widgets/tabs/tab_state_slivers.dart +++ b/lib/widgets/tabs/tab_state_slivers.dart @@ -94,3 +94,29 @@ const List tabBottomInsetSlivers = [ SliverToBoxAdapter(child: SizedBox(height: 100)), SliverFillRemaining(hasScrollBody: false, child: SizedBox()), ]; + +/// Wraps a tab's shell top bar ([AppTopBar], passed in as the generic +/// [PreferredSizeWidget] it implements - this file can't import +/// `app_top_bar.dart` without a cycle) as that tab's own first sliver, +/// living inside its `CustomScrollView` instead of `Scaffold.appBar`. Gives +/// it Material's native "floating app bar" behavior, via the framework's own +/// [SliverFloatingHeader]: it scrolls away as the list scrolls down, and - +/// unlike a plain `SliverToBoxAdapter`, which only reappears once scrolled +/// all the way back to the top - reappears immediately on any upward +/// scroll, following the finger while dragging and settling fully open or +/// fully closed once the gesture ends. +/// +/// [SliverFloatingHeader] sizes itself from [topBar]'s own natural layout +/// (like `SliverToBoxAdapter`) rather than a fixed extent declared up +/// front - so [topBar]'s own internal `SafeArea` (see `NooTopBar`'s doc +/// comment) already accounts for the status-bar inset correctly, with no +/// extra height math needed here (unlike building this on the general- +/// purpose `SliverPersistentHeader` would have required). +/// +/// Sits above a tab's own pinned in-content header (built with +/// [StickyHeaderDelegate] - the sort/filter controls row, or the selection +/// bar that replaces it) - put this sliver first in `contentSlivers` so +/// that header stays exactly where it already is, independent of whether +/// [topBar] is currently shown or scrolled away. +Widget topBarSliver(PreferredSizeWidget topBar) => + SliverFloatingHeader(child: topBar); diff --git a/test/widgets/tabs/tab_state_slivers_test.dart b/test/widgets/tabs/tab_state_slivers_test.dart new file mode 100644 index 0000000..1d989ad --- /dev/null +++ b/test/widgets/tabs/tab_state_slivers_test.dart @@ -0,0 +1,143 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:noo/widgets/tabs/tab_state_slivers.dart'; + +import '../noo/noo_test_utils.dart'; + +/// A minimal stand-in for `AppTopBar` - just needs to be a fixed-height +/// [PreferredSizeWidget], same contract `topBarSliver` wraps in real tabs. +class _FakeTopBar extends StatelessWidget implements PreferredSizeWidget { + const _FakeTopBar(); + + static const double height = 56; + + @override + Size get preferredSize => const Size.fromHeight(height); + + @override + Widget build(BuildContext context) => const SizedBox( + height: height, + child: ColoredBox(color: Colors.blue), + ); +} + +/// Matches the one sliver `topBarSliver` inserts, even once it's fully +/// scrolled away - `find.byType`'s default `skipOffstage: true` treats a +/// sliver with `geometry.visible == false` (paintExtent 0, exactly the +/// "fully hidden" state part of this test exercises) as offstage and +/// excludes it, which would otherwise make `tester.renderObject` throw +/// instead of reporting the 0 this test expects. A function, not a +/// top-level constant, so each call re-evaluates against whichever widget +/// tree is currently pumped. +Finder _header() => find.byType(SliverFloatingHeader, skipOffstage: false); + +Future _pumpHost(WidgetTester tester, ScrollController controller) { + return tester.pumpWidget( + MaterialApp( + theme: nooTheme(Brightness.light), + home: Scaffold( + body: CustomScrollView( + controller: controller, + slivers: [ + topBarSliver(const _FakeTopBar()), + SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) => + SizedBox(height: 60, child: Text('Item $index')), + childCount: 40, + ), + ), + ], + ), + ), + ), + ); +} + +void main() { + setUpNooTests(); + + testWidgets( + 'topBarSliver floats away scrolling down and reappears the moment the ' + 'drag reverses, not only once back at the top (Material floating app ' + 'bar behavior)', + (tester) async { + final controller = ScrollController(); + addTearDown(controller.dispose); + await _pumpHost(tester, controller); + + // The render object's own `geometry.paintExtent` is the ground truth + // for how much of the bar is actually visible - more reliable than + // inferring visibility from hit-testing/finding the child, which a + // floating header sliver keeps built regardless of paint extent. + double paintExtent() => + tester.renderObject(_header()).geometry!.paintExtent; + + // Fully visible before any scroll. + expect(paintExtent(), _FakeTopBar.height); + expect(controller.offset, 0); + + // Drive one continuous drag by hand (rather than `tester.drag`, which + // only pumps once the whole gesture - including the final pointer-up + // - has already finished): the floating header's reveal-while- + // dragging behavior keys off the *live* scroll direction as reported + // mid-gesture, so this needs an intermediate pump while the pointer + // is still down to actually observe it. + final gesture = await tester.startGesture(const Offset(200, 300)); + + // Drag up (content scrolls down) well past the bar's own height, so + // it scrolls fully away, same as a plain `SliverToBoxAdapter` would. + await gesture.moveBy(const Offset(0, -200)); + await tester.pump(); + + expect(paintExtent(), 0); + final scrolledOffset = controller.offset; + expect( + scrolledOffset, + greaterThan(_FakeTopBar.height), + reason: "the drag should have moved past the bar's own height", + ); + + // Reverse direction by a small amount, still mid-gesture and nowhere + // near the top of the list. A plain `SliverToBoxAdapter`, or a header + // that only reappears once back at the top, would stay fully hidden + // here. The floating bar should start reappearing immediately + // instead, following the drag. + await gesture.moveBy(const Offset(0, 40)); + await tester.pump(); + + expect(controller.offset, lessThan(scrolledOffset)); + expect( + controller.offset, + greaterThan(_FakeTopBar.height / 2), + reason: 'still far from the top of the list', + ); + expect( + paintExtent(), + greaterThan(0), + reason: + 'the bar should already be reappearing on the first upward ' + 'movement, without needing to reach the top', + ); + + await gesture.up(); + await tester.pumpAndSettle(); + }, + ); + + testWidgets( + "topBarSliver sizes itself from the wrapped bar's own layout (e.g. its " + 'SafeArea-padded height), not a value declared up front', + (tester) async { + final controller = ScrollController(); + addTearDown(controller.dispose); + await _pumpHost(tester, controller); + + expect( + tester.getSize(find.byType(_FakeTopBar)).height, + _FakeTopBar.height, + ); + }, + ); +} From 5e5acc3fc69e91bea00d21e521a951ce947e848d Mon Sep 17 00:00:00 2001 From: ayushya Date: Wed, 30 Sep 2026 16:30:05 -0400 Subject: [PATCH 23/29] 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 --- .../context/design-system/DESIGN_SYSTEM.md | 21 +++- .claude/context/styling.md | 18 ++- lib/main.dart | 1 + lib/theme/design_tokens.dart | 5 +- lib/views/activity_view.dart | 2 +- lib/views/favorites_view.dart | 3 +- lib/views/files_view.dart | 11 ++ lib/views/photos_view.dart | 8 +- lib/views/recent_view.dart | 2 +- lib/views/shares_view.dart | 2 +- lib/views/trash_view.dart | 2 +- lib/widgets/noo/core/noo_fab.dart | 19 +++- lib/widgets/noo/files/noo_swipe_action.dart | 69 ++++++++++-- lib/widgets/noo/nav/noo_bottom_bar.dart | 53 ++++++--- lib/widgets/noo/nav/noo_nav_style.dart | 13 ++- lib/widgets/tabs/tab_state_slivers.dart | 30 ++++- test/widgets/noo/core_test.dart | 51 ++++++++- test/widgets/noo/files_swipe_test.dart | 106 ++++++++++++++++++ 18 files changed, 361 insertions(+), 55 deletions(-) create mode 100644 test/widgets/noo/files_swipe_test.dart diff --git a/.claude/context/design-system/DESIGN_SYSTEM.md b/.claude/context/design-system/DESIGN_SYSTEM.md index 3ddbc59..1250a68 100644 --- a/.claude/context/design-system/DESIGN_SYSTEM.md +++ b/.claude/context/design-system/DESIGN_SYSTEM.md @@ -109,10 +109,18 @@ Use **Lucide** (pinned to `lucide-static@0.460.0`) at a 1.8 stroke. Sizes are 14 - An extended pill: 56px tall, accent fill, labelled "Upload". - 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 (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. + 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. @@ -162,6 +170,7 @@ Use **Lucide** (pinned to `lucide-static@0.460.0`) at a 1.8 stroke. Sizes are 14 **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 @@ -273,9 +282,9 @@ Mobile always has **5 pinned tabs in the bottom bar**. The rest go in the drawer | Status bar | 54px, Dynamic Island | 40px | | Top bar | 44px row: `menu` on the left; `plus` and avatar on the right (all accent-text). Then a 34px large title, then the search field. | 64px: `menu`, 22px title, `search`, avatar | | 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. No shadow (product UI stays flat - see 1.4); a 1px `line` border stands in for elevation instead. Row height drops slightly (64/72 vs 50/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 (27 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. | +| 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. | -| Upload | `plus` in the nav bar | Extended FAB | +| 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" | diff --git a/.claude/context/styling.md b/.claude/context/styling.md index 0220b14..68cc1f2 100644 --- a/.claude/context/styling.md +++ b/.claude/context/styling.md @@ -92,8 +92,10 @@ Gotchas: do). Its `collapsible`/`initiallyExpanded` params (off by default) make `label` a tap target that shows/hides the card - `SettingsSection` is the only caller that opts in, for Settings' mobile sections. -- `NooSwipeAction` only reveals its action. The user has to tap the block to - trigger it; a full swipe never deletes. +- `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 @@ -193,10 +195,16 @@ blocks are noted where they matter: 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 flat/non-blurred, just inset - with a `line` border instead of edge to edge (no opacity/blur knob + don't confuse the two: this one is still non-blurred, just inset with a + `line` border and `nooDialogShadow` (the app's one other shadow user - see + that constant's doc comment) instead of edge to edge (no opacity/blur knob either). The host `Scaffold` needs `extendBody: true` while it's active - (`main.dart` already wires this off `SettingsController.bottomBarStyle`). + (`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`/ diff --git a/lib/main.dart b/lib/main.dart index 0b44578..a4f05e5 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -502,6 +502,7 @@ class _MainShellViewState extends State { pickRequest == null && navStyle == NooNavStyle.android ? NooFab( collapsed: !canUpload, + barStyle: bottomBarStyle, onTap: () => showCreateMenu(context), ) : null, diff --git a/lib/theme/design_tokens.dart b/lib/theme/design_tokens.dart index 6cb33e8..4f2fbbe 100644 --- a/lib/theme/design_tokens.dart +++ b/lib/theme/design_tokens.dart @@ -356,8 +356,9 @@ class NooMotion { } /// The one shadow the design system allows, from `tokens/spacing.css`'s -/// `--shadow-dialog` - desktop dialogs only. Nothing else in the app should -/// use a `BoxShadow`. +/// `--shadow-dialog` - desktop dialogs, and [NooBottomBarStyle.floating]'s +/// bar/search satellite (the only other thing in the app that's genuinely +/// floating above other content). Nothing else should use a `BoxShadow`. const nooDialogShadow = BoxShadow( color: Color(0x241E002F), // rgba(30,0,47,.14) blurRadius: 48, diff --git a/lib/views/activity_view.dart b/lib/views/activity_view.dart index 90f693e..a6a39f0 100644 --- a/lib/views/activity_view.dart +++ b/lib/views/activity_view.dart @@ -92,7 +92,7 @@ class ActivityView extends StatelessWidget { final List contentSlivers = [ const SliverToBoxAdapter(child: SizedBox(height: NooSpace.md)), feed, - ...tabBottomInsetSlivers, + ...tabBottomInsetSlivers(context), ]; final scrollView = CustomScrollView( diff --git a/lib/views/favorites_view.dart b/lib/views/favorites_view.dart index cae784c..3f89b06 100644 --- a/lib/views/favorites_view.dart +++ b/lib/views/favorites_view.dart @@ -29,6 +29,7 @@ import '../widgets/noo/overlays/noo_sheet.dart'; import '../widgets/noo/core/noo_button.dart'; import '../widgets/share_sheet.dart'; import '../widgets/sticky_header_delegate.dart'; +import '../widgets/tabs/tab_state_slivers.dart'; import '../widgets/synced_header_scaffold.dart' show formatBytes; import 'file_viewer_screen.dart'; import 'move_copy_destination_picker.dart'; @@ -638,7 +639,7 @@ class _FavoritesViewState extends State { }, childCount: favorites.length), ), ), - const SliverToBoxAdapter(child: SizedBox(height: 100)), + SliverToBoxAdapter(child: SizedBox(height: bottomBarClearance(context))), ]; return PopScope( diff --git a/lib/views/files_view.dart b/lib/views/files_view.dart index a64f1bc..fd3d0a6 100644 --- a/lib/views/files_view.dart +++ b/lib/views/files_view.dart @@ -39,11 +39,13 @@ import '../widgets/noo/lists/noo_selection_bar.dart'; import '../widgets/noo/lists/noo_settings_row.dart'; import '../widgets/noo/lists/noo_summary_card.dart'; import '../widgets/noo/media/noo_grid_card.dart'; +import '../widgets/noo/nav/noo_bottom_bar.dart'; import '../widgets/noo/noo_layout.dart'; import '../widgets/noo/overlays/noo_sheet.dart'; import '../widgets/share_sheet.dart'; import '../widgets/sticky_header_delegate.dart'; import '../widgets/synced_header_scaffold.dart' show formatBytes; +import '../widgets/tabs/tab_state_slivers.dart'; import 'file_viewer_screen.dart'; import 'move_copy_destination_picker.dart'; @@ -815,6 +817,15 @@ class _FilesViewState extends State { }, childCount: browser.items.length), ), ), + // List/grid/table above only pad NooSpace.lg at the bottom - plenty + // once Scaffold shrinks the body above an attached bar, but floating + // draws the body behind the bar instead, so it needs the bar's own + // footprint added on top or the last row ends up under it. + if (context.watch().bottomBarStyle == + NooBottomBarStyle.floating) + SliverToBoxAdapter( + child: SizedBox(height: bottomBarClearance(context)), + ), ]; return PopScope( diff --git a/lib/views/photos_view.dart b/lib/views/photos_view.dart index 4fa9152..17d9ab0 100644 --- a/lib/views/photos_view.dart +++ b/lib/views/photos_view.dart @@ -30,6 +30,7 @@ import '../widgets/noo/overlays/noo_sheet.dart'; import '../widgets/share_sheet.dart'; import '../widgets/sort_menu_button.dart' show sortFieldLabel; import '../widgets/sticky_header_delegate.dart'; +import '../widgets/tabs/tab_state_slivers.dart'; import 'file_viewer_screen.dart'; import 'move_copy_destination_picker.dart'; @@ -346,9 +347,10 @@ class _PhotosViewState extends State { ), ], - // Fixed clearance so the last row isn't hidden behind the floating - // nav bar, regardless of grid length. - const SliverToBoxAdapter(child: SizedBox(height: 100)), + // Clearance so the last row isn't hidden behind the nav bar, + // regardless of grid length - see `bottomBarClearance`'s own doc + // comment for why this has to be dynamic rather than a flat 100. + SliverToBoxAdapter(child: SizedBox(height: bottomBarClearance(context))), ]; return PopScope( diff --git a/lib/views/recent_view.dart b/lib/views/recent_view.dart index 02af7e3..11cec3a 100644 --- a/lib/views/recent_view.dart +++ b/lib/views/recent_view.dart @@ -74,7 +74,7 @@ class _RecentViewState extends State { _buildDesktopTable(context, groups) else _buildMobileGroups(context, groups), - ...tabBottomInsetSlivers, + ...tabBottomInsetSlivers(context), ]; return ColoredBox( diff --git a/lib/views/shares_view.dart b/lib/views/shares_view.dart index e833975..d074b31 100644 --- a/lib/views/shares_view.dart +++ b/lib/views/shares_view.dart @@ -120,7 +120,7 @@ class _SharesViewState extends State { _buildDesktopTable(context, shares) else _buildMobileList(context, shares), - ...tabBottomInsetSlivers, + ...tabBottomInsetSlivers(context), ]; return ColoredBox( diff --git a/lib/views/trash_view.dart b/lib/views/trash_view.dart index 6a23610..af69288 100644 --- a/lib/views/trash_view.dart +++ b/lib/views/trash_view.dart @@ -89,7 +89,7 @@ class _TrashViewState extends State { _buildDesktopTable(context, trash) else _buildMobileList(context, trash), - ...tabBottomInsetSlivers, + ...tabBottomInsetSlivers(context), ]; return ColoredBox( diff --git a/lib/widgets/noo/core/noo_fab.dart b/lib/widgets/noo/core/noo_fab.dart index 985313e..08f6896 100644 --- a/lib/widgets/noo/core/noo_fab.dart +++ b/lib/widgets/noo/core/noo_fab.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../../theme/design_tokens.dart'; +import '../nav/noo_nav_style.dart'; /// Android-only extended Upload pill, bottom-right of Files and Photos (Noo /// Design System project, `components/core/FAB.jsx`). 56px tall, accent @@ -10,11 +11,21 @@ import '../../../theme/design_tokens.dart'; /// don't support upload, so the button stays in place and resizes as the /// user switches tabs instead of the Scaffold popping it fully in/out - /// the label's width animates away rather than the FAB disappearing. +/// +/// [barStyle] shapes the corners to match the bottom bar it sits above +/// (Settings > Appearance). With [NooBottomBarStyle.floating] the FAB stays +/// fully round (`NooRadii.pill`) - consistent with that mode's other +/// elements, which are already rounder (the bar itself at 28px, its search +/// satellite a full circle). With [NooBottomBarStyle.attached] the FAB +/// squares off to `NooRadii.card`, a rounded square that fits attached's +/// flatter, edge-to-edge aesthetic; this applies whether [collapsed] or not, +/// so the FAB keeps one consistent identity per bar style. class NooFab extends StatelessWidget { final String label; final IconData icon; final VoidCallback? onTap; final bool collapsed; + final NooBottomBarStyle barStyle; const NooFab({ super.key, @@ -22,17 +33,21 @@ class NooFab extends StatelessWidget { this.icon = LucideIcons.plus, this.onTap, this.collapsed = false, + required this.barStyle, }); @override Widget build(BuildContext context) { final colors = context.nooColors; + final radius = BorderRadius.circular( + barStyle == NooBottomBarStyle.attached ? NooRadii.card : NooRadii.pill, + ); return Material( color: colors.accent, - borderRadius: BorderRadius.circular(NooRadii.pill), + borderRadius: radius, child: InkWell( onTap: onTap, - borderRadius: BorderRadius.circular(NooRadii.pill), + borderRadius: radius, child: AnimatedContainer( duration: NooMotion.base, curve: NooMotion.ease, diff --git a/lib/widgets/noo/files/noo_swipe_action.dart b/lib/widgets/noo/files/noo_swipe_action.dart index dcc2f9a..7eb9ce9 100644 --- a/lib/widgets/noo/files/noo_swipe_action.dart +++ b/lib/widgets/noo/files/noo_swipe_action.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../../theme/design_tokens.dart'; @@ -34,6 +35,13 @@ class NooSwipeActionSpec { /// springs shut - both on [NooMotion.base] / [NooMotion.ease], no bounce. /// Tapping the block fires the action and closes it; tapping the row while /// open just closes it. +/// +/// Dragging further still - past [_kTriggerExtent] of the block's own +/// width, the block growing to fill the extra reveal so there's no gap - +/// arms the action (a [HapticFeedback.mediumImpact] marks the crossing); +/// releasing while armed fires it immediately instead of just leaving it +/// open, so a single swipe-through gesture can do the whole thing without a +/// second tap. class NooSwipeAction extends StatefulWidget { final Widget child; @@ -59,12 +67,24 @@ class NooSwipeAction extends StatefulWidget { class _NooSwipeActionState extends State with SingleTickerProviderStateMixin { + /// How far past the fully-revealed block (1.0) a drag has to go before + /// releasing fires the action outright rather than just snapping open - + /// "swipe through" in one gesture instead of open-then-tap. + static const double _kTriggerExtent = 1.8; + + /// Hard ceiling on the controller's value - past [_kTriggerExtent] there's + /// nothing more for further drag to *do*, but a little extra travel still + /// gives the gesture room to keep moving under the finger instead of + /// hitting a dead stop right at the trigger point. + static const double _kMaxDrag = 2.4; + /// -1 = end action fully revealed, 0 = closed, 1 = start action revealed - /// (in visual left/right terms after [_dir] is applied). + /// (in visual left/right terms after [_dir] is applied) - and on past + /// either bound up to [_kMaxDrag] while armed for [_kTriggerExtent]. late final AnimationController _ctrl = AnimationController( vsync: this, - lowerBound: -1, - upperBound: 1, + lowerBound: -_kMaxDrag, + upperBound: _kMaxDrag, value: 0, ); @@ -85,13 +105,21 @@ class _NooSwipeActionState extends State void _onDragUpdate(DragUpdateDetails d) { // Positive = revealing the start action. final delta = d.primaryDelta! * _dir / NooSwipeAction.actionWidth; - final min = widget.endAction != null ? -1.0 : 0.0; - final max = widget.startAction != null ? 1.0 : 0.0; - _ctrl.value = (_ctrl.value + delta).clamp(min, max); + final min = widget.endAction != null ? -_kMaxDrag : 0.0; + final max = widget.startAction != null ? _kMaxDrag : 0.0; + final next = (_ctrl.value + delta).clamp(min, max); + final wasArmed = _ctrl.value.abs() >= _kTriggerExtent; + final nowArmed = next.abs() >= _kTriggerExtent; + if (nowArmed && !wasArmed) HapticFeedback.mediumImpact(); + _ctrl.value = next; } void _onDragEnd(DragEndDetails d) { final v = _ctrl.value; + if (v.abs() >= _kTriggerExtent) { + _trigger(v > 0 ? widget.startAction! : widget.endAction!); + return; + } final velocity = d.primaryVelocity! * _dir; double target; if (velocity.abs() > 700) { @@ -141,6 +169,13 @@ class _NooSwipeActionState extends State child: _ActionBlock( spec: showing, colors: colors, + // Grows past its own min width to fill the extra + // reveal once dragged further than a plain "open" + // - otherwise the row's translated edge would pull + // away from the block and expose bare space behind + // it. + width: v.abs() * NooSwipeAction.actionWidth, + armed: v.abs() >= _kTriggerExtent, onTap: () => _trigger(showing), ), ), @@ -196,11 +231,24 @@ class _OpenAwareChild extends AnimatedWidget { class _ActionBlock extends StatelessWidget { final NooSwipeActionSpec spec; final NooColors colors; + + /// The block's own width - grows past [NooSwipeAction.actionWidth] once + /// dragged further than a plain reveal, so it always fills exactly what's + /// exposed behind the row. + final double width; + + /// True once the drag has gone far enough that releasing now fires the + /// action - bumps the icon up a touch as a "you're past the point of no + /// return" cue, on top of the haptic tick that fired at the same moment. + final bool armed; + final VoidCallback onTap; const _ActionBlock({ required this.spec, required this.colors, + required this.width, + required this.armed, required this.onTap, }); @@ -216,13 +264,18 @@ class _ActionBlock extends StatelessWidget { behavior: HitTestBehavior.opaque, onTap: onTap, child: Container( - width: NooSwipeAction.actionWidth, + width: width.clamp(NooSwipeAction.actionWidth, double.infinity), color: bg, alignment: Alignment.center, child: Column( mainAxisSize: MainAxisSize.min, children: [ - Icon(icon, size: 20, color: Colors.white), + AnimatedScale( + scale: armed ? 1.15 : 1, + duration: NooMotion.fast, + curve: NooMotion.ease, + child: Icon(icon, size: 20, color: Colors.white), + ), const SizedBox(height: 6), Text( label, diff --git a/lib/widgets/noo/nav/noo_bottom_bar.dart b/lib/widgets/noo/nav/noo_bottom_bar.dart index 380f922..61fbbd7 100644 --- a/lib/widgets/noo/nav/noo_bottom_bar.dart +++ b/lib/widgets/noo/nav/noo_bottom_bar.dart @@ -6,10 +6,10 @@ export 'noo_nav_style.dart'; /// Android indicator geometry, shared between the sliding pill and each /// item's own icon box so they line up exactly. Floating's own top offset -/// is shorter than attached's - its row is 8px shorter overall (72 vs 80) +/// is shorter than attached's - its row is 16px shorter overall (64 vs 80) /// and there's no edge-to-edge safe-area strip inside it eating into that. const double _kAttachedPillTop = 14; -const double _kFloatingPillTop = 10; +const double _kFloatingPillTop = 8; const double _kAndroidPillWidth = 56; const double _kAndroidPillHeight = 32; @@ -29,15 +29,21 @@ const double _kAndroidPillHeight = 32; /// [barStyle] (user-configurable in Settings, Appearance) picks between /// that edge-to-edge [NooBottomBarStyle.attached] bar and /// [NooBottomBarStyle.floating] - inset 16px from both side edges, 28px -/// corners, a 1px `line` border instead of a shadow (see that enum's own -/// doc comment for why). Floating's Android row is 8px shorter (72 vs 80) -/// and drops its idle tabs' reserved label space - with no label to leave -/// room for, an idle icon just centers in the whole button and renders a -/// touch bigger (27 vs 24px) instead of sitting high with a gap under it. -/// The home indicator / gesture bar area below the row comes from the -/// bottom safe-area inset rather than a fixed 34/20px spacer, so it's -/// right on every device. Meant for `Scaffold.bottomNavigationBar` - the -/// host `Scaffold` needs `extendBody: true` while floating. +/// corners, a 1px `line` border plus [nooDialogShadow] (the one shadow the +/// rest of the app allows itself, see that constant's own doc comment) so +/// it actually reads as floating above the content scrolling behind it. +/// Floating's Android row is both shorter (64 vs 80) and drops its idle +/// tabs' reserved label space - with no label to leave room for, an idle +/// icon just centers in the whole button, rather than sitting high with a +/// gap under it. The home indicator / gesture bar area below the row comes +/// from the bottom safe-area inset rather than a fixed 34/20px spacer, so +/// it's right on every device. Meant for `Scaffold.bottomNavigationBar` - +/// the host `Scaffold` needs `extendBody: true` while floating, and a +/// scrollable body needs enough trailing padding to clear the bar's own +/// footprint ([NooBottomBar.rowHeight] + [NooBottomBar.floatingBottomMargin] +/// + the bottom safe area) since nothing does that automatically once the +/// body draws behind it - see `tab_state_slivers.dart`'s +/// `bottomBarClearance`. /// /// [searchDestination]/[onSearchTap] (set together, from Settings' /// "Search in bottom bar" - see `DESIGN_SYSTEM.md`'s floating bottom bar @@ -65,6 +71,18 @@ class NooBottomBar extends StatelessWidget { this.onSearchTap, }); + /// The row height [build] draws for [style]/[barStyle] - exposed so a + /// scrollable body sharing the same `Scaffold` can reserve exactly this + /// much clearance (see the class doc comment) instead of guessing. + static double rowHeight(NooNavStyle style, NooBottomBarStyle barStyle) { + if (style == NooNavStyle.ios) return 50; + return barStyle == NooBottomBarStyle.floating ? 64 : 80; + } + + /// Gap between the floating bar's bottom edge and the safe area below it + /// (itself inside the [SafeArea] that consumes the actual device inset). + static const double floatingBottomMargin = 12; + @override Widget build(BuildContext context) { final colors = context.nooColors; @@ -83,7 +101,7 @@ class NooBottomBar extends StatelessWidget { floating: floating, trailingSearch: rowSearch, ); - final barHeight = ios ? (floating ? 64.0 : 50.0) : (floating ? 72.0 : 80.0); + final barHeight = NooBottomBar.rowHeight(style, barStyle); if (floating) { final pill = Container( @@ -93,13 +111,19 @@ class NooBottomBar extends StatelessWidget { color: colors.surface, border: Border.all(color: colors.line), borderRadius: BorderRadius.circular(28), + boxShadow: const [nooDialogShadow], ), child: row, ); return SafeArea( top: false, child: Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), + padding: EdgeInsets.fromLTRB( + 16, + 0, + 16, + NooBottomBar.floatingBottomMargin, + ), child: hasSearch // A fixed-height SizedBox, not just a Row with // crossAxisAlignment.stretch - the bottomNavigationBar slot @@ -255,6 +279,7 @@ class _SearchSatellite extends StatelessWidget { color: colors.surface, border: Border.all(color: colors.line), shape: BoxShape.circle, + boxShadow: const [nooDialogShadow], ), child: Icon(destination.icon, size: 24, color: colors.fg1), ), @@ -338,7 +363,7 @@ class _AndroidItem extends StatelessWidget { final Widget content; if (floating && !selected) { content = Center( - child: Icon(destination.icon, size: 27, color: iconColor), + child: Icon(destination.icon, size: 25, color: iconColor), ); } else { content = Padding( diff --git a/lib/widgets/noo/nav/noo_nav_style.dart b/lib/widgets/noo/nav/noo_nav_style.dart index ca35d6b..d490fe6 100644 --- a/lib/widgets/noo/nav/noo_nav_style.dart +++ b/lib/widgets/noo/nav/noo_nav_style.dart @@ -22,11 +22,14 @@ enum NooNavStyle { /// side/bottom margin. [floating] insets it 16px from both side edges and /// clear of the bottom safe area, rounded (28px - the same radius the /// sheet top/drawer edge use) rather than square, with a 1px `line` border -/// standing in for elevation instead of a shadow (product UI stays flat - -/// DESIGN_SYSTEM.md 1.4 - so this is the one place that border does that -/// job). The host `Scaffold` needs `extendBody: true` while floating, so -/// its body scrolls behind the bar's transparent margin instead of -/// stopping short of it like [attached] does. +/// plus `nooDialogShadow` - product UI otherwise stays flat (DESIGN_SYSTEM.md +/// 1.4), but a bar that's genuinely floating above scrolling content reads +/// as attached without some elevation, so it borrows the one shadow the +/// rest of the app allows itself rather than inventing a second one. The +/// host `Scaffold` needs `extendBody: true` while floating, so its body +/// scrolls behind the bar's transparent margin instead of stopping short of +/// it like [attached] does - which also means that body needs its own +/// trailing padding to clear the bar (see `NooBottomBar.rowHeight`). enum NooBottomBarStyle { attached, floating } /// One destination in a [NooBottomBar]. Data-agnostic on purpose - the app diff --git a/lib/widgets/tabs/tab_state_slivers.dart b/lib/widgets/tabs/tab_state_slivers.dart index 97c5b6f..5b11ed4 100644 --- a/lib/widgets/tabs/tab_state_slivers.dart +++ b/lib/widgets/tabs/tab_state_slivers.dart @@ -1,7 +1,11 @@ import 'package:flutter/material.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:provider/provider.dart'; +import '../../providers/settings_controller.dart'; import '../../theme/design_tokens.dart'; import '../noo/core/noo_button.dart'; +import '../noo/nav/noo_bottom_bar.dart'; +import '../noo/noo_layout.dart'; /// Shared loading/error/empty slivers for Recent/Activity/Trash/Shares (and /// any future tab shaped like them: a `CustomScrollView` over one flat @@ -88,9 +92,29 @@ Widget tabEmptySliver( ); } +/// How much bottom padding a tab's scrollable list needs to clear +/// [NooBottomBar] and not just butt up against it. [NooBottomBarStyle. +/// attached] bars aren't drawn behind (no `Scaffold.extendBody`), so +/// Scaffold already shrinks the body above them - this is then pure +/// breathing room, not overlap prevention. [NooBottomBarStyle.floating] +/// bars draw over an extended body instead, so nothing reserves space for +/// them automatically: the clearance has to cover the bar's own footprint +/// (see [NooBottomBar.rowHeight]/[NooBottomBar.floatingBottomMargin]) plus +/// the safe-area inset below it, or the last row ends up hidden under it. +double bottomBarClearance(BuildContext context) { + final settings = context.watch(); + if (settings.bottomBarStyle == NooBottomBarStyle.attached) return 100; + final barHeight = NooBottomBar.rowHeight( + NooLayout.navStyle(context), + NooBottomBarStyle.floating, + ); + final safeBottom = MediaQuery.paddingOf(context).bottom; + return barHeight + NooBottomBar.floatingBottomMargin + safeBottom + 24; +} + /// Bottom padding sliver so the last row/card clears the bottom nav/FAB - /// same trailing pair every rebuilt tab list ends on. -const List tabBottomInsetSlivers = [ - SliverToBoxAdapter(child: SizedBox(height: 100)), - SliverFillRemaining(hasScrollBody: false, child: SizedBox()), +List tabBottomInsetSlivers(BuildContext context) => [ + SliverToBoxAdapter(child: SizedBox(height: bottomBarClearance(context))), + const SliverFillRemaining(hasScrollBody: false, child: SizedBox()), ]; diff --git a/test/widgets/noo/core_test.dart b/test/widgets/noo/core_test.dart index cbb6059..0bd4af8 100644 --- a/test/widgets/noo/core_test.dart +++ b/test/widgets/noo/core_test.dart @@ -12,6 +12,7 @@ import 'package:noo/widgets/noo/core/noo_progress_bar.dart'; import 'package:noo/widgets/noo/core/noo_search_field.dart'; import 'package:noo/widgets/noo/core/noo_segmented_control.dart'; import 'package:noo/widgets/noo/core/noo_toggle.dart'; +import 'package:noo/widgets/noo/nav/noo_nav_style.dart'; import 'noo_test_utils.dart'; @@ -247,7 +248,11 @@ void main() { group('NooFab', () { testNooWidgets('shows label and fires onTap', (tester, theme, c) async { var taps = 0; - await pumpNoo(tester, NooFab(onTap: () => taps++), theme: theme); + await pumpNoo( + tester, + NooFab(barStyle: NooBottomBarStyle.floating, onTap: () => taps++), + theme: theme, + ); expect(find.text('Upload'), findsOneWidget); expect(tester.getSize(find.byType(NooFab)).height, 56); expect(tester.getSize(find.byType(NooFab)).width, lessThan(200)); @@ -263,7 +268,11 @@ void main() { var taps = 0; await pumpNoo( tester, - NooFab(collapsed: true, onTap: () => taps++), + NooFab( + collapsed: true, + barStyle: NooBottomBarStyle.floating, + onTap: () => taps++, + ), theme: theme, ); await tester.pumpAndSettle(); @@ -274,6 +283,44 @@ void main() { await tester.tap(find.byType(NooFab)); expect(taps, 1); }); + + testNooWidgets('floating bar style stays fully round', ( + tester, + theme, + c, + ) async { + await pumpNoo( + tester, + const NooFab(barStyle: NooBottomBarStyle.floating), + theme: theme, + ); + final material = tester.widget( + find.descendant( + of: find.byType(NooFab), + matching: find.byType(Material), + ), + ); + expect((material.borderRadius as BorderRadius).topLeft.x, NooRadii.pill); + }); + + testNooWidgets('attached bar style squares off to a rounded square', ( + tester, + theme, + c, + ) async { + await pumpNoo( + tester, + const NooFab(barStyle: NooBottomBarStyle.attached), + theme: theme, + ); + final material = tester.widget( + find.descendant( + of: find.byType(NooFab), + matching: find.byType(Material), + ), + ); + expect((material.borderRadius as BorderRadius).topLeft.x, NooRadii.card); + }); }); group('NooProgressBar', () { diff --git a/test/widgets/noo/files_swipe_test.dart b/test/widgets/noo/files_swipe_test.dart new file mode 100644 index 0000000..1a86f7f --- /dev/null +++ b/test/widgets/noo/files_swipe_test.dart @@ -0,0 +1,106 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:noo/widgets/noo/files/noo_swipe_action.dart'; + +import 'noo_test_utils.dart'; + +void main() { + setUpNooTests(); + + group('NooSwipeAction', () { + testNooWidgets('a moderate swipe opens the action without triggering it', ( + tester, + theme, + c, + ) async { + var triggered = 0; + await pumpNoo( + tester, + SizedBox( + width: 360, + height: 64, + child: NooSwipeAction( + endAction: NooSwipeActionSpec( + kind: NooSwipeActionKind.delete, + onTriggered: () => triggered++, + ), + child: const ColoredBox( + color: Colors.white, + child: SizedBox(width: 360, height: 64), + ), + ), + ), + theme: theme, + ); + // Half the block's own width (96) is well past the "snap open" + // threshold but nowhere near the 1.8x trigger extent. + await tester.drag(find.byType(NooSwipeAction), const Offset(-60, 0)); + await tester.pumpAndSettle(); + expect(triggered, 0); + expect(find.text('Delete'), findsOneWidget); + }); + + testNooWidgets( + 'swiping past the trigger extent fires the action on release - no ' + 'second tap needed', + (tester, theme, c) async { + var triggered = 0; + await pumpNoo( + tester, + SizedBox( + width: 360, + height: 64, + child: NooSwipeAction( + endAction: NooSwipeActionSpec( + kind: NooSwipeActionKind.delete, + onTriggered: () => triggered++, + ), + child: const ColoredBox( + color: Colors.white, + child: SizedBox(width: 360, height: 64), + ), + ), + ), + theme: theme, + ); + // 300px of drag on a 96px-wide block clears the 1.8x (~173px) + // trigger extent well past the clamp ceiling. + await tester.drag(find.byType(NooSwipeAction), const Offset(-300, 0)); + await tester.pump(); + expect(triggered, 1); + }, + ); + + testNooWidgets('tapping the revealed block still triggers the action too', ( + tester, + theme, + c, + ) async { + var triggered = 0; + await pumpNoo( + tester, + SizedBox( + width: 360, + height: 64, + child: NooSwipeAction( + startAction: NooSwipeActionSpec( + kind: NooSwipeActionKind.favorite, + onTriggered: () => triggered++, + ), + child: const ColoredBox( + color: Colors.white, + child: SizedBox(width: 360, height: 64), + ), + ), + ), + theme: theme, + ); + await tester.drag(find.byType(NooSwipeAction), const Offset(60, 0)); + await tester.pumpAndSettle(); + expect(triggered, 0); + await tester.tap(find.text('Favorite')); + await tester.pump(); + expect(triggered, 1); + }); + }); +} From 840ec4c301d25e8c87664305aba8f6639114fdf8 Mon Sep 17 00:00:00 2001 From: ayushya Date: Wed, 30 Sep 2026 16:32:15 -0400 Subject: [PATCH 24/29] 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 --- lib/views/favorites_view.dart | 1 - lib/views/files_view.dart | 1 - 2 files changed, 2 deletions(-) diff --git a/lib/views/favorites_view.dart b/lib/views/favorites_view.dart index f7fe907..0608cb8 100644 --- a/lib/views/favorites_view.dart +++ b/lib/views/favorites_view.dart @@ -31,7 +31,6 @@ import '../widgets/share_sheet.dart'; import '../widgets/sticky_header_delegate.dart'; import '../widgets/tabs/tab_state_slivers.dart'; import '../widgets/synced_header_scaffold.dart' show formatBytes; -import '../widgets/tabs/tab_state_slivers.dart'; import 'file_viewer_screen.dart'; import 'move_copy_destination_picker.dart'; diff --git a/lib/views/files_view.dart b/lib/views/files_view.dart index e256a7e..0595328 100644 --- a/lib/views/files_view.dart +++ b/lib/views/files_view.dart @@ -46,7 +46,6 @@ import '../widgets/share_sheet.dart'; import '../widgets/sticky_header_delegate.dart'; import '../widgets/tabs/tab_state_slivers.dart'; import '../widgets/synced_header_scaffold.dart' show formatBytes; -import '../widgets/tabs/tab_state_slivers.dart'; import 'file_viewer_screen.dart'; import 'move_copy_destination_picker.dart'; From e6d96a6f9f8eca7367fd94c15535bb5ee87bdf04 Mon Sep 17 00:00:00 2001 From: ayushya Date: Wed, 30 Sep 2026 22:25:04 -0400 Subject: [PATCH 25/29] 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 --- .../context/design-system/DESIGN_SYSTEM.md | 4 +- .claude/context/styling.md | 20 ++ lib/main.dart | 1 + lib/providers/settings_controller.dart | 17 ++ lib/widgets/app_top_bar.dart | 43 +++-- lib/widgets/avatar_menu.dart | 182 ++++++++++++++++++ lib/widgets/noo/nav/noo_nav_style.dart | 14 ++ lib/widgets/noo/nav/noo_top_bar.dart | 24 ++- lib/widgets/settings/settings_appearance.dart | 61 ++++++ lib/widgets/shell/shell_common.dart | 32 ++- test/widgets/avatar_menu_test.dart | 177 +++++++++++++++++ 11 files changed, 550 insertions(+), 25 deletions(-) create mode 100644 lib/widgets/avatar_menu.dart create mode 100644 test/widgets/avatar_menu_test.dart diff --git a/.claude/context/design-system/DESIGN_SYSTEM.md b/.claude/context/design-system/DESIGN_SYSTEM.md index 046016f..9e689ad 100644 --- a/.claude/context/design-system/DESIGN_SYSTEM.md +++ b/.claude/context/design-system/DESIGN_SYSTEM.md @@ -280,10 +280,12 @@ Mobile always has **5 pinned tabs in the bottom bar**. The rest go in the drawer | 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, `search`, avatar | +| Top bar | 44px row: `menu` on the left; `plus` and avatar on the right (all accent-text). Then a 34px large title, then the search field. | 64px: `menu`, 22px title, avatar - see "Search" below for what replaces `search` there, and "Navigation menu" for `menu` | | Bottom bar | Surface fill, 1px top line. Icon 24 above a 10px label. Active: accent-text. Idle: fg-3. 34px home indicator below. | Surface fill, 80px. Icon 24 above a 12px label (label space is always reserved, just invisible when idle, so nothing shifts on selection). The active icon sits inside a 56×32 accent-soft pill that slides between tabs as one shared indicator, rather than popping in/out per tab. 20px gesture bar below. | | Bottom bar - 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. | diff --git a/.claude/context/styling.md b/.claude/context/styling.md index 569d345..c72088c 100644 --- a/.claude/context/styling.md +++ b/.claude/context/styling.md @@ -221,6 +221,26 @@ blocks are noted where they matter: `onSearchTap`) and lowers `SettingsController.maxVisibleTabs` by one - use that getter, not `defaultMaxVisibleTabs` from `models/app_tab.dart`, anywhere that needs the *current* cap on regular tabs. +- `SettingsController.navMenuStyle` (`NooNavMenuStyle.drawer`/`avatarMenu`, + Settings → Appearance → "Navigation menu") picks what opens hidden tabs + + Settings on mobile: the original hamburger-opens-`AppDrawer` pattern, or + the avatar button opens [`showAvatarMenu`](../../lib/widgets/avatar_menu.dart) + instead. Wired through `AppTopBar`→`NooTopBar.onMenu` (null in `avatarMenu` + mode - no menu icon renders at all, see `NooTopBar`'s `lead` logic) and + `ShellAvatarButton`'s new `onTap`/`label` overrides (`shell_common.dart`) - + a caller passing a custom `onTap` *must* also pass a matching `label`, or + the tooltip/semantics still say "Accounts" for a button that no longer + opens the account switcher. `showAvatarMenu` is this app's first use of + `showGeneralDialog` directly (`barrierColor: Colors.transparent` + + `barrierDismissible: true` for a non-dimming click-outside-to-close menu, + not a modal flow) - there's no existing anchored-popup primitive here + (`PopupMenuButton`'s own width doesn't stretch to a full content column), + so don't reach for `showNooSheet`/`showNooDialog` for something shaped + like this. Also added `NooTopBar.androidTitleReplacement`: 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) replaces the + title in its flexible slot instead of sitting beside it as a bare icon. - [`SyncedHeaderScaffold`](../../lib/widgets/synced_header_scaffold.dart) — the pull-to-sync `CustomScrollView` header with the persistent sync-status chip and pull-to-refresh gesture/spinner. Every screen (including diff --git a/lib/main.dart b/lib/main.dart index 8de4d80..15b9448 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -423,6 +423,7 @@ class _MainShellViewState extends State { style: navStyle, tab: tab, searchInBottomBar: showBottomBarSearch, + navMenuStyle: settings.navMenuStyle, ), ), ) diff --git a/lib/providers/settings_controller.dart b/lib/providers/settings_controller.dart index 0220cc8..fed1cc3 100644 --- a/lib/providers/settings_controller.dart +++ b/lib/providers/settings_controller.dart @@ -32,6 +32,7 @@ class SettingsController extends ChangeNotifier { static const _prefSwipeLeftAction = 'ui_swipe_left_action'; static const _prefSwipeRightAction = 'ui_swipe_right_action'; static const _prefBottomBarStyle = 'ui_bottom_bar_style'; + static const _prefNavMenuStyle = 'ui_nav_menu_style'; static const _prefSearchInBottomBar = 'ui_search_in_bottom_bar'; static const _prefAmoledDark = 'ui_amoled_dark'; static const _prefMediaProgressBarStyle = 'ui_media_progress_bar_style'; @@ -43,6 +44,7 @@ class SettingsController extends ChangeNotifier { Color _seedColor = AppTheme.defaultAccent; ThemeMode _themeMode = ThemeMode.system; NooBottomBarStyle _bottomBarStyle = NooBottomBarStyle.attached; + NooNavMenuStyle _navMenuStyle = NooNavMenuStyle.drawer; bool _searchInBottomBar = false; bool _useDynamicColor = true; bool _amoledDark = false; @@ -86,6 +88,7 @@ class SettingsController extends ChangeNotifier { Color get seedColor => _seedColor; ThemeMode get themeMode => _themeMode; NooBottomBarStyle get bottomBarStyle => _bottomBarStyle; + NooNavMenuStyle get navMenuStyle => _navMenuStyle; bool get searchInBottomBar => _searchInBottomBar; bool get useDynamicColor => _useDynamicColor; bool get amoledDark => _amoledDark; @@ -137,6 +140,13 @@ class SettingsController extends ChangeNotifier { orElse: () => _bottomBarStyle, ); } + final navMenuStyleName = prefs.getString(_prefNavMenuStyle); + if (navMenuStyleName != null) { + _navMenuStyle = NooNavMenuStyle.values.firstWhere( + (s) => s.name == navMenuStyleName, + orElse: () => _navMenuStyle, + ); + } _searchInBottomBar = prefs.getBool(_prefSearchInBottomBar) ?? _searchInBottomBar; _useDynamicColor = @@ -287,6 +297,13 @@ class SettingsController extends ChangeNotifier { _prefsFuture.then((p) => p.setString(_prefBottomBarStyle, style.name)); } + void setNavMenuStyle(NooNavMenuStyle style) { + if (_navMenuStyle == style) return; + _navMenuStyle = style; + notifyListeners(); + _prefsFuture.then((p) => p.setString(_prefNavMenuStyle, style.name)); + } + /// Turning this on lowers [maxVisibleTabs] by one, so re-enforces the cap /// immediately in case the user already has a full 5 regular tabs pinned /// - same cleanup [_enforceMaxVisibleTabs] already does for a fresh diff --git a/lib/widgets/app_top_bar.dart b/lib/widgets/app_top_bar.dart index 0c46fa7..a3fa58e 100644 --- a/lib/widgets/app_top_bar.dart +++ b/lib/widgets/app_top_bar.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../models/app_tab.dart'; +import 'avatar_menu.dart'; import 'create_menu.dart'; import 'noo/nav/noo_top_bar.dart'; import 'shell/shell_common.dart'; @@ -13,8 +14,11 @@ import 'shell/shell_common.dart'; /// - iOS: `menu` leading, `plus` (Files only - no tab besides Files has a /// create/upload flow) and the account avatar trailing, then the large /// title, then an inline search field that opens [SearchView]. -/// - Android: `menu`, title, `search` and avatar actions. Upload has no -/// top-bar icon here - it's the [NooFab] `MainShellView` shows instead. +/// - Android: `menu`, title, avatar actions. Upload has no top-bar icon +/// here - it's the [NooFab] `MainShellView` shows instead. When search +/// isn't in the bottom bar, the title itself is replaced by an inline +/// search-field-styled launcher (`androidTitleReplacement`) rather than +/// a bare search icon sitting beside it. /// /// [style] is resolved by the caller (`NooLayout.navStyle`) rather than /// read from context here, since [preferredSize] is a plain getter with no @@ -22,17 +26,24 @@ import 'shell/shell_common.dart'; /// [searchInBottomBar] is [SettingsController.searchInBottomBar], passed in /// for the same reason: it hides this bar's own search field/icon so /// there's only ever one search entry point on screen once the bottom bar -/// has taken it over. +/// has taken it over. [navMenuStyle] is +/// [SettingsController.navMenuStyle]: [NooNavMenuStyle.drawer] wires the +/// `menu` icon to `Scaffold.of(context).openDrawer()` as before; +/// [NooNavMenuStyle.avatarMenu] drops that icon (`onMenu: null`) and makes +/// the avatar button open `showAvatarMenu` instead of the account +/// switcher. class AppTopBar extends StatelessWidget implements PreferredSizeWidget { final NooNavStyle style; final AppTab tab; final bool searchInBottomBar; + final NooNavMenuStyle navMenuStyle; const AppTopBar({ super.key, required this.style, required this.tab, this.searchInBottomBar = false, + this.navMenuStyle = NooNavMenuStyle.drawer, }); bool get _ios => style == NooNavStyle.ios; @@ -53,21 +64,31 @@ class AppTopBar extends StatelessWidget implements PreferredSizeWidget { return NooTopBar( style: style, title: tab.label, - onMenu: () => Scaffold.of(context).openDrawer(), + onMenu: navMenuStyle == NooNavMenuStyle.drawer + ? () => Scaffold.of(context).openDrawer() + : null, + // Android has no large title to put a second search row under the + // way iOS does (`search:` below) - an inline bar replaces the title + // itself instead of sitting beside it as a plain icon action. + androidTitleReplacement: !_ios && _showSearch + ? const ShellSearchLauncher() + : null, actions: [ - if (!_ios && _showSearch) - NooTopBarButton( - icon: LucideIcons.search, - tooltip: 'Search', - onTap: () => openSearch(context), - ), if (_ios && tab == AppTab.files) NooTopBarButton( icon: LucideIcons.plus, tooltip: 'Create', onTap: () => showCreateMenu(context), ), - ShellAvatarButton(hitBox: _ios ? 44 : 48), + ShellAvatarButton( + hitBox: _ios ? 44 : 48, + onTap: navMenuStyle == NooNavMenuStyle.avatarMenu + ? () => showAvatarMenu(context) + : null, + label: navMenuStyle == NooNavMenuStyle.avatarMenu + ? 'Menu' + : 'Accounts', + ), ], search: _ios && _showSearch ? const ShellSearchLauncher(ios: true) : null, ); diff --git a/lib/widgets/avatar_menu.dart b/lib/widgets/avatar_menu.dart new file mode 100644 index 0000000..dccd3b7 --- /dev/null +++ b/lib/widgets/avatar_menu.dart @@ -0,0 +1,182 @@ +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:provider/provider.dart'; +import '../models/app_tab.dart'; +import '../providers/session_controller.dart'; +import '../providers/settings_controller.dart'; +import '../providers/trash_controller.dart'; +import '../theme/design_tokens.dart'; +import 'noo/core/noo_avatar.dart'; +import 'noo/core/noo_badge.dart'; +import 'noo/lists/noo_settings_row.dart'; +import 'noo/nav/noo_top_bar.dart'; +import 'noo/noo_layout.dart'; +import 'shell/shell_common.dart'; + +/// The dropdown [ShellAvatarButton] opens when +/// [SettingsController.navMenuStyle] is [NooNavMenuStyle.avatarMenu] - +/// everything [AppDrawer] holds beyond its storage bar (hidden tabs, +/// Settings), reached from the avatar instead of a hamburger-triggered +/// left `Drawer`. Built with `showGeneralDialog` rather than a `Drawer`/ +/// `showNooSheet`/`showNooDialog` - this needs a transparent (non-dimming) +/// barrier and a card anchored under the top bar rather than a modal +/// sheet/dialog, and there's no existing anchored-popup primitive in this +/// app to reuse (`PopupMenuButton`'s own width doesn't stretch to the full +/// content column the way this needs to). +Future showAvatarMenu(BuildContext context) { + final navStyle = NooLayout.navStyle(context); + return showGeneralDialog( + context: context, + barrierColor: Colors.transparent, + barrierDismissible: true, + barrierLabel: 'Close menu', + transitionDuration: NooMotion.fast, + pageBuilder: (context, _, _) => _AvatarMenuContent(navStyle: navStyle), + transitionBuilder: (context, animation, _, child) => FadeTransition( + opacity: animation, + child: ScaleTransition( + alignment: Alignment.topRight, + scale: Tween( + begin: 0.96, + end: 1, + ).animate(CurvedAnimation(parent: animation, curve: NooMotion.ease)), + child: child, + ), + ), + ); +} + +class _AvatarMenuContent extends StatelessWidget { + final NooNavStyle navStyle; + + const _AvatarMenuContent({required this.navStyle}); + + @override + Widget build(BuildContext context) { + final colors = context.nooColors; + final session = context.watch(); + final settings = context.watch(); + final trashCount = context.watch().items.length; + final hiddenTabs = settings.tabOrder + .where((t) => settings.hiddenTabs.contains(t)) + .toList(); + // The row/avatar it opens from is always this tall + the status bar + // above it (the button that opens this can't be tapped while its own + // top bar is scrolled away, so it's always on-screen at this exact + // position when that happens) - matches `NooTopBar.preferredSize` + // exactly rather than a guessed constant. + final topBarHeight = NooTopBar( + style: navStyle, + title: '', + ).preferredSize.height; + + void closeAndOpenSettings() { + Navigator.pop(context); + openSettings(context); + } + + return Align( + alignment: Alignment.topCenter, + child: SafeArea( + bottom: false, + child: Padding( + padding: EdgeInsets.fromLTRB( + NooSpace.md, + topBarHeight, + NooSpace.md, + 0, + ), + child: SizedBox( + width: double.infinity, + child: Container( + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: colors.surface, + border: Border.all(color: colors.line), + borderRadius: BorderRadius.circular(NooRadii.card), + boxShadow: const [nooDialogShadow], + ), + child: Material( + color: Colors.transparent, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Avatar on the right, name/host on the left - mirrors the + // top bar's own right-aligned avatar exactly, so this + // header reads as a continuation of the button that + // opened it rather than a disconnected card. + InkWell( + onTap: closeAndOpenSettings, + child: Padding( + padding: const EdgeInsets.all(NooSpace.md), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + session.username, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: NooText.bodyL.copyWith( + fontWeight: FontWeight.w600, + color: colors.fg1, + ), + ), + const SizedBox(height: 2), + Text( + serverHost(session.serverUrl), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: NooText.meta.copyWith( + color: colors.fg3, + ), + ), + ], + ), + ), + const SizedBox(width: 12), + NooAvatar( + initials: accountInitial(session.username), + current: true, + size: 40, + ), + ], + ), + ), + ), + if (hiddenTabs.isNotEmpty) ...[ + Divider(height: 1, color: colors.line), + for (final tab in hiddenTabs) + NooSettingsRow( + icon: tab.icon, + label: Text(tab.label), + trailing: tab == AppTab.trash && trashCount > 0 + ? NooBadge( + tone: NooBadgeTone.accent, + child: Text('$trashCount'), + ) + : null, + onTap: () { + Navigator.pop(context); + settings.requestTab(tab); + }, + ), + ], + Divider(height: 1, color: colors.line), + NooSettingsRow( + icon: LucideIcons.settings, + label: const Text('Settings'), + onTap: closeAndOpenSettings, + ), + ], + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/widgets/noo/nav/noo_nav_style.dart b/lib/widgets/noo/nav/noo_nav_style.dart index d490fe6..dbffcb7 100644 --- a/lib/widgets/noo/nav/noo_nav_style.dart +++ b/lib/widgets/noo/nav/noo_nav_style.dart @@ -32,6 +32,20 @@ enum NooNavStyle { /// trailing padding to clear the bar (see `NooBottomBar.rowHeight`). enum NooBottomBarStyle { attached, floating } +/// Which widget opens the shell's navigation menu (hidden tabs + Settings) +/// on mobile - user-configurable in Settings (Appearance), same precedent +/// as [NooBottomBarStyle]. [drawer] is the original pattern: a `menu` +/// icon, top-left of the top bar, opens a left-edge `Drawer`. [avatarMenu] +/// drops that icon entirely and repurposes the avatar button - already +/// sitting top-right, already a tap target every user already knows - as +/// the one entry point instead, opening a dropdown anchored below it (see +/// `showAvatarMenu` in `widgets/avatar_menu.dart`) rather than a drawer +/// sliding from the opposite edge. Applies on both platforms, since +/// [NooTopBar]'s `onMenu`/avatar wiring is shared chrome, not +/// Android-specific - only [AppDrawer]'s left-`Drawer` convention is being +/// offered an alternative, not anything platform-only. +enum NooNavMenuStyle { drawer, avatarMenu } + /// One destination in a [NooBottomBar]. Data-agnostic on purpose - the app /// maps its own tab model (e.g. `AppTab`) onto these. @immutable diff --git a/lib/widgets/noo/nav/noo_top_bar.dart b/lib/widgets/noo/nav/noo_top_bar.dart index f62bd37..830231a 100644 --- a/lib/widgets/noo/nav/noo_top_bar.dart +++ b/lib/widgets/noo/nav/noo_top_bar.dart @@ -56,6 +56,15 @@ class NooTopBar extends StatelessWidget implements PreferredSizeWidget { /// iOS only: the search field shown below the large title. final Widget? search; + /// Android only: replaces the plain title text with an arbitrary widget + /// (e.g. a search-field-styled launcher) in that same flexible middle + /// slot, between [leading]/the menu button and [actions]. iOS already + /// has a dedicated [search] slot below its large title for this; Android + /// has no large title to put a second row under, so a caller that wants + /// an inline search bar there replaces the title with one instead of + /// adding a row. + final Widget? androidTitleReplacement; + /// Defaults to `bg` - the top bar sits on the screen background. final Color? backgroundColor; @@ -67,6 +76,7 @@ class NooTopBar extends StatelessWidget implements PreferredSizeWidget { this.onMenu, this.actions = const [], this.search, + this.androidTitleReplacement, this.backgroundColor, }); @@ -179,12 +189,14 @@ class NooTopBar extends StatelessWidget implements PreferredSizeWidget { children: [ ?lead, Expanded( - child: Text( - title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: NooText.title.copyWith(height: 1, color: colors.fg1), - ), + child: + androidTitleReplacement ?? + Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: NooText.title.copyWith(height: 1, color: colors.fg1), + ), ), ...actions, ], diff --git a/lib/widgets/settings/settings_appearance.dart b/lib/widgets/settings/settings_appearance.dart index a2c8a79..a0fd0da 100644 --- a/lib/widgets/settings/settings_appearance.dart +++ b/lib/widgets/settings/settings_appearance.dart @@ -51,6 +51,7 @@ class SettingsAppearanceSection extends StatelessWidget { children: [ _ThemeRow(settings: settings), _BottomBarStyleRow(settings: settings), + _NavMenuStyleRow(settings: settings), NooSettingsRow( icon: LucideIcons.search, label: const Text('Search in bottom bar'), @@ -230,6 +231,66 @@ class _BottomBarStyleRow extends StatelessWidget { } } +/// "Navigation menu" (Settings → Appearance): which widget opens hidden +/// tabs + Settings - the original `menu` icon that opens a left `Drawer`, +/// or the avatar button instead (opens `showAvatarMenu`, a dropdown +/// anchored under it) - see [NooNavMenuStyle]'s own doc comment for the +/// full reachability rationale (dropping the hamburger removes one of the +/// two top-corner targets the shell asks a thumb to reach, rather than +/// adding a third kind of chrome like a side rail would). +class _NavMenuStyleRow extends StatelessWidget { + final SettingsController settings; + + const _NavMenuStyleRow({required this.settings}); + + @override + Widget build(BuildContext context) { + final colors = context.nooColors; + return Material( + color: colors.surface, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: NooSpace.md, + vertical: 12, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(LucideIcons.panelLeft, size: 20, color: colors.fg2), + const SizedBox(width: 14), + Text( + 'Navigation menu', + style: NooText.bodyL.copyWith(color: colors.fg1), + ), + ], + ), + const SizedBox(height: 12), + NooSegmentedControl( + fill: true, + value: settings.navMenuStyle, + onChanged: settings.setNavMenuStyle, + options: const [ + NooSegmentOption( + value: NooNavMenuStyle.drawer, + icon: LucideIcons.menu, + label: 'Hamburger', + ), + NooSegmentOption( + value: NooNavMenuStyle.avatarMenu, + icon: LucideIcons.userRound, + label: 'Avatar', + ), + ], + ), + ], + ), + ), + ); + } +} + /// The current accent-color choice, shown as a small filled circle in the /// "Accent color" row - a wallpaper icon when following the OS's dynamic /// color instead of a fixed swatch. diff --git a/lib/widgets/shell/shell_common.dart b/lib/widgets/shell/shell_common.dart index 77b25d4..8bdc699 100644 --- a/lib/widgets/shell/shell_common.dart +++ b/lib/widgets/shell/shell_common.dart @@ -212,13 +212,31 @@ class _AccountSwitchRow extends StatelessWidget { } /// The current user's avatar in the mobile top bar. Tap opens the accounts -/// sheet ([showAccountSwitcher]); swiping up/down on it cycles to the -/// next/previous saved account immediately, with no confirmation - a -/// shortcut alongside the full switcher. +/// sheet ([showAccountSwitcher]) by default - or [onTap], when the caller +/// overrides it (`AppTopBar` does this for +/// `SettingsController.navMenuStyle`'s `avatarMenu` option, which +/// repurposes this same button to open `showAvatarMenu` instead; avoids +/// this file importing `widgets/avatar_menu.dart`, which itself imports +/// this file for `openSettings`/`accountInitial`/`serverHost`). Swiping +/// up/down on it cycles to the next/previous saved account immediately, +/// with no confirmation, regardless of [onTap] - a shortcut that stays +/// available either way. class ShellAvatarButton extends StatelessWidget { final double hitBox; + final VoidCallback? onTap; - const ShellAvatarButton({super.key, this.hitBox = 48}); + /// Describes whatever [onTap] actually does - defaults to "Accounts" + /// (the account switcher), but a caller overriding [onTap] should + /// override this too so the tooltip/semantics describe the real action + /// instead of a stale default. + final String label; + + const ShellAvatarButton({ + super.key, + this.hitBox = 48, + this.onTap, + this.label = 'Accounts', + }); Future _handleVerticalSwipe( BuildContext context, @@ -246,17 +264,17 @@ class ShellAvatarButton extends StatelessWidget { Widget build(BuildContext context) { final session = context.watch(); return Tooltip( - message: 'Accounts', + message: label, child: Semantics( button: true, - label: 'Accounts', + label: label, child: GestureDetector( onVerticalDragEnd: (details) => _handleVerticalSwipe(context, details), child: SizedBox.square( dimension: hitBox, child: InkResponse( - onTap: () => showAccountSwitcher(context), + onTap: onTap ?? () => showAccountSwitcher(context), radius: hitBox / 2, child: Center( child: NooAvatar( diff --git a/test/widgets/avatar_menu_test.dart b/test/widgets/avatar_menu_test.dart new file mode 100644 index 0000000..2d703fc --- /dev/null +++ b/test/widgets/avatar_menu_test.dart @@ -0,0 +1,177 @@ +import 'dart:convert'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:noo/models/app_tab.dart'; +import 'package:noo/models/saved_account.dart'; +import 'package:noo/providers/connectivity_controller.dart'; +import 'package:noo/providers/files_controller.dart'; +import 'package:noo/providers/session_controller.dart'; +import 'package:noo/providers/settings_controller.dart'; +import 'package:noo/providers/sync_status_controller.dart'; +import 'package:noo/providers/trash_controller.dart'; +import 'package:noo/theme/app_theme.dart'; +import 'package:noo/widgets/app_top_bar.dart'; +import 'package:noo/widgets/noo/nav/noo_top_bar.dart'; + +/// Covers `SettingsController.navMenuStyle`'s two options as wired through +/// `AppTopBar`/`ShellAvatarButton`: [NooNavMenuStyle.drawer] (today's +/// hamburger-opens-a-left-Drawer pattern, unchanged) vs +/// [NooNavMenuStyle.avatarMenu] (no hamburger at all - the avatar opens +/// `showAvatarMenu`'s dropdown instead of the account switcher). +void main() { + const secureStorageChannel = MethodChannel( + 'plugins.it_nomads.com/flutter_secure_storage', + ); + const connectivityChannel = MethodChannel( + 'dev.fluttercommunity.plus/connectivity', + ); + const connectivityStatusChannel = EventChannel( + 'dev.fluttercommunity.plus/connectivity_status', + ); + + const accountId = 'server_example_com__alice'; + const passwordKey = 'nc_app_password_$accountId'; + const password = 'app-password'; + + setUpAll(() { + GoogleFonts.config.allowRuntimeFetching = false; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(secureStorageChannel, (call) async { + if (call.method == 'readAll') { + return {passwordKey: password}; + } + if (call.method == 'read') { + final key = (call.arguments as Map)['key'] as String?; + return key == passwordKey ? password : null; + } + return null; + }); + // Offline, same as `account_view_test.dart`'s setup: keeps + // SessionController in a provisional login and stops + // FilesController/SyncStatusController/TrashController's network/timer + // machinery from hanging the test. + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(connectivityChannel, (call) async { + if (call.method == 'check') return ['none']; + return null; + }); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockStreamHandler( + connectivityStatusChannel, + MockStreamHandler.inline(onListen: (arguments, events) {}), + ); + }); + + setUp(() { + SharedPreferences.setMockInitialValues({ + 'account_migration_v1_done': true, + 'accounts_list': jsonEncode([ + const SavedAccount( + id: accountId, + serverUrl: 'https://server.example.com', + username: 'alice', + ).toJson(), + ]), + 'active_account_id': accountId, + }); + }); + + Future pumpTopBar( + WidgetTester tester, { + required NooNavMenuStyle navMenuStyle, + }) async { + await tester.binding.setSurfaceSize(const Size(400, 800)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider(create: (_) => ConnectivityController()), + ChangeNotifierProvider( + create: (context) => SessionController(context.read()), + ), + ChangeNotifierProvider(create: (_) => SettingsController()), + ChangeNotifierProvider( + create: (context) => FilesController(context.read()), + ), + ChangeNotifierProvider( + create: (context) => + SyncStatusController(context.read(), context.read()), + ), + ChangeNotifierProvider( + create: (context) => TrashController(context.read()), + ), + ], + child: MaterialApp( + theme: AppTheme.light(AppTheme.defaultAccent, useDynamicColor: false), + home: Scaffold( + drawer: const Drawer(child: Text('drawer open')), + appBar: AppTopBar( + style: NooNavStyle.android, + tab: AppTab.files, + navMenuStyle: navMenuStyle, + ), + body: const SizedBox(), + ), + ), + ), + ); + + // Not `pumpAndSettle` - the same reasoning as `account_view_test.dart`: + // the active account's dependent controllers start periodic + // timers/futures that would spin it forever. + for (var i = 0; i < 30; i++) { + await tester.pump(const Duration(milliseconds: 10)); + } + } + + testWidgets('avatarMenu style: no hamburger, avatar opens the dropdown', ( + tester, + ) async { + await pumpTopBar(tester, navMenuStyle: NooNavMenuStyle.avatarMenu); + + expect(find.byTooltip('Menu'), findsOneWidget); + expect(find.byIcon(LucideIcons.menu), findsNothing); + + await tester.tap(find.byTooltip('Menu')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + + // The dropdown's own header + Settings row - not the account + // switcher's "Add account"/"Manage accounts" rows. + expect(find.text('alice'), findsOneWidget); + expect(find.text('Settings'), findsOneWidget); + expect(find.text('Add account'), findsNothing); + }); + + testWidgets('drawer style (default): hamburger opens the real drawer', ( + tester, + ) async { + await pumpTopBar(tester, navMenuStyle: NooNavMenuStyle.drawer); + + expect(find.byTooltip('Menu'), findsOneWidget); + await tester.tap(find.byTooltip('Menu')); + await tester.pumpAndSettle(); + + expect(find.text('drawer open'), findsOneWidget); + }); + + testWidgets( + 'drawer style (default): avatar still opens the account switcher', + (tester) async { + await pumpTopBar(tester, navMenuStyle: NooNavMenuStyle.drawer); + + expect(find.byTooltip('Accounts'), findsOneWidget); + await tester.tap(find.byTooltip('Accounts')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + + expect(find.text('Add account'), findsOneWidget); + }, + ); +} From 9f47d0a3e395a853054fc93ba440125d78098485 Mon Sep 17 00:00:00 2001 From: ayushya Date: Wed, 30 Sep 2026 22:59:19 -0400 Subject: [PATCH 26/29] 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 --- .claude/context/architecture.md | 16 ++- .claude/context/styling.md | 11 +- lib/views/activity_view.dart | 19 ++- lib/views/favorites_view.dart | 19 ++- lib/views/files_view.dart | 32 +++-- lib/views/photos_view.dart | 19 ++- lib/views/recent_view.dart | 19 ++- lib/views/shares_view.dart | 19 ++- lib/views/trash_view.dart | 19 ++- lib/widgets/avatar_menu.dart | 49 ++++---- lib/widgets/tabs/tab_state_slivers.dart | 23 +++- test/widgets/tabs/tab_state_slivers_test.dart | 110 ++++++++++++++++++ 12 files changed, 280 insertions(+), 75 deletions(-) diff --git a/.claude/context/architecture.md b/.claude/context/architecture.md index bc60a55..cb533ce 100644 --- a/.claude/context/architecture.md +++ b/.claude/context/architecture.md @@ -297,13 +297,21 @@ bare system prompt gives no context on its own. matching `Scaffold.appBar`'s old `pickRequest == null` guard - see `buildAppTabView`'s doc comment. It sits above each tab's own pinned in-content header (the sort/filter controls row, or Files/Photos' - selection bar - see below); the two float/scroll independently. - `BottomNavBar` (`widgets/bottom_nav_bar.dart`) adapts - the pinned `AppTab`s onto `NooBottomBar`. `AppDrawer` + 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). + section yet). `SettingsController.navMenuStyle` (Settings → Appearance → + "Navigation menu") offers an alternative to the hamburger/drawer pair: + the avatar button opens `showAvatarMenu` (`widgets/avatar_menu.dart`) + instead, a dropdown holding the same hidden-tabs + Settings content - + see `styling.md`'s Gotchas for the wiring. - **Desktop:** a `NooSidebar` (account card, pinned tabs, divider, remaining tabs, storage meter, Settings) sits beside a `NooToolbar` (tab title, search, an "Upload" action on Files/Photos) over the same diff --git a/.claude/context/styling.md b/.claude/context/styling.md index c72088c..2e7ac7a 100644 --- a/.claude/context/styling.md +++ b/.claude/context/styling.md @@ -236,7 +236,16 @@ blocks are noted where they matter: not a modal flow) - there's no existing anchored-popup primitive here (`PopupMenuButton`'s own width doesn't stretch to a full content column), so don't reach for `showNooSheet`/`showNooDialog` for something shaped - like this. Also added `NooTopBar.androidTitleReplacement`: Android has no + like this. It's positioned just past the status bar (`SafeArea`'s own + inset, not the top bar's full height on top of that) so it covers the + top bar - including the tab title - rather than sitting below it, and + its card carries two stacked `boxShadow`s rather than just + `nooDialogShadow` alone: that one shadow's blur is wide and soft enough + to read as basically invisible on a small card over a dark theme's + near-black `bg` (a dark, diffuse shadow needs real density close to the + edge to be visible against an already-dark backdrop), so a second, + tighter, more opaque contact shadow underneath it gives real elevation + in both themes. Also added `NooTopBar.androidTitleReplacement`: 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) replaces the diff --git a/lib/views/activity_view.dart b/lib/views/activity_view.dart index d29eff1..ea91537 100644 --- a/lib/views/activity_view.dart +++ b/lib/views/activity_view.dart @@ -100,12 +100,19 @@ class ActivityView extends StatelessWidget { ...tabBottomInsetSlivers(context), ]; - final scrollView = CustomScrollView( - controller: scrollController, - // See files_view.dart's identical fix - without this, pull-to- - // refresh can't be triggered on an empty or single-item feed. - physics: const AlwaysScrollableScrollPhysics(), - slivers: contentSlivers, + // See files_view.dart's identical fix - without this, the sticky + // controls row rides up under the status bar once the floating top bar + // above it fully collapses. + final scrollView = SafeArea( + top: true, + bottom: false, + child: CustomScrollView( + controller: scrollController, + // See files_view.dart's identical fix - without this, pull-to- + // refresh can't be triggered on an empty or single-item feed. + physics: const AlwaysScrollableScrollPhysics(), + slivers: contentSlivers, + ), ); return ColoredBox( diff --git a/lib/views/favorites_view.dart b/lib/views/favorites_view.dart index 0608cb8..df68879 100644 --- a/lib/views/favorites_view.dart +++ b/lib/views/favorites_view.dart @@ -658,12 +658,19 @@ class _FavoritesViewState extends State { color: colors.accent, backgroundColor: colors.surface, onRefresh: favoritesController.fetchAll, - child: CustomScrollView( - controller: widget.scrollController, - // See files_view.dart's identical fix - without this, pull-to- - // refresh can't be triggered on an empty or single-item list. - physics: const AlwaysScrollableScrollPhysics(), - slivers: contentSlivers, + // See files_view.dart's identical fix - without this, the sticky + // controls row rides up under the status bar once the floating + // top bar above it fully collapses. + child: SafeArea( + top: true, + bottom: false, + child: CustomScrollView( + controller: widget.scrollController, + // See files_view.dart's identical fix - without this, pull-to- + // refresh can't be triggered on an empty or single-item list. + physics: const AlwaysScrollableScrollPhysics(), + slivers: contentSlivers, + ), ), ), ), diff --git a/lib/views/files_view.dart b/lib/views/files_view.dart index 0595328..02d2946 100644 --- a/lib/views/files_view.dart +++ b/lib/views/files_view.dart @@ -853,14 +853,30 @@ class _FilesViewState extends State { unawaited(sync.syncOnPull()); return browser.reload(); }, - child: CustomScrollView( - controller: widget.scrollController, - // Pull-to-refresh needs a scroll physics that allows dragging - // past the edge even when content doesn't fill the viewport - - // an empty or single-item list otherwise can't be pulled at all - // under the platform default physics. - physics: const AlwaysScrollableScrollPhysics(), - slivers: contentSlivers, + // `topBarSliver`'s floating header can collapse all the way to + // zero height (fully scrolled away), at which point the sticky + // controls row right below it in `contentSlivers` would otherwise + // ride up underneath the status bar instead of stopping below it + // - the floating top bar used to be the only thing reserving that + // space (via its own internal `SafeArea`), and that reservation + // disappears along with it once it's fully hidden. Wrapping the + // whole scroll view keeps the inset outside the scrolling region + // entirely, so it's never implicated in the floating header's own + // collapse/reveal math - safe to apply unconditionally, since + // desktop's `MediaQuery.padding.top` is 0 anyway (no topBar / no + // status bar there). + child: SafeArea( + top: true, + bottom: false, + child: CustomScrollView( + controller: widget.scrollController, + // Pull-to-refresh needs a scroll physics that allows dragging + // past the edge even when content doesn't fill the viewport - + // an empty or single-item list otherwise can't be pulled at + // all under the platform default physics. + physics: const AlwaysScrollableScrollPhysics(), + slivers: contentSlivers, + ), ), ), ), diff --git a/lib/views/photos_view.dart b/lib/views/photos_view.dart index b482446..7b02541 100644 --- a/lib/views/photos_view.dart +++ b/lib/views/photos_view.dart @@ -369,12 +369,19 @@ class _PhotosViewState extends State { color: colors.accent, backgroundColor: colors.surface, onRefresh: photosController.fetchAllMedia, - child: CustomScrollView( - controller: widget.scrollController, - // See files_view.dart's identical fix - without this, pull-to- - // refresh can't be triggered on an empty or single-item list. - physics: const AlwaysScrollableScrollPhysics(), - slivers: contentSlivers, + // See files_view.dart's identical fix - without this, the sticky + // controls row rides up under the status bar once the floating + // top bar above it fully collapses. + child: SafeArea( + top: true, + bottom: false, + child: CustomScrollView( + controller: widget.scrollController, + // See files_view.dart's identical fix - without this, pull-to- + // refresh can't be triggered on an empty or single-item list. + physics: const AlwaysScrollableScrollPhysics(), + slivers: contentSlivers, + ), ), ), ), diff --git a/lib/views/recent_view.dart b/lib/views/recent_view.dart index 7a6fa9f..ce2b82d 100644 --- a/lib/views/recent_view.dart +++ b/lib/views/recent_view.dart @@ -88,12 +88,19 @@ class _RecentViewState extends State { color: colors.accent, backgroundColor: colors.surface, onRefresh: recent.fetchAll, - child: CustomScrollView( - controller: widget.scrollController, - // See files_view.dart's identical fix - without this, pull-to- - // refresh can't be triggered on an empty or single-item list. - physics: const AlwaysScrollableScrollPhysics(), - slivers: contentSlivers, + // See files_view.dart's identical fix - without this, the sticky + // controls row rides up under the status bar once the floating top + // bar above it fully collapses. + child: SafeArea( + top: true, + bottom: false, + child: CustomScrollView( + controller: widget.scrollController, + // See files_view.dart's identical fix - without this, pull-to- + // refresh can't be triggered on an empty or single-item list. + physics: const AlwaysScrollableScrollPhysics(), + slivers: contentSlivers, + ), ), ), ); diff --git a/lib/views/shares_view.dart b/lib/views/shares_view.dart index 7c85a43..ecb7f52 100644 --- a/lib/views/shares_view.dart +++ b/lib/views/shares_view.dart @@ -134,12 +134,19 @@ class _SharesViewState extends State { color: colors.accent, backgroundColor: colors.surface, onRefresh: sharesController.fetchAll, - child: CustomScrollView( - controller: widget.scrollController, - // See files_view.dart's identical fix - without this, pull-to- - // refresh can't be triggered on an empty or single-item list. - physics: const AlwaysScrollableScrollPhysics(), - slivers: contentSlivers, + // See files_view.dart's identical fix - without this, the sticky + // controls row rides up under the status bar once the floating top + // bar above it fully collapses. + child: SafeArea( + top: true, + bottom: false, + child: CustomScrollView( + controller: widget.scrollController, + // See files_view.dart's identical fix - without this, pull-to- + // refresh can't be triggered on an empty or single-item list. + physics: const AlwaysScrollableScrollPhysics(), + slivers: contentSlivers, + ), ), ), ); diff --git a/lib/views/trash_view.dart b/lib/views/trash_view.dart index ffad901..c48df6c 100644 --- a/lib/views/trash_view.dart +++ b/lib/views/trash_view.dart @@ -103,12 +103,19 @@ class _TrashViewState extends State { color: colors.accent, backgroundColor: colors.surface, onRefresh: trashController.fetchAll, - child: CustomScrollView( - controller: widget.scrollController, - // See files_view.dart's identical fix - without this, pull-to- - // refresh can't be triggered on an empty or single-item list. - physics: const AlwaysScrollableScrollPhysics(), - slivers: contentSlivers, + // See files_view.dart's identical fix - without this, the sticky + // controls row rides up under the status bar once the floating top + // bar above it fully collapses. + child: SafeArea( + top: true, + bottom: false, + child: CustomScrollView( + controller: widget.scrollController, + // See files_view.dart's identical fix - without this, pull-to- + // refresh can't be triggered on an empty or single-item list. + physics: const AlwaysScrollableScrollPhysics(), + slivers: contentSlivers, + ), ), ), ); diff --git a/lib/widgets/avatar_menu.dart b/lib/widgets/avatar_menu.dart index dccd3b7..1f1f23a 100644 --- a/lib/widgets/avatar_menu.dart +++ b/lib/widgets/avatar_menu.dart @@ -9,8 +9,6 @@ import '../theme/design_tokens.dart'; import 'noo/core/noo_avatar.dart'; import 'noo/core/noo_badge.dart'; import 'noo/lists/noo_settings_row.dart'; -import 'noo/nav/noo_top_bar.dart'; -import 'noo/noo_layout.dart'; import 'shell/shell_common.dart'; /// The dropdown [ShellAvatarButton] opens when @@ -24,14 +22,13 @@ import 'shell/shell_common.dart'; /// app to reuse (`PopupMenuButton`'s own width doesn't stretch to the full /// content column the way this needs to). Future showAvatarMenu(BuildContext context) { - final navStyle = NooLayout.navStyle(context); return showGeneralDialog( context: context, barrierColor: Colors.transparent, barrierDismissible: true, barrierLabel: 'Close menu', transitionDuration: NooMotion.fast, - pageBuilder: (context, _, _) => _AvatarMenuContent(navStyle: navStyle), + pageBuilder: (context, _, _) => const _AvatarMenuContent(), transitionBuilder: (context, animation, _, child) => FadeTransition( opacity: animation, child: ScaleTransition( @@ -47,9 +44,7 @@ Future showAvatarMenu(BuildContext context) { } class _AvatarMenuContent extends StatelessWidget { - final NooNavStyle navStyle; - - const _AvatarMenuContent({required this.navStyle}); + const _AvatarMenuContent(); @override Widget build(BuildContext context) { @@ -60,15 +55,6 @@ class _AvatarMenuContent extends StatelessWidget { final hiddenTabs = settings.tabOrder .where((t) => settings.hiddenTabs.contains(t)) .toList(); - // The row/avatar it opens from is always this tall + the status bar - // above it (the button that opens this can't be tapped while its own - // top bar is scrolled away, so it's always on-screen at this exact - // position when that happens) - matches `NooTopBar.preferredSize` - // exactly rather than a guessed constant. - final topBarHeight = NooTopBar( - style: navStyle, - title: '', - ).preferredSize.height; void closeAndOpenSettings() { Navigator.pop(context); @@ -77,15 +63,15 @@ class _AvatarMenuContent extends StatelessWidget { return Align( alignment: Alignment.topCenter, + // Just the status-bar inset, not the top bar's own height on top of + // it - the card covers the top bar (title included) rather than + // sitting below it, so opening it reads as the avatar growing into + // this instead of a separate element appearing underneath the row + // it came from. child: SafeArea( bottom: false, child: Padding( - padding: EdgeInsets.fromLTRB( - NooSpace.md, - topBarHeight, - NooSpace.md, - 0, - ), + padding: const EdgeInsets.symmetric(horizontal: NooSpace.md), child: SizedBox( width: double.infinity, child: Container( @@ -94,7 +80,24 @@ class _AvatarMenuContent extends StatelessWidget { color: colors.surface, border: Border.all(color: colors.line), borderRadius: BorderRadius.circular(NooRadii.card), - boxShadow: const [nooDialogShadow], + // `nooDialogShadow` alone is a wide, soft, fairly faint + // shadow - built for a desktop dialog with plenty of room + // to fall off into. On a small card over a dark theme's + // near-black `bg`, that falloff is too gradual to read as + // elevation at all (a dark shadow needs real density close + // to the edge to be visible against an already-dark + // backdrop). A second, tighter, more opaque contact shadow + // underneath it gives an immediate value-step right at the + // card's edge in both themes, with the soft one still + // doing the wider ambient falloff on top. + boxShadow: const [ + BoxShadow( + color: Color(0x40000000), + blurRadius: 12, + offset: Offset(0, 4), + ), + nooDialogShadow, + ], ), child: Material( color: Colors.transparent, diff --git a/lib/widgets/tabs/tab_state_slivers.dart b/lib/widgets/tabs/tab_state_slivers.dart index 6211445..d1a4e89 100644 --- a/lib/widgets/tabs/tab_state_slivers.dart +++ b/lib/widgets/tabs/tab_state_slivers.dart @@ -133,14 +133,31 @@ List tabBottomInsetSlivers(BuildContext context) => [ /// [SliverFloatingHeader] sizes itself from [topBar]'s own natural layout /// (like `SliverToBoxAdapter`) rather than a fixed extent declared up /// front - so [topBar]'s own internal `SafeArea` (see `NooTopBar`'s doc -/// comment) already accounts for the status-bar inset correctly, with no -/// extra height math needed here (unlike building this on the general- -/// purpose `SliverPersistentHeader` would have required). +/// comment) already accounts for the status-bar inset correctly while +/// [topBar] itself is visible, with no extra height math needed here +/// (unlike building this on the general-purpose `SliverPersistentHeader` +/// would have required). /// /// Sits above a tab's own pinned in-content header (built with /// [StickyHeaderDelegate] - the sort/filter controls row, or the selection /// bar that replaces it) - put this sliver first in `contentSlivers` so /// that header stays exactly where it already is, independent of whether /// [topBar] is currently shown or scrolled away. +/// +/// That pinned header needs its OWN protection from the status bar too, +/// though: [topBar]'s `SafeArea` only reserves space while [topBar] has +/// some height to put it in - once it's fully collapsed (0 height, [topBar] +/// scrolled all the way away), that reservation disappears with it, and +/// the pinned header would ride up underneath the status bar instead of +/// stopping below it (a real bug this shipped with once already - caught +/// by `tab_state_slivers_test.dart`'s regression test for it). Every tab +/// view wraps its whole `CustomScrollView` (this sliver, the pinned header, +/// and everything else) in `SafeArea(top: true, bottom: false, ...)` to +/// fix this - that reserves the inset outside the scrolling/collapsing +/// region entirely, so it's never implicated in this sliver's own +/// collapse math regardless of [topBar]'s current state. Flutter's +/// `SafeArea` nesting means this doesn't double the inset: the outer one +/// zeroes `MediaQuery.padding.top` for everything below it, so [topBar]'s +/// own inner `SafeArea` sees nothing left to add. Widget topBarSliver(PreferredSizeWidget topBar) => SliverFloatingHeader(child: topBar); diff --git a/test/widgets/tabs/tab_state_slivers_test.dart b/test/widgets/tabs/tab_state_slivers_test.dart index 1d989ad..6810e2e 100644 --- a/test/widgets/tabs/tab_state_slivers_test.dart +++ b/test/widgets/tabs/tab_state_slivers_test.dart @@ -32,6 +32,38 @@ class _FakeTopBar extends StatelessWidget implements PreferredSizeWidget { /// tree is currently pumped. Finder _header() => find.byType(SliverFloatingHeader, skipOffstage: false); +/// A minimal stand-in for each tab's own pinned sort/filter row +/// (`StickyHeaderDelegate`) - just needs to be a `SliverPersistentHeader` +/// with `pinned: true` below `topBarSliver`, same contract every real tab +/// view uses. +class _FakeStickyHeader extends StatelessWidget { + const _FakeStickyHeader(); + + static const double height = 48; + + @override + Widget build(BuildContext context) => const SizedBox( + height: height, + child: ColoredBox(color: Colors.red), + ); +} + +class _FakeStickyHeaderDelegate extends SliverPersistentHeaderDelegate { + @override + double get minExtent => _FakeStickyHeader.height; + @override + double get maxExtent => _FakeStickyHeader.height; + @override + Widget build( + BuildContext context, + double shrinkOffset, + bool overlapsContent, + ) => const _FakeStickyHeader(); + @override + bool shouldRebuild(covariant SliverPersistentHeaderDelegate oldDelegate) => + false; +} + Future _pumpHost(WidgetTester tester, ScrollController controller) { return tester.pumpWidget( MaterialApp( @@ -140,4 +172,82 @@ void main() { ); }, ); + + testWidgets( + 'a pinned header below topBarSliver stays clear of the status bar once ' + 'the floating bar fully collapses (regression: it used to ride up ' + 'underneath the status bar once the only thing reserving that space ' + "disappeared along with the bar's own height)", + (tester) async { + final controller = ScrollController(); + addTearDown(controller.dispose); + const statusBarHeight = 40.0; + + await tester.pumpWidget( + MediaQuery( + data: const MediaQueryData( + padding: EdgeInsets.only(top: statusBarHeight), + ), + child: MaterialApp( + theme: nooTheme(Brightness.light), + home: Scaffold( + // The fix under test: wrapping the scroll view (not just the + // top bar) in `SafeArea(top: true)` reserves the status-bar + // inset outside the scrolling/collapsing region entirely, so + // it's never implicated in `topBarSliver`'s own collapse math. + body: SafeArea( + top: true, + bottom: false, + child: CustomScrollView( + controller: controller, + slivers: [ + topBarSliver(const _FakeTopBar()), + SliverPersistentHeader( + pinned: true, + delegate: _FakeStickyHeaderDelegate(), + ), + SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) => + SizedBox(height: 60, child: Text('Item $index')), + childCount: 40, + ), + ), + ], + ), + ), + ), + ), + ), + ); + + // Fully visible before any scroll - right below the reserved inset. + expect( + tester.getTopLeft(find.byType(_FakeStickyHeader)).dy, + _FakeTopBar.height + statusBarHeight, + ); + + // Scroll well past the top bar's own height so it collapses fully. + final gesture = await tester.startGesture(const Offset(200, 300)); + await gesture.moveBy(const Offset(0, -300)); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + expect( + tester.renderObject(_header()).geometry!.paintExtent, + 0, + reason: + 'the top bar should be fully collapsed for this check to ' + 'mean anything', + ); + // The regression: without the fix, this would be 0 (or negative, + // scrolled up under the status bar) instead of sitting right at the + // reserved inset. + expect( + tester.getTopLeft(find.byType(_FakeStickyHeader)).dy, + statusBarHeight, + ); + }, + ); } From fb4829d03539e8b14e66fd4c9b19b3abf26791cd Mon Sep 17 00:00:00 2001 From: ayushya Date: Thu, 1 Oct 2026 02:16:30 -0400 Subject: [PATCH 27/29] Fix avatar menu's missing border and put the tab title beside search - 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 --- .claude/context/styling.md | 22 +++- lib/widgets/app_top_bar.dart | 12 +- lib/widgets/avatar_menu.dart | 160 +++++++++++++++------------ lib/widgets/noo/nav/noo_top_bar.dart | 54 +++++---- 4 files changed, 147 insertions(+), 101 deletions(-) diff --git a/.claude/context/styling.md b/.claude/context/styling.md index 2e7ac7a..fa09d7b 100644 --- a/.claude/context/styling.md +++ b/.claude/context/styling.md @@ -245,11 +245,23 @@ blocks are noted where they matter: near-black `bg` (a dark, diffuse shadow needs real density close to the edge to be visible against an already-dark backdrop), so a second, tighter, more opaque contact shadow underneath it gives real elevation - in both themes. Also added `NooTopBar.androidTitleReplacement`: 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) replaces the - title in its flexible slot instead of sitting beside it as a bare icon. + in both themes. The card's border can go missing wherever an opaque row + sits against it too (every corner but the header's, which has no + full-bleed fill of its own) if a `Container` combines `border` with its + own `clipBehavior` - that paints the border as part of the *outer* + decoration, then the clipped child on top right up to the same boundary, + with no gap for the border's own stroke to show through. Fixed the same + way any bordered-and-clipped `Container` should be: no `clipBehavior` on + the bordered `Container` itself, and a 1px-inset `ClipRRect` (radius + reduced by that same 1px) around the filled, clipped content instead, so + it never paints over the border. Also added + `NooTopBar.androidTitleTrailing`: Android has no large title to put a + second search row under the way iOS's `search:` slot does, so an inline + search bar (`AppTopBar` passes a plain `ShellSearchLauncher()` when + search isn't in the bottom bar) sits to the title's own right in that + flexible slot - `Flexible`, not `Expanded`, so the title still shrinks/ + ellipsizes if there's truly no room but doesn't claim more than it needs + otherwise - instead of the title being replaced by it. - [`SyncedHeaderScaffold`](../../lib/widgets/synced_header_scaffold.dart) — the pull-to-sync `CustomScrollView` header with the persistent sync-status chip and pull-to-refresh gesture/spinner. Every screen (including diff --git a/lib/widgets/app_top_bar.dart b/lib/widgets/app_top_bar.dart index a3fa58e..8fa27a4 100644 --- a/lib/widgets/app_top_bar.dart +++ b/lib/widgets/app_top_bar.dart @@ -16,9 +16,9 @@ import 'shell/shell_common.dart'; /// title, then an inline search field that opens [SearchView]. /// - Android: `menu`, title, avatar actions. Upload has no top-bar icon /// here - it's the [NooFab] `MainShellView` shows instead. When search -/// isn't in the bottom bar, the title itself is replaced by an inline -/// search-field-styled launcher (`androidTitleReplacement`) rather than -/// a bare search icon sitting beside it. +/// isn't in the bottom bar, an inline search-field-styled launcher +/// (`androidTitleTrailing`) sits to the title's own right, rather than a +/// bare search icon over in `actions`. /// /// [style] is resolved by the caller (`NooLayout.navStyle`) rather than /// read from context here, since [preferredSize] is a plain getter with no @@ -68,9 +68,9 @@ class AppTopBar extends StatelessWidget implements PreferredSizeWidget { ? () => Scaffold.of(context).openDrawer() : null, // Android has no large title to put a second search row under the - // way iOS does (`search:` below) - an inline bar replaces the title - // itself instead of sitting beside it as a plain icon action. - androidTitleReplacement: !_ios && _showSearch + // way iOS does (`search:` below) - an inline bar sits beside the + // title instead, rather than a plain search icon over in `actions`. + androidTitleTrailing: !_ios && _showSearch ? const ShellSearchLauncher() : null, actions: [ diff --git a/lib/widgets/avatar_menu.dart b/lib/widgets/avatar_menu.dart index 1f1f23a..2750243 100644 --- a/lib/widgets/avatar_menu.dart +++ b/lib/widgets/avatar_menu.dart @@ -75,9 +75,20 @@ class _AvatarMenuContent extends StatelessWidget { child: SizedBox( width: double.infinity, child: Container( - clipBehavior: Clip.antiAlias, + // No `color`/`clipBehavior` here - a `Container` with both a + // `border` and `clipBehavior` set paints the border as part + // of its *outer* decoration, then paints its (clipped) child + // on top right up to that same boundary with no gap for the + // border's own stroke width to show through. An opaque child + // touching that edge - every row below has its own full-bleed + // `Material` fill - then paints straight over the inner half + // of the border, which read as the border going missing + // specifically wherever an opaque row sits (every corner but + // the two by the header, which has no opaque fill of its + // own). The 1px `Padding` + inset `ClipRRect` below keeps the + // clipped, filled content entirely inside the border's own + // stroke instead of racing it for the same pixels. decoration: BoxDecoration( - color: colors.surface, border: Border.all(color: colors.line), borderRadius: BorderRadius.circular(NooRadii.card), // `nooDialogShadow` alone is a wide, soft, fairly faint @@ -99,81 +110,88 @@ class _AvatarMenuContent extends StatelessWidget { nooDialogShadow, ], ), - child: Material( - color: Colors.transparent, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - // Avatar on the right, name/host on the left - mirrors the - // top bar's own right-aligned avatar exactly, so this - // header reads as a continuation of the button that - // opened it rather than a disconnected card. - InkWell( - onTap: closeAndOpenSettings, - child: Padding( - padding: const EdgeInsets.all(NooSpace.md), - child: Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - session.username, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: NooText.bodyL.copyWith( - fontWeight: FontWeight.w600, - color: colors.fg1, - ), + child: Padding( + padding: const EdgeInsets.all(1), + child: ClipRRect( + borderRadius: BorderRadius.circular(NooRadii.card - 1), + child: Material( + color: colors.surface, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Avatar on the right, name/host on the left - mirrors the + // top bar's own right-aligned avatar exactly, so this + // header reads as a continuation of the button that + // opened it rather than a disconnected card. + InkWell( + onTap: closeAndOpenSettings, + child: Padding( + padding: const EdgeInsets.all(NooSpace.md), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text( + session.username, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: NooText.bodyL.copyWith( + fontWeight: FontWeight.w600, + color: colors.fg1, + ), + ), + const SizedBox(height: 2), + Text( + serverHost(session.serverUrl), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: NooText.meta.copyWith( + color: colors.fg3, + ), + ), + ], ), - const SizedBox(height: 2), - Text( - serverHost(session.serverUrl), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: NooText.meta.copyWith( - color: colors.fg3, - ), - ), - ], - ), + ), + const SizedBox(width: 12), + NooAvatar( + initials: accountInitial(session.username), + current: true, + size: 40, + ), + ], ), - const SizedBox(width: 12), - NooAvatar( - initials: accountInitial(session.username), - current: true, - size: 40, - ), - ], + ), ), - ), - ), - if (hiddenTabs.isNotEmpty) ...[ - Divider(height: 1, color: colors.line), - for (final tab in hiddenTabs) + if (hiddenTabs.isNotEmpty) ...[ + Divider(height: 1, color: colors.line), + for (final tab in hiddenTabs) + NooSettingsRow( + icon: tab.icon, + label: Text(tab.label), + trailing: tab == AppTab.trash && trashCount > 0 + ? NooBadge( + tone: NooBadgeTone.accent, + child: Text('$trashCount'), + ) + : null, + onTap: () { + Navigator.pop(context); + settings.requestTab(tab); + }, + ), + ], + Divider(height: 1, color: colors.line), NooSettingsRow( - icon: tab.icon, - label: Text(tab.label), - trailing: tab == AppTab.trash && trashCount > 0 - ? NooBadge( - tone: NooBadgeTone.accent, - child: Text('$trashCount'), - ) - : null, - onTap: () { - Navigator.pop(context); - settings.requestTab(tab); - }, + icon: LucideIcons.settings, + label: const Text('Settings'), + onTap: closeAndOpenSettings, ), - ], - Divider(height: 1, color: colors.line), - NooSettingsRow( - icon: LucideIcons.settings, - label: const Text('Settings'), - onTap: closeAndOpenSettings, + ], ), - ], + ), ), ), ), diff --git a/lib/widgets/noo/nav/noo_top_bar.dart b/lib/widgets/noo/nav/noo_top_bar.dart index 830231a..b739141 100644 --- a/lib/widgets/noo/nav/noo_top_bar.dart +++ b/lib/widgets/noo/nav/noo_top_bar.dart @@ -56,14 +56,15 @@ class NooTopBar extends StatelessWidget implements PreferredSizeWidget { /// iOS only: the search field shown below the large title. final Widget? search; - /// Android only: replaces the plain title text with an arbitrary widget - /// (e.g. a search-field-styled launcher) in that same flexible middle - /// slot, between [leading]/the menu button and [actions]. iOS already - /// has a dedicated [search] slot below its large title for this; Android - /// has no large title to put a second row under, so a caller that wants - /// an inline search bar there replaces the title with one instead of - /// adding a row. - final Widget? androidTitleReplacement; + /// Android only: an extra widget (e.g. a search-field-styled launcher) in + /// the flexible middle slot, to the title's own right - [title] keeps + /// its natural (shrink-to-fit, ellipsized) width on the left, and this + /// takes whatever space is left rather than replacing it. iOS already has + /// a dedicated [search] slot below its large title for this; Android has + /// no large title to put a second row under, so a caller that wants an + /// inline search bar there puts it beside the title instead of adding a + /// row. + final Widget? androidTitleTrailing; /// Defaults to `bg` - the top bar sits on the screen background. final Color? backgroundColor; @@ -76,7 +77,7 @@ class NooTopBar extends StatelessWidget implements PreferredSizeWidget { this.onMenu, this.actions = const [], this.search, - this.androidTitleReplacement, + this.androidTitleTrailing, this.backgroundColor, }); @@ -188,16 +189,31 @@ class NooTopBar extends StatelessWidget implements PreferredSizeWidget { spacing: 4, children: [ ?lead, - Expanded( - child: - androidTitleReplacement ?? - Text( - title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: NooText.title.copyWith(height: 1, color: colors.fg1), - ), - ), + // [androidTitleTrailing]'s own doc comment explains why this + // isn't a straight replacement: the title keeps its natural + // width (`Flexible`, not `Expanded` - it still shrinks/ + // ellipsizes if there's truly no room, but doesn't force the + // row that wide otherwise) and sits to the trailing content's + // left rather than being displaced by it. + if (androidTitleTrailing != null) ...[ + Flexible( + child: Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: NooText.title.copyWith(height: 1, color: colors.fg1), + ), + ), + Expanded(child: androidTitleTrailing!), + ] else + Expanded( + child: Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: NooText.title.copyWith(height: 1, color: colors.fg1), + ), + ), ...actions, ], ), From 16aadda7b8abd8ff2208397c05c8e4352e68210a Mon Sep 17 00:00:00 2001 From: ayushya Date: Thu, 1 Oct 2026 07:44:33 -0400 Subject: [PATCH 28/29] Make the top-bar search launcher fill the gap beside the tab title 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 --- lib/widgets/noo/nav/noo_top_bar.dart | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/lib/widgets/noo/nav/noo_top_bar.dart b/lib/widgets/noo/nav/noo_top_bar.dart index b739141..c7204a2 100644 --- a/lib/widgets/noo/nav/noo_top_bar.dart +++ b/lib/widgets/noo/nav/noo_top_bar.dart @@ -109,7 +109,7 @@ class NooTopBar extends StatelessWidget implements PreferredSizeWidget { final Widget body = _ios ? _buildIos(colors, lead) - : _buildAndroid(colors, lead); + : _buildAndroid(context, colors, lead); return _NooTopBarScope( style: style, @@ -177,7 +177,7 @@ class NooTopBar extends StatelessWidget implements PreferredSizeWidget { ); } - Widget _buildAndroid(NooColors colors, Widget? lead) { + Widget _buildAndroid(BuildContext context, NooColors colors, Widget? lead) { return SizedBox( height: _androidRow, child: Padding( @@ -189,14 +189,16 @@ class NooTopBar extends StatelessWidget implements PreferredSizeWidget { spacing: 4, children: [ ?lead, - // [androidTitleTrailing]'s own doc comment explains why this - // isn't a straight replacement: the title keeps its natural - // width (`Flexible`, not `Expanded` - it still shrinks/ - // ellipsizes if there's truly no room, but doesn't force the - // row that wide otherwise) and sits to the trailing content's - // left rather than being displaced by it. + // The title is a non-flex child capped at 40% of the screen + // width (so it keeps its natural width but still ellipsizes), + // leaving the `Expanded` trailing widget all the remaining + // space. A `Flexible` title would instead split the free space + // 1:1 with it, since both default to flex 1. if (androidTitleTrailing != null) ...[ - Flexible( + ConstrainedBox( + constraints: BoxConstraints( + maxWidth: MediaQuery.sizeOf(context).width * 0.4, + ), child: Text( title, maxLines: 1, From 1a232fc71a868704cdfe2dcf7ee0b492c5a5386e Mon Sep 17 00:00:00 2001 From: ayushya Date: Thu, 1 Oct 2026 09:14:26 -0400 Subject: [PATCH 29/29] Drop Play Console upload from the Release workflow The Release-* workflow now only builds Noo-.aab and attaches it to the Gitea release. The now-unused upload script is removed with it. Co-Authored-By: Claude Code --- .gitea/workflows/release.yml | 24 --------------- tool/upload_play_bundle.py | 58 ------------------------------------ 2 files changed, 82 deletions(-) delete mode 100644 tool/upload_play_bundle.py diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 5393067..c2efea5 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -77,27 +77,3 @@ jobs: uses: akkuman/gitea-release-action@v1 with: files: ${{ env.AAB_PATH }} - - - name: Decode Play Console service account key - # Skipped (rather than failing the whole run) until the secret is - # set up - see tool/upload_play_bundle.py's doc comment for what - # that setup involves and its one hard prerequisite: Google won't - # accept API calls for an app that's never had a single manual - # upload through the Play Console web UI. - if: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_BASE64 != '' }} - run: echo "$SERVICE_ACCOUNT_JSON_BASE64" | base64 -d > "${{ runner.temp }}/play-service-account.json" - env: - SERVICE_ACCOUNT_JSON_BASE64: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_BASE64 }} - - - name: Upload App Bundle to Play Console library - # Deliberately doesn't touch any release track - see - # tool/upload_play_bundle.py. Uploaded bundles show up under - # Release > App bundle explorer in Play Console, ready to promote - # to a track by hand whenever it's actually time to ship. - if: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_BASE64 != '' }} - run: | - pip install --quiet google-api-python-client google-auth - python3 tool/upload_play_bundle.py "${{ env.AAB_PATH }}" - env: - GOOGLE_PLAY_PACKAGE_NAME: dev.ayushya.noo - GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_PATH: ${{ runner.temp }}/play-service-account.json diff --git a/tool/upload_play_bundle.py b/tool/upload_play_bundle.py deleted file mode 100644 index d251142..0000000 --- a/tool/upload_play_bundle.py +++ /dev/null @@ -1,58 +0,0 @@ -#!/usr/bin/env python3 -"""Uploads an .aab to the Play Console's app bundle library, without -touching any release track. - -Play Console itself doesn't expose "just upload, don't release" as an -action - every path through the UI attaches a bundle to a track. The Play -Developer API does support it though: open an edit, upload the bundle, -commit the edit, and stop there. The result shows up under Release > App -bundle explorer and nowhere else - nothing goes out to testers or -production until it's promoted to a track by hand, whenever that's wanted. - -Needs: - GOOGLE_PLAY_PACKAGE_NAME e.g. dev.ayushya.noo - GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_PATH path to a service account key file - -The Play Developer API can't create an app's first release - Google -requires at least one manual upload through the Play Console web UI (to -any track) before it'll accept API calls for that app at all. - - python3 tool/upload_play_bundle.py path/to/app-release.aab -""" -import os -import sys - -from google.oauth2 import service_account -from googleapiclient.discovery import build - -SCOPES = ["https://www.googleapis.com/auth/androidpublisher"] - - -def main(bundle_path): - package_name = os.environ["GOOGLE_PLAY_PACKAGE_NAME"] - key_path = os.environ["GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_PATH"] - creds = service_account.Credentials.from_service_account_file( - key_path, scopes=SCOPES - ) - service = build("androidpublisher", "v3", credentials=creds) - - edit_id = service.edits().insert(body={}, packageName=package_name).execute()["id"] - - bundle = ( - service.edits() - .bundles() - .upload(editId=edit_id, packageName=package_name, media_body=bundle_path) - .execute() - ) - print("Uploaded versionCode %s" % bundle["versionCode"]) - - service.edits().commit(editId=edit_id, packageName=package_name).execute() - print( - "Committed - visible under Release > App bundle explorer in Play " - "Console. Not attached to any track, so nothing goes out to " - "testers or production until it's promoted by hand." - ) - - -if __name__ == "__main__": - main(sys.argv[1])