Files
noo/lib/widgets/share_sheet.dart
T
ayushyaandClaude Opus 5.5 56784ef212 Rebuild UI on the Noo design system
Replaces the Material 3 screens with the Noo design system: warm
neutrals, one violet accent, pill controls, Schibsted Grotesk/
Instrument Sans, Lucide icons, no gradients/shadows.

- New lib/widgets/noo/ component kit (core, lists, files, media, nav,
  overlays) plus lib/theme/design_tokens.dart for the color/type/space/
  radius/motion tokens.
- Rebuilt the app shell (top/bottom bars, drawer, desktop sidebar and
  toolbar), every tab (Files/Offline, Photos, Favorites, Recent,
  Activity, Trash, Shares), Settings, the lock and login screens, the
  media viewer, search, the details/share sheets, and the share-upload
  and move/copy destination pickers.
- Added a design canvas (linked from DESIGN_SYSTEM.md) covering the
  screens the spec didn't already describe, with matching Android and
  iOS chrome; wrote up the approved recipes into DESIGN_SYSTEM.md §4.
- Removed now-dead legacy widgets (media_grid_tile, swipeable_item,
  sync_status_badge, selectable_thumbnail) and updated
  architecture.md/styling.md/standards.md to describe the new
  structure and component/testing conventions.
- Added widget tests for the noo/ component kit.

This is a UI-only rework: no provider/model/service behavior changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-26 01:51:58 -04:00

597 lines
20 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:intl/intl.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:provider/provider.dart';
import 'package:share_plus/share_plus.dart';
import '../models/nextcloud_item.dart';
import '../models/nextcloud_share.dart';
import '../models/nextcloud_sharee.dart';
import '../providers/item_operations.dart';
import '../providers/session_controller.dart';
import '../providers/sync_status_controller.dart';
import '../theme/design_tokens.dart';
import 'details/details_sheet.dart' show detailsFileTile, detailsMetaLine;
import 'noo/core/noo_avatar.dart';
import 'noo/core/noo_button.dart';
import 'noo/core/noo_chip.dart';
import 'noo/core/noo_toggle.dart';
import 'noo/lists/noo_settings_row.dart';
import 'noo/media/noo_photo_tile.dart';
import 'noo/noo_layout.dart';
import 'noo/overlays/noo_dialog.dart';
import 'noo/overlays/noo_overlay_header.dart';
import 'noo/overlays/noo_share_parts.dart';
import 'noo/overlays/noo_sheet.dart';
import 'noo/overlays/noo_text_field.dart';
/// Builds the "internal link" URL for a file, or null if [fileId] isn't a
/// numeric Nextcloud file id (WebDAV `oc:fileid` was missing for this item).
String? internalLinkFor(String serverUrl, String fileId) {
if (int.tryParse(fileId) == null) return null;
var url = serverUrl.trim();
if (!url.startsWith('http://') && !url.startsWith('https://')) {
url = 'https://$url';
}
if (url.endsWith('/')) url = url.substring(0, url.length - 1);
return '$url/index.php/f/$fileId';
}
String _initial(String s) {
final trimmed = s.trim();
return trimmed.isEmpty ? '?' : trimmed[0].toUpperCase();
}
/// Standalone "Share" bottom sheet/dialog for a single file/folder -
/// `DESIGN_SYSTEM.md` §4 "Share sheet / dialog": share with people, a
/// public link, and (files only) sending the file itself. Every "Share"
/// action in the app (swipe, multi-select, media viewer) opens this same
/// sheet.
///
/// Only used directly on mobile (bundled behind its own [NooOverlayHeader]
/// by [show]); the desktop path hands this straight to [showNooDialog],
/// which renders the header itself via `leading`/`title`/`subtitle`.
class ShareSheet extends StatefulWidget {
final NextcloudItem item;
const ShareSheet({super.key, required this.item});
static Future<void> show(BuildContext context, NextcloudItem item) {
if (NooLayout.isDesktop(context)) {
return showNooDialog(
context,
leading: detailsFileTile(item),
title: item.name,
subtitle: detailsMetaLine(item),
children: [ShareSheet(item: item)],
);
}
return showNooSheet(
context,
children: [
NooOverlayHeader(
leading: detailsFileTile(item),
title: item.name,
subtitle: detailsMetaLine(item),
onClose: () => Navigator.pop(context),
),
ShareSheet(item: item),
],
);
}
@override
State<ShareSheet> createState() => _ShareSheetState();
}
class _ShareSheetState extends State<ShareSheet> {
bool _requested = false;
bool _isLoading = true;
List<NextcloudShare> _shares = [];
List<NextcloudShare> _inherited = [];
List<NextcloudSharee> _searchResults = [];
bool _isSearching = false;
bool _isAddingPerson = false;
bool _isTogglingLink = false;
bool _isSharingFile = false;
bool _showInherited = false;
final _peopleController = TextEditingController();
final _linkController = TextEditingController();
@override
void dispose() {
_peopleController.dispose();
_linkController.dispose();
super.dispose();
}
Future<void> _load() async {
final ops = context.read<ItemOperations>();
final results = await Future.wait([
ops.fetchItemShares(widget.item),
ops.fetchInheritedShares(widget.item),
]);
if (!mounted) return;
setState(() {
_shares = results[0];
_inherited = results[1];
_isLoading = false;
});
}
Future<void> _search(String query) async {
final trimmed = query.trim();
if (trimmed.isEmpty) {
setState(() => _searchResults = []);
return;
}
setState(() => _isSearching = true);
final ops = context.read<ItemOperations>();
final results = await ops.searchSharees(trimmed);
if (!mounted) return;
setState(() {
_searchResults = results;
_isSearching = false;
});
}
/// The single "Name, email or group" field: reuses whichever match is
/// live, or falls back to an email share when the text looks like one -
/// same two underlying actions the pre-rework sheet split across two
/// separate fields.
Future<void> _submitPeopleField() async {
final text = _peopleController.text.trim();
if (text.isEmpty) return;
if (_searchResults.isNotEmpty) {
await _addSharee(_searchResults.first);
} else if (text.contains('@')) {
await _addEmail(text);
}
}
Future<void> _addSharee(NextcloudSharee sharee) async {
final ops = context.read<ItemOperations>();
final messenger = ScaffoldMessenger.of(context);
final share = await ops.createShare(
path: widget.item.path,
shareType: sharee.shareTypeValue,
shareWith: sharee.shareWith,
);
if (!mounted) return;
if (share == null) {
messenger.showSnackBar(
const SnackBar(
content: Text('Could not share this item'),
behavior: SnackBarBehavior.floating,
),
);
return;
}
setState(() {
_shares = [..._shares, share];
_searchResults = [];
_peopleController.clear();
});
messenger.showSnackBar(
SnackBar(
content: Text('Shared with ${sharee.label}'),
behavior: SnackBarBehavior.floating,
),
);
}
Future<void> _addEmail(String email) async {
setState(() => _isAddingPerson = true);
final ops = context.read<ItemOperations>();
final messenger = ScaffoldMessenger.of(context);
final share = await ops.createShare(
path: widget.item.path,
shareType: 4,
shareWith: email,
);
if (!mounted) return;
if (share == null) {
setState(() => _isAddingPerson = false);
messenger.showSnackBar(
const SnackBar(
content: Text('Could not create email share'),
behavior: SnackBarBehavior.floating,
),
);
return;
}
setState(() {
_shares = [..._shares, share];
_peopleController.clear();
_isAddingPerson = false;
});
messenger.showSnackBar(
SnackBar(
content: Text('Shared with $email'),
behavior: SnackBarBehavior.floating,
),
);
}
Future<void> _createPublicLink() async {
final ops = context.read<ItemOperations>();
final messenger = ScaffoldMessenger.of(context);
final share = await ops.createShare(path: widget.item.path, shareType: 3);
if (!mounted) return;
if (share == null) {
messenger.showSnackBar(
const SnackBar(
content: Text('Could not create public link'),
behavior: SnackBarBehavior.floating,
),
);
return;
}
setState(() => _shares = [..._shares, share]);
}
Future<void> _removeShare(NextcloudShare share) async {
final ops = context.read<ItemOperations>();
final messenger = ScaffoldMessenger.of(context);
final success = await ops.deleteShare(share);
if (!mounted) return;
if (success) {
setState(() => _shares = _shares.where((s) => s.id != share.id).toList());
} else {
messenger.showSnackBar(
const SnackBar(
content: Text('Could not remove share'),
behavior: SnackBarBehavior.floating,
),
);
}
}
/// The link toggle: on creates a public link, off removes every public
/// link share (there's normally at most one - the sheet only ever
/// surfaces the first one - but this cleans up any extras from before the
/// rework's single-link toggle).
Future<void> _toggleLink(bool checked) async {
setState(() => _isTogglingLink = true);
if (checked) {
await _createPublicLink();
} else {
final links = _shares
.where((s) => s.shareType == ShareType.publicLink)
.toList();
for (final link in links) {
await _removeShare(link);
}
}
if (mounted) setState(() => _isTogglingLink = false);
}
void _copy(String text, String label) {
Clipboard.setData(ClipboardData(text: text));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Copied $label'),
behavior: SnackBarBehavior.floating,
),
);
}
/// Downloads the item to a scratch file and hands it to the OS's native
/// "Share to..." sheet (`share_plus`) - a completely different action from
/// the rest of this sheet (which shares *within* Nextcloud, via users/
/// links) and the only one here that reads the file's actual bytes.
Future<void> _shareFileDirectly() async {
final session = context.read<SessionController>();
final sync = context.read<SyncStatusController>();
if (session.service == null) return;
setState(() => _isSharingFile = true);
final messenger = ScaffoldMessenger.of(context);
try {
// Already mirrored locally by device sync? Share that copy straight
// away instead of a fresh WebDAV fetch - see
// SyncStatusController.localSyncedFilePath.
final tempPath =
await sync.localSyncedFilePath(widget.item) ??
await () async {
final tempDir = await getTemporaryDirectory();
final path = p.join(tempDir.path, widget.item.name);
await session.service!.downloadToFile(widget.item.path, path);
return path;
}();
if (!mounted) return;
await SharePlus.instance.share(
ShareParams(
files: [XFile(tempPath)],
fileNameOverrides: [widget.item.name],
),
);
} catch (_) {
if (mounted) {
messenger.showSnackBar(
const SnackBar(
content: Text('Could not share the file'),
behavior: SnackBarBehavior.floating,
),
);
}
} finally {
if (mounted) setState(() => _isSharingFile = false);
}
}
/// A small popup with the one action the compact [NooPersonAccessRow]
/// permission pill has no room for - removing that person's access. There's
/// no permission-update endpoint on [ItemOperations] yet, so the pill
/// itself is read-only (see the rebuild report).
void _openPersonMenu(NextcloudShare share) {
final label = share.sharedWithDisplayName ?? 'Shared';
void remove() {
Navigator.pop(context);
_removeShare(share);
}
final row = NooSettingsRow(
icon: LucideIcons.userMinus,
label: const Text('Remove access'),
danger: true,
onTap: remove,
);
if (NooLayout.isDesktop(context)) {
showNooDialog(context, title: label, children: [row]);
} else {
showNooSheet(
context,
children: [
NooOverlayHeader(title: label, onClose: () => Navigator.pop(context)),
row,
],
);
}
}
Widget _peopleSection(
NooColors colors,
SessionController session,
List<NextcloudShare> peopleShares,
String? internalLink,
) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
AbsorbPointer(
absorbing: _isAddingPerson,
child: NooTextField(
controller: _peopleController,
leadingIcon: LucideIcons.search,
placeholder: 'Name, email or group',
onChanged: _search,
onSubmitted: (_) => _submitPeopleField(),
trailing: (_isSearching || _isAddingPerson)
? const Padding(
padding: EdgeInsets.all(4),
child: SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
),
)
: null,
),
),
for (final sharee in _searchResults)
NooSettingsRow(
icon: sharee.type == ShareeType.group
? LucideIcons.users
: LucideIcons.user,
label: Text(sharee.label),
subtitle: sharee.subtitle != null ? Text(sharee.subtitle!) : null,
onTap: () => _addSharee(sharee),
),
const SizedBox(height: NooSpace.xs),
NooPersonAccessRow(
avatar: NooAvatar(initials: _initial(session.username), current: true),
name: session.username,
owner: true,
),
for (final share in peopleShares)
NooPersonAccessRow(
avatar: NooAvatar(
initials: _initial(share.sharedWithDisplayName ?? share.id),
color: NooPhotoTile.paletteColor(
(share.sharedWithDisplayName ?? share.id).hashCode,
),
icon: share.shareType == ShareType.group ? LucideIcons.users : null,
),
name: share.sharedWithDisplayName ?? 'Shared',
subtitle: share.shareType == ShareType.group
? 'Group'
: (share.shareType == ShareType.email ? 'Invited by email' : null),
permission: (share.permissions & 2) != 0 ? 'Can edit' : 'Can view',
onPermissionTap: () => _openPersonMenu(share),
),
if (_inherited.isNotEmpty) ...[
const SizedBox(height: NooSpace.xs),
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => setState(() => _showInherited = !_showInherited),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: NooSpace.xs),
child: Row(
children: [
Icon(LucideIcons.users, size: 16, color: colors.fg3),
const SizedBox(width: NooSpace.xs),
Expanded(
child: Text(
'Others with access (${_inherited.length})',
style: NooText.meta.copyWith(color: colors.fg3),
),
),
Icon(
_showInherited ? LucideIcons.chevronUp : LucideIcons.chevronDown,
size: 16,
color: colors.fg3,
),
],
),
),
),
if (_showInherited)
for (final share in _inherited)
Padding(
padding: const EdgeInsets.only(bottom: NooSpace.xs),
child: NooPersonAccessRow(
avatar: NooAvatar(
initials: _initial(
share.sharedWithDisplayName ?? share.ownerDisplayName,
),
),
name: share.sharedWithDisplayName ?? share.ownerDisplayName,
owner: true,
ownerLabel: 'Inherited',
),
),
],
if (internalLink != null) ...[
const SizedBox(height: NooSpace.xs),
Row(
children: [
Icon(LucideIcons.link, size: 18, color: colors.fg3),
const SizedBox(width: NooSpace.sm),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text('Internal link', style: NooText.bodyL.copyWith(color: colors.fg1)),
Text(
'For people who already have access',
style: NooText.meta.copyWith(color: colors.fg3),
),
],
),
),
NooButton(
size: NooButtonSize.compact,
variant: NooButtonVariant.secondary,
icon: LucideIcons.copy,
onTap: () => _copy(internalLink, 'internal link'),
child: const Text('Copy'),
),
],
),
],
],
);
}
Widget _linkSection(NextcloudShare link) {
final canEdit = (link.permissions & 2) != 0;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
NooTextField(
controller: _linkController,
mono: true,
readOnly: true,
trailing: NooButton(
size: NooButtonSize.field,
icon: LucideIcons.copy,
onTap: () => _copy(link.url ?? '', 'link'),
child: const Text('Copy link'),
),
),
const SizedBox(height: NooSpace.sm),
Wrap(
spacing: NooSpace.xs,
runSpacing: NooSpace.xs,
children: [
NooChip(child: Text(canEdit ? 'Can edit' : 'View only')),
NooChip(
child: Text(
link.expireDate != null
? 'Expires ${DateFormat.yMMMd().format(link.expireDate!)}'
: 'No expiry',
),
),
],
),
],
);
}
@override
Widget build(BuildContext context) {
if (!_requested) {
_requested = true;
WidgetsBinding.instance.addPostFrameCallback((_) => _load());
}
final colors = context.nooColors;
final session = context.watch<SessionController>();
if (_isLoading) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 40),
child: Center(child: CircularProgressIndicator()),
);
}
final peopleShares = _shares
.where(
(s) =>
s.shareType == ShareType.user ||
s.shareType == ShareType.group ||
s.shareType == ShareType.email,
)
.toList();
final publicLinkShares = _shares
.where((s) => s.shareType == ShareType.publicLink)
.toList();
final hasLink = publicLinkShares.isNotEmpty;
if (hasLink) {
final url = publicLinkShares.first.url ?? '';
if (_linkController.text != url) _linkController.text = url;
}
final internalLink = internalLinkFor(session.serverUrl, widget.item.id);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
NooShareSection(
title: 'Share with people',
child: _peopleSection(colors, session, peopleShares, internalLink),
),
const SizedBox(height: 22),
NooShareSection(
title: 'Share link',
trailing: NooToggle(
checked: hasLink,
onChanged: _isTogglingLink ? null : _toggleLink,
),
caption: hasLink
? null
: 'Anyone with the link can access this ${widget.item.isFolder ? 'folder' : 'file'}.',
child: hasLink ? _linkSection(publicLinkShares.first) : const SizedBox.shrink(),
),
if (!widget.item.isFolder) ...[
const SizedBox(height: 22),
NooShareSection(
title: 'Send file directly',
caption: "Link settings above don't apply - this sends the file's bytes directly.",
child: NooButton(
variant: NooButtonVariant.outline,
size: NooButtonSize.card,
fullWidth: true,
icon: LucideIcons.send,
disabled: _isSharingFile,
onTap: _isSharingFile ? null : _shareFileDirectly,
child: Text(_isSharingFile ? 'Preparing…' : 'Send file directly'),
),
),
],
],
);
}
}