mirror of
https://github.com/kuhyx/todo-app.git
synced 2026-11-10 18:07:03 +01:00
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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XS1m7b1piBtAL22USLW6jL
This commit is contained in:
parent
85a45daaee
commit
e6e1052c59
@ -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();
|
||||
|
||||
@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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, `##`
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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].
|
||||
|
||||
@ -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;
|
||||
@ -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:
|
||||
|
||||
10
pubspec.yaml
10
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
|
||||
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
@ -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', () {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user