diff --git a/.claude/context/design-system/DESIGN_SYSTEM.md b/.claude/context/design-system/DESIGN_SYSTEM.md index 05d61cf..48bfceb 100644 --- a/.claude/context/design-system/DESIGN_SYSTEM.md +++ b/.claude/context/design-system/DESIGN_SYSTEM.md @@ -321,8 +321,13 @@ Sidebar items are 38px tall with radius 12, an 18px icon and a 14/500 label. The (All, Photos, Videos - Camera isn't backed by real data, see the view's doc comment) lives inside the filter sheet as a full-width segmented control, the same "label, then the control on its own row below" shape - Settings' Theme row uses, not its own row of chips. Then the photo grid - grouped by month. + Settings' Theme row uses, not its own row of chips. Every icon (All, + Photos, Videos in the filter sheet; All, Files, Folders in Files' own + filter sheet, same treatment) always shows; only the *selected* segment + also shows its label (`NooSegmentedControl`'s `labelOnlySelected`) - the + same List/Grid-toggle-style pill both screens' filter sheets use, not + Files' old checkmark list or Photos' own always-labelled track. Then the + photo grid grouped by month. - **Favorites:** a file list with a star as the trailing icon. - **Offline:** a summary card (or 4 stat cards on desktop), then a list whose rows show sync status icons and status meta. - **Shares:** a segmented control (With you, By you, Links), then a list. The meta reads "Owner · Permission". @@ -344,12 +349,15 @@ Sidebar items are 38px tall with radius 12, an 18px icon and a 14/500 label. The slots, the rest sit behind "More". See §2 "Selection action bar". 9. Swipe on a file - Mobile uses one column of grouped lists, with a trailing jump rail (one - small icon per section, pinned where the scrollbar would sit) since the - full list runs long enough that scrolling to a specific section by hand - is slow - tapping an icon scrolls straight to that section. Desktop uses - a 2-column grid of cards with a 1px line and radius 20, wide enough to - see most sections without scrolling, so it doesn't get the rail. + Mobile uses one column of grouped lists, each individually collapsible + (tap its label, expanded by default - `NooGroupedList`'s `collapsible` + param) so the full list can be collapsed down instead of needing a + separate way to navigate it; an earlier version had a trailing jump rail + (one small icon per section, pinned where the scrollbar would sit) + instead, dropped for adding a second, redundant navigation method without + shortening the page. Desktop uses a 2-column grid of cards with a 1px + line and radius 20, wide enough to see most sections without scrolling, + so it gets neither. - **Share sheet / dialog:** sections in this order: 1. Header: file tile, name, size · folder, and close. 2. **Share with people:** an input ("Name, email or group"), then the people with access. The owner comes first; the others each have a permission pill ("Can edit ▾"). diff --git a/.claude/context/styling.md b/.claude/context/styling.md index 37116af..c40a2bf 100644 --- a/.claude/context/styling.md +++ b/.claude/context/styling.md @@ -89,7 +89,9 @@ Gotchas: `drawerScrimColor: context.nooColors.scrim`. - `NooGroupedList` draws dividers by showing `line` through 1px gaps, so each child must paint its own surface (`NooSettingsRow` and `NooTabOrderRow` - do). + do). Its `collapsible`/`initiallyExpanded` params (off by default) make + `label` a tap target that shows/hides the card - `SettingsSection` is the + only caller that opts in, for Settings' mobile sections. - `NooSwipeAction` only reveals its action. The user has to tap the block to trigger it; a full swipe never deletes. - Window chrome (macOS traffic lights, the Windows 40px title bar) isn't diff --git a/lib/services/nextcloud_service.dart b/lib/services/nextcloud_service.dart index 3d673f3..f7fe332 100644 --- a/lib/services/nextcloud_service.dart +++ b/lib/services/nextcloud_service.dart @@ -1471,28 +1471,17 @@ class NextcloudService { final rawList = data['ocs']?['data']; if (rawList is! List) return []; - return rawList.map((raw) { - final path = (raw['path'] ?? '/').toString(); - final segments = path.split('/').where((s) => s.isNotEmpty).toList(); - final name = segments.isNotEmpty ? segments.last : path; - final isFolder = (raw['item_type'] ?? '').toString() == 'folder'; - final mimeType = raw['mimetype'] as String?; - - return NextcloudShare( - id: (raw['id'] ?? '').toString(), - path: path, - name: name, - itemType: NextcloudItem.deduceType(name, isFolder, mimeType), - shareType: _mapShareType((raw['share_type'] as num?)?.toInt() ?? -1), - ownerDisplayName: (raw['displayname_owner'] ?? raw['uid_owner'] ?? '') - .toString(), - sharedWithDisplayName: raw['share_with_displayname'] as String?, - sharedAt: DateTime.fromMillisecondsSinceEpoch( - ((raw['stime'] as num?)?.toInt() ?? 0) * 1000, - ), - sharedWithMe: sharedWithMe, - ); - }).toList(); + // Shares out to `_shareFromJson` (the same parser `fetchSharesForPath`/ + // `fetchInheritedShares` use) rather than its own duplicated, thinner + // inline parsing - that copy never set `url`/`token`/`permissions`/ + // `expireDate` at all, so a public-link share always came back with + // `url: null` here and the Shares tab's own "Copy link" action could + // never show up for it. + return rawList + .map( + (raw) => _shareFromJson(raw, sharedWithMe: sharedWithMe), + ) + .toList(); } /// Creates a public link share for [path] and returns its share URL. diff --git a/lib/views/account_view.dart b/lib/views/account_view.dart index 1058de8..d0c3def 100644 --- a/lib/views/account_view.dart +++ b/lib/views/account_view.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../theme/design_tokens.dart'; import '../widgets/noo/nav/noo_top_bar.dart'; import '../widgets/noo/nav/noo_toolbar.dart'; @@ -14,35 +13,33 @@ import '../widgets/settings/settings_security.dart'; import '../widgets/settings/settings_swipe.dart'; import '../widgets/settings/settings_tabs.dart'; -/// One entry in [AccountView._sections] - [icon] and [label] are only used -/// by [_SectionJumpRail]'s tap targets, not shown next to the section -/// itself (each section already titles itself). -typedef _Section = (String label, IconData icon, Widget child); - /// Settings, pushed on top of the shell (DESIGN_SYSTEM.md 4's 9-section /// order: account card, accounts, security, file sync, files cache, /// appearance, tabs, action bar, swipe on a file). One column of /// [SettingsSection]s (and [SettingsActionBarSection], which has no option -/// rows of its own to put in one - just the reorder list) on -/// mobile, with a trailing jump rail (see [_SectionJumpRail]) since the -/// list runs long enough that finding a specific section by scrolling -/// alone is slow; a 2-column grid of cards on desktop, wide enough to see -/// most sections without scrolling, so it doesn't get one. See each -/// `widgets/settings/*.dart` file for a section's own content and any +/// rows of its own to put in one - just the reorder list) on mobile, each +/// individually collapsible (expanded by default, tap its label to +/// collapse - see `SettingsSection`/`NooGroupedList`'s `collapsible` param) +/// so a long Settings screen can be collapsed down rather than needing a +/// separate jump rail (an earlier version had one; it added a second, +/// redundant way to navigate on top of plain scrolling and still didn't +/// shorten the page). Desktop uses a 2-column grid of cards instead, wide +/// enough to see most sections without scrolling, so it gets neither. See +/// each `widgets/settings/*.dart` file for a section's own content and any /// setting that had to be slotted in or grouped under "Advanced appearance". class AccountView extends StatelessWidget { const AccountView({super.key}); - static const _sections = <_Section>[ - ('Account', LucideIcons.circleUser, SettingsAccountCard()), - ('Accounts', LucideIcons.users, SettingsAccountsSection()), - ('Security', LucideIcons.shield, SettingsSecuritySection()), - ('File sync', LucideIcons.folderSync, SettingsFileSyncSection()), - ('Files cache', LucideIcons.hardDrive, SettingsFilesCacheSection()), - ('Appearance', LucideIcons.sunMoon, SettingsAppearanceSection()), - ('Tabs', LucideIcons.layoutGrid, SettingsTabsSection()), - ('Action bar', LucideIcons.slidersHorizontal, SettingsActionBarSection()), - ('Swipe', LucideIcons.moveHorizontal, SettingsSwipeSection()), + static const _sections = [ + SettingsAccountCard(), + SettingsAccountsSection(), + SettingsSecuritySection(), + SettingsFileSyncSection(), + SettingsFilesCacheSection(), + SettingsAppearanceSection(), + SettingsTabsSection(), + SettingsActionBarSection(), + SettingsSwipeSection(), ]; @override @@ -62,119 +59,33 @@ class AccountView extends StatelessWidget { body: SafeArea( top: false, child: desktop - ? _DesktopGrid(sections: [for (final s in _sections) s.$3]) + ? _DesktopGrid(sections: _sections) : _MobileList(sections: _sections), ), ); } } -/// A stable per-section identity, shared between the [KeyedSubtree] each -/// section is wrapped in and [_SectionJumpRail]'s lookup - [GlobalObjectKey] -/// compares by [String] equality, not instance identity, so building a new -/// one from the same label on each side still resolves to the same key. -GlobalKey _sectionKey(String label) => GlobalObjectKey(label); - -class _MobileList extends StatefulWidget { - final List<_Section> sections; +class _MobileList extends StatelessWidget { + final List sections; const _MobileList({required this.sections}); - @override - State<_MobileList> createState() => _MobileListState(); -} - -class _MobileListState extends State<_MobileList> { - final _scrollController = ScrollController(); - - @override - void dispose() { - _scrollController.dispose(); - super.dispose(); - } - - void _jumpTo(String label) { - final target = _sectionKey(label).currentContext; - if (target == null) return; - Scrollable.ensureVisible( - target, - duration: NooMotion.slow, - curve: NooMotion.ease, - alignment: 0.05, - ); - } - @override Widget build(BuildContext context) { - return Stack( - children: [ - ListView( - controller: _scrollController, - padding: const EdgeInsets.fromLTRB( - NooSpace.sm, - NooSpace.sm, - NooSpace.xxl, - NooSpace.xxl, - ), - physics: const BouncingScrollPhysics(), - children: [ - for (final (label, _, child) in widget.sections) ...[ - KeyedSubtree(key: _sectionKey(label), child: child), - const SizedBox(height: NooSpace.xl), - ], - ], - ), - Positioned( - top: 0, - bottom: 0, - right: 2, - child: _SectionJumpRail(sections: widget.sections, onTap: _jumpTo), - ), - ], - ); - } -} - -/// A vertical rail of small per-section icons pinned to the trailing edge, -/// sitting where the scrollbar would otherwise be - tapping one jumps -/// straight to that section instead of scrolling the whole (fairly long) -/// Settings list by hand. Each icon is a generous 32px tap target even -/// though the rail itself is slim, so this stays usable one-handed. -class _SectionJumpRail extends StatelessWidget { - final List<_Section> sections; - final ValueChanged onTap; - - const _SectionJumpRail({required this.sections, required this.onTap}); - - @override - Widget build(BuildContext context) { - final colors = context.nooColors; - return Center( - child: Container( - padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 2), - decoration: BoxDecoration( - color: colors.surface, - borderRadius: BorderRadius.circular(NooRadii.pill), - border: Border.all(color: colors.line), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - for (final (label, icon, _) in sections) - Semantics( - button: true, - label: 'Jump to $label', - child: InkResponse( - onTap: () => onTap(label), - radius: 18, - child: Padding( - padding: const EdgeInsets.all(6), - child: Icon(icon, size: 16, color: colors.fg3), - ), - ), - ), - ], - ), + return ListView( + padding: const EdgeInsets.fromLTRB( + NooSpace.sm, + NooSpace.sm, + NooSpace.sm, + NooSpace.xxl, ), + physics: const BouncingScrollPhysics(), + children: [ + for (final section in sections) ...[ + section, + const SizedBox(height: NooSpace.xl), + ], + ], ); } } diff --git a/lib/views/move_copy_destination_picker.dart b/lib/views/move_copy_destination_picker.dart index ecdc279..78fd460 100644 --- a/lib/views/move_copy_destination_picker.dart +++ b/lib/views/move_copy_destination_picker.dart @@ -255,10 +255,7 @@ class _MoveCopyDestinationPickerState extends State { children: [ SizedBox( height: 44, - child: FilesControlsRow( - folderPath: _currentPath, - showStorageScope: false, - ), + child: FilesControlsRow(folderPath: _currentPath), ), if (hasBreadcrumbs) ...[ const SizedBox(height: 10), diff --git a/lib/views/photos_view.dart b/lib/views/photos_view.dart index eae28ef..2a3122a 100644 --- a/lib/views/photos_view.dart +++ b/lib/views/photos_view.dart @@ -484,65 +484,80 @@ class _PhotosViewState extends State { showNooSheet( context, children: [ - // See `_showSortSheet`'s comment - `Listenable.merge` since these - // toggles span two controllers. - ListenableBuilder( - listenable: Listenable.merge([photos, files]), - builder: (context, _) => Column( - mainAxisSize: MainAxisSize.min, - children: [ - // Same segmented-control design as Settings' Theme row - a - // full-width pill track, not chips, and its own row rather - // than a settings row's trailing slot (matches the - // Ascending/Descending control above, in `_showSortSheet`). - NooSegmentedControl<_PhotoTypeFilter>( - fill: true, - value: _typeFilter, - onChanged: (filter) => setState(() => _typeFilter = filter), - options: const [ - NooSegmentOption(value: _PhotoTypeFilter.all, label: 'All'), - NooSegmentOption( - value: _PhotoTypeFilter.image, - label: 'Photos', - ), - NooSegmentOption( - value: _PhotoTypeFilter.video, - label: 'Videos', - ), - ], - ), - const SizedBox(height: 22), - NooGroupedList( - children: [ - NooSettingsRow( - icon: LucideIcons.heart, - label: const Text('Favorites only'), - trailing: NooToggle( - checked: photos.showFavoritesOnly, - onChanged: (_) => photos.toggleFavoritesFilter(), + // `_typeFilter` lives on this State, not a ChangeNotifier, so + // `Listenable.merge([photos, files])` alone doesn't rebuild this + // sheet when it changes - it used to only visibly move once the + // sheet was closed and reopened. `StatefulBuilder` gives it a + // rebuild trigger of its own; see `_showSortSheet`'s comment for + // why the two controllers still need `ListenableBuilder`. + StatefulBuilder( + builder: (context, setSheetState) => ListenableBuilder( + listenable: Listenable.merge([photos, files]), + builder: (context, _) => Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Same "icon always, label only when selected" pill as + // Files' own type filter - not a fully-labelled track, so + // the two screens' filter sheets look and behave the same. + NooSegmentedControl<_PhotoTypeFilter>( + fill: true, + labelOnlySelected: true, + value: _typeFilter, + onChanged: (filter) => setState(() { + _typeFilter = filter; + setSheetState(() {}); + }), + options: const [ + NooSegmentOption( + value: _PhotoTypeFilter.all, + icon: LucideIcons.layoutGrid, + label: 'All', ), - ), - NooSettingsRow( - icon: LucideIcons.eye, - label: const Text('Show hidden files'), - trailing: NooToggle( - checked: photos.showHidden, - onChanged: (_) => photos.toggleShowHidden(), + NooSegmentOption( + value: _PhotoTypeFilter.image, + icon: LucideIcons.image, + label: 'Photos', ), - ), - NooSettingsRow( - icon: LucideIcons.hardDrive, - label: const Text('External storage'), - trailing: NooToggle( - checked: files.storageScope == StorageScope.external, - onChanged: (external) => files.setStorageScope( - external ? StorageScope.external : StorageScope.cloud, + NooSegmentOption( + value: _PhotoTypeFilter.video, + icon: LucideIcons.film, + label: 'Videos', + ), + ], + ), + const SizedBox(height: 22), + NooGroupedList( + children: [ + NooSettingsRow( + icon: LucideIcons.heart, + label: const Text('Favorites only'), + trailing: NooToggle( + checked: photos.showFavoritesOnly, + onChanged: (_) => photos.toggleFavoritesFilter(), ), ), - ), - ], - ), - ], + NooSettingsRow( + icon: LucideIcons.eye, + label: const Text('Show hidden files'), + trailing: NooToggle( + checked: photos.showHidden, + onChanged: (_) => photos.toggleShowHidden(), + ), + ), + NooSettingsRow( + icon: LucideIcons.hardDrive, + label: const Text('External storage'), + trailing: NooToggle( + checked: files.storageScope == StorageScope.external, + onChanged: (external) => files.setStorageScope( + external ? StorageScope.external : StorageScope.cloud, + ), + ), + ), + ], + ), + ], + ), ), ), ], diff --git a/lib/views/share_upload_view.dart b/lib/views/share_upload_view.dart index c3b1661..35e6178 100644 --- a/lib/views/share_upload_view.dart +++ b/lib/views/share_upload_view.dart @@ -180,10 +180,7 @@ class _ShareUploadViewState extends State { children: [ SizedBox( height: 44, - child: FilesControlsRow( - folderPath: currentPath, - showStorageScope: false, - ), + child: FilesControlsRow(folderPath: currentPath), ), if (hasBreadcrumbs) ...[ const SizedBox(height: 10), diff --git a/lib/widgets/files_controls_row.dart b/lib/widgets/files_controls_row.dart index 1af9bec..faeb5e3 100644 --- a/lib/widgets/files_controls_row.dart +++ b/lib/widgets/files_controls_row.dart @@ -159,52 +159,37 @@ class FilesControlsRow extends StatelessWidget { showNooSheet( context, children: [ - // See `_showSortSheet`'s comment: without this, none of the - // checkmarks/toggles below would move until the sheet is closed - // and reopened. + // See `_showSortSheet`'s comment: without this, neither the type + // filter's selected segment nor the toggles below would move until + // the sheet is closed and reopened. ListenableBuilder( listenable: files, builder: (context, _) => Column( mainAxisSize: MainAxisSize.min, children: [ - NooGroupedList( - label: 'Show', - children: [ - NooSettingsRow( - label: const Text('Files and folders'), - trailing: files.filesTypeFilter == FilesTypeFilter.all - ? Icon( - LucideIcons.check, - size: 18, - color: context.nooColors.accentText, - ) - : null, - onTap: () => files.setFilesTypeFilter(FilesTypeFilter.all), + // Same "icon always, label only when selected" pill as + // Photos' own type filter below - not a checkmark list, so + // the two screens' filter sheets look and behave the same. + NooSegmentedControl( + fill: true, + labelOnlySelected: true, + value: files.filesTypeFilter, + onChanged: files.setFilesTypeFilter, + options: const [ + NooSegmentOption( + value: FilesTypeFilter.all, + icon: LucideIcons.layoutGrid, + label: 'All', ), - NooSettingsRow( - label: const Text('Files only'), - trailing: files.filesTypeFilter == FilesTypeFilter.filesOnly - ? Icon( - LucideIcons.check, - size: 18, - color: context.nooColors.accentText, - ) - : null, - onTap: () => - files.setFilesTypeFilter(FilesTypeFilter.filesOnly), + NooSegmentOption( + value: FilesTypeFilter.filesOnly, + icon: LucideIcons.file, + label: 'Files', ), - NooSettingsRow( - label: const Text('Folders only'), - trailing: - files.filesTypeFilter == FilesTypeFilter.foldersOnly - ? Icon( - LucideIcons.check, - size: 18, - color: context.nooColors.accentText, - ) - : null, - onTap: () => - files.setFilesTypeFilter(FilesTypeFilter.foldersOnly), + NooSegmentOption( + value: FilesTypeFilter.foldersOnly, + icon: LucideIcons.folder, + label: 'Folders', ), ], ), diff --git a/lib/widgets/noo/core/noo_segmented_control.dart b/lib/widgets/noo/core/noo_segmented_control.dart index 5158abb..2738c50 100644 --- a/lib/widgets/noo/core/noo_segmented_control.dart +++ b/lib/widgets/noo/core/noo_segmented_control.dart @@ -11,7 +11,11 @@ class NooSegmentOption { enum NooSegmentedSize { md, sm, xs } -const _heights = {NooSegmentedSize.md: 34.0, NooSegmentedSize.sm: 30.0, NooSegmentedSize.xs: 28.0}; +const _heights = { + NooSegmentedSize.md: 34.0, + NooSegmentedSize.sm: 30.0, + NooSegmentedSize.xs: 28.0, +}; /// Pill track with an accent-soft active segment - List/Grid, Shares scope, /// Theme (Noo Design System project, `components/core/SegmentedControl.jsx`). @@ -21,12 +25,22 @@ class NooSegmentedControl extends StatelessWidget { final List> options; final T value; final ValueChanged? onChanged; + /// Use surface on bg, surface-2 on surface. final bool onSurface; final NooSegmentedSize size; final bool fill; final bool iconOnly; + /// Every segment always shows its icon, but only the *selected* one also + /// shows its label - a middle ground between `iconOnly` (no segment is + /// ever distinguishable without a label) and the default (every segment's + /// label always visible, crowding a 3+-option control). Used for Files'/ + /// Photos' type filter (All/Files/Folders, All/Photos/Videos) so both use + /// the same List/Grid-toggle-style pill instead of Files' old checkmark + /// list and Photos' own always-labelled control. + final bool labelOnlySelected; + const NooSegmentedControl({ super.key, required this.options, @@ -36,6 +50,7 @@ class NooSegmentedControl extends StatelessWidget { this.size = NooSegmentedSize.md, this.fill = false, this.iconOnly = false, + this.labelOnlySelected = false, }); @override @@ -44,10 +59,13 @@ class NooSegmentedControl extends StatelessWidget { final h = iconOnly ? 28.0 : (_heights[size] ?? 34.0); final segments = options.map((o) { final on = o.value == value; + final showLabel = labelOnlySelected ? on : !iconOnly; final child = Container( height: h, width: iconOnly ? 36 : null, - padding: iconOnly || fill ? EdgeInsets.zero : const EdgeInsets.symmetric(horizontal: 12), + padding: iconOnly || fill + ? EdgeInsets.zero + : const EdgeInsets.symmetric(horizontal: 12), alignment: Alignment.center, decoration: BoxDecoration( color: on ? colors.accentSoft : Colors.transparent, @@ -62,8 +80,9 @@ class NooSegmentedControl extends StatelessWidget { size: iconOnly ? (size == NooSegmentedSize.md ? 18 : 16) : 14, color: on ? colors.accentText : colors.fg2, ), - if (o.icon != null && !iconOnly && o.label != null) const SizedBox(width: 6), - if (!iconOnly && o.label != null) + if (o.icon != null && showLabel && o.label != null) + const SizedBox(width: 6), + if (showLabel && o.label != null) Text( o.label!, style: NooText.body.copyWith( @@ -89,7 +108,10 @@ class NooSegmentedControl extends StatelessWidget { color: onSurface ? colors.surface2 : colors.surface, borderRadius: BorderRadius.circular(NooRadii.pill), ), - child: Row(mainAxisSize: fill ? MainAxisSize.max : MainAxisSize.min, children: segments), + child: Row( + mainAxisSize: fill ? MainAxisSize.max : MainAxisSize.min, + children: segments, + ), ); } } diff --git a/lib/widgets/noo/lists/noo_grouped_list.dart b/lib/widgets/noo/lists/noo_grouped_list.dart index 99c949f..cc4ccdc 100644 --- a/lib/widgets/noo/lists/noo_grouped_list.dart +++ b/lib/widgets/noo/lists/noo_grouped_list.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../../theme/design_tokens.dart'; /// Radius-20 surface card of rows with 1px line dividers and an optional @@ -7,12 +8,22 @@ import '../../../theme/design_tokens.dart'; /// titled card isn't built yet). Each child (`SettingsRow`, `FileRow`, ...) /// paints its own `surface` background; this only supplies the 1px `line` /// gaps between them and the rounded clip. Stack groups 18-20px apart. -class NooGroupedList extends StatelessWidget { +/// +/// [collapsible] makes [label] (required alongside it) a tap target that +/// shows/hides the card and [footer] below it, with a trailing chevron - +/// used by `SettingsSection` for Settings' mobile sections so a long +/// Settings screen can be collapsed section by section instead of needing a +/// separate jump rail. Every other call site leaves this false and renders +/// exactly as before, always expanded and non-interactive. +class NooGroupedList extends StatefulWidget { final String? label; + /// Right-aligned secondary text next to [label]. final String? aside; final List children; final Widget? footer; + final bool collapsible; + final bool initiallyExpanded; const NooGroupedList({ super.key, @@ -20,60 +31,115 @@ class NooGroupedList extends StatelessWidget { this.aside, required this.children, this.footer, + this.collapsible = false, + this.initiallyExpanded = true, }); + @override + State createState() => _NooGroupedListState(); +} + +class _NooGroupedListState extends State { + late bool _expanded = widget.initiallyExpanded; + @override Widget build(BuildContext context) { final colors = context.nooColors; + final expanded = !widget.collapsible || _expanded; + final label = widget.label; + return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (label != null) + if (label != null) ...[ Padding( padding: const EdgeInsets.symmetric(horizontal: 8), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text(label!, style: NooText.label.copyWith(color: colors.fg2)), - if (aside != null) - Text( - aside!, - style: NooText.body.copyWith( - fontSize: 13, - height: 1, - fontWeight: FontWeight.w500, - color: colors.fg3, - ), - ), - ], - ), + child: _buildLabelRow(label, colors, expanded), ), - if (label != null) const SizedBox(height: 8), - ClipRRect( - borderRadius: BorderRadius.circular(NooRadii.card), - child: DecoratedBox( - decoration: BoxDecoration(color: colors.line), - child: Column( - children: [ - for (var i = 0; i < children.length; i++) ...[ - if (i > 0) const SizedBox(height: 1), - children[i], - ], - ], - ), - ), - ), - if (footer != null) ...[ const SizedBox(height: 8), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: DefaultTextStyle( - style: NooText.meta.copyWith(color: colors.fg3), - child: footer!, - ), - ), ], + AnimatedCrossFade( + duration: NooMotion.fast, + sizeCurve: NooMotion.ease, + crossFadeState: expanded + ? CrossFadeState.showFirst + : CrossFadeState.showSecond, + firstChild: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(NooRadii.card), + child: DecoratedBox( + decoration: BoxDecoration(color: colors.line), + child: Column( + children: [ + for (var i = 0; i < widget.children.length; i++) ...[ + if (i > 0) const SizedBox(height: 1), + widget.children[i], + ], + ], + ), + ), + ), + if (widget.footer != null) ...[ + const SizedBox(height: 8), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: DefaultTextStyle( + style: NooText.meta.copyWith(color: colors.fg3), + child: widget.footer!, + ), + ), + ], + ], + ), + secondChild: const SizedBox(width: double.infinity), + ), ], ); } + + Widget _buildLabelRow(String label, NooColors colors, bool expanded) { + final row = Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label, style: NooText.label.copyWith(color: colors.fg2)), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (widget.aside != null) + Padding( + padding: EdgeInsets.only(right: widget.collapsible ? 8 : 0), + child: Text( + widget.aside!, + style: NooText.body.copyWith( + fontSize: 13, + height: 1, + fontWeight: FontWeight.w500, + color: colors.fg3, + ), + ), + ), + if (widget.collapsible) + AnimatedRotation( + turns: expanded ? 0.5 : 0, + duration: NooMotion.fast, + curve: NooMotion.ease, + child: Icon( + LucideIcons.chevronDown, + size: 16, + color: colors.fg3, + ), + ), + ], + ), + ], + ); + if (!widget.collapsible) return row; + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => setState(() => _expanded = !_expanded), + child: row, + ); + } } diff --git a/lib/widgets/settings/settings_action_bar.dart b/lib/widgets/settings/settings_action_bar.dart index 46e9225..0fef176 100644 --- a/lib/widgets/settings/settings_action_bar.dart +++ b/lib/widgets/settings/settings_action_bar.dart @@ -5,6 +5,7 @@ import '../../providers/settings_controller.dart'; import '../../theme/design_tokens.dart'; import '../noo/lists/noo_tab_order_row.dart'; import '../noo/noo_layout.dart'; +import 'settings_section.dart'; /// Settings section: the priority order bulk actions (favorite, share, /// download, ...) show in on the Files/Photos/Favorites multi-select @@ -40,27 +41,10 @@ class SettingsActionBarSection extends StatelessWidget { final colors = context.nooColors; if (!desktop) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: Text( - 'Action bar', - style: NooText.label.copyWith(color: colors.fg2), - ), - ), - const SizedBox(height: 4), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: Text( - _subtitle, - style: NooText.meta.copyWith(color: colors.fg3), - ), - ), - const SizedBox(height: 8), - orderList, - ], + return SettingsSection( + title: 'Action bar', + subtitle: _subtitle, + children: [orderList], ); } diff --git a/lib/widgets/settings/settings_section.dart b/lib/widgets/settings/settings_section.dart index 5c086e0..7ceae70 100644 --- a/lib/widgets/settings/settings_section.dart +++ b/lib/widgets/settings/settings_section.dart @@ -31,6 +31,11 @@ class SettingsSection extends StatelessWidget { return NooGroupedList( label: title, footer: subtitle != null ? Text(subtitle!) : null, + // Every mobile Settings section is individually collapsible, + // expanded by default - replaces the old trailing jump rail (see + // `account_view.dart`'s doc comment) as the way to navigate a long + // Settings screen quickly. + collapsible: true, children: children, ); } @@ -99,8 +104,15 @@ void showSettingsPicker( required List options, }) { if (NooLayout.isDesktop(context)) { - showNooDialog(context, title: title, children: [Column(children: options)]); + showNooDialog( + context, + title: title, + children: [Column(children: options)], + ); } else { - showNooSheet(context, children: [NooGroupedList(label: title, children: options)]); + showNooSheet( + context, + children: [NooGroupedList(label: title, children: options)], + ); } } diff --git a/test/widgets/noo/core_test.dart b/test/widgets/noo/core_test.dart index 30f04d2..cbb6059 100644 --- a/test/widgets/noo/core_test.dart +++ b/test/widgets/noo/core_test.dart @@ -369,6 +369,46 @@ void main() { const Size(78, 34), ); }); + + testNooWidgets( + 'labelOnlySelected shows every icon but only the selected label', + (tester, theme, c) async { + var value = 'all'; + await pumpNoo( + tester, + StatefulBuilder( + builder: (context, setState) => NooSegmentedControl( + fill: true, + labelOnlySelected: true, + value: value, + onChanged: (v) => setState(() => value = v), + options: const [ + NooSegmentOption( + value: 'all', + icon: LucideIcons.layoutGrid, + label: 'All', + ), + NooSegmentOption( + value: 'files', + icon: LucideIcons.file, + label: 'Files', + ), + ], + ), + ), + theme: theme, + ); + expect(find.byIcon(LucideIcons.layoutGrid), findsOneWidget); + expect(find.byIcon(LucideIcons.file), findsOneWidget); + expect(find.text('All'), findsOneWidget); + expect(find.text('Files'), findsNothing); + + await tester.tap(find.byIcon(LucideIcons.file)); + await tester.pump(); + expect(find.text('Files'), findsOneWidget); + expect(find.text('All'), findsNothing); + }, + ); }); group('NooToggle', () { diff --git a/test/widgets/noo/lists_test.dart b/test/widgets/noo/lists_test.dart index 1966eca..f9e081f 100644 --- a/test/widgets/noo/lists_test.dart +++ b/test/widgets/noo/lists_test.dart @@ -105,6 +105,69 @@ void main() { // Rows paint their own surface; the gaps show the list's line fill. expect(decorationOf(tester, find.byKey(const Key('r1'))).color, c.line); }); + + testNooWidgets('collapsible starts expanded, tapping the label hides ' + 'and re-shows the card', (tester, theme, c) async { + await pumpNoo( + tester, + SizedBox( + width: 360, + child: NooGroupedList( + label: 'Appearance', + collapsible: true, + children: [ + Container(key: const Key('r1'), height: 52, color: c.surface), + ], + ), + ), + theme: theme, + ); + expect(find.byIcon(LucideIcons.chevronDown), findsOneWidget); + expect(tester.getSize(find.byKey(const Key('r1'))).height, 52); + AnimatedCrossFade crossFade() => + tester.widget(find.byType(AnimatedCrossFade)); + expect(crossFade().crossFadeState, CrossFadeState.showFirst); + + await tester.tap(find.text('Appearance')); + await tester.pump(); + expect(crossFade().crossFadeState, CrossFadeState.showSecond); + expect( + tester.widget(find.byType(AnimatedRotation)).turns, + 0, + ); + + await tester.tap(find.text('Appearance')); + await tester.pump(); + expect(crossFade().crossFadeState, CrossFadeState.showFirst); + expect( + tester.widget(find.byType(AnimatedRotation)).turns, + 0.5, + ); + }); + + testNooWidgets('non-collapsible has no chevron and ignores label taps', ( + tester, + theme, + c, + ) async { + await pumpNoo( + tester, + SizedBox( + width: 360, + child: NooGroupedList( + label: 'Accounts', + children: [ + Container(key: const Key('r1'), height: 52, color: c.surface), + ], + ), + ), + theme: theme, + ); + expect(find.byIcon(LucideIcons.chevronDown), findsNothing); + await tester.tap(find.text('Accounts')); + await tester.pumpAndSettle(); + expect(tester.getSize(find.byKey(const Key('r1'))).height, 52); + }); }); group('NooSelectionBar', () {