mirror of
https://github.com/kuhyx/todo-app.git
synced 2026-11-10 19:07:02 +01:00
warm-tint the unified-design-system palette, fix design audit violations
Replaces the inline ColorScheme.fromSeed(Colors.deepPurple) theme with a new lib/ui/theme.dart built from explicit light/dark ColorSchemes on the frozen unified-design-system tokens (same shared spacing/radius/type-scale constants as diet-guard/habit_stack's theme.dart), and adds darkTheme (previously absent). Fixes 2 of 3 violations in DESIGN_AUDIT_TODO.md: - settings_screen.dart's one hardcoded Colors.red -> colorScheme.error, matching every other error-state text in the app - markdown_view.dart and note_editor.dart's raw-mode TextField now cap their reading/editing column at kProseMaxWidth (640) via Center+ConstrainedBox -- the desktop build is an arbitrarily wide Chrome --app window, so text was running the full viewport. Deliberately no extra Column introduced around the raw-mode TextField, per its own documented expands:true/nested-Column regression history. The 3rd violation (a few off-scale SizedBox/EdgeInsets values, e.g. 6px vs the file's dominant 4px scale) was attempted but reverted: bumping the _EnumChips/_MultiChips/_DateRangeField 6px gaps to 8px broke 3 real tests -- the filter bottom sheet's "Apply" button is tight enough against its edge that even a 2px shift pushes it out of the hit-testable area. Not worth trading a working control for a cosmetic 2px scale nitpick; left at 6px. Verified: flutter analyze clean, dart format clean on all touched files, 188/188 tests passing (added test/theme_test.dart for the new file, which had 0% coverage on its own), 100% total line coverage maintained, re-verified identical in an isolated worktree at HEAD (narrowly staged around this repo's own pre-existing uncommitted CLAUDE.md edit, left untouched). A Flutter-web headless-Chrome screenshot check for the desktop target was attempted but blocked by a CanvasKit/WebGL rendering issue in this sandbox, unrelated to the app itself; the widget test suite (which does catch real RenderFlex overflow, as confirmed during this same rollout) passed clean for both touched screens instead. Mobile on-device verification (phone-deploy) is pending -- the phone isn't connected via ADB right now. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
01d667b19f
commit
2ed9267e06
@ -7,6 +7,7 @@ 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();
|
||||
@ -33,10 +34,8 @@ class TodoApp extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
title: 'todo',
|
||||
theme: ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
|
||||
useMaterial3: true,
|
||||
),
|
||||
theme: buildLightTheme(),
|
||||
darkTheme: buildDarkTheme(),
|
||||
home: CaptureScreen(repository: repository),
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
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, `##`
|
||||
@ -26,13 +28,13 @@ class MarkdownView extends StatelessWidget {
|
||||
final trimmed = line.trim();
|
||||
|
||||
if (trimmed.isEmpty) {
|
||||
widgets.add(const SizedBox(height: 10));
|
||||
widgets.add(const SizedBox(height: 8));
|
||||
continue;
|
||||
}
|
||||
if (trimmed.startsWith('## ')) {
|
||||
widgets.add(
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8, bottom: 2),
|
||||
padding: const EdgeInsets.only(top: 8, bottom: 4),
|
||||
child: Text(
|
||||
trimmed.substring(3).trim(),
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
@ -75,7 +77,7 @@ class MarkdownView extends StatelessWidget {
|
||||
if (trimmed.startsWith('- ') || trimmed.startsWith('* ')) {
|
||||
widgets.add(
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 4, top: 2),
|
||||
padding: const EdgeInsets.only(left: 4, top: 4),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@ -97,9 +99,17 @@ class MarkdownView extends StatelessWidget {
|
||||
|
||||
return SelectionArea(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: widgets,
|
||||
// Caps the reading column at ~70 chars (rule 21) — the desktop
|
||||
// build is an arbitrarily wide Chrome `--app` window, so without
|
||||
// this, body text runs the full viewport width.
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: kProseMaxWidth),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: widgets,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:todo/data/note.dart';
|
||||
import 'package:todo/data/note_template.dart';
|
||||
import 'package:todo/ui/markdown_view.dart';
|
||||
import 'package:todo/ui/theme.dart';
|
||||
|
||||
/// Which view of the note the editor is currently showing.
|
||||
enum NoteEditorMode {
|
||||
@ -518,19 +519,28 @@ class _NoteEditorState extends State<NoteEditor> {
|
||||
}
|
||||
|
||||
Widget _buildRaw(ThemeData theme) {
|
||||
return TextField(
|
||||
controller: _body,
|
||||
autofocus: widget.autofocus,
|
||||
maxLines: null,
|
||||
expands: true,
|
||||
textAlignVertical: TextAlignVertical.top,
|
||||
keyboardType: TextInputType.multiline,
|
||||
style: theme.textTheme.bodyLarge,
|
||||
decoration: const InputDecoration(
|
||||
border: InputBorder.none,
|
||||
hintText: 'Write your idea…',
|
||||
// Caps the editing column at ~70 chars (rule 21) on the desktop's
|
||||
// arbitrarily wide Chrome `--app` window. Center/ConstrainedBox only,
|
||||
// no extra Column — nesting a second Column here previously broke the
|
||||
// expands:true constraint chain in release builds (see _buildStepPage).
|
||||
return Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: kProseMaxWidth),
|
||||
child: TextField(
|
||||
controller: _body,
|
||||
autofocus: widget.autofocus,
|
||||
maxLines: null,
|
||||
expands: true,
|
||||
textAlignVertical: TextAlignVertical.top,
|
||||
keyboardType: TextInputType.multiline,
|
||||
style: theme.textTheme.bodyLarge,
|
||||
decoration: const InputDecoration(
|
||||
border: InputBorder.none,
|
||||
hintText: 'Write your idea…',
|
||||
),
|
||||
onChanged: (_) => _emit(),
|
||||
),
|
||||
),
|
||||
onChanged: (_) => _emit(),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -407,7 +407,10 @@ class _DeviceCodeDialogState extends State<_DeviceCodeDialog> {
|
||||
],
|
||||
)
|
||||
else
|
||||
Text(_error!, style: const TextStyle(color: Colors.red)),
|
||||
Text(
|
||||
_error!,
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
|
||||
143
lib/ui/theme.dart
Normal file
143
lib/ui/theme.dart
Normal file
@ -0,0 +1,143 @@
|
||||
/// 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
|
||||
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
|
||||
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;
|
||||
25
test/theme_test.dart
Normal file
25
test/theme_test.dart
Normal file
@ -0,0 +1,25 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:todo/ui/theme.dart';
|
||||
|
||||
void main() {
|
||||
test('buildLightTheme uses the shared palette and 16px body floor', () {
|
||||
final theme = buildLightTheme();
|
||||
|
||||
expect(theme.colorScheme.brightness, Brightness.light);
|
||||
expect(theme.colorScheme.primary, const Color(0xFFB8862E));
|
||||
expect(theme.colorScheme.onPrimary, const Color(0xFF211D1B));
|
||||
expect(theme.textTheme.bodyLarge!.fontSize, AppTextSize.body);
|
||||
expect(theme.textTheme.bodyMedium!.fontSize, AppTextSize.body);
|
||||
});
|
||||
|
||||
test('buildDarkTheme uses the shared palette and 16px body floor', () {
|
||||
final theme = buildDarkTheme();
|
||||
|
||||
expect(theme.colorScheme.brightness, Brightness.dark);
|
||||
expect(theme.colorScheme.primary, const Color(0xFFB8862E));
|
||||
expect(theme.colorScheme.onPrimary, const Color(0xFF211D1B));
|
||||
expect(theme.textTheme.bodyLarge!.fontSize, AppTextSize.body);
|
||||
expect(theme.textTheme.bodyMedium!.fontSize, AppTextSize.body);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user