Files
noo/lib/widgets/noo/overlays/noo_sheet.dart
T
ayushya 0abea46b1c Fix sheet peek for async content, and rework several screens' details
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.
2026-09-28 19:26:18 -04:00

147 lines
5.6 KiB
Dart

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. 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<T?> showNooSheet<T>(
BuildContext context, {
required List<Widget> children,
}) {
final colors = context.nooColors;
return showModalBottomSheet<T>(
context: context,
backgroundColor: colors.surface,
barrierColor: colors.scrim,
isScrollControlled: true,
useSafeArea: true,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(
top: Radius.circular(NooRadii.sheetTop),
),
),
builder: (context) => _NooSheetBody(children: children),
);
}
/// Caps content at [_kNooSheetPeekFraction] of the available height from the
/// very first frame - never a one-shot "measure, then decide" pass, since
/// some sheets (Details' Versions/Activity tabs) fetch their content
/// asynchronously and only reach their real (long) size well after that
/// first frame, which a one-shot measurement would miss entirely. Content
/// that never needs more than that cap just sizes to itself as a plain
/// scroll view, at whatever height that is - the common case for a short
/// menu/option list, and visually identical to before. [_needsPeek] flips
/// true - upgrading to a draggable [DraggableScrollableSheet], opened at the
/// exact same peek height so nothing visibly jumps - the moment a
/// [ScrollMetricsNotification] reports the content actually overflowing
/// that cap, however and whenever that happens (immediately for a long
/// static list, or later, once async content finishes loading and grows
/// past it).
class _NooSheetBody extends StatefulWidget {
final List<Widget> children;
const _NooSheetBody({required this.children});
@override
State<_NooSheetBody> createState() => _NooSheetBodyState();
}
class _NooSheetBodyState extends State<_NooSheetBody> {
bool _needsPeek = false;
void _handleOverflow(ScrollMetrics metrics) {
if (_needsPeek || metrics.maxScrollExtent <= 0) return;
// `ScrollMetricsNotification` is dispatched mid-layout; deferring avoids
// a "setState during build" error.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && !_needsPeek) setState(() => _needsPeek = true);
});
}
Widget _buildScrollable(ScrollController? scrollController) {
final colors = context.nooColors;
return NotificationListener<ScrollMetricsNotification>(
onNotification: (notification) {
_handleOverflow(notification.metrics);
return false;
},
child: SingleChildScrollView(
controller: scrollController,
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 widget.children) ...[
const SizedBox(height: 22),
child,
],
],
),
),
);
}
@override
Widget build(BuildContext context) {
return SafeArea(
child: LayoutBuilder(
builder: (context, constraints) {
if (!_needsPeek) {
return ConstrainedBox(
constraints: BoxConstraints(
maxHeight: constraints.maxHeight * _kNooSheetPeekFraction,
),
child: _buildScrollable(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<DraggableScrollableNotification>(
onNotification: (notification) {
if (notification.extent <= notification.minExtent + 0.01) {
Navigator.of(context).maybePop();
}
return false;
},
child: _buildScrollable(scrollController),
);
},
);
},
),
);
}
}