Noo design system rebuild #1
@@ -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 ▾").
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1471,28 +1471,17 @@ class NextcloudService {
|
||||
final rawList = data['ocs']?['data'];
|
||||
if (rawList is! List) return [];
|
||||
|
||||
return rawList.map<NextcloudShare>((raw) {
|
||||
final path = (raw['path'] ?? '/').toString();
|
||||
final segments = path.split('/').where((s) => s.isNotEmpty).toList();
|
||||
final name = segments.isNotEmpty ? segments.last : path;
|
||||
final isFolder = (raw['item_type'] ?? '').toString() == 'folder';
|
||||
final mimeType = raw['mimetype'] as String?;
|
||||
|
||||
return NextcloudShare(
|
||||
id: (raw['id'] ?? '').toString(),
|
||||
path: path,
|
||||
name: name,
|
||||
itemType: NextcloudItem.deduceType(name, isFolder, mimeType),
|
||||
shareType: _mapShareType((raw['share_type'] as num?)?.toInt() ?? -1),
|
||||
ownerDisplayName: (raw['displayname_owner'] ?? raw['uid_owner'] ?? '')
|
||||
.toString(),
|
||||
sharedWithDisplayName: raw['share_with_displayname'] as String?,
|
||||
sharedAt: DateTime.fromMillisecondsSinceEpoch(
|
||||
((raw['stime'] as num?)?.toInt() ?? 0) * 1000,
|
||||
),
|
||||
sharedWithMe: sharedWithMe,
|
||||
);
|
||||
}).toList();
|
||||
// Shares out to `_shareFromJson` (the same parser `fetchSharesForPath`/
|
||||
// `fetchInheritedShares` use) rather than its own duplicated, thinner
|
||||
// inline parsing - that copy never set `url`/`token`/`permissions`/
|
||||
// `expireDate` at all, so a public-link share always came back with
|
||||
// `url: null` here and the Shares tab's own "Copy link" action could
|
||||
// never show up for it.
|
||||
return rawList
|
||||
.map<NextcloudShare>(
|
||||
(raw) => _shareFromJson(raw, sharedWithMe: sharedWithMe),
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// Creates a public link share for [path] and returns its share URL.
|
||||
|
||||
+35
-124
@@ -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 = <Widget>[
|
||||
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<Widget> 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<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),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
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),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,10 +255,7 @@ class _MoveCopyDestinationPickerState extends State<MoveCopyDestinationPicker> {
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 44,
|
||||
child: FilesControlsRow(
|
||||
folderPath: _currentPath,
|
||||
showStorageScope: false,
|
||||
),
|
||||
child: FilesControlsRow(folderPath: _currentPath),
|
||||
),
|
||||
if (hasBreadcrumbs) ...[
|
||||
const SizedBox(height: 10),
|
||||
|
||||
+70
-55
@@ -484,65 +484,80 @@ class _PhotosViewState extends State<PhotosView> {
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -180,10 +180,7 @@ class _ShareUploadViewState extends State<ShareUploadView> {
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 44,
|
||||
child: FilesControlsRow(
|
||||
folderPath: currentPath,
|
||||
showStorageScope: false,
|
||||
),
|
||||
child: FilesControlsRow(folderPath: currentPath),
|
||||
),
|
||||
if (hasBreadcrumbs) ...[
|
||||
const SizedBox(height: 10),
|
||||
|
||||
@@ -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<FilesTypeFilter>(
|
||||
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',
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -11,7 +11,11 @@ class NooSegmentOption<T> {
|
||||
|
||||
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<T> extends StatelessWidget {
|
||||
final List<NooSegmentOption<T>> options;
|
||||
final T value;
|
||||
final ValueChanged<T>? 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<T> extends StatelessWidget {
|
||||
this.size = NooSegmentedSize.md,
|
||||
this.fill = false,
|
||||
this.iconOnly = false,
|
||||
this.labelOnlySelected = false,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -44,10 +59,13 @@ class NooSegmentedControl<T> 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<T> 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<T> 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Widget> 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<NooGroupedList> createState() => _NooGroupedListState();
|
||||
}
|
||||
|
||||
class _NooGroupedListState extends State<NooGroupedList> {
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Widget> 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)],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<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', () {
|
||||
|
||||
@@ -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<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', () {
|
||||
|
||||
Reference in New Issue
Block a user