Files
noo/lib/widgets/move_copy_conflict_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

224 lines
7.4 KiB
Dart

import 'package:flutter/material.dart';
import '../models/move_copy_result.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
/// destination) - summarizes every conflict at once rather than prompting
/// 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. 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;
const MoveCopyConflictSheet({super.key, required this.conflicts});
static Future<Map<String, ConflictChoice>?> show(
BuildContext context,
List<MoveCopyConflict> conflicts,
) {
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,
children: [content],
);
}
@override
State<MoveCopyConflictSheet> createState() => _MoveCopyConflictSheetState();
}
class _MoveCopyConflictSheetState extends State<MoveCopyConflictSheet> {
bool _decidingPerItem = false;
late final Map<String, ConflictChoice> _perItemChoices = {
for (final conflict in widget.conflicts)
conflict.item.id: ConflictChoice.keepBoth,
};
void _resolveAll(ConflictChoice choice) {
Navigator.pop(context, {
for (final conflict in widget.conflicts) conflict.item.id: choice,
});
}
void _confirmPerItem() {
Navigator.pop(context, _perItemChoices);
}
@override
Widget build(BuildContext context) {
final isDesktop = NooLayout.isDesktop(context);
final count = widget.conflicts.length;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// 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: NooSpace.lg),
],
if (!_decidingPerItem) ...[
NooButton(
variant: NooButtonVariant.primary,
size: NooButtonSize.card,
fullWidth: true,
onTap: () => _resolveAll(ConflictChoice.overwrite),
child: const Text('Overwrite all'),
),
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: NooSpace.xs),
NooButton(
variant: NooButtonVariant.outline,
size: NooButtonSize.card,
fullWidth: true,
onTap: () => setState(() => _decidingPerItem = true),
child: const Text('Decide per item'),
),
const SizedBox(height: NooSpace.lg),
],
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: 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'),
],
),
],
],
),
),
);
}
}