diff --git a/phone_app/lib/main.dart b/phone_app/lib/main.dart index c56c898..1df1e4a 100644 --- a/phone_app/lib/main.dart +++ b/phone_app/lib/main.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:wake_alarm_sync/screens/home_screen.dart'; +import 'package:wake_alarm_sync/ui/theme.dart'; /// Entry point. void main() => runApp(const WakeAlarmSyncApp()); @@ -14,12 +15,7 @@ class WakeAlarmSyncApp extends StatelessWidget { Widget build(BuildContext context) { return MaterialApp( title: 'Wake Alarm Sync', - theme: ThemeData.dark(useMaterial3: true).copyWith( - colorScheme: ColorScheme.fromSeed( - seedColor: Colors.orange, - brightness: Brightness.dark, - ), - ), + theme: buildAppTheme(), home: const HomeScreen(), ); } diff --git a/phone_app/lib/screens/home_screen.dart b/phone_app/lib/screens/home_screen.dart index 69ee913..319fffc 100644 --- a/phone_app/lib/screens/home_screen.dart +++ b/phone_app/lib/screens/home_screen.dart @@ -6,6 +6,7 @@ import 'package:flutter/material.dart'; import 'package:wake_alarm_sync/screens/settings_screen.dart'; import 'package:wake_alarm_sync/services/alarm_sync_service.dart'; import 'package:wake_alarm_sync/services/sync_settings.dart'; +import 'package:wake_alarm_sync/ui/theme.dart'; /// Main screen: displays the current alarm time and syncs it to the PC via /// the shared crdt_sync transport. @@ -131,7 +132,11 @@ class _HomeScreenState extends State { title: const Text('Wake Alarm Sync'), actions: [ IconButton( - icon: const Icon(Icons.settings), + icon: Icon( + Icons.settings, + // Dimmed relative to the adjacent title text (rule 28). + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), onPressed: () async { await Navigator.push( context, @@ -195,16 +200,22 @@ class _HomeScreenState extends State { textAlign: TextAlign.center, style: TextStyle( color: _status.contains('Error') - ? Colors.redAccent - : Colors.greenAccent, + ? Theme.of(context).colorScheme.error + : Theme.of( + context, + ).extension()!.success, ), ), if (!_configured) ...[ const SizedBox(height: 16), - const Text( + Text( 'Open Settings (gear icon) to connect GitHub.', textAlign: TextAlign.center, - style: TextStyle(color: Colors.orange), + style: TextStyle( + color: Theme.of( + context, + ).extension()!.warning, + ), ), ], ], diff --git a/phone_app/lib/screens/settings_screen.dart b/phone_app/lib/screens/settings_screen.dart index b5bbf9c..77815cd 100644 --- a/phone_app/lib/screens/settings_screen.dart +++ b/phone_app/lib/screens/settings_screen.dart @@ -6,6 +6,7 @@ import 'package:url_launcher/url_launcher.dart'; import 'package:wake_alarm_sync/services/github_device_auth.dart'; import 'package:wake_alarm_sync/services/sync_settings.dart'; +import 'package:wake_alarm_sync/ui/theme.dart'; /// Settings screen for the GitHub connection used to sync the alarm. /// @@ -108,7 +109,7 @@ class _SettingsScreenState extends State { : const Icon(Icons.link), label: const Text('Connect GitHub'), style: ElevatedButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 14), + padding: const EdgeInsets.symmetric(vertical: 16), textStyle: const TextStyle(fontSize: 16), ), ) @@ -124,18 +125,26 @@ class _SettingsScreenState extends State { _status, style: TextStyle( color: _status.contains('Error') - ? Colors.redAccent - : Colors.greenAccent, + ? Theme.of(context).colorScheme.error + : Theme.of( + context, + ).extension()!.success, ), ), const Divider(height: 32), - const Text( + Text( 'PC config — create this file:\n' '~/.config/wake_alarm/sync.json\n\n' '{ "token": "" }\n\n' 'The token needs "repo" scope so the PC can read ' 'kuhyx/syncs at shutdown.', - style: TextStyle(fontSize: 12, color: Colors.grey), + // Was 12px/Colors.grey — below the 16px reading floor (rule + // 20) and off-palette (rule 4); this is real setup + // instructions, not decorative chrome. + style: TextStyle( + fontSize: 16, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), ), ], ), @@ -151,6 +160,8 @@ class _StatusCard extends StatelessWidget { @override Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final success = Theme.of(context).extension()!.success; return Card( child: Padding( padding: const EdgeInsets.all(16), @@ -158,9 +169,11 @@ class _StatusCard extends StatelessWidget { children: [ Icon( connected ? Icons.check_circle : Icons.cancel, - color: connected ? Colors.greenAccent : Colors.redAccent, + // Dimmed relative to the adjacent label text (rule 28). + color: (connected ? success : colorScheme.error) + .withValues(alpha: 0.8), ), - const SizedBox(width: 12), + const SizedBox(width: 8), Expanded( child: Text( connected ? 'Connected to GitHub' : 'Not connected', @@ -242,7 +255,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/phone_app/lib/ui/theme.dart b/phone_app/lib/ui/theme.dart new file mode 100644 index 0000000..b2f8d69 --- /dev/null +++ b/phone_app/lib/ui/theme.dart @@ -0,0 +1,86 @@ +/// 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 an explicit +/// `ColorScheme`, 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 single dark `ThemeData` from the shared token set. +ThemeData buildAppTheme() { + 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 — filled surfaces use dark text + // secondary/tertiary mirror primary — the shared palette has one accent, + // not a separate secondary hue. Leaving these undefined lets Flutter + // fall back to stock Material teal on any widget that reaches for + // secondaryContainer (Chip, SegmentedButton, etc.) — confirmed as a + // real, live bug on two other apps sharing this template. + secondary: Color(0xFFB8862E), + onSecondary: Color(0xFF211D1B), + secondaryContainer: Color(0xFF463E3A), + onSecondaryContainer: Color(0xFFB8862E), + tertiary: Color(0xFFB8862E), + onTertiary: Color(0xFF211D1B), + tertiaryContainer: Color(0xFF463E3A), + onTertiaryContainer: Color(0xFFB8862E), + error: Color(0xFFE2585F), // danger + onError: Color(0xFF211D1B), // on-fill + ); + return ThemeData( + useMaterial3: true, + colorScheme: colorScheme, + scaffoldBackgroundColor: colorScheme.surface, + extensions: const [AppStatusColors.dark], + // No custom shadow/elevation exists elsewhere in this app — the one + // Card (_StatusCard) is the sole place M3's default elevation would + // otherwise introduce a shadow in an all-dark UI (rule 26). + cardTheme: const CardThemeData(elevation: 0), + ); +} + +/// Semantic status colors M3's [ColorScheme] has no role for (it only has +/// `error`). Used instead of the ad hoc `Colors.greenAccent`/`Colors.orange` +/// literals previously scattered across both screens. +@immutable +class AppStatusColors extends ThemeExtension { + /// Creates a status-color set. + const AppStatusColors({required this.success, required this.warning}); + + /// The shared dark-theme instance — success/warning from the unified + /// palette (danger already exists as `colorScheme.error`). + static const dark = AppStatusColors( + success: Color(0xFF8A9A3C), + warning: Color(0xFFE0A63C), + ); + + /// Positive/connected status (e.g. "Connected to GitHub"). + final Color success; + + /// Caution/pending status (e.g. "Open Settings to connect GitHub"). + final Color warning; + + @override + AppStatusColors copyWith({Color? success, Color? warning}) => + AppStatusColors( + success: success ?? this.success, + warning: warning ?? this.warning, + ); + + @override + AppStatusColors lerp(AppStatusColors? other, double t) { + if (other is! AppStatusColors) return this; + return AppStatusColors( + success: Color.lerp(success, other.success, t) ?? success, + warning: Color.lerp(warning, other.warning, t) ?? warning, + ); + } +}