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:
2026-09-26 01:51:58 -04:00
co-authored by Claude Opus 5.5
parent 51682a901d
commit 56784ef212
125 changed files with 15263 additions and 6804 deletions
+1 -1
View File
@@ -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);
});
}
+265
View File
@@ -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();
});
});
}
+224
View File
@@ -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);
});
});
}
+67
View File
@@ -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;
}
+336
View File
@@ -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);
});
});
}