Noo design system rebuild #1

Merged
ayushya merged 31 commits from noo-design-system-rebuild into main 2026-10-01 09:23:58 -04:00
12 changed files with 441 additions and 207 deletions
Showing only changes of commit 0abea46b1c - Show all commits
+17 -11
View File
@@ -343,13 +343,16 @@ Sidebar items are 38px tall with radius 12, an 18px icon and a 14/500 label. The
- **Media viewer** (the full-screen photo/video viewer): the stage is `bg`, - **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 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 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 bar (back, filename, meta) and bottom bar float over the media in a
dark panel regardless of theme (it needs to read over arbitrary photo/ `surface`-tinted panel (also theme-following, at a higher opacity than a
video brightness, not over `bg`); this is the one deliberate exception to typical blur so it stays legible over arbitrary photo/video brightness
"no blur" in product UI, since it's chrome over photo/video content, not underneath - icons/text are `fg1`, matching); this is the one deliberate
over the app's own surfaces. Both bars' background extends edge-to-edge exception to "no blur" in product UI, since it's chrome over photo/video
behind the status bar/gesture area, with only their content padded clear content, not over the app's own surfaces. A short edge gradient outside
of it. Back is a plain arrow (`arrow-left`), not the iOS each panel (toward `surface`, not a fixed black) extends that same
contrast a little past the panel's hard edge. 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 chevron+label pushed-screen pattern - platform split still to do. The
bottom bar holds every action in one row (share, favorite, open bottom bar holds every action in one row (share, favorite, open
externally, download, delete, details) on every platform; don't add a externally, download, delete, details) on every platform; don't add a
@@ -369,10 +372,13 @@ Sidebar items are 38px tall with radius 12, an 18px icon and a 14/500 label. The
- **Upload / Move / Copy destination picker:** a pushed screen (outside the - **Upload / Move / Copy destination picker:** a pushed screen (outside the
tab shell, so it carries its own complete top bar) titled "Upload to" / tab shell, so it carries its own complete top bar) titled "Upload to" /
"Move to" / "Copy to". Back arrow (Android) or "Cancel" text (iOS) leading, "Move to" / "Copy to". Back arrow (Android) or "Cancel" text (iOS) leading,
no trailing action. Breadcrumb row, then a folder-only list (no files: this no trailing action. The same `FilesControlsRow` (sort chip, filter chip,
screen only browses folders) using the standard file row/tile at the List/Grid toggle right-anchored) Files itself uses, then a breadcrumb row,
folder kind. Bottom bar: a meta line ("Moving 3 items") above a full-width then a folder-only list/grid (no files: this screen only browses folders)
52px primary CTA ("Upload here" / "Move here" / "Copy here"). using the standard file row/tile/grid-card at the folder kind. Bottom bar:
a centered meta line ("Moving 3 items") above a full-width 52px primary
CTA ("Upload here" / "Move here" / "Copy here") - a count, never a
filename, even for a single item.
--- ---
+22 -16
View File
@@ -379,9 +379,9 @@ class _FileViewerScreenState extends State<FileViewerScreen> {
// The stage follows the app's own theme (`bg`) rather than being pinned // 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 // 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 // next to the rest of the light-themed app. The floating top/action
// bars stay a fixed dark frosted panel regardless (see // bars now follow the theme too (FrostedGlassContainer's own `surface`
// FrostedGlassContainer's doc comment) since they need to read over // default), at a higher blur opacity to stay legible over arbitrary
// arbitrary photo/video brightness, not over this background. // photo/video brightness underneath.
final stageColor = colors.bg; final stageColor = colors.bg;
return Scaffold( return Scaffold(
@@ -413,6 +413,10 @@ class _FileViewerScreenState extends State<FileViewerScreen> {
// gradient fade, not `FrostedGlassContainer`'s blur - just // gradient fade, not `FrostedGlassContainer`'s blur - just
// enough to keep the back button/title legible over bright // enough to keep the back button/title legible over bright
// media without another blurred layer stacked on the bar itself. // media without another blurred layer stacked on the bar itself.
// Fades toward `surface` (the same tint the panel itself uses),
// not a fixed black - a black scrim would fight the panel's own
// theme-matched icons/text (dark in light theme) instead of
// extending its contrast strategy past the panel's hard edge.
if (_isSwipeable) if (_isSwipeable)
IgnorePointer( IgnorePointer(
child: AnimatedOpacity( child: AnimatedOpacity(
@@ -420,11 +424,14 @@ class _FileViewerScreenState extends State<FileViewerScreen> {
opacity: _controlsVisible ? 1 : 0, opacity: _controlsVisible ? 1 : 0,
child: Container( child: Container(
height: 150, height: 150,
decoration: const BoxDecoration( decoration: BoxDecoration(
gradient: LinearGradient( gradient: LinearGradient(
begin: Alignment.topCenter, begin: Alignment.topCenter,
end: Alignment.bottomCenter, end: Alignment.bottomCenter,
colors: [Color(0x8C000000), Colors.transparent], colors: [
colors.surface.withValues(alpha: 0.7),
Colors.transparent,
],
), ),
), ),
), ),
@@ -449,14 +456,10 @@ class _FileViewerScreenState extends State<FileViewerScreen> {
vertical: 6, vertical: 6,
), ),
child: IconTheme.merge( child: IconTheme.merge(
// Fixed white, not `colors.fg1`: this bar always // `fg1`, matching FrostedGlassContainer's own
// sits on the dark translucent panel over the media // theme-following panel underneath - both used to
// stage (see `FrostedGlassContainer`'s doc comment), // be a fixed white-on-black regardless of theme.
// regardless of the app's light/dark theme. data: IconThemeData(color: colors.fg1, size: 24),
data: const IconThemeData(
color: Colors.white,
size: 24,
),
child: Row( child: Row(
children: [ children: [
NooTopBarButton( NooTopBarButton(
@@ -469,7 +472,7 @@ class _FileViewerScreenState extends State<FileViewerScreen> {
child: MarqueeTitle( child: MarqueeTitle(
text: _currentItem.name, text: _currentItem.name,
style: NooText.label.copyWith( style: NooText.label.copyWith(
color: Colors.white, color: colors.fg1,
), ),
), ),
), ),
@@ -491,11 +494,14 @@ class _FileViewerScreenState extends State<FileViewerScreen> {
opacity: _controlsVisible ? 1 : 0, opacity: _controlsVisible ? 1 : 0,
child: Container( child: Container(
height: 260, height: 260,
decoration: const BoxDecoration( decoration: BoxDecoration(
gradient: LinearGradient( gradient: LinearGradient(
begin: Alignment.bottomCenter, begin: Alignment.bottomCenter,
end: Alignment.topCenter, end: Alignment.topCenter,
colors: [Color(0x99000000), Colors.transparent], colors: [
colors.surface.withValues(alpha: 0.75),
Colors.transparent,
],
), ),
), ),
), ),
+66 -12
View File
@@ -7,12 +7,14 @@ import '../providers/files_controller.dart';
import '../providers/item_operations.dart'; import '../providers/item_operations.dart';
import '../theme/design_tokens.dart'; import '../theme/design_tokens.dart';
import '../widgets/breadcrumbs.dart'; import '../widgets/breadcrumbs.dart';
import '../widgets/files_controls_row.dart';
import '../widgets/more_tabs_button.dart'; import '../widgets/more_tabs_button.dart';
import '../widgets/move_copy_conflict_sheet.dart'; import '../widgets/move_copy_conflict_sheet.dart';
import '../widgets/noo/core/noo_button.dart'; import '../widgets/noo/core/noo_button.dart';
import '../widgets/noo/files/noo_file_kind.dart'; import '../widgets/noo/files/noo_file_kind.dart';
import '../widgets/noo/files/noo_file_row.dart'; import '../widgets/noo/files/noo_file_row.dart';
import '../widgets/noo/files/noo_file_table.dart'; import '../widgets/noo/files/noo_file_table.dart';
import '../widgets/noo/media/noo_grid_card.dart';
import '../widgets/noo/nav/noo_top_bar.dart'; import '../widgets/noo/nav/noo_top_bar.dart';
import '../widgets/noo/nav/noo_toolbar.dart'; import '../widgets/noo/nav/noo_toolbar.dart';
import '../widgets/noo/noo_layout.dart'; import '../widgets/noo/noo_layout.dart';
@@ -86,9 +88,7 @@ class _MoveCopyDestinationPickerState extends State<MoveCopyDestinationPicker> {
Future<void> _fetch(String path) async { Future<void> _fetch(String path) async {
setState(() => _isLoading = true); setState(() => _isLoading = true);
final raw = await context.read<FilesController>().fetchFolderListing( final raw = await context.read<FilesController>().fetchFolderListing(path);
path,
);
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_rawItems = raw; _rawItems = raw;
@@ -212,6 +212,19 @@ class _MoveCopyDestinationPickerState extends State<MoveCopyDestinationPicker> {
); );
} }
Widget _buildGridCard(NextcloudItem item) {
final colors = context.nooColors;
return NooGridCard(
name: item.name,
meta: 'Folder',
placeholderColor: colors.accentSoft,
icon: LucideIcons.folder,
iconColor: colors.accentText,
thumbnailHeight: NooLayout.isDesktop(context) ? 118 : 104,
onTap: () => _navigateToFolder(item.path),
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colors = context.nooColors; final colors = context.nooColors;
@@ -231,16 +244,34 @@ class _MoveCopyDestinationPickerState extends State<MoveCopyDestinationPicker> {
final slivers = <Widget>[ final slivers = <Widget>[
SliverToBoxAdapter( SliverToBoxAdapter(
child: Padding( child: Padding(
padding: EdgeInsets.fromLTRB(gutter, NooSpace.md, gutter, NooSpace.sm), padding: EdgeInsets.fromLTRB(
child: hasBreadcrumbs gutter,
? SizedBox( NooSpace.md,
gutter,
NooSpace.sm,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
height: 44,
child: FilesControlsRow(
folderPath: _currentPath,
showStorageScope: false,
),
),
if (hasBreadcrumbs) ...[
const SizedBox(height: 10),
SizedBox(
height: 32, height: 32,
child: Breadcrumbs( child: Breadcrumbs(
pathStack: _pathStack, pathStack: _pathStack,
onTap: _navigateToPathIndex, onTap: _navigateToPathIndex,
), ),
) ),
: const SizedBox.shrink(), ],
],
),
), ),
), ),
if (_isLoading) if (_isLoading)
@@ -251,6 +282,22 @@ class _MoveCopyDestinationPickerState extends State<MoveCopyDestinationPicker> {
icon: LucideIcons.folder, icon: LucideIcons.folder,
message: 'No folders here', message: 'No folders here',
) )
else if (files.isGridView)
SliverPadding(
padding: EdgeInsets.symmetric(horizontal: gutter),
sliver: SliverGrid(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: desktop ? 5 : 2,
childAspectRatio: desktop ? 1.05 : 0.92,
crossAxisSpacing: desktop ? 16 : 10,
mainAxisSpacing: desktop ? 16 : 10,
),
delegate: SliverChildBuilderDelegate(
(context, index) => _buildGridCard(folders[index]),
childCount: folders.length,
),
),
)
else if (desktop) else if (desktop)
SliverPadding( SliverPadding(
padding: EdgeInsets.symmetric(horizontal: gutter), padding: EdgeInsets.symmetric(horizontal: gutter),
@@ -266,7 +313,8 @@ class _MoveCopyDestinationPickerState extends State<MoveCopyDestinationPicker> {
padding: EdgeInsets.symmetric(horizontal: gutter), padding: EdgeInsets.symmetric(horizontal: gutter),
sliver: SliverList( sliver: SliverList(
delegate: SliverChildBuilderDelegate( delegate: SliverChildBuilderDelegate(
(context, index) => _buildRow(folders[index], index, folders.length), (context, index) =>
_buildRow(folders[index], index, folders.length),
childCount: folders.length, childCount: folders.length,
), ),
), ),
@@ -312,7 +360,10 @@ class _MoveCopyDestinationPickerState extends State<MoveCopyDestinationPicker> {
color: colors.accent, color: colors.accent,
backgroundColor: colors.surface, backgroundColor: colors.surface,
onRefresh: () => _fetch(_currentPath), onRefresh: () => _fetch(_currentPath),
child: CustomScrollView(controller: _scrollController, slivers: slivers), child: CustomScrollView(
controller: _scrollController,
slivers: slivers,
),
), ),
), ),
bottomNavigationBar: DecoratedBox( bottomNavigationBar: DecoratedBox(
@@ -331,13 +382,14 @@ class _MoveCopyDestinationPickerState extends State<MoveCopyDestinationPicker> {
), ),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
Text( Text(
invalidDestination invalidDestination
? "Can't ${widget.copy ? 'copy' : 'move'} a folder into " ? "Can't ${widget.copy ? 'copy' : 'move'} a folder into "
"itself or one of its own subfolders" "itself or one of its own subfolders"
: '$verb $itemCount item${itemCount == 1 ? '' : 's'}', : '$verb $itemCount item${itemCount == 1 ? '' : 's'}',
textAlign: TextAlign.center,
style: NooText.meta.copyWith( style: NooText.meta.copyWith(
color: invalidDestination ? colors.danger : colors.fg3, color: invalidDestination ? colors.danger : colors.fg3,
), ),
@@ -350,7 +402,9 @@ class _MoveCopyDestinationPickerState extends State<MoveCopyDestinationPicker> {
disabled: invalidDestination || _isSubmitting, disabled: invalidDestination || _isSubmitting,
icon: _isSubmitting icon: _isSubmitting
? null ? null
: (widget.copy ? LucideIcons.copy : LucideIcons.folderInput), : (widget.copy
? LucideIcons.copy
: LucideIcons.folderInput),
onTap: invalidDestination || _isSubmitting onTap: invalidDestination || _isSubmitting
? null ? null
: () => _confirm(ops), : () => _confirm(ops),
+80 -52
View File
@@ -10,11 +10,12 @@ import '../services/share_intent_service.dart';
import '../services/upload_service.dart'; import '../services/upload_service.dart';
import '../theme/design_tokens.dart'; import '../theme/design_tokens.dart';
import '../widgets/breadcrumbs.dart'; import '../widgets/breadcrumbs.dart';
import '../widgets/marquee_title.dart'; import '../widgets/files_controls_row.dart';
import '../widgets/noo/core/noo_button.dart'; import '../widgets/noo/core/noo_button.dart';
import '../widgets/noo/files/noo_file_kind.dart'; import '../widgets/noo/files/noo_file_kind.dart';
import '../widgets/noo/files/noo_file_row.dart'; import '../widgets/noo/files/noo_file_row.dart';
import '../widgets/noo/files/noo_file_table.dart'; import '../widgets/noo/files/noo_file_table.dart';
import '../widgets/noo/media/noo_grid_card.dart';
import '../widgets/noo/nav/noo_top_bar.dart'; import '../widgets/noo/nav/noo_top_bar.dart';
import '../widgets/noo/nav/noo_toolbar.dart'; import '../widgets/noo/nav/noo_toolbar.dart';
import '../widgets/noo/noo_layout.dart'; import '../widgets/noo/noo_layout.dart';
@@ -51,7 +52,9 @@ class _ShareUploadViewState extends State<ShareUploadView> {
// Shared files have no relationship to wherever the user was last // Shared files have no relationship to wherever the user was last
// browsing, so start the destination picker fresh at the root. // browsing, so start the destination picker fresh at the root.
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) context.read<FilesController>().navigateToAbsoluteFolder('/'); if (mounted) {
context.read<FilesController>().navigateToAbsoluteFolder('/');
}
}); });
} }
@@ -94,7 +97,12 @@ class _ShareUploadViewState extends State<ShareUploadView> {
// Only folders are valid upload destinations, so the listing - unlike // Only folders are valid upload destinations, so the listing - unlike
// Files' own - never shows plain files at all. // Files' own - never shows plain files at all.
Widget _buildRow(BuildContext context, NextcloudItem item, int index, int count) { Widget _buildRow(
BuildContext context,
NextcloudItem item,
int index,
int count,
) {
final colors = context.nooColors; final colors = context.nooColors;
final files = context.read<FilesController>(); final files = context.read<FilesController>();
final row = NooFileRow( final row = NooFileRow(
@@ -130,6 +138,20 @@ class _ShareUploadViewState extends State<ShareUploadView> {
); );
} }
Widget _buildGridCard(BuildContext context, NextcloudItem item) {
final colors = context.nooColors;
final files = context.read<FilesController>();
return NooGridCard(
name: item.name,
meta: 'Folder',
placeholderColor: colors.accentSoft,
icon: LucideIcons.folder,
iconColor: colors.accentText,
thumbnailHeight: NooLayout.isDesktop(context) ? 118 : 104,
onTap: () => files.navigateToFolder(item.path),
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colors = context.nooColors; final colors = context.nooColors;
@@ -147,16 +169,34 @@ class _ShareUploadViewState extends State<ShareUploadView> {
final slivers = <Widget>[ final slivers = <Widget>[
SliverToBoxAdapter( SliverToBoxAdapter(
child: Padding( child: Padding(
padding: EdgeInsets.fromLTRB(gutter, NooSpace.md, gutter, NooSpace.sm), padding: EdgeInsets.fromLTRB(
child: hasBreadcrumbs gutter,
? SizedBox( NooSpace.md,
gutter,
NooSpace.sm,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
height: 44,
child: FilesControlsRow(
folderPath: currentPath,
showStorageScope: false,
),
),
if (hasBreadcrumbs) ...[
const SizedBox(height: 10),
SizedBox(
height: 32, height: 32,
child: Breadcrumbs( child: Breadcrumbs(
pathStack: files.pathStack, pathStack: files.pathStack,
onTap: (index) => files.navigateToPathIndex(index), onTap: (index) => files.navigateToPathIndex(index),
), ),
) ),
: const SizedBox.shrink(), ],
],
),
), ),
), ),
if (files.isLoading) if (files.isLoading)
@@ -167,6 +207,22 @@ class _ShareUploadViewState extends State<ShareUploadView> {
icon: LucideIcons.folder, icon: LucideIcons.folder,
message: 'No folders here', message: 'No folders here',
) )
else if (files.isGridView)
SliverPadding(
padding: EdgeInsets.symmetric(horizontal: gutter),
sliver: SliverGrid(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: desktop ? 5 : 2,
childAspectRatio: desktop ? 1.05 : 0.92,
crossAxisSpacing: desktop ? 16 : 10,
mainAxisSpacing: desktop ? 16 : 10,
),
delegate: SliverChildBuilderDelegate(
(context, index) => _buildGridCard(context, folders[index]),
childCount: folders.length,
),
),
)
else if (desktop) else if (desktop)
SliverPadding( SliverPadding(
padding: EdgeInsets.symmetric(horizontal: gutter), padding: EdgeInsets.symmetric(horizontal: gutter),
@@ -223,12 +279,20 @@ class _ShareUploadViewState extends State<ShareUploadView> {
color: colors.accent, color: colors.accent,
backgroundColor: colors.surface, backgroundColor: colors.surface,
onRefresh: files.refreshData, onRefresh: files.refreshData,
child: CustomScrollView(controller: _scrollController, slivers: slivers), child: CustomScrollView(
controller: _scrollController,
slivers: slivers,
),
), ),
), ),
// A flat surface bar with a top line, peeking up from the bottom edge - // A flat surface bar with a top line, peeking up from the bottom edge -
// the uploading-file summary (marqueed if it doesn't fit on one line) // a centered item-count meta line sits directly above the destination
// sits directly above the destination button. // button (DESIGN_SYSTEM.md's "Upload / Move / Copy destination
// picker" recipe: "a meta line ... above a full-width ... primary
// CTA" - matches MoveCopyDestinationPicker's identical bar). A count,
// not a filename: this is the one-file case just as much as the
// many-files case, and a long filename has no good fixed-width
// treatment the way a short count always does.
bottomNavigationBar: DecoratedBox( bottomNavigationBar: DecoratedBox(
decoration: BoxDecoration( decoration: BoxDecoration(
color: colors.surface, color: colors.surface,
@@ -245,48 +309,12 @@ class _ShareUploadViewState extends State<ShareUploadView> {
), ),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
// Sized to the text itself (like a real chip) up to the Text(
// row's available width - MarqueeTitle needs a concrete 'Uploading ${widget.files.length} item${widget.files.length == 1 ? '' : 's'}',
// (not just loose) width to know whether/how far to scroll, textAlign: TextAlign.center,
// so this measures the text once up front rather than style: NooText.meta.copyWith(color: colors.fg3),
// leaving the chip unconstrained.
LayoutBuilder(
builder: (context, constraints) {
final uploadingText = widget.files.length == 1
? 'Uploading ${widget.files.first.name}'
: 'Uploading ${widget.files.length} files';
final chipTextStyle = NooText.label.copyWith(
color: colors.fg2,
);
const horizontalPadding = 28.0;
final painter = TextPainter(
text: TextSpan(text: uploadingText, style: chipTextStyle),
maxLines: 1,
textDirection: Directionality.of(context),
)..layout(maxWidth: double.infinity);
final chipWidth = (painter.width + horizontalPadding).clamp(
0.0,
constraints.maxWidth,
);
return Container(
width: chipWidth,
padding: const EdgeInsets.symmetric(
horizontal: NooSpace.smd,
vertical: NooSpace.xs,
),
decoration: BoxDecoration(
color: colors.surface2,
borderRadius: BorderRadius.circular(NooRadii.pill),
),
child: SizedBox(
height: 20,
child: MarqueeTitle(text: uploadingText, style: chipTextStyle),
),
);
},
), ),
const SizedBox(height: NooSpace.sm), const SizedBox(height: NooSpace.sm),
NooButton( NooButton(
+66 -27
View File
@@ -60,8 +60,12 @@ class _TrashViewState extends State<TrashView> {
sliver: SliverToBoxAdapter( sliver: SliverToBoxAdapter(
child: NooBanner( child: NooBanner(
actionLabel: trash.isEmpty ? null : 'Empty trash', actionLabel: trash.isEmpty ? null : 'Empty trash',
onAction: trash.isEmpty ? null : () => _confirmEmptyTrash(context, trash), onAction: trash.isEmpty
child: const Text('Deleted items are kept for 30 days, then removed automatically.'), ? null
: () => _confirmEmptyTrash(context, trash),
child: const Text(
'Deleted items are kept for 30 days, then removed automatically.',
),
), ),
), ),
), ),
@@ -115,6 +119,10 @@ class _TrashViewState extends State<TrashView> {
sliver: SliverList( sliver: SliverList(
delegate: SliverChildBuilderDelegate((context, index) { delegate: SliverChildBuilderDelegate((context, index) {
final item = trash[index]; final item = trash[index];
// The overflow menu's only action here is "Delete forever" - a
// bare ellipsis obscures that behind an extra tap to discover it,
// so this row shows the delete icon directly instead of going
// through `NooFileRow.onMore`.
final row = NooFileRow( final row = NooFileRow(
kind: NooFileKind.from( kind: NooFileKind.from(
name: item.name, name: item.name,
@@ -124,19 +132,30 @@ class _TrashViewState extends State<TrashView> {
name: item.name, name: item.name,
meta: _meta(item), meta: _meta(item),
iosStyle: NooLayout.iosStyle(context), iosStyle: NooLayout.iosStyle(context),
trailing: SizedBox.square( trailing: Row(
dimension: 40, mainAxisSize: MainAxisSize.min,
child: Semantics( children: [
button: true, Semantics(
label: 'Restore', button: true,
child: InkResponse( label: 'Restore',
onTap: () => _restore(context, item), child: InkResponse(
radius: 20, onTap: () => _restore(context, item),
child: const Icon(LucideIcons.rotateCcw), radius: 20,
child: const Icon(LucideIcons.rotateCcw),
),
), ),
), const SizedBox(width: NooSpace.xs),
Semantics(
button: true,
label: 'Delete forever',
child: InkResponse(
onTap: () => _confirmDeleteForever(context, item),
radius: 20,
child: Icon(LucideIcons.trash2, color: colors.danger),
),
),
],
), ),
onMore: () => _confirmDeleteForever(context, item),
); );
final isFirst = index == 0; final isFirst = index == 0;
final isLast = index == trash.length - 1; final isLast = index == trash.length - 1;
@@ -186,7 +205,7 @@ class _TrashViewState extends State<TrashView> {
col2: _deletedLabel(item), col2: _deletedLabel(item),
col3: item.originalLocation ?? 'Unknown location', col3: item.originalLocation ?? 'Unknown location',
onRestore: () => _restore(context, item), onRestore: () => _restore(context, item),
onMore: () => _confirmDeleteForever(context, item), onDelete: () => _confirmDeleteForever(context, item),
); );
}, childCount: trash.length + 1), }, childCount: trash.length + 1),
), ),
@@ -196,8 +215,9 @@ class _TrashViewState extends State<TrashView> {
String _meta(NextcloudItem item) => String _meta(NextcloudItem item) =>
'Deleted ${_deletedLabel(item)} · ${tabLocationLabel(item.originalLocation ?? item.path, item.name)}'; 'Deleted ${_deletedLabel(item)} · ${tabLocationLabel(item.originalLocation ?? item.path, item.name)}';
String _deletedLabel(NextcloudItem item) => String _deletedLabel(NextcloudItem item) => item.deletedAt != null
item.deletedAt != null ? DateFormat.yMMMd().format(item.deletedAt!) : 'recently'; ? DateFormat.yMMMd().format(item.deletedAt!)
: 'recently';
Future<void> _restore(BuildContext context, NextcloudItem item) async { Future<void> _restore(BuildContext context, NextcloudItem item) async {
final messenger = ScaffoldMessenger.of(context); final messenger = ScaffoldMessenger.of(context);
@@ -301,19 +321,23 @@ class _TrashViewState extends State<TrashView> {
} }
/// Desktop trash row: like `NooFileTableRow`, but its 120px last column /// Desktop trash row: like `NooFileTableRow`, but its 120px last column
/// holds a tonal "Restore" button plus the overflow button (permanent /// holds a tonal "Restore" button plus a danger-tonal "Delete" button
/// delete) instead of status icons - `NooFileTableRow` has no slot for /// (permanent delete) instead of status icons - `NooFileTableRow` has no
/// screen-specific actions there. Column widths (180/160/120) are matched /// slot for screen-specific actions there. A labelled pill, not a bare
/// by hand to `NooFileTableHeader`'s (private in `noo_file_table.dart`) so /// overflow icon: it's the row's only destructive action, not a menu of
/// this still lines up under it; promoting an optional `actions` slot onto /// several, and desktop already prefers labelled buttons to bare icons
/// `NooFileTableRow` would let this fold back into the shared component. /// (see `NooSelectionBar`'s own desktop actions). Column widths
/// (180/160/120) are matched by hand to `NooFileTableHeader`'s (private in
/// `noo_file_table.dart`) so this still lines up under it; promoting an
/// optional `actions` slot onto `NooFileTableRow` would let this fold back
/// into the shared component.
class _TrashDesktopRow extends StatelessWidget { class _TrashDesktopRow extends StatelessWidget {
final NooFileKind kind; final NooFileKind kind;
final String name; final String name;
final String col2; final String col2;
final String col3; final String col3;
final VoidCallback onRestore; final VoidCallback onRestore;
final VoidCallback onMore; final VoidCallback onDelete;
const _TrashDesktopRow({ const _TrashDesktopRow({
required this.kind, required this.kind,
@@ -321,7 +345,7 @@ class _TrashDesktopRow extends StatelessWidget {
required this.col2, required this.col2,
required this.col3, required this.col3,
required this.onRestore, required this.onRestore,
required this.onMore, required this.onDelete,
}); });
@override @override
@@ -358,11 +382,21 @@ class _TrashDesktopRow extends StatelessWidget {
), ),
SizedBox( SizedBox(
width: 180, width: 180,
child: Text(col2, maxLines: 1, overflow: TextOverflow.ellipsis, style: metaStyle), child: Text(
col2,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: metaStyle,
),
), ),
SizedBox( SizedBox(
width: 160, width: 160,
child: Text(col3, maxLines: 1, overflow: TextOverflow.ellipsis, style: metaStyle), child: Text(
col3,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: metaStyle,
),
), ),
SizedBox( SizedBox(
width: 120, width: 120,
@@ -379,7 +413,12 @@ class _TrashDesktopRow extends StatelessWidget {
child: const Text('Restore'), child: const Text('Restore'),
), ),
const SizedBox(width: 4), const SizedBox(width: 4),
NooOverflowButton(size: 24, onTap: onMore), NooButton(
variant: NooButtonVariant.danger,
size: NooButtonSize.xs,
onTap: onDelete,
child: const Text('Delete'),
),
], ],
), ),
), ),
+39 -11
View File
@@ -14,7 +14,7 @@ import 'sort_menu_button.dart' show sortFieldLabel;
String _typeFilterLabel(FilesTypeFilter filter) { String _typeFilterLabel(FilesTypeFilter filter) {
switch (filter) { switch (filter) {
case FilesTypeFilter.all: case FilesTypeFilter.all:
return 'All types'; return 'Filters';
case FilesTypeFilter.filesOnly: case FilesTypeFilter.filesOnly:
return 'Files only'; return 'Files only';
case FilesTypeFilter.foldersOnly: case FilesTypeFilter.foldersOnly:
@@ -47,7 +47,8 @@ class FilesControlsRow extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final files = context.watch<FilesController>(); final files = context.watch<FilesController>();
final filtersActive = files.filesTypeFilter != FilesTypeFilter.all || final filtersActive =
files.filesTypeFilter != FilesTypeFilter.all ||
files.showHiddenFiles || files.showHiddenFiles ||
(showStorageScope && files.storageScope != StorageScope.cloud); (showStorageScope && files.storageScope != StorageScope.cloud);
@@ -114,8 +115,16 @@ class FilesControlsRow extends StatelessWidget {
} }
}, },
options: const [ options: const [
NooSegmentOption(value: true, icon: LucideIcons.arrowUp, label: 'Ascending'), NooSegmentOption(
NooSegmentOption(value: false, icon: LucideIcons.arrowDown, label: 'Descending'), value: true,
icon: LucideIcons.arrowUp,
label: 'Ascending',
),
NooSegmentOption(
value: false,
icon: LucideIcons.arrowDown,
label: 'Descending',
),
], ],
), ),
const SizedBox(height: 22), const SizedBox(height: 22),
@@ -125,7 +134,11 @@ class FilesControlsRow extends StatelessWidget {
NooSettingsRow( NooSettingsRow(
label: Text(sortFieldLabel(field)), label: Text(sortFieldLabel(field)),
trailing: field == files.sortFieldFor(folderPath) trailing: field == files.sortFieldFor(folderPath)
? Icon(LucideIcons.check, size: 18, color: context.nooColors.accentText) ? Icon(
LucideIcons.check,
size: 18,
color: context.nooColors.accentText,
)
: null, : null,
onTap: () { onTap: () {
files.setSortFieldFor(folderPath, field); files.setSortFieldFor(folderPath, field);
@@ -159,23 +172,38 @@ class FilesControlsRow extends StatelessWidget {
NooSettingsRow( NooSettingsRow(
label: const Text('Files and folders'), label: const Text('Files and folders'),
trailing: files.filesTypeFilter == FilesTypeFilter.all trailing: files.filesTypeFilter == FilesTypeFilter.all
? Icon(LucideIcons.check, size: 18, color: context.nooColors.accentText) ? Icon(
LucideIcons.check,
size: 18,
color: context.nooColors.accentText,
)
: null, : null,
onTap: () => files.setFilesTypeFilter(FilesTypeFilter.all), onTap: () => files.setFilesTypeFilter(FilesTypeFilter.all),
), ),
NooSettingsRow( NooSettingsRow(
label: const Text('Files only'), label: const Text('Files only'),
trailing: files.filesTypeFilter == FilesTypeFilter.filesOnly trailing: files.filesTypeFilter == FilesTypeFilter.filesOnly
? Icon(LucideIcons.check, size: 18, color: context.nooColors.accentText) ? Icon(
LucideIcons.check,
size: 18,
color: context.nooColors.accentText,
)
: null, : null,
onTap: () => files.setFilesTypeFilter(FilesTypeFilter.filesOnly), onTap: () =>
files.setFilesTypeFilter(FilesTypeFilter.filesOnly),
), ),
NooSettingsRow( NooSettingsRow(
label: const Text('Folders only'), label: const Text('Folders only'),
trailing: files.filesTypeFilter == FilesTypeFilter.foldersOnly trailing:
? Icon(LucideIcons.check, size: 18, color: context.nooColors.accentText) files.filesTypeFilter == FilesTypeFilter.foldersOnly
? Icon(
LucideIcons.check,
size: 18,
color: context.nooColors.accentText,
)
: null, : null,
onTap: () => files.setFilesTypeFilter(FilesTypeFilter.foldersOnly), onTap: () =>
files.setFilesTypeFilter(FilesTypeFilter.foldersOnly),
), ),
], ],
), ),
+15 -12
View File
@@ -1,15 +1,18 @@
import 'dart:ui'; import 'dart:ui';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../theme/design_tokens.dart';
/// The blurred/translucent chrome for the media viewer's overlay bars /// The blurred/translucent chrome for the media viewer's overlay bars
/// (`file_viewer_screen.dart`'s top bar, `MediaActionBar`, the video /// (`file_viewer_screen.dart`'s top bar, `MediaActionBar`, the video
/// transport row) - the one deliberate exception to the design system's /// transport row) - the one deliberate exception to the design system's
/// flat, no-shadow product UI (DESIGN_SYSTEM.md 1.4), since this chrome /// 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 /// floats over photo/video content rather than over the app's own
/// surfaces. No shadow, no outline, no `ColorScheme` tint: a fixed dark /// surfaces. No shadow, no outline: [color] defaults to the app's own
/// translucent fill (`color`) that reads correctly over any media, /// `surface` token (so this panel reads as light or dark to match the
/// regardless of the app's light/dark theme - matching the canvas at /// active theme, like the rest of the media viewer, instead of a fixed
/// https://claude.ai/artifact/3AGPqqMdkLSC2ypCh2CQs4. /// 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 { class FrostedGlassContainer extends StatelessWidget {
final Widget child; final Widget child;
final double borderRadius; final double borderRadius;
@@ -23,30 +26,30 @@ class FrostedGlassContainer extends StatelessWidget {
/// adjustable. /// adjustable.
final double opacity; final double opacity;
/// The tint under the blur. Defaults to the design's fixed dark tone /// The tint under the blur. Defaults to [NooColors.surface] (resolved at
/// (`#141311`, the spec's `fg-1` dark value) - deliberately not a /// build time, so it always matches the active theme) - pass an explicit
/// `ColorScheme`/`NooColors` lookup, since this chrome always sits over a /// color only to override that, e.g. for a fixed-color panel elsewhere.
/// black media stage, not over a themed surface. final Color? color;
final Color color;
const FrostedGlassContainer({ const FrostedGlassContainer({
super.key, super.key,
required this.child, required this.child,
this.borderRadius = 0, this.borderRadius = 0,
this.blurSigma = 20, this.blurSigma = 20,
this.opacity = 0.55, this.opacity = 0.8,
this.color = const Color(0xFF141311), this.color,
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final tint = color ?? context.nooColors.surface;
return ClipRRect( return ClipRRect(
borderRadius: BorderRadius.circular(borderRadius), borderRadius: BorderRadius.circular(borderRadius),
child: BackdropFilter( child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: blurSigma, sigmaY: blurSigma), filter: ImageFilter.blur(sigmaX: blurSigma, sigmaY: blurSigma),
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: color.withValues(alpha: opacity), color: tint.withValues(alpha: opacity),
borderRadius: BorderRadius.circular(borderRadius), borderRadius: BorderRadius.circular(borderRadius),
), ),
child: child, child: child,
+50 -44
View File
@@ -38,15 +38,20 @@ Future<T?> showNooSheet<T>(
); );
} }
/// Renders [children] once, unconstrained, to measure their natural height /// Caps content at [_kNooSheetPeekFraction] of the available height from the
/// against the sheet's available height. Content that fits within /// very first frame - never a one-shot "measure, then decide" pass, since
/// [_kNooSheetPeekFraction] just stays as that first (plain, content-sized) /// some sheets (Details' Versions/Activity tabs) fetch their content
/// render - the common case for a short menu/option list. Content taller /// asynchronously and only reach their real (long) size well after that
/// than that switches to a [DraggableScrollableSheet] opened at the peek /// first frame, which a one-shot measurement would miss entirely. Content
/// fraction instead, so it never jumps straight to (near) full height with /// that never needs more than that cap just sizes to itself as a plain
/// no room left to adjust. The one extra layout pass for tall content /// scroll view, at whatever height that is - the common case for a short
/// happens within the sheet's own entrance animation, so it isn't visible /// menu/option list, and visually identical to before. [_needsPeek] flips
/// as a jump. /// 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 { class _NooSheetBody extends StatefulWidget {
final List<Widget> children; final List<Widget> children;
@@ -57,44 +62,44 @@ class _NooSheetBody extends StatefulWidget {
} }
class _NooSheetBodyState extends State<_NooSheetBody> { class _NooSheetBodyState extends State<_NooSheetBody> {
final _contentKey = GlobalKey();
bool _measured = false;
bool _needsPeek = 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((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || _measured) return; if (mounted && !_needsPeek) setState(() => _needsPeek = true);
final box = _contentKey.currentContext?.findRenderObject() as RenderBox?;
final contentHeight = box?.size.height ?? 0;
setState(() {
_measured = true;
_needsPeek = contentHeight > availableHeight * _kNooSheetPeekFraction;
});
}); });
} }
Widget _buildContent(ScrollController? scrollController) { Widget _buildScrollable(ScrollController? scrollController) {
final colors = context.nooColors; final colors = context.nooColors;
return SingleChildScrollView( return NotificationListener<ScrollMetricsNotification>(
controller: scrollController, onNotification: (notification) {
padding: const EdgeInsets.fromLTRB(20, 10, 20, 20), _handleOverflow(notification.metrics);
child: Column( return false;
key: _measured ? null : _contentKey, },
mainAxisSize: MainAxisSize.min, child: SingleChildScrollView(
children: [ controller: scrollController,
Container( padding: const EdgeInsets.fromLTRB(20, 10, 20, 20),
width: 36, child: Column(
height: 5, mainAxisSize: MainAxisSize.min,
decoration: BoxDecoration( children: [
color: colors.surface3, Container(
borderRadius: BorderRadius.circular(NooRadii.pill), width: 36,
height: 5,
decoration: BoxDecoration(
color: colors.surface3,
borderRadius: BorderRadius.circular(NooRadii.pill),
),
), ),
), for (final child in widget.children) ...[
for (final child in widget.children) ...[ const SizedBox(height: 22),
const SizedBox(height: 22), child,
child, ],
], ],
], ),
), ),
); );
} }
@@ -104,12 +109,13 @@ class _NooSheetBodyState extends State<_NooSheetBody> {
return SafeArea( return SafeArea(
child: LayoutBuilder( child: LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
if (!_measured) {
_measureAfterFrame(constraints.maxHeight);
return _buildContent(null);
}
if (!_needsPeek) { if (!_needsPeek) {
return _buildContent(null); return ConstrainedBox(
constraints: BoxConstraints(
maxHeight: constraints.maxHeight * _kNooSheetPeekFraction,
),
child: _buildScrollable(null),
);
} }
return DraggableScrollableSheet( return DraggableScrollableSheet(
initialChildSize: _kNooSheetPeekFraction, initialChildSize: _kNooSheetPeekFraction,
@@ -129,7 +135,7 @@ class _NooSheetBodyState extends State<_NooSheetBody> {
} }
return false; return false;
}, },
child: _buildContent(scrollController), child: _buildScrollable(scrollController),
); );
}, },
); );
+4 -8
View File
@@ -40,11 +40,7 @@ class MediaActionBar extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// This panel always sits on a black media stage regardless of the final colors = context.nooColors;
// 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;
// The blurred/tinted background lives outside the SafeArea (not inside // The blurred/tinted background lives outside the SafeArea (not inside
// it) so it extends all the way to the physical bottom edge, behind the // it) so it extends all the way to the physical bottom edge, behind the
@@ -59,7 +55,7 @@ class MediaActionBar extends StatelessWidget {
children: [ children: [
if (transportControls != null) ...[ if (transportControls != null) ...[
transportControls!, transportControls!,
Divider(height: 1, color: Colors.white.withValues(alpha: 0.14)), Divider(height: 1, color: colors.fg1.withValues(alpha: 0.14)),
], ],
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 6), padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 6),
@@ -80,7 +76,7 @@ class MediaActionBar extends StatelessWidget {
tooltip: isFavorite tooltip: isFavorite
? 'Remove from favorites' ? 'Remove from favorites'
: 'Favorite', : 'Favorite',
color: isFavorite ? dark.accentText : null, color: isFavorite ? colors.accentText : null,
onTap: onFavorite, onTap: onFavorite,
), ),
ViewerIconButton( ViewerIconButton(
@@ -98,7 +94,7 @@ class MediaActionBar extends StatelessWidget {
ViewerIconButton( ViewerIconButton(
icon: LucideIcons.trash2, icon: LucideIcons.trash2,
tooltip: 'Delete', tooltip: 'Delete',
color: dark.danger, color: colors.danger,
onTap: isBusy ? null : onDelete, onTap: isBusy ? null : onDelete,
), ),
ViewerIconButton( ViewerIconButton(
+1 -1
View File
@@ -161,7 +161,7 @@ class VideoTransportControls extends StatelessWidget {
return AnimatedBuilder( return AnimatedBuilder(
animation: controller, animation: controller,
builder: (context, _) { builder: (context, _) {
const fg = Colors.white; final fg = context.nooColors.fg1;
return Padding( return Padding(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4), padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4),
child: Column( child: Column(
+12 -13
View File
@@ -1,22 +1,22 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../theme/design_tokens.dart';
/// Icon-only action button for the media viewer's floating chrome (top bar, /// 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 /// bottom action bar, video transport controls) - a fixed 22px icon in a
/// round hit box. Colored white by default, not from [NooColors]/ /// round hit box. Colored from [NooColors.fg1] by default, matching
/// [ColorScheme]: this chrome always sits on the dark translucent panel /// [FrostedGlassContainer]'s own theme-following panel underneath it (both
/// over a black media stage (see `FrostedGlassContainer`'s doc comment), /// used to be fixed white-on-black regardless of theme - see their git
/// regardless of the app's light/dark theme, so a theme-derived color would /// history). Kept as one small widget so every floating control in this
/// go near-invisible in light mode. Kept as one small widget so every /// screen looks the same instead of drifting apart across the three call
/// floating control in this screen looks the same instead of drifting /// sites that need one.
/// apart across the three call sites that need one.
class ViewerIconButton extends StatelessWidget { class ViewerIconButton extends StatelessWidget {
final IconData icon; final IconData icon;
final String tooltip; final String tooltip;
final VoidCallback? onTap; final VoidCallback? onTap;
/// Overrides the default white (e.g. `danger` for delete, `accent-text` /// Overrides the default [NooColors.fg1] (e.g. `danger` for delete,
/// for a favorited state). Ignored while [onTap] is null - a disabled /// `accent-text` for a favorited state). Ignored while [onTap] is null -
/// button always dims to faded white regardless of its normal color. /// a disabled button always dims regardless of its normal color.
final Color? color; final Color? color;
const ViewerIconButton({ const ViewerIconButton({
@@ -29,9 +29,8 @@ class ViewerIconButton extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final fg = onTap == null final fg1 = context.nooColors.fg1;
? Colors.white.withValues(alpha: 0.4) final fg = onTap == null ? fg1.withValues(alpha: 0.4) : (color ?? fg1);
: (color ?? Colors.white);
return Tooltip( return Tooltip(
message: tooltip, message: tooltip,
+69
View File
@@ -18,6 +18,43 @@ Widget _launcher(void Function(BuildContext) open) => Builder(
TextButton(onPressed: () => open(context), child: const Text('open')), 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( Finder _dialogCard() => find.byWidgetPredicate(
(w) => (w) =>
w is Container && w is Container &&
@@ -136,6 +173,38 @@ void main() {
expect(sheetHeight, lessThan(500)); expect(sheetHeight, lessThan(500));
expect(sheetHeight, greaterThan(150)); 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', () { group('NooDialog', () {