Fix share copy-link, destination-picker filters, and type-filter UX

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-29 03:49:24 -04:00
co-authored by Claude Sonnet 5
parent 4e6477a3ae
commit f9653bc0f2
14 changed files with 417 additions and 326 deletions
+16 -8
View File
@@ -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 (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 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 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 Settings' Theme row uses, not its own row of chips. Every icon (All,
grouped by month. 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. - **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. - **Offline:** a summary card (or 4 stat cards on desktop), then a list whose rows show sync status icons and status meta.
- **Shares:** a segmented control (With you, By you, Links), then a list. The meta reads "Owner · Permission". - **Shares:** a segmented control (With you, By you, Links), then a list. The meta reads "Owner · Permission".
@@ -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". slots, the rest sit behind "More". See §2 "Selection action bar".
9. Swipe on a file 9. Swipe on a file
Mobile uses one column of grouped lists, with a trailing jump rail (one Mobile uses one column of grouped lists, each individually collapsible
small icon per section, pinned where the scrollbar would sit) since the (tap its label, expanded by default - `NooGroupedList`'s `collapsible`
full list runs long enough that scrolling to a specific section by hand param) so the full list can be collapsed down instead of needing a
is slow - tapping an icon scrolls straight to that section. Desktop uses separate way to navigate it; an earlier version had a trailing jump rail
a 2-column grid of cards with a 1px line and radius 20, wide enough to (one small icon per section, pinned where the scrollbar would sit)
see most sections without scrolling, so it doesn't get the rail. 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: - **Share sheet / dialog:** sections in this order:
1. Header: file tile, name, size · folder, and close. 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 ▾"). 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 -1
View File
@@ -89,7 +89,9 @@ Gotchas:
`drawerScrimColor: context.nooColors.scrim`. `drawerScrimColor: context.nooColors.scrim`.
- `NooGroupedList` draws dividers by showing `line` through 1px gaps, so each - `NooGroupedList` draws dividers by showing `line` through 1px gaps, so each
child must paint its own surface (`NooSettingsRow` and `NooTabOrderRow` 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 - `NooSwipeAction` only reveals its action. The user has to tap the block to
trigger it; a full swipe never deletes. trigger it; a full swipe never deletes.
- Window chrome (macOS traffic lights, the Windows 40px title bar) isn't - Window chrome (macOS traffic lights, the Windows 40px title bar) isn't
+11 -22
View File
@@ -1471,28 +1471,17 @@ class NextcloudService {
final rawList = data['ocs']?['data']; final rawList = data['ocs']?['data'];
if (rawList is! List) return []; if (rawList is! List) return [];
return rawList.map<NextcloudShare>((raw) { // Shares out to `_shareFromJson` (the same parser `fetchSharesForPath`/
final path = (raw['path'] ?? '/').toString(); // `fetchInheritedShares` use) rather than its own duplicated, thinner
final segments = path.split('/').where((s) => s.isNotEmpty).toList(); // inline parsing - that copy never set `url`/`token`/`permissions`/
final name = segments.isNotEmpty ? segments.last : path; // `expireDate` at all, so a public-link share always came back with
final isFolder = (raw['item_type'] ?? '').toString() == 'folder'; // `url: null` here and the Shares tab's own "Copy link" action could
final mimeType = raw['mimetype'] as String?; // never show up for it.
return rawList
return NextcloudShare( .map<NextcloudShare>(
id: (raw['id'] ?? '').toString(), (raw) => _shareFromJson(raw, sharedWithMe: sharedWithMe),
path: path, )
name: name, .toList();
itemType: NextcloudItem.deduceType(name, isFolder, mimeType),
shareType: _mapShareType((raw['share_type'] as num?)?.toInt() ?? -1),
ownerDisplayName: (raw['displayname_owner'] ?? raw['uid_owner'] ?? '')
.toString(),
sharedWithDisplayName: raw['share_with_displayname'] as String?,
sharedAt: DateTime.fromMillisecondsSinceEpoch(
((raw['stime'] as num?)?.toInt() ?? 0) * 1000,
),
sharedWithMe: sharedWithMe,
);
}).toList();
} }
/// Creates a public link share for [path] and returns its share URL. /// Creates a public link share for [path] and returns its share URL.
+35 -124
View File
@@ -1,5 +1,4 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../theme/design_tokens.dart'; import '../theme/design_tokens.dart';
import '../widgets/noo/nav/noo_top_bar.dart'; import '../widgets/noo/nav/noo_top_bar.dart';
import '../widgets/noo/nav/noo_toolbar.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_swipe.dart';
import '../widgets/settings/settings_tabs.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 /// Settings, pushed on top of the shell (DESIGN_SYSTEM.md 4's 9-section
/// order: account card, accounts, security, file sync, files cache, /// order: account card, accounts, security, file sync, files cache,
/// appearance, tabs, action bar, swipe on a file). One column of /// appearance, tabs, action bar, swipe on a file). One column of
/// [SettingsSection]s (and [SettingsActionBarSection], which has no option /// [SettingsSection]s (and [SettingsActionBarSection], which has no option
/// rows of its own to put in one - just the reorder list) on /// rows of its own to put in one - just the reorder list) on mobile, each
/// mobile, with a trailing jump rail (see [_SectionJumpRail]) since the /// individually collapsible (expanded by default, tap its label to
/// list runs long enough that finding a specific section by scrolling /// collapse - see `SettingsSection`/`NooGroupedList`'s `collapsible` param)
/// alone is slow; a 2-column grid of cards on desktop, wide enough to see /// so a long Settings screen can be collapsed down rather than needing a
/// most sections without scrolling, so it doesn't get one. See each /// separate jump rail (an earlier version had one; it added a second,
/// `widgets/settings/*.dart` file for a section's own content and any /// 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". /// setting that had to be slotted in or grouped under "Advanced appearance".
class AccountView extends StatelessWidget { class AccountView extends StatelessWidget {
const AccountView({super.key}); const AccountView({super.key});
static const _sections = <_Section>[ static const _sections = <Widget>[
('Account', LucideIcons.circleUser, SettingsAccountCard()), SettingsAccountCard(),
('Accounts', LucideIcons.users, SettingsAccountsSection()), SettingsAccountsSection(),
('Security', LucideIcons.shield, SettingsSecuritySection()), SettingsSecuritySection(),
('File sync', LucideIcons.folderSync, SettingsFileSyncSection()), SettingsFileSyncSection(),
('Files cache', LucideIcons.hardDrive, SettingsFilesCacheSection()), SettingsFilesCacheSection(),
('Appearance', LucideIcons.sunMoon, SettingsAppearanceSection()), SettingsAppearanceSection(),
('Tabs', LucideIcons.layoutGrid, SettingsTabsSection()), SettingsTabsSection(),
('Action bar', LucideIcons.slidersHorizontal, SettingsActionBarSection()), SettingsActionBarSection(),
('Swipe', LucideIcons.moveHorizontal, SettingsSwipeSection()), SettingsSwipeSection(),
]; ];
@override @override
@@ -62,119 +59,33 @@ class AccountView extends StatelessWidget {
body: SafeArea( body: SafeArea(
top: false, top: false,
child: desktop child: desktop
? _DesktopGrid(sections: [for (final s in _sections) s.$3]) ? _DesktopGrid(sections: _sections)
: _MobileList(sections: _sections), : _MobileList(sections: _sections),
), ),
); );
} }
} }
/// A stable per-section identity, shared between the [KeyedSubtree] each class _MobileList extends StatelessWidget {
/// section is wrapped in and [_SectionJumpRail]'s lookup - [GlobalObjectKey] final List<Widget> sections;
/// compares by [String] equality, not instance identity, so building a new
/// one from the same label on each side still resolves to the same key.
GlobalKey _sectionKey(String label) => GlobalObjectKey(label);
class _MobileList extends StatefulWidget {
final List<_Section> sections;
const _MobileList({required this.sections}); 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Stack( return ListView(
children: [ padding: const EdgeInsets.fromLTRB(
ListView( NooSpace.sm,
controller: _scrollController, NooSpace.sm,
padding: const EdgeInsets.fromLTRB( NooSpace.sm,
NooSpace.sm, NooSpace.xxl,
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<String> 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),
),
),
),
],
),
), ),
physics: const BouncingScrollPhysics(),
children: [
for (final section in sections) ...[
section,
const SizedBox(height: NooSpace.xl),
],
],
); );
} }
} }
+1 -4
View File
@@ -255,10 +255,7 @@ class _MoveCopyDestinationPickerState extends State<MoveCopyDestinationPicker> {
children: [ children: [
SizedBox( SizedBox(
height: 44, height: 44,
child: FilesControlsRow( child: FilesControlsRow(folderPath: _currentPath),
folderPath: _currentPath,
showStorageScope: false,
),
), ),
if (hasBreadcrumbs) ...[ if (hasBreadcrumbs) ...[
const SizedBox(height: 10), const SizedBox(height: 10),
+70 -55
View File
@@ -484,65 +484,80 @@ class _PhotosViewState extends State<PhotosView> {
showNooSheet( showNooSheet(
context, context,
children: [ children: [
// See `_showSortSheet`'s comment - `Listenable.merge` since these // `_typeFilter` lives on this State, not a ChangeNotifier, so
// toggles span two controllers. // `Listenable.merge([photos, files])` alone doesn't rebuild this
ListenableBuilder( // sheet when it changes - it used to only visibly move once the
listenable: Listenable.merge([photos, files]), // sheet was closed and reopened. `StatefulBuilder` gives it a
builder: (context, _) => Column( // rebuild trigger of its own; see `_showSortSheet`'s comment for
mainAxisSize: MainAxisSize.min, // why the two controllers still need `ListenableBuilder`.
children: [ StatefulBuilder(
// Same segmented-control design as Settings' Theme row - a builder: (context, setSheetState) => ListenableBuilder(
// full-width pill track, not chips, and its own row rather listenable: Listenable.merge([photos, files]),
// than a settings row's trailing slot (matches the builder: (context, _) => Column(
// Ascending/Descending control above, in `_showSortSheet`). mainAxisSize: MainAxisSize.min,
NooSegmentedControl<_PhotoTypeFilter>( children: [
fill: true, // Same "icon always, label only when selected" pill as
value: _typeFilter, // Files' own type filter - not a fully-labelled track, so
onChanged: (filter) => setState(() => _typeFilter = filter), // the two screens' filter sheets look and behave the same.
options: const [ NooSegmentedControl<_PhotoTypeFilter>(
NooSegmentOption(value: _PhotoTypeFilter.all, label: 'All'), fill: true,
NooSegmentOption( labelOnlySelected: true,
value: _PhotoTypeFilter.image, value: _typeFilter,
label: 'Photos', onChanged: (filter) => setState(() {
), _typeFilter = filter;
NooSegmentOption( setSheetState(() {});
value: _PhotoTypeFilter.video, }),
label: 'Videos', options: const [
), NooSegmentOption(
], value: _PhotoTypeFilter.all,
), icon: LucideIcons.layoutGrid,
const SizedBox(height: 22), label: 'All',
NooGroupedList(
children: [
NooSettingsRow(
icon: LucideIcons.heart,
label: const Text('Favorites only'),
trailing: NooToggle(
checked: photos.showFavoritesOnly,
onChanged: (_) => photos.toggleFavoritesFilter(),
), ),
), NooSegmentOption(
NooSettingsRow( value: _PhotoTypeFilter.image,
icon: LucideIcons.eye, icon: LucideIcons.image,
label: const Text('Show hidden files'), label: 'Photos',
trailing: NooToggle(
checked: photos.showHidden,
onChanged: (_) => photos.toggleShowHidden(),
), ),
), NooSegmentOption(
NooSettingsRow( value: _PhotoTypeFilter.video,
icon: LucideIcons.hardDrive, icon: LucideIcons.film,
label: const Text('External storage'), label: 'Videos',
trailing: NooToggle( ),
checked: files.storageScope == StorageScope.external, ],
onChanged: (external) => files.setStorageScope( ),
external ? StorageScope.external : StorageScope.cloud, 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,
),
),
),
],
),
],
),
), ),
), ),
], ],
+1 -4
View File
@@ -180,10 +180,7 @@ class _ShareUploadViewState extends State<ShareUploadView> {
children: [ children: [
SizedBox( SizedBox(
height: 44, height: 44,
child: FilesControlsRow( child: FilesControlsRow(folderPath: currentPath),
folderPath: currentPath,
showStorageScope: false,
),
), ),
if (hasBreadcrumbs) ...[ if (hasBreadcrumbs) ...[
const SizedBox(height: 10), const SizedBox(height: 10),
+24 -39
View File
@@ -159,52 +159,37 @@ class FilesControlsRow extends StatelessWidget {
showNooSheet( showNooSheet(
context, context,
children: [ children: [
// See `_showSortSheet`'s comment: without this, none of the // See `_showSortSheet`'s comment: without this, neither the type
// checkmarks/toggles below would move until the sheet is closed // filter's selected segment nor the toggles below would move until
// and reopened. // the sheet is closed and reopened.
ListenableBuilder( ListenableBuilder(
listenable: files, listenable: files,
builder: (context, _) => Column( builder: (context, _) => Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
NooGroupedList( // Same "icon always, label only when selected" pill as
label: 'Show', // Photos' own type filter below - not a checkmark list, so
children: [ // the two screens' filter sheets look and behave the same.
NooSettingsRow( NooSegmentedControl<FilesTypeFilter>(
label: const Text('Files and folders'), fill: true,
trailing: files.filesTypeFilter == FilesTypeFilter.all labelOnlySelected: true,
? Icon( value: files.filesTypeFilter,
LucideIcons.check, onChanged: files.setFilesTypeFilter,
size: 18, options: const [
color: context.nooColors.accentText, NooSegmentOption(
) value: FilesTypeFilter.all,
: null, icon: LucideIcons.layoutGrid,
onTap: () => files.setFilesTypeFilter(FilesTypeFilter.all), label: 'All',
), ),
NooSettingsRow( NooSegmentOption(
label: const Text('Files only'), value: FilesTypeFilter.filesOnly,
trailing: files.filesTypeFilter == FilesTypeFilter.filesOnly icon: LucideIcons.file,
? Icon( label: 'Files',
LucideIcons.check,
size: 18,
color: context.nooColors.accentText,
)
: null,
onTap: () =>
files.setFilesTypeFilter(FilesTypeFilter.filesOnly),
), ),
NooSettingsRow( NooSegmentOption(
label: const Text('Folders only'), value: FilesTypeFilter.foldersOnly,
trailing: icon: LucideIcons.folder,
files.filesTypeFilter == FilesTypeFilter.foldersOnly label: 'Folders',
? Icon(
LucideIcons.check,
size: 18,
color: context.nooColors.accentText,
)
: null,
onTap: () =>
files.setFilesTypeFilter(FilesTypeFilter.foldersOnly),
), ),
], ],
), ),
@@ -11,7 +11,11 @@ class NooSegmentOption<T> {
enum NooSegmentedSize { md, sm, xs } 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, /// Pill track with an accent-soft active segment - List/Grid, Shares scope,
/// Theme (Noo Design System project, `components/core/SegmentedControl.jsx`). /// Theme (Noo Design System project, `components/core/SegmentedControl.jsx`).
@@ -21,12 +25,22 @@ class NooSegmentedControl<T> extends StatelessWidget {
final List<NooSegmentOption<T>> options; final List<NooSegmentOption<T>> options;
final T value; final T value;
final ValueChanged<T>? onChanged; final ValueChanged<T>? onChanged;
/// Use surface on bg, surface-2 on surface. /// Use surface on bg, surface-2 on surface.
final bool onSurface; final bool onSurface;
final NooSegmentedSize size; final NooSegmentedSize size;
final bool fill; final bool fill;
final bool iconOnly; 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({ const NooSegmentedControl({
super.key, super.key,
required this.options, required this.options,
@@ -36,6 +50,7 @@ class NooSegmentedControl<T> extends StatelessWidget {
this.size = NooSegmentedSize.md, this.size = NooSegmentedSize.md,
this.fill = false, this.fill = false,
this.iconOnly = false, this.iconOnly = false,
this.labelOnlySelected = false,
}); });
@override @override
@@ -44,10 +59,13 @@ class NooSegmentedControl<T> extends StatelessWidget {
final h = iconOnly ? 28.0 : (_heights[size] ?? 34.0); final h = iconOnly ? 28.0 : (_heights[size] ?? 34.0);
final segments = options.map((o) { final segments = options.map((o) {
final on = o.value == value; final on = o.value == value;
final showLabel = labelOnlySelected ? on : !iconOnly;
final child = Container( final child = Container(
height: h, height: h,
width: iconOnly ? 36 : null, 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, alignment: Alignment.center,
decoration: BoxDecoration( decoration: BoxDecoration(
color: on ? colors.accentSoft : Colors.transparent, color: on ? colors.accentSoft : Colors.transparent,
@@ -62,8 +80,9 @@ class NooSegmentedControl<T> extends StatelessWidget {
size: iconOnly ? (size == NooSegmentedSize.md ? 18 : 16) : 14, size: iconOnly ? (size == NooSegmentedSize.md ? 18 : 16) : 14,
color: on ? colors.accentText : colors.fg2, color: on ? colors.accentText : colors.fg2,
), ),
if (o.icon != null && !iconOnly && o.label != null) const SizedBox(width: 6), if (o.icon != null && showLabel && o.label != null)
if (!iconOnly && o.label != null) const SizedBox(width: 6),
if (showLabel && o.label != null)
Text( Text(
o.label!, o.label!,
style: NooText.body.copyWith( style: NooText.body.copyWith(
@@ -89,7 +108,10 @@ class NooSegmentedControl<T> extends StatelessWidget {
color: onSurface ? colors.surface2 : colors.surface, color: onSurface ? colors.surface2 : colors.surface,
borderRadius: BorderRadius.circular(NooRadii.pill), 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,
),
); );
} }
} }
+107 -41
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../../theme/design_tokens.dart'; import '../../../theme/design_tokens.dart';
/// Radius-20 surface card of rows with 1px line dividers and an optional /// 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`, ...) /// titled card isn't built yet). Each child (`SettingsRow`, `FileRow`, ...)
/// paints its own `surface` background; this only supplies the 1px `line` /// paints its own `surface` background; this only supplies the 1px `line`
/// gaps between them and the rounded clip. Stack groups 18-20px apart. /// 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; final String? label;
/// Right-aligned secondary text next to [label]. /// Right-aligned secondary text next to [label].
final String? aside; final String? aside;
final List<Widget> children; final List<Widget> children;
final Widget? footer; final Widget? footer;
final bool collapsible;
final bool initiallyExpanded;
const NooGroupedList({ const NooGroupedList({
super.key, super.key,
@@ -20,60 +31,115 @@ class NooGroupedList extends StatelessWidget {
this.aside, this.aside,
required this.children, required this.children,
this.footer, this.footer,
this.collapsible = false,
this.initiallyExpanded = true,
}); });
@override
State<NooGroupedList> createState() => _NooGroupedListState();
}
class _NooGroupedListState extends State<NooGroupedList> {
late bool _expanded = widget.initiallyExpanded;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colors = context.nooColors; final colors = context.nooColors;
final expanded = !widget.collapsible || _expanded;
final label = widget.label;
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
if (label != null) if (label != null) ...[
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 8), padding: const EdgeInsets.symmetric(horizontal: 8),
child: Row( child: _buildLabelRow(label, colors, expanded),
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), 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,
);
}
} }
+5 -21
View File
@@ -5,6 +5,7 @@ import '../../providers/settings_controller.dart';
import '../../theme/design_tokens.dart'; import '../../theme/design_tokens.dart';
import '../noo/lists/noo_tab_order_row.dart'; import '../noo/lists/noo_tab_order_row.dart';
import '../noo/noo_layout.dart'; import '../noo/noo_layout.dart';
import 'settings_section.dart';
/// Settings section: the priority order bulk actions (favorite, share, /// Settings section: the priority order bulk actions (favorite, share,
/// download, ...) show in on the Files/Photos/Favorites multi-select /// download, ...) show in on the Files/Photos/Favorites multi-select
@@ -40,27 +41,10 @@ class SettingsActionBarSection extends StatelessWidget {
final colors = context.nooColors; final colors = context.nooColors;
if (!desktop) { if (!desktop) {
return Column( return SettingsSection(
crossAxisAlignment: CrossAxisAlignment.start, title: 'Action bar',
children: [ subtitle: _subtitle,
Padding( children: [orderList],
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,
],
); );
} }
+14 -2
View File
@@ -31,6 +31,11 @@ class SettingsSection extends StatelessWidget {
return NooGroupedList( return NooGroupedList(
label: title, label: title,
footer: subtitle != null ? Text(subtitle!) : null, 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, children: children,
); );
} }
@@ -99,8 +104,15 @@ void showSettingsPicker(
required List<Widget> options, required List<Widget> options,
}) { }) {
if (NooLayout.isDesktop(context)) { if (NooLayout.isDesktop(context)) {
showNooDialog(context, title: title, children: [Column(children: options)]); showNooDialog(
context,
title: title,
children: [Column(children: options)],
);
} else { } else {
showNooSheet(context, children: [NooGroupedList(label: title, children: options)]); showNooSheet(
context,
children: [NooGroupedList(label: title, children: options)],
);
} }
} }
+40
View File
@@ -369,6 +369,46 @@ void main() {
const Size(78, 34), 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<String>(
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', () { group('NooToggle', () {
+63
View File
@@ -105,6 +105,69 @@ void main() {
// Rows paint their own surface; the gaps show the list's line fill. // Rows paint their own surface; the gaps show the list's line fill.
expect(decorationOf(tester, find.byKey(const Key('r1'))).color, c.line); 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<AnimatedCrossFade>(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<AnimatedRotation>(find.byType(AnimatedRotation)).turns,
0,
);
await tester.tap(find.text('Appearance'));
await tester.pump();
expect(crossFade().crossFadeState, CrossFadeState.showFirst);
expect(
tester.widget<AnimatedRotation>(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', () { group('NooSelectionBar', () {