showNooSheet's peek detection previously measured content once, right
after the first frame - Details' Versions/Activity tabs fetch their
data asynchronously, so that one-shot measurement only ever saw the
initial loading-spinner height, never the eventual (often long) list,
and the sheet still snapped to full height once data arrived. Replaced
it with a ScrollMetricsNotification-based check that keeps content
capped at the peek height from frame one and upgrades to a draggable
sheet whenever it actually overflows that cap, however and whenever
that happens - fixed for good this time, with a regression test that
mirrors the async-load shape.
Also, in the same pass:
- Upload/Move/Copy's bottom bar meta line now always reads as a count
("Uploading 3 items"), never a filename for the single-item case,
and is centered rather than left-aligned - matches the approved
design and removes a bespoke marquee/chip implementation that only
Upload had.
- Upload/Move/Copy now show the same FilesControlsRow (sort, filter,
List/Grid) Files itself uses, including a folder-grid rendering
path for when List/Grid is set to grid.
- Files' filter chip reads "Filters" instead of "All types" when idle.
- Trash's overflow button - whose only action is "Delete forever" -
is now a direct delete icon (mobile) / "Delete" danger pill
(desktop) instead of a generic "..." menu that hid a single action
behind an extra tap.
- The media viewer's floating chrome (top bar, bottom action bar,
video transport row) now follows the app's light/dark theme instead
of a fixed dark panel - a dark panel read as out of place over a
light-theme stage. Panel tint and icon/text colors come from the
theme's own surface/fg1 tokens (at a higher blur opacity to stay
legible over arbitrary photo/video brightness), and the edge scrim
gradients fade toward the panel's own tint instead of a fixed black.
Mirrored the same change into the design canvas's Main.dc.html/
MainAndroid.dc.html mockups (now with a theme prop) so they match.
534 lines
16 KiB
Dart
534 lines
16 KiB
Dart
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')),
|
|
);
|
|
|
|
/// A short placeholder that swaps in a long list one microtask later -
|
|
/// standing in for a sheet tab that fetches its content on first build
|
|
/// (`_requested`, as `DetailsVersionsTab`/`DetailsActivityTab` do) rather
|
|
/// than having it all synchronously on the very first frame.
|
|
class _AsyncGrowingContent extends StatefulWidget {
|
|
const _AsyncGrowingContent();
|
|
|
|
@override
|
|
State<_AsyncGrowingContent> createState() => _AsyncGrowingContentState();
|
|
}
|
|
|
|
class _AsyncGrowingContentState extends State<_AsyncGrowingContent> {
|
|
bool _loaded = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
Future.delayed(Duration.zero, () {
|
|
if (mounted) setState(() => _loaded = true);
|
|
});
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
if (!_loaded) {
|
|
return const SizedBox(height: 40, child: Center(child: Text('Loading')));
|
|
}
|
|
return Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
for (var i = 0; i < 30; i++)
|
|
SizedBox(height: 40, child: Text('Row $i')),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
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);
|
|
});
|
|
|
|
testNooWidgets('long content opens as a draggable peek, not full height', (
|
|
tester,
|
|
theme,
|
|
c,
|
|
) async {
|
|
await pumpNoo(
|
|
tester,
|
|
_launcher(
|
|
(ctx) => showNooSheet<void>(
|
|
ctx,
|
|
children: [
|
|
for (var i = 0; i < 30; i++)
|
|
SizedBox(height: 40, child: Text('Row $i')),
|
|
],
|
|
),
|
|
),
|
|
theme: theme,
|
|
surfaceSize: const Size(400, 700),
|
|
);
|
|
await tester.tap(find.text('open'));
|
|
await tester.pumpAndSettle();
|
|
|
|
// Content (30 * (40 + 22) =~ 1860px) far exceeds the peek fraction of
|
|
// the 700px-tall surface, so it should switch to a draggable sheet
|
|
// instead of the plain content-sized scroll view, opened at a peek
|
|
// (well under half the content height) rather than snapping to
|
|
// (near) the full 700px available.
|
|
expect(find.text('Row 0'), findsOneWidget);
|
|
final sheetHeight = tester
|
|
.getSize(find.byType(DraggableScrollableSheet))
|
|
.height;
|
|
expect(sheetHeight, lessThan(500));
|
|
expect(sheetHeight, greaterThan(150));
|
|
});
|
|
|
|
testNooWidgets(
|
|
'content that grows after an async load still switches to peek',
|
|
(tester, theme, c) async {
|
|
// Mirrors DetailsVersionsTab/DetailsActivityTab: a small placeholder
|
|
// first, then a long list once a fetch "completes" - the sheet has
|
|
// to catch the overflow whenever it happens, not just on its first
|
|
// frame (that one-shot approach is what shipped the original bug).
|
|
await pumpNoo(
|
|
tester,
|
|
_launcher(
|
|
(ctx) => showNooSheet<void>(
|
|
ctx,
|
|
children: [const _AsyncGrowingContent()],
|
|
),
|
|
),
|
|
theme: theme,
|
|
surfaceSize: const Size(400, 700),
|
|
);
|
|
await tester.tap(find.text('open'));
|
|
await tester.pump();
|
|
expect(find.byType(DraggableScrollableSheet), findsNothing);
|
|
|
|
await tester.pumpAndSettle();
|
|
expect(find.byType(DraggableScrollableSheet), findsOneWidget);
|
|
final sheetHeight = tester
|
|
.getSize(find.byType(DraggableScrollableSheet))
|
|
.height;
|
|
expect(sheetHeight, lessThan(500));
|
|
expect(sheetHeight, greaterThan(150));
|
|
},
|
|
);
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|
|
}
|