From 56784ef212fac79511bc97389e19d2302847b0c5 Mon Sep 17 00:00:00 2001 From: ayushya Date: Sat, 26 Sep 2026 01:51:58 -0400 Subject: [PATCH] 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 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 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])