diff --git a/lib/main.dart b/lib/main.dart index 4d795fe..a50d1fe 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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 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), ); } diff --git a/lib/ui/markdown_view.dart b/lib/ui/markdown_view.dart index 9180422..6a7abee 100644 --- a/lib/ui/markdown_view.dart +++ b/lib/ui/markdown_view.dart @@ -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, + ), + ), ), ), ); diff --git a/lib/ui/note_editor.dart b/lib/ui/note_editor.dart index 9b79472..3909b32 100644 --- a/lib/ui/note_editor.dart +++ b/lib/ui/note_editor.dart @@ -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 { } 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(), ); } diff --git a/lib/ui/settings_screen.dart b/lib/ui/settings_screen.dart index eafa43c..0581cc8 100644 --- a/lib/ui/settings_screen.dart +++ b/lib/ui/settings_screen.dart @@ -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: [ diff --git a/lib/ui/theme.dart b/lib/ui/theme.dart new file mode 100644 index 0000000..b7d1957 --- /dev/null +++ b/lib/ui/theme.dart @@ -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; diff --git a/test/theme_test.dart b/test/theme_test.dart new file mode 100644 index 0000000..d25f0db --- /dev/null +++ b/test/theme_test.dart @@ -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); + }); +}