Sheet peek/expand, theme-aware media stage, and edge-to-edge viewer bars
Long-content bottom sheets (Details' Versions/Activity tabs with many rows) opened locked to nearly full screen height with no room left to adjust. showNooSheet now measures its content once against the available height: anything that fits comfortably still sizes to content as before, but content tall enough to need scrolling opens at a native-style draggable peek instead, which the user can drag up to reveal more of (and drag back down to dismiss). The media viewer's stage was pinned to black regardless of theme, which read as jarringly out of place in light mode - it now follows the app's own `bg`. The floating top/bottom bars stay a fixed dark panel either way (they need to read over arbitrary photo/video brightness, not over the stage), but both had their SafeArea nested the wrong way around: it wrapped the whole panel instead of just its content, so the panel's background stopped short of the actual screen edge - most visibly the bottom bar ending above the gesture area instead of extending behind it. The background now wraps the SafeArea instead.
This commit is contained in:
@@ -376,14 +376,13 @@ class _FileViewerScreenState extends State<FileViewerScreen> {
|
||||
final session = context.read<SessionController>();
|
||||
final ops = context.read<ItemOperations>();
|
||||
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<FileViewerScreen> {
|
||||
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<FileViewerScreen> {
|
||||
),
|
||||
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,
|
||||
|
||||
@@ -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<T?> showNooSheet<T>(
|
||||
BuildContext context, {
|
||||
required List<Widget> children,
|
||||
@@ -20,31 +30,111 @@ Future<T?> showNooSheet<T>(
|
||||
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<Widget> 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<DraggableScrollableNotification>(
|
||||
onNotification: (notification) {
|
||||
if (notification.extent <= notification.minExtent + 0.01) {
|
||||
Navigator.of(context).maybePop();
|
||||
}
|
||||
return false;
|
||||
},
|
||||
child: _buildContent(scrollController),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: [
|
||||
|
||||
Reference in New Issue
Block a user