diff --git a/.claude/context/design-system/DESIGN_SYSTEM.md b/.claude/context/design-system/DESIGN_SYSTEM.md index 1ab5159..55f3de6 100644 --- a/.claude/context/design-system/DESIGN_SYSTEM.md +++ b/.claude/context/design-system/DESIGN_SYSTEM.md @@ -340,12 +340,16 @@ Sidebar items are 38px tall with radius 12, an 18px icon and a 14/500 label. The Location, Modified, Created, ...). Versions and Activity reuse their own row/feed treatment. Same on every platform - this sheet has no iOS/Android split. -- **Media viewer** (the full-screen photo/video viewer): a black stage - regardless of theme, like a native photo/video viewer - not `bg`. A - translucent, blurred top bar (back, filename, meta) and bottom bar float - over the media; this is the one deliberate exception to "no blur" in - product UI, since it's chrome over photo/video content, not over the app's - own surfaces. Back is a plain arrow (`arrow-left`), not the iOS +- **Media viewer** (the full-screen photo/video viewer): the stage is `bg`, + following the app's own theme rather than a fixed black - a black stage + in light mode read as jarringly out of place. A translucent, blurred top + bar (back, filename, meta) and bottom bar float over the media in a fixed + dark panel regardless of theme (it needs to read over arbitrary photo/ + video brightness, not over `bg`); this is the one deliberate exception to + "no blur" in product UI, since it's chrome over photo/video content, not + over the app's own surfaces. Both bars' background extends edge-to-edge + behind the status bar/gesture area, with only their content padded clear + of it. Back is a plain arrow (`arrow-left`), not the iOS chevron+label pushed-screen pattern - platform split still to do. The bottom bar holds every action in one row (share, favorite, open externally, download, delete, details) on every platform; don't add a diff --git a/lib/views/file_viewer_screen.dart b/lib/views/file_viewer_screen.dart index 1dc4df4..66ec85b 100644 --- a/lib/views/file_viewer_screen.dart +++ b/lib/views/file_viewer_screen.dart @@ -376,14 +376,13 @@ class _FileViewerScreenState extends State { final session = context.read(); final ops = context.read(); final colors = context.nooColors; - // The stage behind the media itself is pure black rather than `bg` - - // the near-universal "letterbox" convention of photo/video viewers - // (matting an image/video in the app's warm neutral background reads - // as unfinished, and black also hides any letterboxing from - // `BoxFit.contain`/`AspectRatio` at the screen edges). PDFs/text/ - // unsupported-file previews aren't "viewed" the same way - they're - // read, so they stay on the normal `bg` like any other screen. - final stageColor = _isSwipeable ? Colors.black : colors.bg; + // The stage follows the app's own theme (`bg`) rather than being pinned + // to black - a black stage in light mode read as jarringly out of place + // next to the rest of the light-themed app. The floating top/action + // bars stay a fixed dark frosted panel regardless (see + // FrostedGlassContainer's doc comment) since they need to read over + // arbitrary photo/video brightness, not over this background. + final stageColor = colors.bg; return Scaffold( backgroundColor: stageColor, @@ -437,9 +436,13 @@ class _FileViewerScreenState extends State { offset: _controlsVisible ? Offset.zero : const Offset(0, -1.4), child: IgnorePointer( ignoring: !_controlsVisible, - child: SafeArea( - bottom: false, - child: FrostedGlassContainer( + // The blurred/tinted background lives outside the SafeArea + // (not inside it) so it extends up behind the status bar + // instead of stopping short beneath it - see the matching + // fix/comment on MediaActionBar's own SafeArea. + child: FrostedGlassContainer( + child: SafeArea( + bottom: false, child: Padding( padding: const EdgeInsets.symmetric( horizontal: 6, @@ -447,10 +450,9 @@ class _FileViewerScreenState extends State { ), child: IconTheme.merge( // Fixed white, not `colors.fg1`: this bar always - // sits on the dark translucent panel over a black - // media stage (see `FrostedGlassContainer`'s doc - // comment), regardless of the app's light/dark - // theme. + // sits on the dark translucent panel over the media + // stage (see `FrostedGlassContainer`'s doc comment), + // regardless of the app's light/dark theme. data: const IconThemeData( color: Colors.white, size: 24, diff --git a/lib/widgets/noo/overlays/noo_sheet.dart b/lib/widgets/noo/overlays/noo_sheet.dart index 78fbfb1..f23c9eb 100644 --- a/lib/widgets/noo/overlays/noo_sheet.dart +++ b/lib/widgets/noo/overlays/noo_sheet.dart @@ -1,13 +1,23 @@ import 'package:flutter/material.dart'; import '../../../theme/design_tokens.dart'; +/// Fraction of the available height a sheet can reach before it switches +/// from sizing to its content to a draggable peek (see [_NooSheetBody]). +const double _kNooSheetPeekFraction = 0.6; +const double _kNooSheetMinFraction = 0.3; +const double _kNooSheetMaxFraction = 0.95; + /// Mobile bottom sheet on a scrim: radius-28 top, grabber, 22px gap between /// sections (Noo Design System project, `components/overlays/Sheet.jsx`). /// `showModalBottomSheet` already supplies the scrim/backdrop-dismiss /// machinery, so this only standardizes the shape/padding - use it instead -/// of a bare `showModalBottomSheet` for any new sheet. Scroll-controlled so a -/// tall sheet (e.g. share) can grow past half the screen; content scrolls -/// once it hits the top. +/// of a bare `showModalBottomSheet` for any new sheet. Sizes to its content +/// like a plain scroll view for anything that fits comfortably on screen +/// (most sheets: a menu, a handful of settings rows); content long enough to +/// need scrolling (Details' Versions/Activity tabs with many rows, a long +/// accounts list, ...) instead opens at a native-style peek height the user +/// can drag up to reveal more of, rather than snapping straight to nearly +/// the full screen with nothing left to adjust - see [_NooSheetBody]. Future showNooSheet( BuildContext context, { required List children, @@ -20,31 +30,111 @@ Future showNooSheet( isScrollControlled: true, useSafeArea: true, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(NooRadii.sheetTop)), + borderRadius: BorderRadius.vertical( + top: Radius.circular(NooRadii.sheetTop), + ), ), - builder: (context) { - return SafeArea( - child: SingleChildScrollView( - padding: const EdgeInsets.fromLTRB(20, 10, 20, 20), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: 36, - height: 5, - decoration: BoxDecoration( - color: colors.surface3, - borderRadius: BorderRadius.circular(NooRadii.pill), - ), - ), - for (final child in children) ...[ - const SizedBox(height: 22), - child, - ], - ], - ), - ), - ); - }, + builder: (context) => _NooSheetBody(children: children), ); } + +/// Renders [children] once, unconstrained, to measure their natural height +/// against the sheet's available height. Content that fits within +/// [_kNooSheetPeekFraction] just stays as that first (plain, content-sized) +/// render - the common case for a short menu/option list. Content taller +/// than that switches to a [DraggableScrollableSheet] opened at the peek +/// fraction instead, so it never jumps straight to (near) full height with +/// no room left to adjust. The one extra layout pass for tall content +/// happens within the sheet's own entrance animation, so it isn't visible +/// as a jump. +class _NooSheetBody extends StatefulWidget { + final List children; + + const _NooSheetBody({required this.children}); + + @override + State<_NooSheetBody> createState() => _NooSheetBodyState(); +} + +class _NooSheetBodyState extends State<_NooSheetBody> { + final _contentKey = GlobalKey(); + bool _measured = false; + bool _needsPeek = false; + + void _measureAfterFrame(double availableHeight) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || _measured) return; + final box = _contentKey.currentContext?.findRenderObject() as RenderBox?; + final contentHeight = box?.size.height ?? 0; + setState(() { + _measured = true; + _needsPeek = contentHeight > availableHeight * _kNooSheetPeekFraction; + }); + }); + } + + Widget _buildContent(ScrollController? scrollController) { + final colors = context.nooColors; + return SingleChildScrollView( + controller: scrollController, + padding: const EdgeInsets.fromLTRB(20, 10, 20, 20), + child: Column( + key: _measured ? null : _contentKey, + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 36, + height: 5, + decoration: BoxDecoration( + color: colors.surface3, + borderRadius: BorderRadius.circular(NooRadii.pill), + ), + ), + for (final child in widget.children) ...[ + const SizedBox(height: 22), + child, + ], + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + return SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + if (!_measured) { + _measureAfterFrame(constraints.maxHeight); + return _buildContent(null); + } + if (!_needsPeek) { + return _buildContent(null); + } + return DraggableScrollableSheet( + initialChildSize: _kNooSheetPeekFraction, + minChildSize: _kNooSheetMinFraction, + maxChildSize: _kNooSheetMaxFraction, + expand: false, + builder: (context, scrollController) { + // Plain `DraggableScrollableSheet` doesn't dismiss the modal + // route on its own when dragged down to its floor - it just + // stops resizing there - so pop explicitly once it's been + // dragged (near) all the way down, matching a native + // peek sheet's swipe-to-dismiss. + return NotificationListener( + onNotification: (notification) { + if (notification.extent <= notification.minExtent + 0.01) { + Navigator.of(context).maybePop(); + } + return false; + }, + child: _buildContent(scrollController), + ); + }, + ); + }, + ), + ); + } +} diff --git a/lib/widgets/viewer/media_action_bar.dart b/lib/widgets/viewer/media_action_bar.dart index 208c055..e5ae8bb 100644 --- a/lib/widgets/viewer/media_action_bar.dart +++ b/lib/widgets/viewer/media_action_bar.dart @@ -46,9 +46,14 @@ class MediaActionBar extends StatelessWidget { // favorite/delete stay legible even when the ambient theme is light. const dark = NooColors.dark; - return SafeArea( - top: false, - child: FrostedGlassContainer( + // The blurred/tinted background lives outside the SafeArea (not inside + // it) so it extends all the way to the physical bottom edge, behind the + // gesture bar/home indicator, instead of the panel itself stopping + // short and leaving that strip unstyled - only the actual row content + // needs padding up and away from the gesture area. + return FrostedGlassContainer( + child: SafeArea( + top: false, child: Column( mainAxisSize: MainAxisSize.min, children: [ diff --git a/test/widgets/noo/overlays_test.dart b/test/widgets/noo/overlays_test.dart index d2a6a85..222e75b 100644 --- a/test/widgets/noo/overlays_test.dart +++ b/test/widgets/noo/overlays_test.dart @@ -14,18 +14,26 @@ 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')), + 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, + (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 { + testNooWidgets('title, subtitle, leading and close', ( + tester, + theme, + c, + ) async { var closed = 0; await pumpNoo( tester, @@ -41,23 +49,41 @@ void main() { theme: theme, ); expect(find.text('Report.pdf'), findsOneWidget); - expect(tester.widget(find.text('2 MB · Documents')).style!.color, c.fg3); - expect(decorationOf(tester, find.byIcon(LucideIcons.x)).color, c.surface2); + expect( + tester.widget(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'))); + 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 { + testNooWidgets('shows children on a surface sheet and dismisses', ( + tester, + theme, + c, + ) async { await pumpNoo( tester, - _launcher((ctx) => showNooSheet(ctx, children: const [Text('Section A'), Text('Section B')])), + _launcher( + (ctx) => showNooSheet( + ctx, + children: const [Text('Section A'), Text('Section B')], + ), + ), theme: theme, ); await tester.tap(find.text('open')); @@ -66,7 +92,8 @@ void main() { expect(find.text('Section B'), findsOneWidget); final sheet = tester.widget(find.byType(BottomSheet)); expect(sheet.backgroundColor, c.surface); - final gap = tester.getTopLeft(find.text('Section B')).dy - + final gap = + tester.getTopLeft(find.text('Section B')).dy - tester.getBottomLeft(find.text('Section A')).dy; expect(gap, 22); @@ -74,13 +101,56 @@ void main() { 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( + 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)); + }); }); group('NooDialog', () { - testNooWidgets('card is 540 wide, r24, single dialog shadow', (tester, theme, c) async { + 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')]), + NooDialog( + title: 'Share "Report.pdf"', + onClose: () {}, + children: const [Text('body')], + ), theme: theme, surfaceSize: const Size(1200, 900), ); @@ -92,7 +162,9 @@ void main() { expect(tester.getSize(card).width, 540); }); - testWidgets('narrow screens shrink the card to fit with a 24px margin', (tester) async { + 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( @@ -105,7 +177,11 @@ void main() { expect(tester.getSize(card).width, 400 - 48); }); - testNooWidgets('header close is a 32px surface-2 circle', (tester, theme, c) async { + testNooWidgets('header close is a 32px surface-2 circle', ( + tester, + theme, + c, + ) async { var closed = 0; await pumpNoo( tester, @@ -118,12 +194,19 @@ void main() { theme: theme, ); expect(tester.getSize(find.byType(NooCloseButton)), const Size(32, 32)); - expect(decorationOf(tester, find.byIcon(LucideIcons.x)).color, c.surface2); + 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 { + testNooWidgets('showNooDialog uses the scrim and closes via the button', ( + tester, + theme, + c, + ) async { Future? result; await pumpNoo( tester, @@ -140,7 +223,9 @@ void main() { await tester.tap(find.text('open')); await tester.pumpAndSettle(); expect(find.text('dialog body'), findsOneWidget); - final barrier = tester.widgetList(find.byType(ModalBarrier)).last; + final barrier = tester + .widgetList(find.byType(ModalBarrier)) + .last; expect(barrier.color, c.scrim); await tester.tap(find.byType(NooCloseButton)); @@ -176,7 +261,10 @@ void main() { (ctx) => showNooDialog( ctx, title: 'T', - children: [for (var i = 0; i < 40; i++) SizedBox(height: 60, child: Text('row $i'))], + children: [ + for (var i = 0; i < 40; i++) + SizedBox(height: 60, child: Text('row $i')), + ], ), ), surfaceSize: const Size(1200, 600), @@ -189,13 +277,20 @@ void main() { }); group('NooTextField', () { - testNooWidgets('surface-2, r14, fg-3 placeholder, typing', (tester, theme, c) async { + 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), + child: NooTextField( + placeholder: 'Name, email or group', + onChanged: (v) => typed = v, + ), ), theme: theme, ); @@ -209,14 +304,20 @@ void main() { expect(typed, 'ali'); }); - testNooWidgets('mono URL field with inline Copy link button', (tester, theme, c) async { + 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'), + controller: TextEditingController( + text: 'https://cloud.example/s/abc', + ), mono: true, readOnly: true, onSurface: false, @@ -233,7 +334,10 @@ void main() { 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); + expect( + tester.getSize(find.byType(NooButton)).height, + NooSizes.buttonField, + ); await tester.tap(find.text('Copy link')); await tester.pumpAndSettle(); expect(copied, 1); @@ -241,7 +345,11 @@ void main() { }); group('Share parts', () { - testNooWidgets('NooShareSection title, trailing and caption', (tester, theme, c) async { + testNooWidgets('NooShareSection title, trailing and caption', ( + tester, + theme, + c, + ) async { await pumpNoo( tester, const SizedBox( @@ -255,13 +363,26 @@ void main() { ), theme: theme, ); - expect(tester.widget(find.text('Send file directly')).style!.color, c.fg1); - expect(tester.widget(find.text("Link settings don't apply")).style!.color, c.fg3); + expect( + tester.widget(find.text('Send file directly')).style!.color, + c.fg1, + ); + expect( + tester + .widget(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 { + testNooWidgets('owner row is static, others get a tappable pill', ( + tester, + theme, + c, + ) async { var taps = 0; await pumpNoo( tester, @@ -296,13 +417,19 @@ void main() { expect(taps, 1); }); - testWidgets('same parts lay out inside the sheet and the dialog', (tester) async { + testWidgets('same parts lay out inside the sheet and the dialog', ( + tester, + ) async { List 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'), + const NooPersonAccessRow( + avatar: NooAvatar(initials: 'JD'), + name: 'Jane', + permission: 'Can view', + ), ]; await pumpNoo( tester, @@ -312,7 +439,8 @@ void main() { _launcher((ctx) => showNooSheet(ctx, children: parts())), Builder( builder: (ctx) => TextButton( - onPressed: () => showNooDialog(ctx, title: 'Share', children: parts()), + onPressed: () => + showNooDialog(ctx, title: 'Share', children: parts()), child: const Text('dialog'), ), ),