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:
2026-09-26 01:51:58 -04:00
co-authored by Claude Opus 5.5
parent 51682a901d
commit 56784ef212
125 changed files with 15263 additions and 6804 deletions
+333 -242
View File
@@ -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,
),
),
],
);
}