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:
@@ -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 ▾").
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
+26
-115
@@ -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: [
|
|
||||||
ListView(
|
|
||||||
controller: _scrollController,
|
|
||||||
padding: const EdgeInsets.fromLTRB(
|
padding: const EdgeInsets.fromLTRB(
|
||||||
NooSpace.sm,
|
NooSpace.sm,
|
||||||
NooSpace.sm,
|
NooSpace.sm,
|
||||||
NooSpace.xxl,
|
NooSpace.sm,
|
||||||
NooSpace.xxl,
|
NooSpace.xxl,
|
||||||
),
|
),
|
||||||
physics: const BouncingScrollPhysics(),
|
physics: const BouncingScrollPhysics(),
|
||||||
children: [
|
children: [
|
||||||
for (final (label, _, child) in widget.sections) ...[
|
for (final section in sections) ...[
|
||||||
KeyedSubtree(key: _sectionKey(label), child: child),
|
section,
|
||||||
const SizedBox(height: NooSpace.xl),
|
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),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -484,29 +484,43 @@ 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
|
||||||
|
// 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]),
|
listenable: Listenable.merge([photos, files]),
|
||||||
builder: (context, _) => Column(
|
builder: (context, _) => Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
// Same segmented-control design as Settings' Theme row - a
|
// Same "icon always, label only when selected" pill as
|
||||||
// full-width pill track, not chips, and its own row rather
|
// Files' own type filter - not a fully-labelled track, so
|
||||||
// than a settings row's trailing slot (matches the
|
// the two screens' filter sheets look and behave the same.
|
||||||
// Ascending/Descending control above, in `_showSortSheet`).
|
|
||||||
NooSegmentedControl<_PhotoTypeFilter>(
|
NooSegmentedControl<_PhotoTypeFilter>(
|
||||||
fill: true,
|
fill: true,
|
||||||
|
labelOnlySelected: true,
|
||||||
value: _typeFilter,
|
value: _typeFilter,
|
||||||
onChanged: (filter) => setState(() => _typeFilter = filter),
|
onChanged: (filter) => setState(() {
|
||||||
|
_typeFilter = filter;
|
||||||
|
setSheetState(() {});
|
||||||
|
}),
|
||||||
options: const [
|
options: const [
|
||||||
NooSegmentOption(value: _PhotoTypeFilter.all, label: 'All'),
|
NooSegmentOption(
|
||||||
|
value: _PhotoTypeFilter.all,
|
||||||
|
icon: LucideIcons.layoutGrid,
|
||||||
|
label: 'All',
|
||||||
|
),
|
||||||
NooSegmentOption(
|
NooSegmentOption(
|
||||||
value: _PhotoTypeFilter.image,
|
value: _PhotoTypeFilter.image,
|
||||||
|
icon: LucideIcons.image,
|
||||||
label: 'Photos',
|
label: 'Photos',
|
||||||
),
|
),
|
||||||
NooSegmentOption(
|
NooSegmentOption(
|
||||||
value: _PhotoTypeFilter.video,
|
value: _PhotoTypeFilter.video,
|
||||||
|
icon: LucideIcons.film,
|
||||||
label: 'Videos',
|
label: 'Videos',
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -545,6 +559,7 @@ class _PhotosViewState extends State<PhotosView> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,24 +31,87 @@ 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),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
],
|
||||||
|
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,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
Text(label!, style: NooText.label.copyWith(color: colors.fg2)),
|
Text(label, style: NooText.label.copyWith(color: colors.fg2)),
|
||||||
if (aside != null)
|
Row(
|
||||||
Text(
|
mainAxisSize: MainAxisSize.min,
|
||||||
aside!,
|
children: [
|
||||||
|
if (widget.aside != null)
|
||||||
|
Padding(
|
||||||
|
padding: EdgeInsets.only(right: widget.collapsible ? 8 : 0),
|
||||||
|
child: Text(
|
||||||
|
widget.aside!,
|
||||||
style: NooText.body.copyWith(
|
style: NooText.body.copyWith(
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
height: 1,
|
height: 1,
|
||||||
@@ -45,35 +119,27 @@ class NooGroupedList extends StatelessWidget {
|
|||||||
color: colors.fg3,
|
color: colors.fg3,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
if (widget.collapsible)
|
||||||
if (label != null) const SizedBox(height: 8),
|
AnimatedRotation(
|
||||||
ClipRRect(
|
turns: expanded ? 0.5 : 0,
|
||||||
borderRadius: BorderRadius.circular(NooRadii.card),
|
duration: NooMotion.fast,
|
||||||
child: DecoratedBox(
|
curve: NooMotion.ease,
|
||||||
decoration: BoxDecoration(color: colors.line),
|
child: Icon(
|
||||||
child: Column(
|
LucideIcons.chevronDown,
|
||||||
children: [
|
size: 16,
|
||||||
for (var i = 0; i < children.length; i++) ...[
|
color: colors.fg3,
|
||||||
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!,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
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 '../../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,
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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', () {
|
||||||
|
|||||||
@@ -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', () {
|
||||||
|
|||||||
Reference in New Issue
Block a user