Rebuild UI on the Noo design system
Replaces the Material 3 screens with the Noo design system: warm neutrals, one violet accent, pill controls, Schibsted Grotesk/ Instrument Sans, Lucide icons, no gradients/shadows. - New lib/widgets/noo/ component kit (core, lists, files, media, nav, overlays) plus lib/theme/design_tokens.dart for the color/type/space/ radius/motion tokens. - Rebuilt the app shell (top/bottom bars, drawer, desktop sidebar and toolbar), every tab (Files/Offline, Photos, Favorites, Recent, Activity, Trash, Shares), Settings, the lock and login screens, the media viewer, search, the details/share sheets, and the share-upload and move/copy destination pickers. - Added a design canvas (linked from DESIGN_SYSTEM.md) covering the screens the spec didn't already describe, with matching Android and iOS chrome; wrote up the approved recipes into DESIGN_SYSTEM.md §4. - Removed now-dead legacy widgets (media_grid_tile, swipeable_item, sync_status_badge, selectable_thumbnail) and updated architecture.md/styling.md/standards.md to describe the new structure and component/testing conventions. - Added widget tests for the noo/ component kit. This is a UI-only rework: no provider/model/service behavior changed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -114,7 +114,7 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Noo'), findsOneWidget);
|
||||
expect(find.text('Server Address'), findsOneWidget);
|
||||
expect(find.text('cloud.example.com'), findsOneWidget);
|
||||
expect(find.text('Continue'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:noo/theme/design_tokens.dart';
|
||||
import 'package:noo/widgets/noo/core/noo_avatar.dart';
|
||||
import 'package:noo/widgets/noo/core/noo_badge.dart';
|
||||
import 'package:noo/widgets/noo/core/noo_button.dart';
|
||||
import 'package:noo/widgets/noo/core/noo_chip.dart';
|
||||
import 'package:noo/widgets/noo/core/noo_fab.dart';
|
||||
import 'package:noo/widgets/noo/core/noo_progress_bar.dart';
|
||||
import 'package:noo/widgets/noo/core/noo_search_field.dart';
|
||||
import 'package:noo/widgets/noo/core/noo_segmented_control.dart';
|
||||
import 'package:noo/widgets/noo/core/noo_toggle.dart';
|
||||
|
||||
import 'noo_test_utils.dart';
|
||||
|
||||
void main() {
|
||||
setUpNooTests();
|
||||
|
||||
group('NooAvatar', () {
|
||||
testNooWidgets('current user uses accent-soft/accent-text', (tester, theme, c) async {
|
||||
await pumpNoo(tester, const NooAvatar(initials: 'AY', current: true), theme: theme);
|
||||
expect(find.text('AY'), findsOneWidget);
|
||||
expect(decorationOf(tester, find.text('AY')).color, c.accentSoft);
|
||||
expect(tester.widget<Text>(find.text('AY')).style!.color, c.accentText);
|
||||
});
|
||||
|
||||
testNooWidgets('palette avatar uses fixed dark text', (tester, theme, c) async {
|
||||
await pumpNoo(tester, NooAvatar(initials: 'JD', color: NooColors.avatarPalette[3]), theme: theme);
|
||||
expect(decorationOf(tester, find.text('JD')).color, NooColors.avatarPalette[3]);
|
||||
expect(tester.widget<Text>(find.text('JD')).style!.color, NooColors.avatarTextColor);
|
||||
});
|
||||
|
||||
testWidgets('icon avatar and size', (tester) async {
|
||||
await pumpNoo(tester, const NooAvatar(icon: LucideIcons.users, size: 48));
|
||||
expect(find.byIcon(LucideIcons.users), findsOneWidget);
|
||||
expect(tester.getSize(find.byType(NooAvatar)), const Size(48, 48));
|
||||
});
|
||||
});
|
||||
|
||||
group('NooBadge', () {
|
||||
testNooWidgets('tones map to soft/strong pairs', (tester, theme, c) async {
|
||||
await pumpNoo(
|
||||
tester,
|
||||
const Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
NooBadge(child: Text('ok')),
|
||||
NooBadge(tone: NooBadgeTone.danger, child: Text('bad')),
|
||||
],
|
||||
),
|
||||
theme: theme,
|
||||
);
|
||||
expect(decorationOf(tester, find.text('ok')).color, c.successSoft);
|
||||
expect(decorationOf(tester, find.text('bad')).color, c.dangerSoft);
|
||||
expect(tester.getSize(find.byType(NooBadge).first).height, 24);
|
||||
});
|
||||
});
|
||||
|
||||
group('NooButton', () {
|
||||
testNooWidgets('primary renders and fires onTap', (tester, theme, c) async {
|
||||
var taps = 0;
|
||||
await pumpNoo(
|
||||
tester,
|
||||
NooButton(icon: LucideIcons.upload, onTap: () => taps++, child: const Text('Upload')),
|
||||
theme: theme,
|
||||
);
|
||||
expect(decorationOf(tester, find.text('Upload')).color, c.accent);
|
||||
await tester.tap(find.text('Upload'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(taps, 1);
|
||||
});
|
||||
|
||||
testWidgets('disabled ignores taps', (tester) async {
|
||||
var taps = 0;
|
||||
await pumpNoo(tester, NooButton(disabled: true, onTap: () => taps++, child: const Text('No')));
|
||||
await tester.tap(find.text('No'), warnIfMissed: false);
|
||||
await tester.pumpAndSettle();
|
||||
expect(taps, 0);
|
||||
});
|
||||
|
||||
testWidgets('shrink-wraps unless fullWidth, heights follow size', (tester) async {
|
||||
await pumpNoo(
|
||||
tester,
|
||||
const Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
NooButton(key: Key('a'), size: NooButtonSize.cta, child: Text('A')),
|
||||
NooButton(key: Key('b'), fullWidth: true, child: Text('B')),
|
||||
],
|
||||
),
|
||||
);
|
||||
final a = tester.getSize(find.byKey(const Key('a')));
|
||||
final b = tester.getSize(find.byKey(const Key('b')));
|
||||
expect(a.height, NooSizes.buttonCta);
|
||||
expect(a.width, lessThan(200));
|
||||
expect(b.height, NooSizes.buttonToolbar);
|
||||
expect(b.width, greaterThan(700));
|
||||
});
|
||||
|
||||
testNooWidgets('variants pick matching fill', (tester, theme, c) async {
|
||||
await pumpNoo(
|
||||
tester,
|
||||
const Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
NooButton(variant: NooButtonVariant.tonal, child: Text('t')),
|
||||
NooButton(variant: NooButtonVariant.secondary, child: Text('s')),
|
||||
NooButton(variant: NooButtonVariant.danger, child: Text('d')),
|
||||
],
|
||||
),
|
||||
theme: theme,
|
||||
);
|
||||
expect(decorationOf(tester, find.text('t')).color, c.accentSoft);
|
||||
expect(decorationOf(tester, find.text('s')).color, c.surface2);
|
||||
expect(decorationOf(tester, find.text('d')).color, c.dangerSoft);
|
||||
});
|
||||
});
|
||||
|
||||
group('NooChip', () {
|
||||
testNooWidgets('selected vs idle fill, onTap fires', (tester, theme, c) async {
|
||||
var taps = 0;
|
||||
await pumpNoo(
|
||||
tester,
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
NooChip(selected: true, onTap: () => taps++, child: const Text('Name')),
|
||||
const NooChip(trailing: NooChipTrailing.menu, child: Text('Type')),
|
||||
],
|
||||
),
|
||||
theme: theme,
|
||||
);
|
||||
expect(decorationOf(tester, find.text('Name')).color, c.accentSoft);
|
||||
expect(decorationOf(tester, find.text('Type')).color, c.surface);
|
||||
await tester.tap(find.text('Name'));
|
||||
expect(taps, 1);
|
||||
});
|
||||
|
||||
testWidgets('outline chip is 32px with a line border', (tester) async {
|
||||
await pumpNoo(tester, const NooChip(outline: true, child: Text('Modified')));
|
||||
expect(tester.getSize(find.byType(NooChip)).height, 32);
|
||||
final border = decorationOf(tester, find.text('Modified')).border as Border;
|
||||
expect(border.top.color, NooColors.light.line);
|
||||
});
|
||||
});
|
||||
|
||||
group('NooFab', () {
|
||||
testNooWidgets('shows label and fires onTap', (tester, theme, c) async {
|
||||
var taps = 0;
|
||||
await pumpNoo(tester, NooFab(onTap: () => taps++), theme: theme);
|
||||
expect(find.text('Upload'), findsOneWidget);
|
||||
expect(tester.getSize(find.byType(NooFab)).height, 56);
|
||||
expect(tester.getSize(find.byType(NooFab)).width, lessThan(200));
|
||||
await tester.tap(find.byType(NooFab));
|
||||
expect(taps, 1);
|
||||
});
|
||||
});
|
||||
|
||||
group('NooProgressBar', () {
|
||||
testNooWidgets('clamps value and uses track/fill tokens', (tester, theme, c) async {
|
||||
await pumpNoo(tester, const SizedBox(width: 200, child: NooProgressBar(value: 1.7)), theme: theme);
|
||||
final bar = tester.widget<LinearProgressIndicator>(find.byType(LinearProgressIndicator));
|
||||
expect(bar.value, 1.0);
|
||||
expect(bar.backgroundColor, c.surface3);
|
||||
expect(bar.valueColor!.value, c.accent);
|
||||
expect(tester.getSize(find.byType(NooProgressBar)).height, 6);
|
||||
});
|
||||
});
|
||||
|
||||
group('NooSearchField', () {
|
||||
testNooWidgets('placeholder, typing, surface fill', (tester, theme, c) async {
|
||||
String? typed;
|
||||
await pumpNoo(
|
||||
tester,
|
||||
SizedBox(width: 260, child: NooSearchField(onSurface: true, onChanged: (v) => typed = v)),
|
||||
theme: theme,
|
||||
);
|
||||
expect(find.text('Search'), findsOneWidget);
|
||||
expect(decorationOf(tester, find.byType(TextField)).color, c.surface2);
|
||||
await tester.enterText(find.byType(TextField), 'cats');
|
||||
expect(typed, 'cats');
|
||||
});
|
||||
});
|
||||
|
||||
group('NooSegmentedControl', () {
|
||||
Widget host() {
|
||||
var value = 'list';
|
||||
return StatefulBuilder(
|
||||
builder: (context, setState) => NooSegmentedControl<String>(
|
||||
value: value,
|
||||
onChanged: (v) => setState(() => value = v),
|
||||
options: const [
|
||||
NooSegmentOption(value: 'list', label: 'List'),
|
||||
NooSegmentOption(value: 'grid', label: 'Grid'),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
testNooWidgets('tapping a segment moves the active fill', (tester, theme, c) async {
|
||||
await pumpNoo(tester, host(), theme: theme);
|
||||
expect(decorationOf(tester, find.text('List')).color, c.accentSoft);
|
||||
expect(tester.widget<Text>(find.text('Grid')).style!.color, c.fg2);
|
||||
|
||||
await tester.tap(find.text('Grid'));
|
||||
await tester.pump();
|
||||
expect(decorationOf(tester, find.text('Grid')).color, c.accentSoft);
|
||||
expect(tester.widget<Text>(find.text('Grid')).style!.color, c.accentText);
|
||||
expect(tester.widget<Text>(find.text('List')).style!.color, c.fg2);
|
||||
});
|
||||
|
||||
testWidgets('icon-only segments are 36x28', (tester) async {
|
||||
await pumpNoo(
|
||||
tester,
|
||||
NooSegmentedControl<int>(
|
||||
value: 0,
|
||||
iconOnly: true,
|
||||
options: const [
|
||||
NooSegmentOption(value: 0, icon: LucideIcons.list),
|
||||
NooSegmentOption(value: 1, icon: LucideIcons.layoutGrid),
|
||||
],
|
||||
),
|
||||
);
|
||||
expect(find.byType(Text), findsNothing);
|
||||
// 2 x 36 wide segments + 3px track padding each side.
|
||||
expect(tester.getSize(find.byType(NooSegmentedControl<int>)), const Size(78, 34));
|
||||
});
|
||||
});
|
||||
|
||||
group('NooToggle', () {
|
||||
testNooWidgets('tapping flips state and track colour', (tester, theme, c) async {
|
||||
var on = false;
|
||||
await pumpNoo(
|
||||
tester,
|
||||
StatefulBuilder(
|
||||
builder: (context, setState) =>
|
||||
NooToggle(checked: on, onChanged: (v) => setState(() => on = v)),
|
||||
),
|
||||
theme: theme,
|
||||
);
|
||||
expect(tester.getSize(find.byType(NooToggle)), const Size(48, 28));
|
||||
Color track() => (tester.widget<AnimatedContainer>(find.byType(AnimatedContainer)).decoration!
|
||||
as BoxDecoration)
|
||||
.color!;
|
||||
expect(track(), c.surface3);
|
||||
|
||||
await tester.tap(find.byType(NooToggle));
|
||||
await tester.pumpAndSettle();
|
||||
expect(on, isTrue);
|
||||
expect(track(), c.accent);
|
||||
|
||||
await tester.tap(find.byType(NooToggle));
|
||||
await tester.pumpAndSettle();
|
||||
expect(on, isFalse);
|
||||
});
|
||||
|
||||
testWidgets('desktop size and null onChanged is inert', (tester) async {
|
||||
await pumpNoo(tester, const NooToggle(checked: true, desktop: true));
|
||||
expect(tester.getSize(find.byType(NooToggle)), const Size(44, 26));
|
||||
await tester.tap(find.byType(NooToggle));
|
||||
await tester.pumpAndSettle();
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:noo/widgets/noo/core/noo_progress_bar.dart';
|
||||
import 'package:noo/widgets/noo/core/noo_toggle.dart';
|
||||
import 'package:noo/widgets/noo/lists/noo_banner.dart';
|
||||
import 'package:noo/widgets/noo/lists/noo_grouped_list.dart';
|
||||
import 'package:noo/widgets/noo/lists/noo_settings_row.dart';
|
||||
import 'package:noo/widgets/noo/lists/noo_summary_card.dart';
|
||||
import 'package:noo/widgets/noo/lists/noo_tab_order_row.dart';
|
||||
|
||||
import 'noo_test_utils.dart';
|
||||
|
||||
void main() {
|
||||
setUpNooTests();
|
||||
|
||||
group('NooBanner', () {
|
||||
testNooWidgets('renders text and fires action', (tester, theme, c) async {
|
||||
var taps = 0;
|
||||
await pumpNoo(
|
||||
tester,
|
||||
NooBanner(
|
||||
actionLabel: 'Empty trash',
|
||||
onAction: () => taps++,
|
||||
child: const Text('Items are deleted after 30 days'),
|
||||
),
|
||||
theme: theme,
|
||||
);
|
||||
expect(find.byIcon(LucideIcons.info), findsOneWidget);
|
||||
expect(decorationOf(tester, find.text('Empty trash')).color, c.surface);
|
||||
expect(tester.widget<Text>(find.text('Empty trash')).style!.color, c.danger);
|
||||
await tester.tap(find.text('Empty trash'));
|
||||
expect(taps, 1);
|
||||
});
|
||||
|
||||
testNooWidgets('non-danger action uses accent text', (tester, theme, c) async {
|
||||
await pumpNoo(
|
||||
tester,
|
||||
const NooBanner(actionLabel: 'Undo', actionIsDanger: false, child: Text('x')),
|
||||
theme: theme,
|
||||
);
|
||||
expect(tester.widget<Text>(find.text('Undo')).style!.color, c.accentText);
|
||||
});
|
||||
});
|
||||
|
||||
group('NooGroupedList', () {
|
||||
testNooWidgets('label, aside, 1px line gaps and footer', (tester, theme, c) async {
|
||||
await pumpNoo(
|
||||
tester,
|
||||
SizedBox(
|
||||
width: 360,
|
||||
child: NooGroupedList(
|
||||
label: 'Accounts',
|
||||
aside: '2',
|
||||
footer: const Text('Footer note'),
|
||||
children: [
|
||||
Container(key: const Key('r1'), height: 52, color: c.surface),
|
||||
Container(key: const Key('r2'), height: 52, color: c.surface),
|
||||
],
|
||||
),
|
||||
),
|
||||
theme: theme,
|
||||
);
|
||||
expect(find.text('Accounts'), findsOneWidget);
|
||||
expect(find.text('2'), findsOneWidget);
|
||||
expect(find.text('Footer note'), findsOneWidget);
|
||||
expect(tester.widget<Text>(find.text('Accounts')).style!.color, c.fg2);
|
||||
final gap = tester.getTopLeft(find.byKey(const Key('r2'))).dy -
|
||||
tester.getBottomLeft(find.byKey(const Key('r1'))).dy;
|
||||
expect(gap, 1);
|
||||
// Rows paint their own surface; the gaps show the list's line fill.
|
||||
expect(decorationOf(tester, find.byKey(const Key('r1'))).color, c.line);
|
||||
});
|
||||
});
|
||||
|
||||
group('NooSettingsRow', () {
|
||||
testNooWidgets('value row shows chevron and fires onTap', (tester, theme, c) async {
|
||||
var taps = 0;
|
||||
await pumpNoo(
|
||||
tester,
|
||||
SizedBox(
|
||||
width: 360,
|
||||
child: NooGroupedList(
|
||||
children: [
|
||||
NooSettingsRow(
|
||||
icon: LucideIcons.palette,
|
||||
label: const Text('Theme'),
|
||||
value: 'System',
|
||||
onTap: () => taps++,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
theme: theme,
|
||||
);
|
||||
expect(find.text('System'), findsOneWidget);
|
||||
expect(find.byIcon(LucideIcons.chevronRight), findsOneWidget);
|
||||
expect(tester.getSize(find.byType(NooSettingsRow)).height, 52);
|
||||
await tester.tap(find.text('Theme'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(taps, 1);
|
||||
});
|
||||
|
||||
testWidgets('subtitle row is 60px, trailing wins over value', (tester) async {
|
||||
await pumpNoo(
|
||||
tester,
|
||||
const SizedBox(
|
||||
width: 600,
|
||||
child: NooGroupedList(
|
||||
children: [
|
||||
NooSettingsRow(
|
||||
label: Text('Sync on Wi-Fi only'),
|
||||
subtitle: Text('Saves mobile data'),
|
||||
value: 'ignored',
|
||||
trailing: NooToggle(checked: true),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(find.text('ignored'), findsNothing);
|
||||
expect(find.byType(NooToggle), findsOneWidget);
|
||||
expect(tester.getSize(find.byType(NooSettingsRow)).height, 60);
|
||||
});
|
||||
|
||||
testWidgets('keeps its 52px height inside a tall bounded parent', (tester) async {
|
||||
await pumpNoo(
|
||||
tester,
|
||||
const SizedBox(
|
||||
width: 360,
|
||||
height: 400,
|
||||
child: Align(
|
||||
alignment: Alignment.topCenter,
|
||||
child: NooSettingsRow(label: Text('Theme'), value: 'System'),
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(tester.getSize(find.byType(NooSettingsRow)).height, 52);
|
||||
});
|
||||
|
||||
testNooWidgets('danger row uses danger colour', (tester, theme, c) async {
|
||||
await pumpNoo(
|
||||
tester,
|
||||
const SizedBox(
|
||||
width: 360,
|
||||
child: NooSettingsRow(icon: LucideIcons.logOut, label: Text('Log out'), danger: true),
|
||||
),
|
||||
theme: theme,
|
||||
);
|
||||
final style = DefaultTextStyle.of(tester.element(find.text('Log out'))).style;
|
||||
expect(style.color, c.danger);
|
||||
expect(tester.widget<Icon>(find.byIcon(LucideIcons.logOut)).color, c.danger);
|
||||
});
|
||||
});
|
||||
|
||||
group('NooSummaryCard', () {
|
||||
testNooWidgets('stat, caption, progress, meta, action', (tester, theme, c) async {
|
||||
await pumpNoo(
|
||||
tester,
|
||||
const SizedBox(
|
||||
width: 360,
|
||||
child: NooSummaryCard(
|
||||
stat: '2.4 GB',
|
||||
caption: 'Offline',
|
||||
progress: 0.4,
|
||||
meta: 'Synced 2 min ago',
|
||||
action: Text('Sync now'),
|
||||
),
|
||||
),
|
||||
theme: theme,
|
||||
);
|
||||
for (final t in ['2.4 GB', 'Offline', 'Synced 2 min ago', 'Sync now']) {
|
||||
expect(find.text(t), findsOneWidget);
|
||||
}
|
||||
expect(find.byType(NooProgressBar), findsOneWidget);
|
||||
expect(decorationOf(tester, find.text('2.4 GB')).color, c.surface);
|
||||
expect(tester.widget<Text>(find.text('2.4 GB')).style!.color, c.fg1);
|
||||
});
|
||||
|
||||
testNooWidgets('danger tone', (tester, theme, c) async {
|
||||
await pumpNoo(
|
||||
tester,
|
||||
const SizedBox(
|
||||
width: 360,
|
||||
child: NooSummaryCard(stat: '3', caption: 'Errors', tone: NooSummaryCardTone.danger),
|
||||
),
|
||||
theme: theme,
|
||||
);
|
||||
expect(find.byType(NooProgressBar), findsNothing);
|
||||
expect(decorationOf(tester, find.text('3')).color, c.dangerSoft);
|
||||
expect(tester.widget<Text>(find.text('3')).style!.color, c.danger);
|
||||
expect(tester.widget<Text>(find.text('Errors')).style!.color, c.danger);
|
||||
});
|
||||
});
|
||||
|
||||
group('NooTabOrderRow', () {
|
||||
testNooWidgets('pin state drives icon/fill, toggle fires', (tester, theme, c) async {
|
||||
var pinned = true;
|
||||
await pumpNoo(
|
||||
tester,
|
||||
SizedBox(
|
||||
width: 360,
|
||||
child: StatefulBuilder(
|
||||
builder: (context, setState) => NooTabOrderRow(
|
||||
icon: LucideIcons.folder,
|
||||
label: 'Files',
|
||||
pinned: pinned,
|
||||
onTogglePin: () => setState(() => pinned = !pinned),
|
||||
),
|
||||
),
|
||||
),
|
||||
theme: theme,
|
||||
);
|
||||
expect(find.text('Files'), findsOneWidget);
|
||||
expect(decorationOf(tester, find.byIcon(LucideIcons.pin)).color, c.accentSoft);
|
||||
|
||||
await tester.tap(find.byIcon(LucideIcons.pin));
|
||||
await tester.pump();
|
||||
expect(pinned, isFalse);
|
||||
expect(find.byIcon(LucideIcons.pin), findsNothing);
|
||||
expect(decorationOf(tester, find.byIcon(LucideIcons.pinOff)).color, c.surface2);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:noo/theme/app_theme.dart';
|
||||
import 'package:noo/theme/design_tokens.dart';
|
||||
|
||||
/// Shared setup for the design-system component tests. Google Fonts can't
|
||||
/// fetch in the sandbox, so runtime fetching is off (see standards.md
|
||||
/// "Testing"); the resulting missing-font load errors are async and don't
|
||||
/// affect layout assertions.
|
||||
void setUpNooTests() {
|
||||
setUpAll(() => GoogleFonts.config.allowRuntimeFetching = false);
|
||||
}
|
||||
|
||||
/// Themes built through the real [AppTheme] (non-dynamic), so the
|
||||
/// [NooColors] extension is registered exactly as in the app. Built lazily
|
||||
/// inside test bodies - AppTheme touches Google Fonts, which needs the test
|
||||
/// binding to exist first.
|
||||
ThemeData nooTheme(Brightness brightness) => brightness == Brightness.light
|
||||
? AppTheme.light(AppTheme.defaultNextcloudBlue, useDynamicColor: false)
|
||||
: AppTheme.dark(AppTheme.defaultNextcloudBlue, useDynamicColor: false);
|
||||
|
||||
Future<void> pumpNoo(
|
||||
WidgetTester tester,
|
||||
Widget child, {
|
||||
ThemeData? theme,
|
||||
Size surfaceSize = const Size(800, 900),
|
||||
}) async {
|
||||
await tester.binding.setSurfaceSize(surfaceSize);
|
||||
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: theme ?? nooTheme(Brightness.light),
|
||||
home: Scaffold(
|
||||
body: Center(
|
||||
child: Padding(padding: const EdgeInsets.all(16), child: child),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Runs [body] once per theme, each as its own named test.
|
||||
void testNooWidgets(
|
||||
String description,
|
||||
Future<void> Function(WidgetTester tester, ThemeData theme, NooColors colors) body,
|
||||
) {
|
||||
for (final brightness in Brightness.values) {
|
||||
testWidgets('$description (${brightness.name})', (tester) async {
|
||||
final theme = nooTheme(brightness);
|
||||
await body(tester, theme, theme.extension<NooColors>()!);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// The nearest colored [BoxDecoration] painted around [finder] (Container
|
||||
/// and AnimatedContainer both build a [DecoratedBox]).
|
||||
BoxDecoration decorationOf(WidgetTester tester, Finder finder) {
|
||||
final boxes = find.ancestor(
|
||||
of: finder,
|
||||
matching: find.byWidgetPredicate(
|
||||
(w) => w is DecoratedBox && w.decoration is BoxDecoration && (w.decoration as BoxDecoration).color != null,
|
||||
),
|
||||
matchRoot: true,
|
||||
);
|
||||
return tester.widget<DecoratedBox>(boxes.first).decoration as BoxDecoration;
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:noo/theme/design_tokens.dart';
|
||||
import 'package:noo/widgets/noo/core/noo_avatar.dart';
|
||||
import 'package:noo/widgets/noo/core/noo_button.dart';
|
||||
import 'package:noo/widgets/noo/overlays/noo_dialog.dart';
|
||||
import 'package:noo/widgets/noo/overlays/noo_overlay_header.dart';
|
||||
import 'package:noo/widgets/noo/overlays/noo_share_parts.dart';
|
||||
import 'package:noo/widgets/noo/overlays/noo_sheet.dart';
|
||||
import 'package:noo/widgets/noo/overlays/noo_text_field.dart';
|
||||
|
||||
import 'noo_test_utils.dart';
|
||||
|
||||
/// A button that opens an overlay from a context below MaterialApp.
|
||||
Widget _launcher(void Function(BuildContext) open) => Builder(
|
||||
builder: (context) => TextButton(onPressed: () => open(context), child: const Text('open')),
|
||||
);
|
||||
|
||||
Finder _dialogCard() => find.byWidgetPredicate(
|
||||
(w) => w is Container && w.decoration is BoxDecoration && (w.decoration as BoxDecoration).boxShadow != null,
|
||||
);
|
||||
|
||||
void main() {
|
||||
setUpNooTests();
|
||||
|
||||
group('NooOverlayHeader', () {
|
||||
testNooWidgets('title, subtitle, leading and close', (tester, theme, c) async {
|
||||
var closed = 0;
|
||||
await pumpNoo(
|
||||
tester,
|
||||
SizedBox(
|
||||
width: 360,
|
||||
child: NooOverlayHeader(
|
||||
leading: const Icon(LucideIcons.fileText),
|
||||
title: 'Report.pdf',
|
||||
subtitle: '2 MB · Documents',
|
||||
onClose: () => closed++,
|
||||
),
|
||||
),
|
||||
theme: theme,
|
||||
);
|
||||
expect(find.text('Report.pdf'), findsOneWidget);
|
||||
expect(tester.widget<Text>(find.text('2 MB · Documents')).style!.color, c.fg3);
|
||||
expect(decorationOf(tester, find.byIcon(LucideIcons.x)).color, c.surface2);
|
||||
await tester.tap(find.byIcon(LucideIcons.x));
|
||||
expect(closed, 1);
|
||||
});
|
||||
|
||||
testWidgets('no close button without onClose', (tester) async {
|
||||
await pumpNoo(tester, const SizedBox(width: 360, child: NooOverlayHeader(title: 'T')));
|
||||
expect(find.byIcon(LucideIcons.x), findsNothing);
|
||||
});
|
||||
});
|
||||
|
||||
group('showNooSheet', () {
|
||||
testNooWidgets('shows children on a surface sheet and dismisses', (tester, theme, c) async {
|
||||
await pumpNoo(
|
||||
tester,
|
||||
_launcher((ctx) => showNooSheet<void>(ctx, children: const [Text('Section A'), Text('Section B')])),
|
||||
theme: theme,
|
||||
);
|
||||
await tester.tap(find.text('open'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Section A'), findsOneWidget);
|
||||
expect(find.text('Section B'), findsOneWidget);
|
||||
final sheet = tester.widget<BottomSheet>(find.byType(BottomSheet));
|
||||
expect(sheet.backgroundColor, c.surface);
|
||||
final gap = tester.getTopLeft(find.text('Section B')).dy -
|
||||
tester.getBottomLeft(find.text('Section A')).dy;
|
||||
expect(gap, 22);
|
||||
|
||||
await tester.tapAt(const Offset(10, 10));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Section A'), findsNothing);
|
||||
});
|
||||
});
|
||||
|
||||
group('NooDialog', () {
|
||||
testNooWidgets('card is 540 wide, r24, single dialog shadow', (tester, theme, c) async {
|
||||
await pumpNoo(
|
||||
tester,
|
||||
NooDialog(title: 'Share "Report.pdf"', onClose: () {}, children: const [Text('body')]),
|
||||
theme: theme,
|
||||
surfaceSize: const Size(1200, 900),
|
||||
);
|
||||
final deco = decorationOf(tester, find.text('body'));
|
||||
expect(deco.color, c.surface);
|
||||
expect(deco.borderRadius, BorderRadius.circular(NooRadii.dialog));
|
||||
expect(deco.boxShadow, const [nooDialogShadow]);
|
||||
final card = _dialogCard();
|
||||
expect(tester.getSize(card).width, 540);
|
||||
});
|
||||
|
||||
testWidgets('narrow screens shrink the card to fit with a 24px margin', (tester) async {
|
||||
await tester.binding.setSurfaceSize(const Size(400, 800));
|
||||
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: nooTheme(Brightness.light),
|
||||
home: const NooDialog(title: 'T', children: [Text('body')]),
|
||||
),
|
||||
);
|
||||
final card = _dialogCard();
|
||||
expect(tester.getSize(card).width, 400 - 48);
|
||||
});
|
||||
|
||||
testNooWidgets('header close is a 32px surface-2 circle', (tester, theme, c) async {
|
||||
var closed = 0;
|
||||
await pumpNoo(
|
||||
tester,
|
||||
NooDialogHeader(
|
||||
leading: const Icon(LucideIcons.fileText),
|
||||
title: 'Share "Report.pdf"',
|
||||
subtitle: '2 MB · Documents',
|
||||
onClose: () => closed++,
|
||||
),
|
||||
theme: theme,
|
||||
);
|
||||
expect(tester.getSize(find.byType(NooCloseButton)), const Size(32, 32));
|
||||
expect(decorationOf(tester, find.byIcon(LucideIcons.x)).color, c.surface2);
|
||||
await tester.tap(find.byType(NooCloseButton));
|
||||
expect(closed, 1);
|
||||
});
|
||||
|
||||
testNooWidgets('showNooDialog uses the scrim and closes via the button', (tester, theme, c) async {
|
||||
Future<String?>? result;
|
||||
await pumpNoo(
|
||||
tester,
|
||||
_launcher(
|
||||
(ctx) => result = showNooDialog<String>(
|
||||
ctx,
|
||||
title: 'Share "Report.pdf"',
|
||||
children: const [Text('dialog body')],
|
||||
),
|
||||
),
|
||||
theme: theme,
|
||||
surfaceSize: const Size(1200, 900),
|
||||
);
|
||||
await tester.tap(find.text('open'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('dialog body'), findsOneWidget);
|
||||
final barrier = tester.widgetList<ModalBarrier>(find.byType(ModalBarrier)).last;
|
||||
expect(barrier.color, c.scrim);
|
||||
|
||||
await tester.tap(find.byType(NooCloseButton));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('dialog body'), findsNothing);
|
||||
expect(await result, isNull);
|
||||
});
|
||||
|
||||
testWidgets('barrier tap dismisses unless disabled', (tester) async {
|
||||
await pumpNoo(
|
||||
tester,
|
||||
_launcher(
|
||||
(ctx) => showNooDialog<void>(
|
||||
ctx,
|
||||
title: 'T',
|
||||
barrierDismissible: false,
|
||||
children: const [Text('sticky')],
|
||||
),
|
||||
),
|
||||
surfaceSize: const Size(1200, 900),
|
||||
);
|
||||
await tester.tap(find.text('open'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tapAt(const Offset(5, 5));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('sticky'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('long content scrolls instead of overflowing', (tester) async {
|
||||
await pumpNoo(
|
||||
tester,
|
||||
_launcher(
|
||||
(ctx) => showNooDialog<void>(
|
||||
ctx,
|
||||
title: 'T',
|
||||
children: [for (var i = 0; i < 40; i++) SizedBox(height: 60, child: Text('row $i'))],
|
||||
),
|
||||
),
|
||||
surfaceSize: const Size(1200, 600),
|
||||
);
|
||||
await tester.tap(find.text('open'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(tester.takeException(), isNull);
|
||||
expect(find.byType(SingleChildScrollView), findsOneWidget);
|
||||
});
|
||||
});
|
||||
|
||||
group('NooTextField', () {
|
||||
testNooWidgets('surface-2, r14, fg-3 placeholder, typing', (tester, theme, c) async {
|
||||
String? typed;
|
||||
await pumpNoo(
|
||||
tester,
|
||||
SizedBox(
|
||||
width: 400,
|
||||
child: NooTextField(placeholder: 'Name, email or group', onChanged: (v) => typed = v),
|
||||
),
|
||||
theme: theme,
|
||||
);
|
||||
final deco = decorationOf(tester, find.byType(TextField));
|
||||
expect(deco.color, c.surface2);
|
||||
expect(deco.borderRadius, BorderRadius.circular(NooRadii.input));
|
||||
final field = tester.widget<TextField>(find.byType(TextField));
|
||||
expect(field.decoration!.hintStyle!.color, c.fg3);
|
||||
expect(field.style!.color, c.fg1);
|
||||
await tester.enterText(find.byType(TextField), 'ali');
|
||||
expect(typed, 'ali');
|
||||
});
|
||||
|
||||
testNooWidgets('mono URL field with inline Copy link button', (tester, theme, c) async {
|
||||
var copied = 0;
|
||||
await pumpNoo(
|
||||
tester,
|
||||
SizedBox(
|
||||
width: 480,
|
||||
child: NooTextField(
|
||||
controller: TextEditingController(text: 'https://cloud.example/s/abc'),
|
||||
mono: true,
|
||||
readOnly: true,
|
||||
onSurface: false,
|
||||
trailing: NooButton(
|
||||
size: NooButtonSize.field,
|
||||
onTap: () => copied++,
|
||||
child: const Text('Copy link'),
|
||||
),
|
||||
),
|
||||
),
|
||||
theme: theme,
|
||||
);
|
||||
final field = tester.widget<TextField>(find.byType(TextField));
|
||||
expect(field.style!.fontFamily, NooText.mono.fontFamily);
|
||||
expect(field.readOnly, isTrue);
|
||||
expect(decorationOf(tester, find.byType(TextField)).color, c.surface);
|
||||
expect(tester.getSize(find.byType(NooButton)).height, NooSizes.buttonField);
|
||||
await tester.tap(find.text('Copy link'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(copied, 1);
|
||||
});
|
||||
});
|
||||
|
||||
group('Share parts', () {
|
||||
testNooWidgets('NooShareSection title, trailing and caption', (tester, theme, c) async {
|
||||
await pumpNoo(
|
||||
tester,
|
||||
const SizedBox(
|
||||
width: 400,
|
||||
child: NooShareSection(
|
||||
title: 'Send file directly',
|
||||
trailing: Icon(LucideIcons.link),
|
||||
caption: "Link settings don't apply",
|
||||
child: Text('content'),
|
||||
),
|
||||
),
|
||||
theme: theme,
|
||||
);
|
||||
expect(tester.widget<Text>(find.text('Send file directly')).style!.color, c.fg1);
|
||||
expect(tester.widget<Text>(find.text("Link settings don't apply")).style!.color, c.fg3);
|
||||
expect(find.byIcon(LucideIcons.link), findsOneWidget);
|
||||
expect(find.text('content'), findsOneWidget);
|
||||
});
|
||||
|
||||
testNooWidgets('owner row is static, others get a tappable pill', (tester, theme, c) async {
|
||||
var taps = 0;
|
||||
await pumpNoo(
|
||||
tester,
|
||||
SizedBox(
|
||||
width: 400,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const NooPersonAccessRow(
|
||||
avatar: NooAvatar(initials: 'AY', current: true),
|
||||
name: 'Ayushya (you)',
|
||||
subtitle: 'ayushya@example.com',
|
||||
owner: true,
|
||||
),
|
||||
NooPersonAccessRow(
|
||||
avatar: const NooAvatar(initials: 'JD'),
|
||||
name: 'Jane Doe',
|
||||
onPermissionTap: () => taps++,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
theme: theme,
|
||||
);
|
||||
expect(find.text('Owner'), findsOneWidget);
|
||||
expect(tester.widget<Text>(find.text('Owner')).style!.color, c.fg3);
|
||||
expect(find.byType(NooPermissionPill), findsOneWidget);
|
||||
expect(find.byIcon(LucideIcons.chevronDown), findsOneWidget);
|
||||
expect(decorationOf(tester, find.text('Can edit')).color, c.surface2);
|
||||
|
||||
await tester.tap(find.text('Can edit'));
|
||||
expect(taps, 1);
|
||||
});
|
||||
|
||||
testWidgets('same parts lay out inside the sheet and the dialog', (tester) async {
|
||||
List<Widget> parts() => [
|
||||
NooShareSection(
|
||||
title: 'Share with people',
|
||||
child: const NooTextField(placeholder: 'Name, email or group'),
|
||||
),
|
||||
const NooPersonAccessRow(avatar: NooAvatar(initials: 'JD'), name: 'Jane', permission: 'Can view'),
|
||||
];
|
||||
await pumpNoo(
|
||||
tester,
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_launcher((ctx) => showNooSheet<void>(ctx, children: parts())),
|
||||
Builder(
|
||||
builder: (ctx) => TextButton(
|
||||
onPressed: () => showNooDialog<void>(ctx, title: 'Share', children: parts()),
|
||||
child: const Text('dialog'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
surfaceSize: const Size(1200, 900),
|
||||
);
|
||||
await tester.tap(find.text('open'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Can view'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
await tester.tapAt(const Offset(5, 5));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('dialog'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Can view'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user