Adopt the shared design_system package, dropping theme.dart and confirm.dart
Some checks are pending
file-length / file length <= 250 lines (push) Waiting to run
release-apk / release (APK) (push) Waiting to run

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XS1m7b1piBtAL22USLW6jL
This commit is contained in:
Krzysztof Rudnicki 2026-08-14 22:59:59 +02:00
parent 85a45daaee
commit e6e1052c59
11 changed files with 24 additions and 365 deletions

View File

@ -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<void> main() async {
WidgetsFlutterBinding.ensureInitialized();

View File

@ -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<bool> confirmDestructive(
BuildContext context, {
required String title,
required String message,
String confirmLabel = 'Delete',
String cancelLabel = 'Cancel',
}) async {
final confirmed = await showDialog<bool>(
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),
),
],
),
);
}
}

View File

@ -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, `##`

View File

@ -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 {

View File

@ -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

View File

@ -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].

View File

@ -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;

View File

@ -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:

View File

@ -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

View File

@ -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<bool?> 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<TextButton>(
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);
});
}

View File

@ -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', () {