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
+160 -109
View File
@@ -1,6 +1,15 @@
import 'package:flutter/material.dart';
import '../models/move_copy_result.dart';
import 'gradual_bottom_sheet.dart';
import '../theme/design_tokens.dart';
import 'noo/core/noo_button.dart';
import 'noo/core/noo_segmented_control.dart';
import 'noo/files/noo_file_kind.dart';
import 'noo/files/noo_file_tile.dart';
import 'noo/lists/noo_grouped_list.dart';
import 'noo/noo_layout.dart';
import 'noo/overlays/noo_dialog.dart';
import 'noo/overlays/noo_overlay_header.dart';
import 'noo/overlays/noo_sheet.dart';
/// Shown after a Move/Copy batch comes back with `MoveCopyResult.conflicts`
/// non-empty (an item with the same name already exists at the
@@ -8,27 +17,36 @@ import 'gradual_bottom_sheet.dart';
/// per item as they're hit, and lets the user resolve them all the same
/// way (overwrite/keep both) or open a per-item breakdown. Returns the
/// `ConflictChoice` map `ItemOperations.resolveConflicts` expects, or null
/// if dismissed without choosing.
/// if dismissed without choosing. A `showNooDialog` (desktop) / `showNooSheet`
/// (mobile) instead of the old `showGradualBottomSheet` - the content never
/// grows past a couple of screens, so the drag-to-resize sheet that needed
/// isn't worth it here.
class MoveCopyConflictSheet extends StatefulWidget {
final List<MoveCopyConflict> conflicts;
final ScrollController? scrollController;
const MoveCopyConflictSheet({
super.key,
required this.conflicts,
this.scrollController,
});
const MoveCopyConflictSheet({super.key, required this.conflicts});
static Future<Map<String, ConflictChoice>?> show(
BuildContext context,
List<MoveCopyConflict> conflicts,
) {
return showGradualBottomSheet<Map<String, ConflictChoice>>(
final content = MoveCopyConflictSheet(conflicts: conflicts);
final count = conflicts.length;
final title = count == 1
? '1 item already exists'
: '$count items already exist';
if (NooLayout.isDesktop(context)) {
return showNooDialog<Map<String, ConflictChoice>>(
context,
title: title,
subtitle: 'Choose how to handle the conflicts',
children: [content],
);
}
return showNooSheet<Map<String, ConflictChoice>>(
context,
builder: (context, scrollController) => MoveCopyConflictSheet(
conflicts: conflicts,
scrollController: scrollController,
),
children: [content],
);
}
@@ -55,118 +73,151 @@ class _MoveCopyConflictSheetState extends State<MoveCopyConflictSheet> {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final isDesktop = NooLayout.isDesktop(context);
final count = widget.conflicts.length;
return ListView(
controller: widget.scrollController,
padding: const EdgeInsets.fromLTRB(20, 12, 20, 24),
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
count == 1
? '1 item already exists at the destination'
: '$count items already exist at the destination',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
// The dialog already renders this title/subtitle in its own header
// (see `show` above); the sheet has no header of its own, so it
// needs one here.
if (!isDesktop) ...[
NooOverlayHeader(
title: count == 1
? '1 item already exists'
: '$count items already exist',
subtitle: 'Choose how to handle the conflicts',
onClose: () => Navigator.pop(context),
),
),
const SizedBox(height: 4),
Text(
'Choose how to handle the conflicts.',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 16),
const SizedBox(height: NooSpace.lg),
],
if (!_decidingPerItem) ...[
FilledButton(
onPressed: () => _resolveAll(ConflictChoice.overwrite),
NooButton(
variant: NooButtonVariant.primary,
size: NooButtonSize.card,
fullWidth: true,
onTap: () => _resolveAll(ConflictChoice.overwrite),
child: const Text('Overwrite all'),
),
const SizedBox(height: 8),
FilledButton.tonal(
onPressed: () => _resolveAll(ConflictChoice.keepBoth),
const SizedBox(height: NooSpace.xs),
NooButton(
variant: NooButtonVariant.tonal,
size: NooButtonSize.card,
fullWidth: true,
onTap: () => _resolveAll(ConflictChoice.keepBoth),
child: const Text('Keep both (rename)'),
),
const SizedBox(height: 8),
OutlinedButton(
onPressed: () => setState(() => _decidingPerItem = true),
const SizedBox(height: NooSpace.xs),
NooButton(
variant: NooButtonVariant.outline,
size: NooButtonSize.card,
fullWidth: true,
onTap: () => setState(() => _decidingPerItem = true),
child: const Text('Decide per item'),
),
const SizedBox(height: 16),
const Divider(),
const SizedBox(height: 8),
const SizedBox(height: NooSpace.lg),
],
for (final conflict in widget.conflicts)
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
conflict.item.isFolder
? Icons.folder_rounded
: Icons.insert_drive_file_rounded,
color: colorScheme.onSurfaceVariant,
size: 20,
),
const SizedBox(width: 10),
Expanded(
child: Text(
conflict.item.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
),
],
),
if (_decidingPerItem) ...[
const SizedBox(height: 6),
SizedBox(
width: double.infinity,
child: SegmentedButton<ConflictChoice>(
segments: const [
ButtonSegment(
value: ConflictChoice.overwrite,
label: Text('Overwrite'),
),
ButtonSegment(
value: ConflictChoice.keepBoth,
label: Text('Keep both'),
),
ButtonSegment(
value: ConflictChoice.skip,
label: Text('Skip'),
),
],
selected: {_perItemChoices[conflict.item.id]!},
showSelectedIcon: false,
onSelectionChanged: (selection) => setState(() {
_perItemChoices[conflict.item.id] = selection.first;
}),
),
),
],
],
),
),
NooGroupedList(
label: _decidingPerItem ? 'Items' : null,
children: [
for (final conflict in widget.conflicts)
_ConflictRow(
conflict: conflict,
decidingPerItem: _decidingPerItem,
choice: _perItemChoices[conflict.item.id]!,
onChoiceChanged: (choice) => setState(() {
_perItemChoices[conflict.item.id] = choice;
}),
),
],
),
if (_decidingPerItem) ...[
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: _confirmPerItem,
child: const Text('Confirm'),
),
const SizedBox(height: NooSpace.lg),
NooButton(
variant: NooButtonVariant.primary,
size: NooButtonSize.card,
fullWidth: true,
onTap: _confirmPerItem,
child: const Text('Confirm'),
),
],
],
);
}
}
/// One conflicting item: its file-type tile and name, plus (once "Decide
/// per item" is active) a segmented overwrite/keep both/skip choice.
class _ConflictRow extends StatelessWidget {
final MoveCopyConflict conflict;
final bool decidingPerItem;
final ConflictChoice choice;
final ValueChanged<ConflictChoice> onChoiceChanged;
const _ConflictRow({
required this.conflict,
required this.decidingPerItem,
required this.choice,
required this.onChoiceChanged,
});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
final item = conflict.item;
final kind = NooFileKind.from(name: item.name, isDirectory: item.isFolder);
return Material(
color: colors.surface,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: NooSpace.md,
vertical: NooSpace.sm,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
NooFileTile(kind: kind),
const SizedBox(width: NooSpace.sm),
Expanded(
child: Text(
item.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: NooText.bodyL.copyWith(
fontWeight: FontWeight.w500,
color: colors.fg1,
),
),
),
],
),
if (decidingPerItem) ...[
const SizedBox(height: NooSpace.xs),
NooSegmentedControl<ConflictChoice>(
fill: true,
onSurface: true,
value: choice,
onChanged: onChoiceChanged,
options: const [
NooSegmentOption(
value: ConflictChoice.overwrite,
label: 'Overwrite',
),
NooSegmentOption(
value: ConflictChoice.keepBoth,
label: 'Keep both',
),
NooSegmentOption(value: ConflictChoice.skip, label: 'Skip'),
],
),
],
],
),
),
);
}
}