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>
This commit is contained in:
+333
-242
@@ -1,5 +1,7 @@
|
||||
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';
|
||||
@@ -10,25 +12,20 @@ import '../models/nextcloud_sharee.dart';
|
||||
import '../providers/item_operations.dart';
|
||||
import '../providers/session_controller.dart';
|
||||
import '../providers/sync_status_controller.dart';
|
||||
import 'details/details_sheet.dart' show DetailsHeader;
|
||||
import 'gradual_bottom_sheet.dart';
|
||||
|
||||
IconData _shareTypeIcon(ShareType type) {
|
||||
switch (type) {
|
||||
case ShareType.user:
|
||||
return Icons.person_rounded;
|
||||
case ShareType.group:
|
||||
return Icons.groups_rounded;
|
||||
case ShareType.publicLink:
|
||||
return Icons.link_rounded;
|
||||
case ShareType.email:
|
||||
return Icons.email_rounded;
|
||||
case ShareType.federated:
|
||||
return Icons.public_rounded;
|
||||
case ShareType.other:
|
||||
return Icons.share_rounded;
|
||||
}
|
||||
}
|
||||
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).
|
||||
@@ -42,22 +39,46 @@ String? internalLinkFor(String serverUrl, String fileId) {
|
||||
return '$url/index.php/f/$fileId';
|
||||
}
|
||||
|
||||
/// Standalone "Share" bottom sheet for a single file/folder — replicates
|
||||
/// the web client's Sharing panel: search users/teams, "Others with
|
||||
/// access" (inherited shares), an internal link to copy, an email-share
|
||||
/// field, and a public link. Every "Share" action in the app (swipe,
|
||||
/// multi-select, media viewer) opens this same sheet.
|
||||
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;
|
||||
final ScrollController? scrollController;
|
||||
|
||||
const ShareSheet({super.key, required this.item, this.scrollController});
|
||||
const ShareSheet({super.key, required this.item});
|
||||
|
||||
static Future<void> show(BuildContext context, NextcloudItem item) {
|
||||
return showGradualBottomSheet(
|
||||
if (NooLayout.isDesktop(context)) {
|
||||
return showNooDialog(
|
||||
context,
|
||||
leading: detailsFileTile(item),
|
||||
title: item.name,
|
||||
subtitle: detailsMetaLine(item),
|
||||
children: [ShareSheet(item: item)],
|
||||
);
|
||||
}
|
||||
return showNooSheet(
|
||||
context,
|
||||
builder: (context, scrollController) =>
|
||||
ShareSheet(item: item, scrollController: scrollController),
|
||||
children: [
|
||||
NooOverlayHeader(
|
||||
leading: detailsFileTile(item),
|
||||
title: item.name,
|
||||
subtitle: detailsMetaLine(item),
|
||||
onClose: () => Navigator.pop(context),
|
||||
),
|
||||
ShareSheet(item: item),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -72,15 +93,17 @@ class _ShareSheetState extends State<ShareSheet> {
|
||||
List<NextcloudShare> _inherited = [];
|
||||
List<NextcloudSharee> _searchResults = [];
|
||||
bool _isSearching = false;
|
||||
bool _isAddingEmail = false;
|
||||
bool _isAddingPerson = false;
|
||||
bool _isTogglingLink = false;
|
||||
bool _isSharingFile = false;
|
||||
final _searchController = TextEditingController();
|
||||
final _emailController = TextEditingController();
|
||||
bool _showInherited = false;
|
||||
final _peopleController = TextEditingController();
|
||||
final _linkController = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
_emailController.dispose();
|
||||
_peopleController.dispose();
|
||||
_linkController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -114,6 +137,20 @@ class _ShareSheetState extends State<ShareSheet> {
|
||||
});
|
||||
}
|
||||
|
||||
/// 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);
|
||||
@@ -135,7 +172,7 @@ class _ShareSheetState extends State<ShareSheet> {
|
||||
setState(() {
|
||||
_shares = [..._shares, share];
|
||||
_searchResults = [];
|
||||
_searchController.clear();
|
||||
_peopleController.clear();
|
||||
});
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
@@ -145,10 +182,8 @@ class _ShareSheetState extends State<ShareSheet> {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _addEmailShare() async {
|
||||
final email = _emailController.text.trim();
|
||||
if (email.isEmpty) return;
|
||||
setState(() => _isAddingEmail = true);
|
||||
Future<void> _addEmail(String email) async {
|
||||
setState(() => _isAddingPerson = true);
|
||||
final ops = context.read<ItemOperations>();
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final share = await ops.createShare(
|
||||
@@ -158,7 +193,7 @@ class _ShareSheetState extends State<ShareSheet> {
|
||||
);
|
||||
if (!mounted) return;
|
||||
if (share == null) {
|
||||
setState(() => _isAddingEmail = false);
|
||||
setState(() => _isAddingPerson = false);
|
||||
messenger.showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Could not create email share'),
|
||||
@@ -169,8 +204,8 @@ class _ShareSheetState extends State<ShareSheet> {
|
||||
}
|
||||
setState(() {
|
||||
_shares = [..._shares, share];
|
||||
_emailController.clear();
|
||||
_isAddingEmail = false;
|
||||
_peopleController.clear();
|
||||
_isAddingPerson = false;
|
||||
});
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
@@ -183,10 +218,7 @@ class _ShareSheetState extends State<ShareSheet> {
|
||||
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,
|
||||
);
|
||||
final share = await ops.createShare(path: widget.item.path, shareType: 3);
|
||||
if (!mounted) return;
|
||||
if (share == null) {
|
||||
messenger.showSnackBar(
|
||||
@@ -217,6 +249,25 @@ class _ShareSheetState extends State<ShareSheet> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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(
|
||||
@@ -227,11 +278,10 @@ class _ShareSheetState extends State<ShareSheet> {
|
||||
);
|
||||
}
|
||||
|
||||
/// Downloads the item to a scratch file and hands it to Android'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.
|
||||
/// 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>();
|
||||
@@ -271,61 +321,203 @@ class _ShareSheetState extends State<ShareSheet> {
|
||||
}
|
||||
}
|
||||
|
||||
Widget _sectionHeader(String label) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
return Row(
|
||||
/// 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: [
|
||||
Text(
|
||||
label,
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
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,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Icon(
|
||||
Icons.info_outline_rounded,
|
||||
size: 16,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
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 _shareRow(
|
||||
NextcloudShare share, {
|
||||
bool removable = true,
|
||||
bool copyUrl = false,
|
||||
}) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final label = switch (share.shareType) {
|
||||
ShareType.publicLink => 'Public link',
|
||||
_ => share.sharedWithDisplayName ?? 'Shared',
|
||||
};
|
||||
return ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: Icon(
|
||||
_shareTypeIcon(share.shareType),
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
title: Text(label),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (copyUrl && share.url != null)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.copy_rounded),
|
||||
tooltip: 'Copy link',
|
||||
onPressed: () => _copy(share.url!, 'link'),
|
||||
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',
|
||||
),
|
||||
),
|
||||
if (removable)
|
||||
IconButton(
|
||||
icon: Icon(Icons.close_rounded, color: colorScheme.error),
|
||||
tooltip: 'Remove',
|
||||
onPressed: () => _removeShare(share),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -335,170 +527,69 @@ class _ShareSheetState extends State<ShareSheet> {
|
||||
_requested = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _load());
|
||||
}
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
final colors = context.nooColors;
|
||||
final session = context.watch<SessionController>();
|
||||
|
||||
if (_isLoading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
return const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 40),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
|
||||
final userGroupShares = _shares
|
||||
final peopleShares = _shares
|
||||
.where(
|
||||
(s) =>
|
||||
s.shareType == ShareType.user || s.shareType == ShareType.group,
|
||||
s.shareType == ShareType.user ||
|
||||
s.shareType == ShareType.group ||
|
||||
s.shareType == ShareType.email,
|
||||
)
|
||||
.toList();
|
||||
final publicLinkShares = _shares
|
||||
.where((s) => s.shareType == ShareType.publicLink)
|
||||
.toList();
|
||||
final emailShares = _shares
|
||||
.where((s) => s.shareType == ShareType.email)
|
||||
.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 ListView(
|
||||
controller: widget.scrollController,
|
||||
padding: const EdgeInsets.fromLTRB(20, 0, 20, 24),
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Same icon/name/meta header as DetailsSheet, so this and its
|
||||
// sibling per-item bottom sheet open on identical chrome - just a
|
||||
// bare title `Text` here read as under-designed by comparison.
|
||||
DetailsHeader(
|
||||
item: widget.item,
|
||||
padding: const EdgeInsets.only(top: 12, bottom: 8),
|
||||
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: 4),
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: _isSharingFile
|
||||
? const Padding(
|
||||
padding: EdgeInsets.all(12),
|
||||
child: SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.ios_share_rounded),
|
||||
title: const Text('Share file directly'),
|
||||
subtitle: const Text('Send the file itself via another app'),
|
||||
onTap: _isSharingFile ? null : _shareFileDirectly,
|
||||
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'),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const Divider(),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
_sectionHeader('Internal shares'),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: _searchController,
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
hintText: 'Type names or teams',
|
||||
prefixIcon: const Icon(Icons.search_rounded),
|
||||
suffixIcon: _isSearching
|
||||
? const Padding(
|
||||
padding: EdgeInsets.all(12),
|
||||
child: SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16)),
|
||||
filled: true,
|
||||
fillColor: colorScheme.surfaceContainerLow,
|
||||
),
|
||||
onChanged: _search,
|
||||
),
|
||||
for (final sharee in _searchResults)
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: Icon(
|
||||
sharee.type == ShareeType.group
|
||||
? Icons.groups_rounded
|
||||
: Icons.person_rounded,
|
||||
),
|
||||
title: Text(sharee.label),
|
||||
subtitle: sharee.subtitle != null ? Text(sharee.subtitle!) : null,
|
||||
onTap: () => _addSharee(sharee),
|
||||
),
|
||||
for (final share in userGroupShares) _shareRow(share),
|
||||
if (_inherited.isNotEmpty)
|
||||
ExpansionTile(
|
||||
tilePadding: EdgeInsets.zero,
|
||||
leading: const Icon(Icons.more_horiz_rounded),
|
||||
title: Text('Others with access (${_inherited.length})'),
|
||||
children: [
|
||||
for (final share in _inherited)
|
||||
_shareRow(share, removable: false),
|
||||
],
|
||||
),
|
||||
if (internalLink != null)
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(Icons.link_rounded),
|
||||
title: const Text('Internal link'),
|
||||
subtitle: const Text('For people who already have access'),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.copy_rounded),
|
||||
tooltip: 'Copy link',
|
||||
onPressed: () => _copy(internalLink, 'internal link'),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
const Divider(),
|
||||
const SizedBox(height: 12),
|
||||
_sectionHeader('External shares'),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: _emailController,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
enabled: !_isAddingEmail,
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
hintText: 'Type an email',
|
||||
prefixIcon: const Icon(Icons.email_rounded),
|
||||
suffixIcon: _isAddingEmail
|
||||
? const Padding(
|
||||
padding: EdgeInsets.all(12),
|
||||
child: SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16)),
|
||||
filled: true,
|
||||
fillColor: colorScheme.surfaceContainerLow,
|
||||
),
|
||||
onSubmitted: (_) => _addEmailShare(),
|
||||
),
|
||||
for (final share in emailShares) _shareRow(share),
|
||||
const SizedBox(height: 8),
|
||||
for (final share in publicLinkShares) _shareRow(share, copyUrl: true),
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(Icons.link_rounded),
|
||||
title: const Text('Create public link'),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.add_rounded),
|
||||
tooltip: 'Create public link',
|
||||
onPressed: _createPublicLink,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user