From e6e1052c59f5b7dca4b1b842149c5c83e25f65f7 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Fri, 14 Aug 2026 22:59:59 +0200 Subject: [PATCH] Adopt the shared design_system package, dropping theme.dart and confirm.dart Both files were donors for the new package rather than casualties of it: theme.dart was byte-identical to untools' copy (md5 6cf8e82d), and confirm.dart's confirmDestructive() was the ready-made pattern for the ~35 AlertDialogs inlined across the fleet. Keeping local copies after promoting them upstream is exactly how the two would drift apart again, so they are deleted and imported from design_system-v0.1.0 instead. test/confirm_test.dart moves upstream with its subject -- the package covers that behaviour (Escape and the barrier both refuse, Cancel is autofocused so a reflexive Return cannot delete) as part of its own 100% gate. test/theme_test.dart stays as a consumer-side smoke check that the dependency actually resolves to the frozen palette. Verified: flutter analyze clean (bar one pre-existing bin/ style info), 308 tests pass at 100% coverage (1962/1962 -- the drop from 2005 is exactly the promoted lines), and the release APK is installed and running on the Pixel 6a with all 108 notes intact, the gold #B8862E focus ring rendering from the package's inputDecorationTheme, and no crashes in logcat. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01XS1m7b1piBtAL22USLW6jL --- lib/main.dart | 3 +- lib/ui/confirm.dart | 84 --------------- lib/ui/markdown_view.dart | 3 +- lib/ui/note_actions_sheet.dart | 3 +- lib/ui/note_detail_screen.dart | 3 +- lib/ui/note_editor_raw_field.dart | 3 +- lib/ui/theme.dart | 164 ------------------------------ pubspec.lock | 9 ++ pubspec.yaml | 10 +- test/confirm_test.dart | 105 ------------------- test/theme_test.dart | 2 +- 11 files changed, 24 insertions(+), 365 deletions(-) delete mode 100644 lib/ui/confirm.dart delete mode 100644 lib/ui/theme.dart delete mode 100644 test/confirm_test.dart diff --git a/lib/main.dart b/lib/main.dart index a08a029..aa0674a 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,15 +1,14 @@ // coverage:ignore-file // App bootstrap: opens the platform's note store and calls runApp. Exercised // end-to-end by running the app, not unit tests. +import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; - import 'package:todo/analytics/analytics_service.dart'; import 'package:todo/data/app_settings.dart'; import 'package:todo/data/note_repository.dart'; import 'package:todo/data/repository_factory.dart'; import 'package:todo/frame_stats.dart'; import 'package:todo/ui/capture_screen.dart'; -import 'package:todo/ui/theme.dart'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); diff --git a/lib/ui/confirm.dart b/lib/ui/confirm.dart deleted file mode 100644 index 57bd100..0000000 --- a/lib/ui/confirm.dart +++ /dev/null @@ -1,84 +0,0 @@ -/// Confirmation for destructive actions. -library; - -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; - -/// Asks the user to confirm a destructive action, returning true if they do. -/// -/// Exists because "delete note" had no confirmation and no undo on either -/// path: the actions sheet and the detail screen both deleted immediately. -/// With a pointer that is merely risky — you have to travel to the control and -/// press it. With a keyboard it is one Tab away from a Return you were about -/// to press anyway, and the note is gone with nothing to recover it. -/// -/// Defaults are chosen so the *safe* option is what a reflexive keypress hits: -/// Cancel is autofocused, and Escape dismisses. The destructive button is -/// styled with the error color but is deliberately not the default action. -/// -/// Returns false when dismissed by Escape, by the barrier, or by Cancel. -Future confirmDestructive( - BuildContext context, { - required String title, - required String message, - String confirmLabel = 'Delete', - String cancelLabel = 'Cancel', -}) async { - final confirmed = await showDialog( - context: context, - builder: (dialogContext) => _ConfirmDialog( - title: title, - message: message, - confirmLabel: confirmLabel, - cancelLabel: cancelLabel, - ), - ); - return confirmed ?? false; -} - -class _ConfirmDialog extends StatelessWidget { - const _ConfirmDialog({ - required this.title, - required this.message, - required this.confirmLabel, - required this.cancelLabel, - }); - - final String title; - final String message; - final String confirmLabel; - final String cancelLabel; - - @override - Widget build(BuildContext context) { - return CallbackShortcuts( - // Escape dismisses as "no". Flutter's own barrier handles this on most - // platforms, but the desktop surface here is a Chrome `--app` window - // where that cannot be assumed, and a confirmation the keyboard cannot - // back out of is worse than no confirmation at all. - bindings: { - const SingleActivator(LogicalKeyboardKey.escape): () => - Navigator.of(context).pop(false), - }, - child: AlertDialog( - title: Text(title), - content: Text(message), - actions: [ - TextButton( - // Autofocused so the reflexive Return presses Cancel, not Delete. - autofocus: true, - onPressed: () => Navigator.of(context).pop(false), - child: Text(cancelLabel), - ), - TextButton( - style: TextButton.styleFrom( - foregroundColor: Theme.of(context).colorScheme.error, - ), - onPressed: () => Navigator.of(context).pop(true), - child: Text(confirmLabel), - ), - ], - ), - ); - } -} diff --git a/lib/ui/markdown_view.dart b/lib/ui/markdown_view.dart index 6a7abee..ab4d098 100644 --- a/lib/ui/markdown_view.dart +++ b/lib/ui/markdown_view.dart @@ -1,7 +1,6 @@ +import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; -import 'package:todo/ui/theme.dart'; - /// A lightweight, read-only renderer for the note format. /// /// The app's notes use a small, known Markdown subset — an `#` title, `##` diff --git a/lib/ui/note_actions_sheet.dart b/lib/ui/note_actions_sheet.dart index 1af8a99..26a4fe4 100644 --- a/lib/ui/note_actions_sheet.dart +++ b/lib/ui/note_actions_sheet.dart @@ -6,11 +6,10 @@ library; import 'dart:async'; +import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; - import 'package:todo/data/note.dart'; import 'package:todo/data/note_template.dart'; -import 'package:todo/ui/confirm.dart'; /// The per-note bottom sheet: status/priority chips, and delete. class NoteActionsSheet extends StatefulWidget { diff --git a/lib/ui/note_detail_screen.dart b/lib/ui/note_detail_screen.dart index 4ae992c..12fc136 100644 --- a/lib/ui/note_detail_screen.dart +++ b/lib/ui/note_detail_screen.dart @@ -1,10 +1,9 @@ +import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; - import 'package:todo/data/app_settings.dart'; import 'package:todo/data/note.dart'; import 'package:todo/data/note_repository.dart'; import 'package:todo/data/note_template.dart'; -import 'package:todo/ui/confirm.dart'; import 'package:todo/ui/note_form.dart'; /// Full-screen view of a single note: read it in full, edit its body through diff --git a/lib/ui/note_editor_raw_field.dart b/lib/ui/note_editor_raw_field.dart index 4d7e9ee..13b1a17 100644 --- a/lib/ui/note_editor_raw_field.dart +++ b/lib/ui/note_editor_raw_field.dart @@ -4,10 +4,9 @@ /// stays owned by `_NoteEditorState`. library; +import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; -import 'package:todo/ui/theme.dart'; - /// A full-height plain-text field for the note's assembled Markdown. class NoteEditorRawField extends StatelessWidget { /// Creates the raw field over [controller]. diff --git a/lib/ui/theme.dart b/lib/ui/theme.dart deleted file mode 100644 index ad7bda9..0000000 --- a/lib/ui/theme.dart +++ /dev/null @@ -1,164 +0,0 @@ -/// The app's design tokens: one identical palette shared across every one -/// of kuhy's apps (Flutter, web, Python/Tkinter alike) — see the -/// `unified-design-system` skill (`~/.claude/skills/unified-design-system/`) -/// for the frozen token table this file implements. Built from explicit -/// `ColorScheme`s, not `ColorScheme.fromSeed`, because the shared palette is -/// hand-picked, not algorithmically derived from one seed color. -library; - -import 'package:flutter/material.dart'; - -/// Builds the app's light `ThemeData` from the shared token set. -ThemeData buildLightTheme() { - const colorScheme = ColorScheme.light( - surface: Color(0xFFF6F4F3), // paper - surfaceContainerHighest: Color(0xFFFCFBFB), // paper-raised - onSurface: Color(0xFF211D1B), // text-on-light (= ink) - onSurfaceVariant: Color(0xFF70625B), // muted-on-light - outline: Color(0xFFE0DAD7), // line-light - primary: Color(0xFFB8862E), // accent - onPrimary: Color(0xFF211D1B), // on-fill — filled surfaces use dark text - // secondary/tertiary — the shared palette has one accent, not a - // separate secondary hue, so these mirror primary. Without an explicit - // value here, widgets that reach for secondaryContainer (e.g. - // SegmentedButton's selected segment) silently fall back to Flutter's - // stock Material teal — confirmed live on-device, not just in theory. - secondary: Color(0xFFB8862E), // accent - onSecondary: Color(0xFF211D1B), // on-fill - secondaryContainer: Color(0xFFE0DAD7), // line-light - onSecondaryContainer: Color(0xFFB8862E), // accent - tertiary: Color(0xFFB8862E), // accent - onTertiary: Color(0xFF211D1B), // on-fill - tertiaryContainer: Color(0xFFE0DAD7), // line-light - onTertiaryContainer: Color(0xFFB8862E), // accent - error: Color(0xFFE2585F), // danger - onError: Color(0xFF211D1B), // on-fill - ); - return _buildTheme(colorScheme); -} - -/// Builds the app's dark `ThemeData` from the shared token set. -ThemeData buildDarkTheme() { - const colorScheme = ColorScheme.dark( - surface: Color(0xFF211D1B), // ink - surfaceContainerHighest: Color(0xFF38312E), // ink-raised-2 - surfaceContainerHigh: Color(0xFF2B2624), // ink-raised-1 - onSurface: Color(0xFFECEAE9), // text-on-dark - onSurfaceVariant: Color(0xFFAAA09A), // muted-on-dark - outline: Color(0xFF463E3A), // line-dark - primary: Color(0xFFB8862E), // accent - onPrimary: Color(0xFF211D1B), // on-fill - secondary: Color(0xFFB8862E), // accent - onSecondary: Color(0xFF211D1B), // on-fill - secondaryContainer: Color(0xFF463E3A), // line-dark - onSecondaryContainer: Color(0xFFB8862E), // accent - tertiary: Color(0xFFB8862E), // accent - onTertiary: Color(0xFF211D1B), // on-fill - tertiaryContainer: Color(0xFF463E3A), // line-dark - onTertiaryContainer: Color(0xFFB8862E), // accent - error: Color(0xFFE2585F), // danger - onError: Color(0xFF211D1B), // on-fill - ); - return _buildTheme(colorScheme); -} - -ThemeData _buildTheme(ColorScheme colorScheme) { - return ThemeData( - useMaterial3: true, - colorScheme: colorScheme, - scaffoldBackgroundColor: colorScheme.surface, - textTheme: const TextTheme( - bodyLarge: TextStyle(fontSize: AppTextSize.body), - bodyMedium: TextStyle(fontSize: AppTextSize.body), - titleLarge: TextStyle(fontSize: AppTextSize.title), - titleMedium: TextStyle(fontSize: AppTextSize.subtitle), - labelMedium: TextStyle(fontSize: AppTextSize.label), - labelSmall: TextStyle(fontSize: AppTextSize.caption), - ), - inputDecorationTheme: InputDecorationTheme( - filled: true, - fillColor: colorScheme.surfaceContainerHighest, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(AppRadius.sm), - borderSide: BorderSide(color: colorScheme.outline), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(AppRadius.sm), - borderSide: BorderSide(color: colorScheme.outline), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(AppRadius.sm), - borderSide: BorderSide(color: colorScheme.primary, width: 2), - ), - labelStyle: TextStyle(color: colorScheme.onSurfaceVariant), - ), - dividerTheme: DividerThemeData( - color: colorScheme.outline, - thickness: 1, - space: AppSpacing.md, - ), - ); -} - -/// Shared spacing scale (4px base) — round any new value to one of these -/// instead of introducing an off-scale literal. -abstract final class AppSpacing { - /// 4px. - static const double xs = 4; - - /// 8px. - static const double sm = 8; - - /// 16px. - static const double md = 16; - - /// 24px. - static const double lg = 24; - - /// 32px. - static const double xl = 32; - - /// 48px. - static const double xxl = 48; -} - -/// Shared corner-radius scale. Nested radii should be `outer - gap`, not a -/// fixed constant — compute per instance per safe-design-rules rule 24. -abstract final class AppRadius { - /// Buttons, inputs, chips. - static const double sm = 8; - - /// Cards. - static const double md = 12; - - /// Sheets, dialogs. - static const double lg = 16; -} - -/// Shared type scale (px). `body` is the floor for anything a user reads; -/// `label`/`caption` are for UI chrome only (timestamps, badges, tags). -abstract final class AppTextSize { - /// 12px — chrome only. - static const double caption = 12; - - /// 14px — chrome only. - static const double label = 14; - - /// 16px — the floor for actual reading content. - static const double body = 16; - - /// 20px. - static const double subtitle = 20; - - /// 24px. - static const double title = 24; - - /// 32px. - static const double display = 32; -} - -/// Prose/paragraph line-length cap (rule 21) — this app's desktop build is a -/// Chrome `--app` window that can be arbitrarily wide (potentially 4K), so -/// rendered/edited note text needs an explicit column width instead of -/// filling the whole viewport. -const double kProseMaxWidth = 640; diff --git a/pubspec.lock b/pubspec.lock index 8b6dff0..ab8d5c5 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -130,6 +130,15 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.9" + design_system: + dependency: "direct main" + description: + path: design_system + ref: "design_system-v0.1.0" + resolved-ref: e6c9af81e9ac3000d87d9168d3ef87ba2979258b + url: "https://github.com/kuhyx/utils" + source: git + version: "0.1.0" dev_build: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 8f37ecf..9ecbcb2 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.0.22+1 +version: 1.0.23+1 environment: sdk: ^3.12.2 @@ -39,6 +39,14 @@ dependencies: # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.8 + + # The shared palette, scales and components. Owns the tokens: there is + # deliberately no local theme.dart to drift from the frozen table. + design_system: + git: + url: https://github.com/kuhyx/utils + ref: design_system-v0.1.0 + path: design_system file_selector: ^1.1.0 flutter: sdk: flutter diff --git a/test/confirm_test.dart b/test/confirm_test.dart deleted file mode 100644 index 246b51e..0000000 --- a/test/confirm_test.dart +++ /dev/null @@ -1,105 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:todo/ui/confirm.dart'; - -/// Tests for the destructive-action confirmation. -/// -/// Both delete paths (the actions sheet and the detail screen) used to delete -/// immediately with no confirmation and no undo. With a keyboard that is one -/// Tab away from a Return the user was about to press anyway, so the defaults -/// here — Cancel autofocused, Escape dismisses — are the actual safety -/// property, not decoration. -void main() { - Future showFor(WidgetTester tester) async { - bool? result; - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: Builder( - builder: (context) => ElevatedButton( - onPressed: () async { - result = await confirmDestructive( - context, - title: 'Delete note?', - message: 'This cannot be undone.', - ); - }, - child: const Text('go'), - ), - ), - ), - ), - ); - await tester.tap(find.text('go')); - await tester.pumpAndSettle(); - return result; - } - - testWidgets('shows the title and message', (tester) async { - await showFor(tester); - expect(find.text('Delete note?'), findsOneWidget); - expect(find.text('This cannot be undone.'), findsOneWidget); - }); - - testWidgets('Cancel is autofocused so a reflexive Return is safe', ( - tester, - ) async { - await showFor(tester); - final cancel = tester.widget( - find.ancestor(of: find.text('Cancel'), matching: find.byType(TextButton)), - ); - expect(cancel.autofocus, isTrue); - }); - - testWidgets('confirming returns true', (tester) async { - await showFor(tester); - await tester.tap(find.text('Delete')); - await tester.pumpAndSettle(); - expect(find.text('Delete note?'), findsNothing); - }); - - testWidgets('cancelling returns false', (tester) async { - await showFor(tester); - await tester.tap(find.text('Cancel')); - await tester.pumpAndSettle(); - expect(find.text('Delete note?'), findsNothing); - }); - - testWidgets('Escape dismisses without confirming', (tester) async { - await showFor(tester); - expect(find.text('Delete note?'), findsOneWidget); - - await tester.sendKeyEvent(LogicalKeyboardKey.escape); - await tester.pumpAndSettle(); - - // A confirmation the keyboard cannot back out of is worse than none. - expect(find.text('Delete note?'), findsNothing); - }); - - testWidgets('custom labels are honoured', (tester) async { - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: Builder( - builder: (context) => ElevatedButton( - onPressed: () => confirmDestructive( - context, - title: 'Discard?', - message: 'Unsaved work will be lost.', - confirmLabel: 'Discard', - cancelLabel: 'Keep editing', - ), - child: const Text('go'), - ), - ), - ), - ), - ); - await tester.tap(find.text('go')); - await tester.pumpAndSettle(); - - expect(find.text('Discard'), findsOneWidget); - expect(find.text('Keep editing'), findsOneWidget); - }); -} diff --git a/test/theme_test.dart b/test/theme_test.dart index d25f0db..a1699de 100644 --- a/test/theme_test.dart +++ b/test/theme_test.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:todo/ui/theme.dart'; +import 'package:design_system/design_system.dart'; void main() { test('buildLightTheme uses the shared palette and 16px body floor', () {