Files
noo/lib/views/share_upload_view.dart
T
ayushya 0abea46b1c Fix sheet peek for async content, and rework several screens' details
showNooSheet's peek detection previously measured content once, right
after the first frame - Details' Versions/Activity tabs fetch their
data asynchronously, so that one-shot measurement only ever saw the
initial loading-spinner height, never the eventual (often long) list,
and the sheet still snapped to full height once data arrived. Replaced
it with a ScrollMetricsNotification-based check that keeps content
capped at the peek height from frame one and upgrades to a draggable
sheet whenever it actually overflows that cap, however and whenever
that happens - fixed for good this time, with a regression test that
mirrors the async-load shape.

Also, in the same pass:
- Upload/Move/Copy's bottom bar meta line now always reads as a count
  ("Uploading 3 items"), never a filename for the single-item case,
  and is centered rather than left-aligned - matches the approved
  design and removes a bespoke marquee/chip implementation that only
  Upload had.
- Upload/Move/Copy now show the same FilesControlsRow (sort, filter,
  List/Grid) Files itself uses, including a folder-grid rendering
  path for when List/Grid is set to grid.
- Files' filter chip reads "Filters" instead of "All types" when idle.
- Trash's overflow button - whose only action is "Delete forever" -
  is now a direct delete icon (mobile) / "Delete" danger pill
  (desktop) instead of a generic "..." menu that hid a single action
  behind an extra tap.
- The media viewer's floating chrome (top bar, bottom action bar,
  video transport row) now follows the app's light/dark theme instead
  of a fixed dark panel - a dark panel read as out of place over a
  light-theme stage. Panel tint and icon/text colors come from the
  theme's own surface/fg1 tokens (at a higher blur opacity to stay
  legible over arbitrary photo/video brightness), and the edge scrim
  gradients fade toward the panel's own tint instead of a fixed black.
  Mirrored the same change into the design canvas's Main.dc.html/
  MainAndroid.dc.html mockups (now with a theme prop) so they match.
2026-09-28 19:26:18 -04:00

336 lines
12 KiB
Dart

import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:provider/provider.dart';
import '../models/app_tab.dart';
import '../models/nextcloud_item.dart';
import '../providers/files_controller.dart';
import '../providers/session_controller.dart';
import '../providers/settings_controller.dart';
import '../services/share_intent_service.dart';
import '../services/upload_service.dart';
import '../theme/design_tokens.dart';
import '../widgets/breadcrumbs.dart';
import '../widgets/files_controls_row.dart';
import '../widgets/noo/core/noo_button.dart';
import '../widgets/noo/files/noo_file_kind.dart';
import '../widgets/noo/files/noo_file_row.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_toolbar.dart';
import '../widgets/noo/noo_layout.dart';
import '../widgets/shell/shell_common.dart';
import '../widgets/tabs/tab_state_slivers.dart';
/// Shown when another app shares one or more files to Noo (Android's
/// "Share to..." sheet). Lets the user browse to a destination folder,
/// reusing `FilesController`'s shared folder-browsing state so this feels
/// like the same browser Files itself uses, then hands the actual
/// prepare+upload off to [UploadService] - a real Android foreground
/// service (see `ShareUploadService.kt`'s doc comment), not something this
/// screen or even the app needs to stay open for. [files] only ever carry
/// cheap Uri metadata (see [ShareIntentService]'s doc comment); that
/// service is the only thing that ever reads their actual bytes. Pushed
/// via `Navigator` from outside `MainShellView` (a cold share-intent
/// launch, or Files' own "+" -> "Upload file"), so - unlike a tab - it
/// builds its own complete top chrome rather than relying on the shell's.
class ShareUploadView extends StatefulWidget {
final List<SharedFileRef> files;
const ShareUploadView({super.key, required this.files});
@override
State<ShareUploadView> createState() => _ShareUploadViewState();
}
class _ShareUploadViewState extends State<ShareUploadView> {
final ScrollController _scrollController = ScrollController();
@override
void initState() {
super.initState();
// Shared files have no relationship to wherever the user was last
// browsing, so start the destination picker fresh at the root.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
context.read<FilesController>().navigateToAbsoluteFolder('/');
}
});
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
Future<void> _uploadHere(SessionController session) async {
final messenger = ScaffoldMessenger.of(context);
final navigator = Navigator.of(context);
final files = context.read<FilesController>();
final settings = context.read<SettingsController>();
try {
await UploadService.startUpload(session, files, widget.files);
} catch (e) {
messenger.showSnackBar(
SnackBar(
content: Text('Could not start upload: $e'),
behavior: SnackBarBehavior.floating,
),
);
return;
}
settings.requestTab(AppTab.files);
navigator.popUntil((route) => route.isFirst);
messenger.showSnackBar(
SnackBar(
content: Text(
widget.files.length == 1
? 'Uploading ${widget.files.first.name} - see the notification for progress'
: 'Uploading ${widget.files.length} files - see the notification for progress',
),
behavior: SnackBarBehavior.floating,
),
);
}
// Only folders are valid upload destinations, so the listing - unlike
// Files' own - never shows plain files at all.
Widget _buildRow(
BuildContext context,
NextcloudItem item,
int index,
int count,
) {
final colors = context.nooColors;
final files = context.read<FilesController>();
final row = NooFileRow(
kind: NooFileKind.folder,
name: item.name,
meta: 'Folder',
iosStyle: NooLayout.iosStyle(context),
onTap: () => files.navigateToFolder(item.path),
);
final isFirst = index == 0;
final isLast = index == count - 1;
return Column(
children: [
ClipRRect(
borderRadius: BorderRadius.vertical(
top: isFirst ? const Radius.circular(NooRadii.card) : Radius.zero,
bottom: isLast ? const Radius.circular(NooRadii.card) : Radius.zero,
),
child: row,
),
if (!isLast) Container(height: 1, color: colors.line),
],
);
}
Widget _buildDesktopRow(BuildContext context, NextcloudItem item) {
final files = context.read<FilesController>();
return NooFileTableRow(
kind: NooFileKind.folder,
name: item.name,
onTap: () => files.navigateToFolder(item.path),
);
}
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
Widget build(BuildContext context) {
final colors = context.nooColors;
final desktop = NooLayout.isDesktop(context);
final files = context.watch<FilesController>();
final session = context.watch<SessionController>();
final hasBreadcrumbs = files.pathStack.length > 1;
final currentPath = files.pathStack.last;
final currentLabel = currentPath == '/'
? 'Home'
: currentPath.split('/').where((s) => s.isNotEmpty).last;
final folders = files.items.where((item) => item.isFolder).toList();
final gutter = NooLayout.gutter(context);
final slivers = <Widget>[
SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.fromLTRB(
gutter,
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,
child: Breadcrumbs(
pathStack: files.pathStack,
onTap: (index) => files.navigateToPathIndex(index),
),
),
],
],
),
),
),
if (files.isLoading)
tabLoadingSliver
else if (folders.isEmpty)
tabEmptySliver(
context,
icon: LucideIcons.folder,
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)
SliverPadding(
padding: EdgeInsets.symmetric(horizontal: gutter),
sliver: SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) => _buildDesktopRow(context, folders[index]),
childCount: folders.length,
),
),
)
else
SliverPadding(
padding: EdgeInsets.symmetric(horizontal: gutter),
sliver: SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) =>
_buildRow(context, folders[index], index, folders.length),
childCount: folders.length,
),
),
),
const SliverToBoxAdapter(child: SizedBox(height: NooSpace.xl)),
];
final title = 'Upload to...';
return Scaffold(
backgroundColor: colors.bg,
appBar: desktop
? NooToolbar(
title: title,
actions: [
NooButton(
variant: NooButtonVariant.secondary,
size: NooButtonSize.toolbar,
onTap: () => Navigator.of(context).maybePop(),
child: const Text('Cancel'),
),
],
)
: NooTopBar(
style: NooLayout.navStyle(context),
title: title,
leading: const NooTopBarBack(label: 'Cancel'),
// Same top-bar access as every tab (avatar -> Settings, swipe
// to switch accounts) - nothing else belongs here: jumping to
// another tab mid-upload would abandon this destination
// picker, so there's no "more tabs" entry.
actions: const [ShellAvatarButton()],
),
body: SafeArea(
top: false,
child: RefreshIndicator(
color: colors.accent,
backgroundColor: colors.surface,
onRefresh: files.refreshData,
child: CustomScrollView(
controller: _scrollController,
slivers: slivers,
),
),
),
// A flat surface bar with a top line, peeking up from the bottom edge -
// a centered item-count meta line sits directly above the destination
// 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(
decoration: BoxDecoration(
color: colors.surface,
border: Border(top: BorderSide(color: colors.line)),
),
child: SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(
NooSpace.lg,
NooSpace.md,
NooSpace.lg,
NooSpace.md,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
'Uploading ${widget.files.length} item${widget.files.length == 1 ? '' : 's'}',
textAlign: TextAlign.center,
style: NooText.meta.copyWith(color: colors.fg3),
),
const SizedBox(height: NooSpace.sm),
NooButton(
variant: NooButtonVariant.primary,
size: NooButtonSize.cta,
fullWidth: true,
icon: LucideIcons.upload,
onTap: () => _uploadHere(session),
child: Text('Upload to $currentLabel'),
),
],
),
),
),
),
);
}
}