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.
This commit is contained in:
@@ -14,7 +14,7 @@ import 'sort_menu_button.dart' show sortFieldLabel;
|
||||
String _typeFilterLabel(FilesTypeFilter filter) {
|
||||
switch (filter) {
|
||||
case FilesTypeFilter.all:
|
||||
return 'All types';
|
||||
return 'Filters';
|
||||
case FilesTypeFilter.filesOnly:
|
||||
return 'Files only';
|
||||
case FilesTypeFilter.foldersOnly:
|
||||
@@ -47,7 +47,8 @@ class FilesControlsRow extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final files = context.watch<FilesController>();
|
||||
final filtersActive = files.filesTypeFilter != FilesTypeFilter.all ||
|
||||
final filtersActive =
|
||||
files.filesTypeFilter != FilesTypeFilter.all ||
|
||||
files.showHiddenFiles ||
|
||||
(showStorageScope && files.storageScope != StorageScope.cloud);
|
||||
|
||||
@@ -114,8 +115,16 @@ class FilesControlsRow extends StatelessWidget {
|
||||
}
|
||||
},
|
||||
options: const [
|
||||
NooSegmentOption(value: true, icon: LucideIcons.arrowUp, label: 'Ascending'),
|
||||
NooSegmentOption(value: false, icon: LucideIcons.arrowDown, label: 'Descending'),
|
||||
NooSegmentOption(
|
||||
value: true,
|
||||
icon: LucideIcons.arrowUp,
|
||||
label: 'Ascending',
|
||||
),
|
||||
NooSegmentOption(
|
||||
value: false,
|
||||
icon: LucideIcons.arrowDown,
|
||||
label: 'Descending',
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 22),
|
||||
@@ -125,7 +134,11 @@ class FilesControlsRow extends StatelessWidget {
|
||||
NooSettingsRow(
|
||||
label: Text(sortFieldLabel(field)),
|
||||
trailing: field == files.sortFieldFor(folderPath)
|
||||
? Icon(LucideIcons.check, size: 18, color: context.nooColors.accentText)
|
||||
? Icon(
|
||||
LucideIcons.check,
|
||||
size: 18,
|
||||
color: context.nooColors.accentText,
|
||||
)
|
||||
: null,
|
||||
onTap: () {
|
||||
files.setSortFieldFor(folderPath, field);
|
||||
@@ -159,23 +172,38 @@ class FilesControlsRow extends StatelessWidget {
|
||||
NooSettingsRow(
|
||||
label: const Text('Files and folders'),
|
||||
trailing: files.filesTypeFilter == FilesTypeFilter.all
|
||||
? Icon(LucideIcons.check, size: 18, color: context.nooColors.accentText)
|
||||
? Icon(
|
||||
LucideIcons.check,
|
||||
size: 18,
|
||||
color: context.nooColors.accentText,
|
||||
)
|
||||
: null,
|
||||
onTap: () => files.setFilesTypeFilter(FilesTypeFilter.all),
|
||||
),
|
||||
NooSettingsRow(
|
||||
label: const Text('Files only'),
|
||||
trailing: files.filesTypeFilter == FilesTypeFilter.filesOnly
|
||||
? Icon(LucideIcons.check, size: 18, color: context.nooColors.accentText)
|
||||
? Icon(
|
||||
LucideIcons.check,
|
||||
size: 18,
|
||||
color: context.nooColors.accentText,
|
||||
)
|
||||
: null,
|
||||
onTap: () => files.setFilesTypeFilter(FilesTypeFilter.filesOnly),
|
||||
onTap: () =>
|
||||
files.setFilesTypeFilter(FilesTypeFilter.filesOnly),
|
||||
),
|
||||
NooSettingsRow(
|
||||
label: const Text('Folders only'),
|
||||
trailing: files.filesTypeFilter == FilesTypeFilter.foldersOnly
|
||||
? Icon(LucideIcons.check, size: 18, color: context.nooColors.accentText)
|
||||
trailing:
|
||||
files.filesTypeFilter == FilesTypeFilter.foldersOnly
|
||||
? Icon(
|
||||
LucideIcons.check,
|
||||
size: 18,
|
||||
color: context.nooColors.accentText,
|
||||
)
|
||||
: null,
|
||||
onTap: () => files.setFilesTypeFilter(FilesTypeFilter.foldersOnly),
|
||||
onTap: () =>
|
||||
files.setFilesTypeFilter(FilesTypeFilter.foldersOnly),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import 'dart:ui';
|
||||
import 'package:flutter/material.dart';
|
||||
import '../theme/design_tokens.dart';
|
||||
|
||||
/// The blurred/translucent chrome for the media viewer's overlay bars
|
||||
/// (`file_viewer_screen.dart`'s top bar, `MediaActionBar`, the video
|
||||
/// transport row) - the one deliberate exception to the design system's
|
||||
/// flat, no-shadow product UI (DESIGN_SYSTEM.md 1.4), since this chrome
|
||||
/// floats over photo/video content rather than over the app's own
|
||||
/// surfaces. No shadow, no outline, no `ColorScheme` tint: a fixed dark
|
||||
/// translucent fill (`color`) that reads correctly over any media,
|
||||
/// regardless of the app's light/dark theme - matching the canvas at
|
||||
/// https://claude.ai/artifact/3AGPqqMdkLSC2ypCh2CQs4.
|
||||
/// surfaces. No shadow, no outline: [color] defaults to the app's own
|
||||
/// `surface` token (so this panel reads as light or dark to match the
|
||||
/// active theme, like the rest of the media viewer, instead of a fixed
|
||||
/// dark tone regardless of theme) at a higher-than-usual [opacity], since
|
||||
/// a light tint needs denser coverage than a near-black one did to stay
|
||||
/// legible over arbitrary photo/video brightness underneath.
|
||||
class FrostedGlassContainer extends StatelessWidget {
|
||||
final Widget child;
|
||||
final double borderRadius;
|
||||
@@ -23,30 +26,30 @@ class FrostedGlassContainer extends StatelessWidget {
|
||||
/// adjustable.
|
||||
final double opacity;
|
||||
|
||||
/// The tint under the blur. Defaults to the design's fixed dark tone
|
||||
/// (`#141311`, the spec's `fg-1` dark value) - deliberately not a
|
||||
/// `ColorScheme`/`NooColors` lookup, since this chrome always sits over a
|
||||
/// black media stage, not over a themed surface.
|
||||
final Color color;
|
||||
/// The tint under the blur. Defaults to [NooColors.surface] (resolved at
|
||||
/// build time, so it always matches the active theme) - pass an explicit
|
||||
/// color only to override that, e.g. for a fixed-color panel elsewhere.
|
||||
final Color? color;
|
||||
|
||||
const FrostedGlassContainer({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.borderRadius = 0,
|
||||
this.blurSigma = 20,
|
||||
this.opacity = 0.55,
|
||||
this.color = const Color(0xFF141311),
|
||||
this.opacity = 0.8,
|
||||
this.color,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tint = color ?? context.nooColors.surface;
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(borderRadius),
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: blurSigma, sigmaY: blurSigma),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: opacity),
|
||||
color: tint.withValues(alpha: opacity),
|
||||
borderRadius: BorderRadius.circular(borderRadius),
|
||||
),
|
||||
child: child,
|
||||
|
||||
@@ -38,15 +38,20 @@ Future<T?> showNooSheet<T>(
|
||||
);
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// 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;
|
||||
|
||||
@@ -57,44 +62,44 @@ class _NooSheetBody extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _NooSheetBodyState extends State<_NooSheetBody> {
|
||||
final _contentKey = GlobalKey();
|
||||
bool _measured = false;
|
||||
bool _needsPeek = false;
|
||||
|
||||
void _measureAfterFrame(double availableHeight) {
|
||||
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 || _measured) return;
|
||||
final box = _contentKey.currentContext?.findRenderObject() as RenderBox?;
|
||||
final contentHeight = box?.size.height ?? 0;
|
||||
setState(() {
|
||||
_measured = true;
|
||||
_needsPeek = contentHeight > availableHeight * _kNooSheetPeekFraction;
|
||||
});
|
||||
if (mounted && !_needsPeek) setState(() => _needsPeek = true);
|
||||
});
|
||||
}
|
||||
|
||||
Widget _buildContent(ScrollController? scrollController) {
|
||||
Widget _buildScrollable(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),
|
||||
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,
|
||||
for (final child in widget.children) ...[
|
||||
const SizedBox(height: 22),
|
||||
child,
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -104,12 +109,13 @@ class _NooSheetBodyState extends State<_NooSheetBody> {
|
||||
return SafeArea(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
if (!_measured) {
|
||||
_measureAfterFrame(constraints.maxHeight);
|
||||
return _buildContent(null);
|
||||
}
|
||||
if (!_needsPeek) {
|
||||
return _buildContent(null);
|
||||
return ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: constraints.maxHeight * _kNooSheetPeekFraction,
|
||||
),
|
||||
child: _buildScrollable(null),
|
||||
);
|
||||
}
|
||||
return DraggableScrollableSheet(
|
||||
initialChildSize: _kNooSheetPeekFraction,
|
||||
@@ -129,7 +135,7 @@ class _NooSheetBodyState extends State<_NooSheetBody> {
|
||||
}
|
||||
return false;
|
||||
},
|
||||
child: _buildContent(scrollController),
|
||||
child: _buildScrollable(scrollController),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -40,11 +40,7 @@ class MediaActionBar extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// This panel always sits on a black media stage regardless of the
|
||||
// app's own light/dark theme (see FrostedGlassContainer's doc
|
||||
// comment) - fixed dark-palette values, not `context.nooColors`, so
|
||||
// favorite/delete stay legible even when the ambient theme is light.
|
||||
const dark = NooColors.dark;
|
||||
final colors = context.nooColors;
|
||||
|
||||
// The blurred/tinted background lives outside the SafeArea (not inside
|
||||
// it) so it extends all the way to the physical bottom edge, behind the
|
||||
@@ -59,7 +55,7 @@ class MediaActionBar extends StatelessWidget {
|
||||
children: [
|
||||
if (transportControls != null) ...[
|
||||
transportControls!,
|
||||
Divider(height: 1, color: Colors.white.withValues(alpha: 0.14)),
|
||||
Divider(height: 1, color: colors.fg1.withValues(alpha: 0.14)),
|
||||
],
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 6),
|
||||
@@ -80,7 +76,7 @@ class MediaActionBar extends StatelessWidget {
|
||||
tooltip: isFavorite
|
||||
? 'Remove from favorites'
|
||||
: 'Favorite',
|
||||
color: isFavorite ? dark.accentText : null,
|
||||
color: isFavorite ? colors.accentText : null,
|
||||
onTap: onFavorite,
|
||||
),
|
||||
ViewerIconButton(
|
||||
@@ -98,7 +94,7 @@ class MediaActionBar extends StatelessWidget {
|
||||
ViewerIconButton(
|
||||
icon: LucideIcons.trash2,
|
||||
tooltip: 'Delete',
|
||||
color: dark.danger,
|
||||
color: colors.danger,
|
||||
onTap: isBusy ? null : onDelete,
|
||||
),
|
||||
ViewerIconButton(
|
||||
|
||||
@@ -161,7 +161,7 @@ class VideoTransportControls extends StatelessWidget {
|
||||
return AnimatedBuilder(
|
||||
animation: controller,
|
||||
builder: (context, _) {
|
||||
const fg = Colors.white;
|
||||
final fg = context.nooColors.fg1;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4),
|
||||
child: Column(
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../theme/design_tokens.dart';
|
||||
|
||||
/// Icon-only action button for the media viewer's floating chrome (top bar,
|
||||
/// bottom action bar, video transport controls) - a fixed 22px icon in a
|
||||
/// round hit box. Colored white by default, not from [NooColors]/
|
||||
/// [ColorScheme]: this chrome 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, so a theme-derived color would
|
||||
/// go near-invisible in light mode. Kept as one small widget so every
|
||||
/// floating control in this screen looks the same instead of drifting
|
||||
/// apart across the three call sites that need one.
|
||||
/// round hit box. Colored from [NooColors.fg1] by default, matching
|
||||
/// [FrostedGlassContainer]'s own theme-following panel underneath it (both
|
||||
/// used to be fixed white-on-black regardless of theme - see their git
|
||||
/// history). Kept as one small widget so every floating control in this
|
||||
/// screen looks the same instead of drifting apart across the three call
|
||||
/// sites that need one.
|
||||
class ViewerIconButton extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String tooltip;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
/// Overrides the default white (e.g. `danger` for delete, `accent-text`
|
||||
/// for a favorited state). Ignored while [onTap] is null - a disabled
|
||||
/// button always dims to faded white regardless of its normal color.
|
||||
/// Overrides the default [NooColors.fg1] (e.g. `danger` for delete,
|
||||
/// `accent-text` for a favorited state). Ignored while [onTap] is null -
|
||||
/// a disabled button always dims regardless of its normal color.
|
||||
final Color? color;
|
||||
|
||||
const ViewerIconButton({
|
||||
@@ -29,9 +29,8 @@ class ViewerIconButton extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fg = onTap == null
|
||||
? Colors.white.withValues(alpha: 0.4)
|
||||
: (color ?? Colors.white);
|
||||
final fg1 = context.nooColors.fg1;
|
||||
final fg = onTap == null ? fg1.withValues(alpha: 0.4) : (color ?? fg1);
|
||||
|
||||
return Tooltip(
|
||||
message: tooltip,
|
||||
|
||||
Reference in New Issue
Block a user