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:
@@ -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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
+59
-20
@@ -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,9 +132,10 @@ 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: [
|
||||||
|
Semantics(
|
||||||
button: true,
|
button: true,
|
||||||
label: 'Restore',
|
label: 'Restore',
|
||||||
child: InkResponse(
|
child: InkResponse(
|
||||||
@@ -135,8 +144,18 @@ class _TrashViewState extends State<TrashView> {
|
|||||||
child: const Icon(LucideIcons.rotateCcw),
|
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'),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,29 +62,28 @@ 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>(
|
||||||
|
onNotification: (notification) {
|
||||||
|
_handleOverflow(notification.metrics);
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
child: SingleChildScrollView(
|
||||||
controller: scrollController,
|
controller: scrollController,
|
||||||
padding: const EdgeInsets.fromLTRB(20, 10, 20, 20),
|
padding: const EdgeInsets.fromLTRB(20, 10, 20, 20),
|
||||||
child: Column(
|
child: Column(
|
||||||
key: _measured ? null : _contentKey,
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Container(
|
||||||
@@ -96,6 +100,7 @@ class _NooSheetBodyState extends State<_NooSheetBody> {
|
|||||||
],
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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', () {
|
||||||
|
|||||||
Reference in New Issue
Block a user