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:
@@ -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'),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user