From 50f5c1e4462bbe3f53ecd4018d38aa3f2eec9f6a Mon Sep 17 00:00:00 2001 From: Ayyappa Pravin Date: Tue, 3 Nov 2026 23:58:07 +0530 Subject: [PATCH] feat: keep screen on, persist settings, accurate timer alert; split main.dart and add tests - Wake lock with a Keep Screen On setting - Persist theme, font, display options, mode, countdown and timer length - Timer runs from an absolute end time and plays alert sound + vibration - Rebuild only the time display on each tick - Split lib/main.dart into logic, services, theme, views and widgets - Fix Design Studio selection not updating and sheet not opening - Add semantics and tooltips for screen readers - 22 unit/widget tests, format check in CI, cleaned pubspec and README Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/build-apk.yml | 3 + README.md | 27 +- lib/logic/time_logic.dart | 196 +++ lib/main.dart | 2066 ++++---------------------- lib/models/time_mode.dart | 2 + lib/services/device_services.dart | 27 + lib/services/settings_store.dart | 151 ++ lib/theme/clock_font.dart | 98 ++ lib/theme/clock_theme.dart | 343 +++++ lib/views/clock_view.dart | 144 ++ lib/views/countdown_view.dart | 145 ++ lib/views/display_style.dart | 40 + lib/views/stopwatch_view.dart | 124 ++ lib/views/timer_view.dart | 96 ++ lib/widgets/design_studio_sheet.dart | 505 +++++++ lib/widgets/hud.dart | 361 +++++ pubspec.lock | 141 ++ pubspec.yaml | 75 +- test/settings_store_test.dart | 70 + test/time_logic_test.dart | 167 +++ test/widget_test.dart | 96 +- 21 files changed, 2981 insertions(+), 1896 deletions(-) create mode 100644 lib/logic/time_logic.dart create mode 100644 lib/models/time_mode.dart create mode 100644 lib/services/device_services.dart create mode 100644 lib/services/settings_store.dart create mode 100644 lib/theme/clock_font.dart create mode 100644 lib/theme/clock_theme.dart create mode 100644 lib/views/clock_view.dart create mode 100644 lib/views/countdown_view.dart create mode 100644 lib/views/display_style.dart create mode 100644 lib/views/stopwatch_view.dart create mode 100644 lib/views/timer_view.dart create mode 100644 lib/widgets/design_studio_sheet.dart create mode 100644 lib/widgets/hud.dart create mode 100644 test/settings_store_test.dart create mode 100644 test/time_logic_test.dart diff --git a/.github/workflows/build-apk.yml b/.github/workflows/build-apk.yml index 6600f3d..3f236c0 100644 --- a/.github/workflows/build-apk.yml +++ b/.github/workflows/build-apk.yml @@ -40,6 +40,9 @@ jobs: - name: Install dependencies run: flutter pub get + - name: Check formatting + run: dart format --output=none --set-exit-if-changed lib test + - name: Run code analyzer run: flutter analyze diff --git a/README.md b/README.md index 80c9f99..af73b9a 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ * **Countdown Timer**: - Focus presets: **Pomodoro (25m)**, **Short Break (5m)**, **Deep Work (45m)**, and **Power Nap (20m)**. - Giant digital countdown with tactile **START / PAUSE / RESET** buttons. - - System bell chime and haptic feedback on completion. + - System alert sound and vibration (three pulses) when the timer finishes. The timer runs from an absolute end time, so it stays accurate if the app is paused or briefly backgrounded. There is no background notification: keep the app open (the screen stays on) to hear the alert. * **Precision Stopwatch**: - Millisecond precision (`MM:SS.ms`). - Lap times recording with scrollable delta list. @@ -51,6 +51,10 @@ ## 📳 Immersion & Orientation +* **Screen stays on**: the display will not sleep while the clock is open (toggle *Keep Screen On* in the Design Studio). +* **Remembers your setup**: theme, font, display toggles, last mode, countdown target and timer length are restored on launch. +* **Screen-reader friendly**: each mode announces its time, and every control has a label. + * **Tap-to-Hide Immersion**: Tap anywhere on the background to hide the top bar, theme selector, and navigation bar—leaving only the pure glowing clock numbers against OLED black. * **Adaptive Landscape Dock**: Rotating your device horizontally automatically transforms the layout into a widescreen bedside / desk stand clock with time on the left and focus telemetry on the right. @@ -85,8 +89,22 @@ Every commit or version tag (`v*`) triggers [`.github/workflows/build-apk.yml`]( ## 🚀 Building & Running Locally +### Project layout + +``` +lib/ + main.dart app shell, gestures, keyboard shortcuts + logic/ pure timer / stopwatch / countdown logic (unit tested) + services/ saved settings, wake lock, alerts + theme/ 24 palettes and 14 fonts + views/ clock, countdown, focus timer and stopwatch screens + widgets/ Design Studio sheet and the control bars +test/ unit and widget tests +index.html standalone web live demo (separate from the Flutter app) +``` + ### Prerequisites -* Flutter SDK (3.22+) +* Flutter SDK (stable, Dart ^3.11.5 — see `pubspec.yaml`) * Android SDK (API 34+) or an active Android device / emulator ```bash @@ -100,7 +118,10 @@ flutter pub get # 3. Run on connected device or emulator flutter run -# 4. Build release APK +# 4. Run checks (same as CI) +flutter analyze && flutter test + +# 5. Build release APK flutter build apk --release ``` diff --git a/lib/logic/time_logic.dart b/lib/logic/time_logic.dart new file mode 100644 index 0000000..e6bb429 --- /dev/null +++ b/lib/logic/time_logic.dart @@ -0,0 +1,196 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; + +/// Injectable wall clock so controllers can be tested deterministically. +typedef NowProvider = DateTime Function(); + +/// Remaining time to a countdown target, split into display units. +class CountdownParts { + final int days; + final int hours; + final int minutes; + final int seconds; + final bool expired; + + const CountdownParts({ + required this.days, + required this.hours, + required this.minutes, + required this.seconds, + required this.expired, + }); +} + +CountdownParts countdownParts(DateTime target, DateTime now) { + final diff = target.difference(now); + if (diff.isNegative || diff == Duration.zero) { + return const CountdownParts( + days: 0, + hours: 0, + minutes: 0, + seconds: 0, + expired: true, + ); + } + return CountdownParts( + days: diff.inDays, + hours: diff.inHours % 24, + minutes: diff.inMinutes % 60, + seconds: diff.inSeconds % 60, + expired: false, + ); +} + +/// Fraction of the local day that has elapsed (0.0 - 1.0). +double dayProgress(DateTime now) => + (now.hour * 3600 + now.minute * 60 + now.second) / 86400; + +String two(int v) => v.toString().padLeft(2, '0'); + +/// `MM:SS` for the focus timer. +String formatTimer(int seconds) => '${two(seconds ~/ 60)}:${two(seconds % 60)}'; + +/// Stopwatch milliseconds split as `MM:SS` and hundredths. +class StopwatchParts { + final String minutesSeconds; + final String hundredths; + + const StopwatchParts(this.minutesSeconds, this.hundredths); + + String get full => '$minutesSeconds.$hundredths'; +} + +StopwatchParts splitStopwatch(int milliseconds) { + final totalSeconds = milliseconds ~/ 1000; + return StopwatchParts( + '${two(totalSeconds ~/ 60)}:${two(totalSeconds % 60)}', + two((milliseconds % 1000) ~/ 10), + ); +} + +/// Countdown timer anchored to an absolute end time, so it stays accurate +/// when the app is backgrounded or the event loop is delayed. +class FocusTimerController { + FocusTimerController({ + int totalSeconds = 25 * 60, + NowProvider? now, + this.onFinished, + }) : _now = now ?? DateTime.now, + _totalSeconds = totalSeconds, + remaining = ValueNotifier(totalSeconds); + + final NowProvider _now; + final VoidCallback? onFinished; + + /// Whole seconds left on the clock. + final ValueNotifier remaining; + final ValueNotifier running = ValueNotifier(false); + + int _totalSeconds; + DateTime? _endsAt; + Timer? _ticker; + + int get totalSeconds => _totalSeconds; + + double get progress => + _totalSeconds > 0 ? remaining.value / _totalSeconds : 0.0; + + void toggle() { + if (running.value) { + _stop(); + return; + } + if (remaining.value <= 0) remaining.value = _totalSeconds; + _endsAt = _now().add(Duration(seconds: remaining.value)); + running.value = true; + _ticker = Timer.periodic(const Duration(milliseconds: 200), (_) => tick()); + } + + void reset([int? seconds]) { + _stop(); + if (seconds != null) _totalSeconds = seconds; + remaining.value = _totalSeconds; + } + + /// Recomputes the remaining time from the wall clock (also called on resume). + void tick() { + final endsAt = _endsAt; + if (endsAt == null) return; + final ms = endsAt.difference(_now()).inMilliseconds; + final secs = ms <= 0 ? 0 : (ms + 999) ~/ 1000; + remaining.value = secs; + if (secs == 0) { + _stop(); + onFinished?.call(); + } + } + + void _stop() { + _ticker?.cancel(); + _ticker = null; + _endsAt = null; + running.value = false; + } + + void dispose() { + _ticker?.cancel(); + remaining.dispose(); + running.dispose(); + } +} + +/// Stopwatch with lap recording, driven by the wall clock. +class StopwatchController { + StopwatchController({NowProvider? now}) : _now = now ?? DateTime.now; + + final NowProvider _now; + + final ValueNotifier elapsedMs = ValueNotifier(0); + final ValueNotifier running = ValueNotifier(false); + + /// Laps, newest first. + final ValueNotifier> laps = ValueNotifier>(const []); + + DateTime? _startedAt; + Timer? _ticker; + + void toggle() { + if (running.value) { + tick(); + _ticker?.cancel(); + _ticker = null; + running.value = false; + return; + } + _startedAt = _now().subtract(Duration(milliseconds: elapsedMs.value)); + running.value = true; + _ticker = Timer.periodic(const Duration(milliseconds: 16), (_) => tick()); + } + + void lap() { + if (!running.value) return; + laps.value = [elapsedMs.value, ...laps.value]; + } + + void reset() { + _ticker?.cancel(); + _ticker = null; + running.value = false; + elapsedMs.value = 0; + laps.value = const []; + } + + void tick() { + final start = _startedAt; + if (start == null) return; + elapsedMs.value = _now().difference(start).inMilliseconds; + } + + void dispose() { + _ticker?.cancel(); + elapsedMs.dispose(); + running.dispose(); + laps.dispose(); + } +} diff --git a/lib/main.dart b/lib/main.dart index fb4a837..a11dd33 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,536 +1,146 @@ import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:google_fonts/google_fonts.dart'; -import 'package:intl/intl.dart'; -void main() { +import 'logic/time_logic.dart'; +import 'models/time_mode.dart'; +import 'services/device_services.dart'; +import 'services/settings_store.dart'; +import 'theme/clock_font.dart'; +import 'theme/clock_theme.dart'; +import 'views/clock_view.dart'; +import 'views/countdown_view.dart'; +import 'views/display_style.dart'; +import 'views/stopwatch_view.dart'; +import 'views/timer_view.dart'; +import 'widgets/design_studio_sheet.dart'; +import 'widgets/hud.dart'; + +Future main() async { WidgetsFlutterBinding.ensureInitialized(); SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); - runApp(const ChronoCraftApp()); -} - -/// Available clock operating modes -enum TimeMode { clock, countdown, timer, stopwatch } -/// Curated high-performance horology themes -class ClockTheme { - final String name; - final String category; - final Color background; - final Color primary; - final Color accent; - final Color secondary; - final Color surface; - final Color border; - final Color glow; - final bool isDark; - - const ClockTheme({ - required this.name, - required this.category, - required this.background, - required this.primary, - required this.accent, - required this.secondary, - required this.surface, - required this.border, - required this.glow, - required this.isDark, - }); -} - -const List kThemes = [ - // 1. OLED Pitch Black - ClockTheme( - name: 'OLED Void', - category: 'OLED Minimal', - background: Color(0xFF000000), - primary: Color(0xFFFFFFFF), - accent: Color(0xFF9E9E9E), - secondary: Color(0xFF424242), - surface: Color(0xFF121212), - border: Color(0xFF262626), - glow: Color(0x33FFFFFF), - isDark: true, - ), - // 2. Cyberpunk Electric Cyan & Magenta - ClockTheme( - name: 'Cyberpunk Neon', - category: 'Cyberpunk', - background: Color(0xFF05050A), - primary: Color(0xFF00F0FF), - accent: Color(0xFFFF0055), - secondary: Color(0xFF7000FF), - surface: Color(0xFF0F0F1A), - border: Color(0xFF00F0FF), - glow: Color(0x8000F0FF), - isDark: true, - ), - // 3. Tokyo Midnight Slate - ClockTheme( - name: 'Tokyo Midnight', - category: 'Dark Studio', - background: Color(0xFF1A1B26), - primary: Color(0xFF7AA2F7), - accent: Color(0xFFBB9AF7), - secondary: Color(0xFF73DACA), - surface: Color(0xFF24283B), - border: Color(0xFF414868), - glow: Color(0x407AA2F7), - isDark: true, - ), - // 4. Solar Amber Cockpit - ClockTheme( - name: 'Solar Amber', - category: 'Aviation', - background: Color(0xFF070500), - primary: Color(0xFFFFB000), - accent: Color(0xFFFF5500), - secondary: Color(0xFFFFCC33), - surface: Color(0xFF140D00), - border: Color(0xFFFFB000), - glow: Color(0x66FFB000), - isDark: true, - ), - // 5. Matrix CRT Terminal - ClockTheme( - name: 'Matrix CRT', - category: 'Terminal', - background: Color(0xFF000F04), - primary: Color(0xFF00FF66), - accent: Color(0xFF33FF99), - secondary: Color(0xFF008833), - surface: Color(0xFF051C0A), - border: Color(0xFF00FF66), - glow: Color(0x7700FF66), - isDark: true, - ), - // 6. Synthwave 1984 - ClockTheme( - name: 'Synthwave 84', - category: 'Retro', - background: Color(0xFF150026), - primary: Color(0xFFFF2A85), - accent: Color(0xFF00FFF0), - secondary: Color(0xFFFFB800), - surface: Color(0xFF240040), - border: Color(0xFFFF2A85), - glow: Color(0x66FF2A85), - isDark: true, - ), - // 7. Nordic Frost Glacier - ClockTheme( - name: 'Nordic Frost', - category: 'Minimal', - background: Color(0xFF0B1017), - primary: Color(0xFF58A6FF), - accent: Color(0xFF79C0FF), - secondary: Color(0xFF388BFD), - surface: Color(0xFF161F2C), - border: Color(0xFF303E54), - glow: Color(0x4058A6FF), - isDark: true, - ), - // 8. Dieter Rams / Braun Bauhaus - ClockTheme( - name: 'Braun Bauhaus', - category: 'Industrial', - background: Color(0xFFECE8E1), - primary: Color(0xFF161616), - accent: Color(0xFFFF5500), - secondary: Color(0xFF5E5E5E), - surface: Color(0xFFDDD8D0), - border: Color(0xFF161616), - glow: Color(0x00000000), - isDark: false, - ), - // 9. Ferrari Scuderia Corsa - ClockTheme( - name: 'Ferrari Corsa', - category: 'Motorsport', - background: Color(0xFF080808), - primary: Color(0xFFFF1801), - accent: Color(0xFFFFFFFF), - secondary: Color(0xFFFF8000), - surface: Color(0xFF1A1A1A), - border: Color(0xFFFF1801), - glow: Color(0x66FF1801), - isDark: true, - ), - // 10. Royal Emerald & Brass - ClockTheme( - name: 'Emerald Royale', - category: 'Horology', - background: Color(0xFF04140C), - primary: Color(0xFFF3C761), - accent: Color(0xFF4ECCA3), - secondary: Color(0xFF2E8B57), - surface: Color(0xFF0C2417), - border: Color(0xFFF3C761), - glow: Color(0x40F3C761), - isDark: true, - ), - // 11. Dracula Twilight - ClockTheme( - name: 'Dracula', - category: 'Dark Studio', - background: Color(0xFF1E1F29), - primary: Color(0xFFBD93F9), - accent: Color(0xFFFF79C6), - secondary: Color(0xFF8BE9FD), - surface: Color(0xFF282A36), - border: Color(0xFF6272A4), - glow: Color(0x55BD93F9), - isDark: true, - ), - // 12. Monokai Pro - ClockTheme( - name: 'Monokai Pro', - category: 'Developer', - background: Color(0xFF222222), - primary: Color(0xFFFFD866), - accent: Color(0xFFA9DC76), - secondary: Color(0xFFFF6188), - surface: Color(0xFF2D2A2E), - border: Color(0xFF403E41), - glow: Color(0x40FFD866), - isDark: true, - ), - // 13. Japanese Sumi & Washi Paper - ClockTheme( - name: 'Sumi Paper', - category: 'Organic', - background: Color(0xFFF6F4EB), - primary: Color(0xFF1C1C1C), - accent: Color(0xFFBA1B1D), - secondary: Color(0xFF555555), - surface: Color(0xFFEBE7DB), - border: Color(0xFF1C1C1C), - glow: Color(0x00000000), - isDark: false, - ), - // 14. Deep Abyss Cobalt - ClockTheme( - name: 'Abyss Cobalt', - category: 'Sci-Fi', - background: Color(0xFF020B1A), - primary: Color(0xFF00D2FF), - accent: Color(0xFF3A7BD5), - secondary: Color(0xFF64FFDA), - surface: Color(0xFF0B1930), - border: Color(0xFF00D2FF), - glow: Color(0x6000D2FF), - isDark: true, - ), - // 15. Lilac Dusk - ClockTheme( - name: 'Lilac Dusk', - category: 'Dreamscape', - background: Color(0xFF13111C), - primary: Color(0xFFD8B4FE), - accent: Color(0xFFF472B6), - secondary: Color(0xFFA78BFA), - surface: Color(0xFF201B30), - border: Color(0xFFD8B4FE), - glow: Color(0x50D8B4FE), - isDark: true, - ), - // 16. Sunset Horizons - ClockTheme( - name: 'Sunset Horizons', - category: 'Atmospheric', - background: Color(0xFF0F0814), - primary: Color(0xFFFB923C), - accent: Color(0xFFF43F5E), - secondary: Color(0xFFFBBF24), - surface: Color(0xFF21132C), - border: Color(0xFFFB923C), - glow: Color(0x55FB923C), - isDark: true, - ), - // 17. Gruvbox Retro Basalt - ClockTheme( - name: 'Gruvbox Dark', - category: 'Retro', - background: Color(0xFF1D2021), - primary: Color(0xFFFABD2F), - accent: Color(0xFFFE8019), - secondary: Color(0xFFB8BB26), - surface: Color(0xFF282828), - border: Color(0xFF504945), - glow: Color(0x40FABD2F), - isDark: true, - ), - // 18. Acid Techno Radioactive - ClockTheme( - name: 'Acid Techno', - category: 'Brutalist', - background: Color(0xFF000000), - primary: Color(0xFFCCFF00), - accent: Color(0xFF00FFA3), - secondary: Color(0xFFFFFFFF), - surface: Color(0xFF12140A), - border: Color(0xFFCCFF00), - glow: Color(0x77CCFF00), - isDark: true, - ), - // 19. Arctic Clean Modern - ClockTheme( - name: 'Arctic Snow', - category: 'Minimal Light', - background: Color(0xFFF8FAFC), - primary: Color(0xFF0F172A), - accent: Color(0xFF0284C7), - secondary: Color(0xFF64748B), - surface: Color(0xFFE2E8F0), - border: Color(0xFF0F172A), - glow: Color(0x00000000), - isDark: false, - ), - // 20. Vaporwave Pastel Glow - ClockTheme( - name: 'Vaporwave', - category: 'Retro', - background: Color(0xFF1D0E36), - primary: Color(0xFFFF71CE), - accent: Color(0xFF01CDFE), - secondary: Color(0xFF05FFA1), - surface: Color(0xFF2D1850), - border: Color(0xFFFF71CE), - glow: Color(0x66FF71CE), - isDark: true, - ), - // 21. Zen Bamboo Garden - ClockTheme( - name: 'Zen Bamboo', - category: 'Organic', - background: Color(0xFF0C140E), - primary: Color(0xFFA3E635), - accent: Color(0xFF4ADE80), - secondary: Color(0xFF22C55E), - surface: Color(0xFF17241A), - border: Color(0xFFA3E635), - glow: Color(0x40A3E635), - isDark: true, - ), - // 22. Stealth Bomber Graphite - ClockTheme( - name: 'Stealth Bomber', - category: 'Tactical', - background: Color(0xFF121214), - primary: Color(0xFFEF4444), - accent: Color(0xFFF97316), - secondary: Color(0xFF71717A), - surface: Color(0xFF1C1C20), - border: Color(0xFFEF4444), - glow: Color(0x55EF4444), - isDark: true, - ), - // 23. Tiffany Diamond - ClockTheme( - name: 'Tiffany Cyan', - category: 'Luxury', - background: Color(0xFF060909), - primary: Color(0xFF0ABAB5), - accent: Color(0xFFE2F8F7), - secondary: Color(0xFF56DFDB), - surface: Color(0xFF0F1818), - border: Color(0xFF0ABAB5), - glow: Color(0x500ABAB5), - isDark: true, - ), - // 24. Vintage Gold Pocketwatch - ClockTheme( - name: 'Chronograph Gold', - category: 'Horology', - background: Color(0xFF110E0A), - primary: Color(0xFFFFD700), - accent: Color(0xFFFFA500), - secondary: Color(0xFFE6C280), - surface: Color(0xFF241C14), - border: Color(0xFFFFD700), - glow: Color(0x50FFD700), - isDark: true, - ), -]; - -/// Curated designer typography styles -class ClockFont { - final String name; - final String style; - final TextStyle Function(TextStyle base) builder; - - const ClockFont({ - required this.name, - required this.style, - required this.builder, - }); + SettingsStore? store; + try { + store = await SettingsStore.open(); + } catch (_) { + // Run with defaults if storage is unavailable. + } + runApp(ChronoCraftApp(store: store, initialSettings: store?.load())); } -final List kFonts = [ - ClockFont( - name: 'Bebas Neue', - style: 'Bold Condensed Horology', - builder: (b) => GoogleFonts.bebasNeue(textStyle: b), - ), - ClockFont( - name: 'Orbitron', - style: 'Sci-Fi Cyberpunk', - builder: (b) => GoogleFonts.orbitron(textStyle: b), - ), - ClockFont( - name: 'Space Mono', - style: 'Hacker Terminal Monospace', - builder: (b) => GoogleFonts.spaceMono(textStyle: b), - ), - ClockFont( - name: 'Oswald', - style: 'Industrial Architecture', - builder: (b) => GoogleFonts.oswald(textStyle: b), - ), - ClockFont( - name: 'Montserrat', - style: 'Clean Geometric Bauhaus', - builder: (b) => GoogleFonts.montserrat(textStyle: b), - ), - ClockFont( - name: 'Playfair Display', - style: 'Luxury Swiss Horology Serif', - builder: (b) => GoogleFonts.playfairDisplay(textStyle: b), - ), - ClockFont( - name: 'Syne', - style: 'Radical Avant-Garde', - builder: (b) => GoogleFonts.syne(textStyle: b), - ), - ClockFont( - name: 'JetBrains Mono', - style: 'Precision Engineering', - builder: (b) => GoogleFonts.jetBrainsMono(textStyle: b), - ), - ClockFont( - name: 'Anton', - style: 'Ultra-Heavy Headline', - builder: (b) => GoogleFonts.anton(textStyle: b), - ), - ClockFont( - name: 'Cinzel', - style: 'Imperial Chiseled Stone', - builder: (b) => GoogleFonts.cinzel(textStyle: b), - ), - ClockFont( - name: 'Audiowide', - style: 'Digital Hypercar Dashboard', - builder: (b) => GoogleFonts.audiowide(textStyle: b), - ), - ClockFont( - name: 'Comfortaa', - style: 'Smooth Futuristic Rounded', - builder: (b) => GoogleFonts.comfortaa(textStyle: b), - ), - ClockFont( - name: 'Righteous', - style: 'Electric Art Deco', - builder: (b) => GoogleFonts.righteous(textStyle: b), - ), - ClockFont( - name: 'Silkscreen', - style: '8-Bit Retro Arcade', - builder: (b) => GoogleFonts.silkscreen(textStyle: b), - ), - ClockFont( - name: 'Major Mono', - style: 'Minimalist Abstract Mono', - builder: (b) => GoogleFonts.majorMonoDisplay(textStyle: b), - ), - ClockFont( - name: 'System Monospace', - style: 'Offline Hardware Terminal', - builder: (b) => b.copyWith(fontFamily: 'monospace'), - ), -]; - class ChronoCraftApp extends StatefulWidget { - const ChronoCraftApp({super.key}); + const ChronoCraftApp({super.key, this.store, this.initialSettings}); + + /// Where settings are persisted; null disables persistence. + final SettingsStore? store; + final AppSettings? initialSettings; @override State createState() => _ChronoCraftAppState(); } -class _ChronoCraftAppState extends State { - // App state - TimeMode _mode = TimeMode.clock; - int _themeIndex = 0; - int _fontIndex = 0; - bool _is12Hour = false; - bool _showSeconds = true; - bool _showDate = true; - bool _enableGlow = true; - bool _colonBlink = true; - bool _burnInProtection = true; - bool _controlsVisible = false; +class _ChronoCraftAppState extends State + with WidgetsBindingObserver { + static const _burnInInterval = Duration(minutes: 3); - // Auto-hide controls timer - Timer? _hideTimer; + late final ValueNotifier _settings; + late final FocusTimerController _timer; + late final StopwatchController _stopwatch; + final ValueNotifier _now = ValueNotifier(DateTime.now()); - // Real-time ticker - late Timer _clockTimer; - DateTime _now = DateTime.now(); + bool _controlsVisible = false; + Timer? _hideTimer; + Timer? _clockTicker; + Duration? _clockInterval; // Burn-in micro-shift offsets double _burnInX = 0; double _burnInY = 0; - int _burnInTicks = 0; - - // Countdown mode state - DateTime _countdownTarget = DateTime.now().add(const Duration(days: 7, hours: 14)); - String _countdownTitle = 'PROJECT LAUNCH'; + int _burnInStep = 0; + DateTime _lastBurnInShift = DateTime.now(); - // Focus Timer state - int _timerTotalSeconds = 25 * 60; - int _timerRemainingSeconds = 25 * 60; - bool _isTimerRunning = false; - Timer? _focusTimerTicker; - - // Precision Stopwatch state - int _stopwatchMilliseconds = 0; - bool _isStopwatchRunning = false; - Timer? _stopwatchTicker; - final List _laps = []; + AppSettings get _s => _settings.value; @override void initState() { super.initState(); - // 60fps tick for stopwatch precision, 1s tick for clock/countdown - _clockTimer = Timer.periodic(const Duration(milliseconds: 100), (timer) { - if (mounted) { - setState(() { - _now = DateTime.now(); - _burnInTicks++; - if (_burnInProtection && _burnInTicks % 1800 == 0) { - // Micro-shift by 1-2px periodically to protect OLED displays - _burnInX = ((_burnInTicks ~/ 1800) % 3 - 1) * 1.5; - _burnInY = (((_burnInTicks ~/ 1800) ~/ 3) % 3 - 1) * 1.5; - } - }); - } - }); + WidgetsBinding.instance.addObserver(this); + final initial = widget.initialSettings ?? AppSettings.defaults(); + _settings = ValueNotifier(initial); + _timer = FocusTimerController( + totalSeconds: initial.timerTotalSeconds, + onFinished: Alerts.timerFinished, + ); + _stopwatch = StopwatchController(); + _syncClockTicker(); + ScreenAwake.set(initial.keepAwake); } @override void dispose() { - _clockTimer.cancel(); + WidgetsBinding.instance.removeObserver(this); + _clockTicker?.cancel(); _hideTimer?.cancel(); - _focusTimerTicker?.cancel(); - _stopwatchTicker?.cancel(); + _timer.dispose(); + _stopwatch.dispose(); + _now.dispose(); + _settings.dispose(); super.dispose(); } - void _triggerHaptic() { - HapticFeedback.selectionClick(); + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) { + _now.value = DateTime.now(); + _timer.tick(); + ScreenAwake.set(_s.keepAwake); + } + } + + // --- Settings ----------------------------------------------------------- + + void _update(AppSettings Function(AppSettings current) change) { + final before = _s; + final after = change(before); + setState(() => _settings.value = after); + widget.store?.save(after); + if (after.keepAwake != before.keepAwake) ScreenAwake.set(after.keepAwake); + if (after.mode != before.mode || after.colonBlink != before.colonBlink) { + _syncClockTicker(); + } + } + + /// Ticks fast enough for the blinking colon only when it is visible; + /// otherwise a slower tick is enough for seconds and countdowns. + void _syncClockTicker() { + final interval = (_s.mode == TimeMode.clock && _s.colonBlink) + ? const Duration(milliseconds: 100) + : const Duration(milliseconds: 250); + if (_clockInterval == interval) return; + _clockInterval = interval; + _clockTicker?.cancel(); + _clockTicker = Timer.periodic(interval, (_) => _onClockTick()); + } + + void _onClockTick() { + final now = DateTime.now(); + _now.value = now; + if (_s.burnInProtection && + now.difference(_lastBurnInShift) >= _burnInInterval) { + _lastBurnInShift = now; + _burnInStep++; + setState(() { + // Micro-shift by 1.5px periodically to protect OLED displays + _burnInX = (_burnInStep % 3 - 1) * 1.5; + _burnInY = ((_burnInStep ~/ 3) % 3 - 1) * 1.5; + }); + } } + // --- Controls ----------------------------------------------------------- + void _showControls() { setState(() => _controlsVisible = true); _resetHideTimer(); @@ -542,12 +152,8 @@ class _ChronoCraftAppState extends State { } void _toggleControls() { - _triggerHaptic(); - if (_controlsVisible) { - _hideControls(); - } else { - _showControls(); - } + Alerts.tap(); + _controlsVisible ? _hideControls() : _showControls(); } void _resetHideTimer() { @@ -559,1367 +165,191 @@ class _ChronoCraftAppState extends State { }); } - void _nextTheme() { - _triggerHaptic(); - setState(() { - _themeIndex = (_themeIndex + 1) % kThemes.length; - }); - _resetHideTimer(); - } - - void _prevTheme() { - _triggerHaptic(); - setState(() { - _themeIndex = (_themeIndex - 1 + kThemes.length) % kThemes.length; - }); + void _cycleTheme(int delta) { + Alerts.tap(); + _update( + (s) => s.copyWith( + themeIndex: (s.themeIndex + delta + kThemes.length) % kThemes.length, + ), + ); _resetHideTimer(); } void _nextFont() { - _triggerHaptic(); - setState(() { - _fontIndex = (_fontIndex + 1) % kFonts.length; - }); + Alerts.tap(); + _update((s) => s.copyWith(fontIndex: (s.fontIndex + 1) % kFonts.length)); _resetHideTimer(); } void _setMode(TimeMode mode) { - _triggerHaptic(); - setState(() => _mode = mode); + Alerts.tap(); + _update((s) => s.copyWith(mode: mode)); _resetHideTimer(); } - // Timer controls - void _toggleTimer() { - _triggerHaptic(); - setState(() { - _isTimerRunning = !_isTimerRunning; - if (_isTimerRunning) { - _focusTimerTicker = Timer.periodic(const Duration(seconds: 1), (timer) { - if (_timerRemainingSeconds > 0) { - setState(() => _timerRemainingSeconds--); - } else { - _focusTimerTicker?.cancel(); - setState(() => _isTimerRunning = false); - HapticFeedback.vibrate(); - } - }); - } else { - _focusTimerTicker?.cancel(); - } - }); - } - - void _resetTimer([int? seconds]) { - _triggerHaptic(); - _focusTimerTicker?.cancel(); - setState(() { - _isTimerRunning = false; - if (seconds != null) _timerTotalSeconds = seconds; - _timerRemainingSeconds = _timerTotalSeconds; - }); + void _cycleMode(int delta) { + final n = TimeMode.values.length; + _setMode(TimeMode.values[(_s.mode.index + delta + n) % n]); } - // Stopwatch controls - void _toggleStopwatch() { - _triggerHaptic(); - setState(() { - _isStopwatchRunning = !_isStopwatchRunning; - if (_isStopwatchRunning) { - final start = DateTime.now().millisecondsSinceEpoch - _stopwatchMilliseconds; - _stopwatchTicker = Timer.periodic(const Duration(milliseconds: 16), (timer) { - setState(() { - _stopwatchMilliseconds = DateTime.now().millisecondsSinceEpoch - start; - }); - }); - } else { - _stopwatchTicker?.cancel(); - } - }); - } - - void _resetStopwatch() { - _triggerHaptic(); - _stopwatchTicker?.cancel(); - setState(() { - _isStopwatchRunning = false; - _stopwatchMilliseconds = 0; - _laps.clear(); - }); - } - - void _recordLap() { - if (_isStopwatchRunning) { - _triggerHaptic(); - setState(() { - _laps.insert(0, _stopwatchMilliseconds); - }); - } - } - - TextStyle _getFontStyle(TextStyle base) { - try { - final styled = kFonts[_fontIndex].builder(base); - return styled.copyWith( - fontFeatures: const [FontFeature.tabularFigures()], - ); - } catch (_) { - return base.copyWith(fontFamily: 'monospace'); - } + void _setTimerDuration(int seconds) { + Alerts.tap(); + _timer.reset(seconds); + _update((s) => s.copyWith(timerTotalSeconds: seconds)); } void _openDesignStudio() { - _triggerHaptic(); + Alerts.tap(); _hideControls(); - showModalBottomSheet( - context: context, + showModalBottomSheet( + context: _navigatorContext!, isScrollControlled: true, backgroundColor: Colors.transparent, - builder: (context) => _DesignStudioSheet( - currentThemeIndex: _themeIndex, - currentFontIndex: _fontIndex, - is12Hour: _is12Hour, - showSeconds: _showSeconds, - showDate: _showDate, - enableGlow: _enableGlow, - colonBlink: _colonBlink, - burnInProtection: _burnInProtection, - onThemeSelected: (i) => setState(() => _themeIndex = i), - onFontSelected: (i) => setState(() => _fontIndex = i), - on12HourChanged: (v) => setState(() => _is12Hour = v), - onShowSecondsChanged: (v) => setState(() => _showSeconds = v), - onShowDateChanged: (v) => setState(() => _showDate = v), - onEnableGlowChanged: (v) => setState(() => _enableGlow = v), - onColonBlinkChanged: (v) => setState(() => _colonBlink = v), - onBurnInProtectionChanged: (v) => setState(() => _burnInProtection = v), - onSetCountdownTarget: (dt, title) { - setState(() { - _countdownTarget = dt; - _countdownTitle = title; - }); - }, - onSetTimerDuration: (secs) => _resetTimer(secs), + builder: (context) => DesignStudioSheet( + settings: _settings, + onUpdate: _update, + onSetCountdownTarget: (dt, title) => _update( + (s) => s.copyWith(countdownTarget: dt, countdownTitle: title), + ), + onSetTimerDuration: _setTimerDuration, ), ); } + /// Context below the MaterialApp's Navigator (needed to show sheets). + BuildContext? _navigatorContext; + + KeyEventResult _onKey(FocusNode node, KeyEvent event) { + if (event is! KeyDownEvent) return KeyEventResult.ignored; + final key = event.logicalKey; + if (key == LogicalKeyboardKey.keyT) { + _cycleTheme(HardwareKeyboard.instance.isShiftPressed ? -1 : 1); + } else if (key == LogicalKeyboardKey.keyF) { + _nextFont(); + } else if (key == LogicalKeyboardKey.space) { + if (_s.mode == TimeMode.timer) _timer.toggle(); + if (_s.mode == TimeMode.stopwatch) _stopwatch.toggle(); + } else if (key == LogicalKeyboardKey.keyM) { + _cycleMode(1); + } else if (key == LogicalKeyboardKey.keyS) { + _update((s) => s.copyWith(showSeconds: !s.showSeconds)); + } else if (key == LogicalKeyboardKey.escape) { + _toggleControls(); + } else { + return KeyEventResult.ignored; + } + return KeyEventResult.handled; + } + + // --- Build -------------------------------------------------------------- + @override Widget build(BuildContext context) { - final theme = kThemes[_themeIndex]; - final isLandscape = MediaQuery.of(context).orientation == Orientation.landscape; + final s = _s; + final theme = kThemes[s.themeIndex]; return MaterialApp( debugShowCheckedModeBanner: false, theme: ThemeData.dark().copyWith( scaffoldBackgroundColor: theme.background, ), - home: Scaffold( - backgroundColor: theme.background, - body: Focus( - autofocus: true, - onKeyEvent: (node, event) { - if (event is KeyDownEvent) { - if (event.logicalKey == LogicalKeyboardKey.keyT) { - if (HardwareKeyboard.instance.isShiftPressed) { - _prevTheme(); - } else { - _nextTheme(); - } - return KeyEventResult.handled; - } else if (event.logicalKey == LogicalKeyboardKey.keyF) { - _nextFont(); - return KeyEventResult.handled; - } else if (event.logicalKey == LogicalKeyboardKey.space) { - if (_mode == TimeMode.timer) _toggleTimer(); - if (_mode == TimeMode.stopwatch) _toggleStopwatch(); - return KeyEventResult.handled; - } else if (event.logicalKey == LogicalKeyboardKey.keyM) { - final nextIdx = (_mode.index + 1) % TimeMode.values.length; - _setMode(TimeMode.values[nextIdx]); - return KeyEventResult.handled; - } else if (event.logicalKey == LogicalKeyboardKey.keyS) { - setState(() => _showSeconds = !_showSeconds); - return KeyEventResult.handled; - } else if (event.logicalKey == LogicalKeyboardKey.escape) { - _toggleControls(); - return KeyEventResult.handled; - } - } - return KeyEventResult.ignored; - }, - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: _toggleControls, - onDoubleTap: _nextTheme, - onLongPress: _openDesignStudio, - onVerticalDragEnd: (details) { - if (details.primaryVelocity != null) { - if (details.primaryVelocity! < -200) { - _nextTheme(); - } else if (details.primaryVelocity! > 200) { - _prevTheme(); - } - } - }, - onHorizontalDragEnd: (details) { - if (details.primaryVelocity != null) { - if (details.primaryVelocity! < -200) { - final nextIdx = (_mode.index + 1) % TimeMode.values.length; - _setMode(TimeMode.values[nextIdx]); - } else if (details.primaryVelocity! > 200) { - final prevIdx = (_mode.index - 1 + TimeMode.values.length) % TimeMode.values.length; - _setMode(TimeMode.values[prevIdx]); - } - } - }, - child: Stack( - children: [ - // 1. PURE FULLSCREEN TIME CONTENT (100% viewport) - Positioned.fill( - child: Transform.translate( - offset: Offset(_burnInX, _burnInY), - child: Padding( - padding: EdgeInsets.symmetric( - horizontal: isLandscape ? 16 : 8, - vertical: isLandscape ? 8 : 16, - ), - child: Center( - child: _buildPureFullscreenContent(theme, isLandscape), - ), - ), - ), - ), - - // 2. FLOATING MINIMAL HEADER (Reveals on tap) - AnimatedPositioned( - duration: const Duration(milliseconds: 250), - curve: Curves.easeOutCubic, - top: _controlsVisible ? 16 : -80, - left: 16, - right: 16, - child: SafeArea( - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - // Left: Current Mode & Font Badge - Flexible( - child: GestureDetector( - onTap: _openDesignStudio, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), - decoration: BoxDecoration( - color: theme.surface.withValues(alpha: 0.85), - border: Border.all(color: theme.border.withValues(alpha: 0.5), width: 1), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - _mode.name.toUpperCase(), - style: TextStyle( - color: theme.primary, - fontSize: 11, - fontWeight: FontWeight.w900, - letterSpacing: 1.5, - ), - ), - const SizedBox(width: 6), - Container( - width: 4, - height: 4, - color: theme.accent, - ), - const SizedBox(width: 6), - Flexible( - child: Text( - kFonts[_fontIndex].name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: theme.accent, - fontSize: 11, - letterSpacing: 0.5, - ), - ), - ), - ], - ), - ), - ), - ), - const SizedBox(width: 8), - // Right: Quick Theme Button & Hide HUD - Row( - mainAxisSize: MainAxisSize.min, - children: [ - GestureDetector( - onTap: _nextTheme, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), - decoration: BoxDecoration( - color: theme.surface.withValues(alpha: 0.85), - border: Border.all(color: theme.border.withValues(alpha: 0.5), width: 1), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: 8, - height: 8, - color: theme.primary, - ), - const SizedBox(width: 6), - ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 80), - child: Text( - theme.name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: theme.primary, - fontSize: 10, - fontWeight: FontWeight.bold, - ), - ), - ), - ], - ), - ), - ), - const SizedBox(width: 6), - // Design Studio Button - IconButton( - onPressed: _openDesignStudio, - visualDensity: VisualDensity.compact, - padding: EdgeInsets.zero, - constraints: const BoxConstraints(minWidth: 32, minHeight: 32), - style: IconButton.styleFrom( - backgroundColor: theme.surface.withValues(alpha: 0.85), - shape: const RoundedRectangleBorder(), - side: BorderSide(color: theme.border.withValues(alpha: 0.5)), - ), - icon: Icon(Icons.tune, color: theme.primary, size: 16), - ), - const SizedBox(width: 4), - // Hide Button - IconButton( - onPressed: _hideControls, - visualDensity: VisualDensity.compact, - padding: EdgeInsets.zero, - constraints: const BoxConstraints(minWidth: 32, minHeight: 32), - style: IconButton.styleFrom( - backgroundColor: theme.surface.withValues(alpha: 0.85), - shape: const RoundedRectangleBorder(), - side: BorderSide(color: theme.border.withValues(alpha: 0.5)), - ), - icon: Icon(Icons.fullscreen_exit, color: theme.accent, size: 16), - ), - ], - ), - ], - ), - ), - ), - - // 3. FLOATING ACTION CONTROLS HUD (Reveals on tap at bottom) - AnimatedPositioned( - duration: const Duration(milliseconds: 250), - curve: Curves.easeOutCubic, - bottom: _controlsVisible ? 20 : -100, - left: 20, - right: 20, - child: SafeArea( - child: Center( - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: BoxDecoration( - color: theme.surface.withValues(alpha: 0.92), - border: Border.all(color: theme.border, width: 1.5), - boxShadow: [ - BoxShadow( - color: theme.background.withValues(alpha: 0.7), - blurRadius: 16, - offset: const Offset(0, 4), - ), - ], - ), - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - // Mode Switchers - _buildModeButton(TimeMode.clock, 'CLOCK', Icons.schedule, theme), - const SizedBox(width: 4), - _buildModeButton(TimeMode.countdown, 'COUNTDOWN', Icons.hourglass_top, theme), - const SizedBox(width: 4), - _buildModeButton(TimeMode.timer, 'FOCUS', Icons.timer, theme), - const SizedBox(width: 4), - _buildModeButton(TimeMode.stopwatch, 'STOPWATCH', Icons.speed, theme), - - // Extra Action Controls for Timer / Stopwatch - if (_mode == TimeMode.timer) ...[ - const SizedBox(width: 10), - Container(width: 1, height: 24, color: theme.border), - const SizedBox(width: 10), - IconButton( - onPressed: _toggleTimer, - style: IconButton.styleFrom( - backgroundColor: theme.primary, - shape: const RoundedRectangleBorder(), - ), - icon: Icon( - _isTimerRunning ? Icons.pause : Icons.play_arrow, - color: theme.background, - size: 18, - ), - ), - const SizedBox(width: 6), - IconButton( - onPressed: () => _resetTimer(), - style: IconButton.styleFrom( - backgroundColor: theme.surface, - side: BorderSide(color: theme.border), - shape: const RoundedRectangleBorder(), - ), - icon: Icon(Icons.refresh, color: theme.primary, size: 18), - ), - ], - - if (_mode == TimeMode.stopwatch) ...[ - const SizedBox(width: 10), - Container(width: 1, height: 24, color: theme.border), - const SizedBox(width: 10), - IconButton( - onPressed: _toggleStopwatch, - style: IconButton.styleFrom( - backgroundColor: theme.primary, - shape: const RoundedRectangleBorder(), - ), - icon: Icon( - _isStopwatchRunning ? Icons.pause : Icons.play_arrow, - color: theme.background, - size: 18, - ), - ), - const SizedBox(width: 6), - IconButton( - onPressed: _recordLap, - style: IconButton.styleFrom( - backgroundColor: theme.surface, - side: BorderSide(color: theme.border), - shape: const RoundedRectangleBorder(), - ), - icon: Icon(Icons.flag, color: theme.primary, size: 18), - ), - const SizedBox(width: 6), - IconButton( - onPressed: _resetStopwatch, - style: IconButton.styleFrom( - backgroundColor: theme.surface, - side: BorderSide(color: theme.border), - shape: const RoundedRectangleBorder(), - ), - icon: Icon(Icons.refresh, color: theme.primary, size: 18), - ), - ], - ], + home: Builder( + builder: (context) { + _navigatorContext = context; + final isLandscape = + MediaQuery.of(context).orientation == Orientation.landscape; + final style = DisplayStyle( + theme: theme, + font: kFonts[s.fontIndex], + isLandscape: isLandscape, + enableGlow: s.enableGlow, + ); + + return Scaffold( + backgroundColor: theme.background, + body: Focus( + autofocus: true, + onKeyEvent: _onKey, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: _toggleControls, + onDoubleTap: () => _cycleTheme(1), + onLongPress: _openDesignStudio, + onVerticalDragEnd: (d) { + final v = d.primaryVelocity; + if (v == null) return; + if (v < -200) _cycleTheme(1); + if (v > 200) _cycleTheme(-1); + }, + onHorizontalDragEnd: (d) { + final v = d.primaryVelocity; + if (v == null) return; + if (v < -200) _cycleMode(1); + if (v > 200) _cycleMode(-1); + }, + child: Stack( + children: [ + Positioned.fill( + child: Transform.translate( + offset: Offset(_burnInX, _burnInY), + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: isLandscape ? 16 : 8, + vertical: isLandscape ? 8 : 16, ), + child: Center(child: _buildContent(style, s)), ), ), ), - ), + HudTopBar( + visible: _controlsVisible, + theme: theme, + mode: s.mode, + fontName: kFonts[s.fontIndex].name, + onOpenStudio: _openDesignStudio, + onNextTheme: () => _cycleTheme(1), + onHide: _hideControls, + ), + HudBottomBar( + visible: _controlsVisible, + theme: theme, + mode: s.mode, + timer: _timer, + stopwatch: _stopwatch, + onSetMode: _setMode, + ), + ], ), - ], - ), - ), - ), - ), - ); - } - - Widget _buildModeButton(TimeMode mode, String label, IconData icon, ClockTheme theme) { - final isSelected = _mode == mode; - return GestureDetector( - onTap: () => _setMode(mode), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), - color: isSelected ? theme.primary : Colors.transparent, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - icon, - size: 14, - color: isSelected ? theme.background : theme.accent, - ), - const SizedBox(width: 6), - Text( - label, - style: TextStyle( - color: isSelected ? theme.background : theme.primary, - fontSize: 10, - fontWeight: FontWeight.w900, - letterSpacing: 1, ), ), - ], - ), + ); + }, ), ); } - /// PURE FULLSCREEN TIME CONTENT BUILDER (Edge-to-Edge) - Widget _buildPureFullscreenContent(ClockTheme theme, bool isLandscape) { - switch (_mode) { + Widget _buildContent(DisplayStyle style, AppSettings s) { + switch (s.mode) { case TimeMode.clock: - return _buildGiantClock(theme, isLandscape); + return ClockView( + style: style, + now: _now, + is12Hour: s.is12Hour, + showSeconds: s.showSeconds, + showDate: s.showDate, + colonBlink: s.colonBlink, + ); case TimeMode.countdown: - return _buildGiantCountdown(theme, isLandscape); + return CountdownView( + style: style, + now: _now, + target: s.countdownTarget, + title: s.countdownTitle, + ); case TimeMode.timer: - return _buildGiantFocusTimer(theme, isLandscape); + return TimerView(style: style, controller: _timer); case TimeMode.stopwatch: - return _buildGiantStopwatch(theme, isLandscape); + return StopwatchView(style: style, controller: _stopwatch); } } - - /// 1. CLOCK VIEW: Pure Giant Digits filling the display - Widget _buildGiantClock(ClockTheme theme, bool isLandscape) { - final hourFormat = _is12Hour ? 'hh' : 'HH'; - final hours = DateFormat(hourFormat).format(_now); - final minutes = DateFormat('mm').format(_now); - final seconds = DateFormat('ss').format(_now); - final ampm = _is12Hour ? DateFormat('a').format(_now) : ''; - final dateStr = DateFormat('EEE, MMM d, yyyy').format(_now).toUpperCase(); - - final showColon = !_colonBlink || (_now.millisecond < 500); - - final glowEffects = _enableGlow && theme.glow.a > 0 - ? [ - Shadow(color: theme.glow, blurRadius: 25), - Shadow(color: theme.glow.withValues(alpha: 0.5), blurRadius: 50), - ] - : null; - - final primaryStyle = _getFontStyle( - TextStyle( - color: theme.primary, - fontSize: 180, - fontWeight: FontWeight.w900, - letterSpacing: isLandscape ? 2 : 0, - height: 1.0, - shadows: glowEffects, - ), - ); - - final accentStyle = _getFontStyle( - TextStyle( - color: theme.accent, - fontSize: isLandscape ? 60 : 48, - fontWeight: FontWeight.w700, - letterSpacing: 2, - height: 1.0, - shadows: glowEffects, - ), - ); - - return FittedBox( - fit: BoxFit.contain, - child: Column( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - // Optional Date header - if (_showDate) - Padding( - padding: const EdgeInsets.only(bottom: 8.0), - child: Text( - dateStr, - style: _getFontStyle( - TextStyle( - color: theme.accent, - fontSize: isLandscape ? 18 : 22, - fontWeight: FontWeight.w700, - letterSpacing: 4, - ), - ), - ), - ), - - // Main Time Row - Row( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.baseline, - textBaseline: TextBaseline.alphabetic, - children: [ - Text(hours, style: primaryStyle), - Text( - ':', - style: primaryStyle.copyWith( - color: showColon ? theme.accent : Colors.transparent, - ), - ), - Text(minutes, style: primaryStyle), - - // Seconds & AM/PM - if (_showSeconds) ...[ - const SizedBox(width: 8), - Text( - ':$seconds', - style: accentStyle, - ), - ], - - if (_is12Hour && ampm.isNotEmpty) ...[ - const SizedBox(width: 12), - Text( - ampm, - style: accentStyle.copyWith( - color: theme.secondary, - fontSize: isLandscape ? 40 : 32, - ), - ), - ], - ], - ), - - // Progress line: percentage of day passed - Padding( - padding: const EdgeInsets.only(top: 12.0), - child: SizedBox( - width: isLandscape ? 600 : 320, - height: 3, - child: LinearProgressIndicator( - value: (_now.hour * 3600 + _now.minute * 60 + _now.second) / 86400, - backgroundColor: theme.border.withValues(alpha: 0.3), - valueColor: AlwaysStoppedAnimation(theme.accent), - ), - ), - ), - ], - ), - ); - } - - /// 2. COUNTDOWN VIEW: Giant Horizon Counter - Widget _buildGiantCountdown(ClockTheme theme, bool isLandscape) { - final diff = _countdownTarget.difference(_now); - final isExpired = diff.isNegative; - - final days = isExpired ? 0 : diff.inDays; - final hours = isExpired ? 0 : diff.inHours % 24; - final mins = isExpired ? 0 : diff.inMinutes % 60; - final secs = isExpired ? 0 : diff.inSeconds % 60; - - final glowEffects = _enableGlow && theme.glow.a > 0 - ? [Shadow(color: theme.glow, blurRadius: 25)] - : null; - - final numStyle = _getFontStyle( - TextStyle( - color: theme.primary, - fontSize: isLandscape ? 120 : 96, - fontWeight: FontWeight.w900, - height: 1.0, - shadows: glowEffects, - ), - ); - - final labelStyle = _getFontStyle( - TextStyle( - color: theme.accent, - fontSize: isLandscape ? 14 : 12, - fontWeight: FontWeight.w800, - letterSpacing: 2, - ), - ); - - return FittedBox( - fit: BoxFit.contain, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - // Target Event Title - Padding( - padding: const EdgeInsets.only(bottom: 16.0), - child: Text( - _countdownTitle.toUpperCase(), - style: _getFontStyle( - TextStyle( - color: theme.accent, - fontSize: isLandscape ? 20 : 18, - fontWeight: FontWeight.w900, - letterSpacing: 4, - ), - ), - ), - ), - - // 4-Block Split - Row( - mainAxisSize: MainAxisSize.min, - children: [ - _buildCountdownBlock('$days'.padLeft(2, '0'), 'DAYS', numStyle, labelStyle, theme), - _buildCountdownSeparator(theme), - _buildCountdownBlock('$hours'.padLeft(2, '0'), 'HOURS', numStyle, labelStyle, theme), - _buildCountdownSeparator(theme), - _buildCountdownBlock('$mins'.padLeft(2, '0'), 'MINS', numStyle, labelStyle, theme), - _buildCountdownSeparator(theme), - _buildCountdownBlock('$secs'.padLeft(2, '0'), 'SECS', numStyle, labelStyle, theme), - ], - ), - - const SizedBox(height: 16), - Text( - isExpired ? 'HORIZON REACHED' : 'TARGET: ${DateFormat('yyyy-MM-dd HH:mm').format(_countdownTarget)}', - style: TextStyle( - color: theme.secondary, - fontSize: 12, - letterSpacing: 2, - fontWeight: FontWeight.w600, - ), - ), - ], - ), - ); - } - - Widget _buildCountdownBlock( - String val, - String label, - TextStyle numStyle, - TextStyle labelStyle, - ClockTheme theme, - ) { - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text(val, style: numStyle), - const SizedBox(height: 4), - Text(label, style: labelStyle), - ], - ); - } - - Widget _buildCountdownSeparator(ClockTheme theme) { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 12.0), - child: Text( - ':', - style: _getFontStyle( - TextStyle( - color: theme.accent, - fontSize: 70, - fontWeight: FontWeight.w300, - height: 1.0, - ), - ), - ), - ); - } - - /// 3. FOCUS TIMER VIEW: Pure Massive Timer Numbers - Widget _buildGiantFocusTimer(ClockTheme theme, bool isLandscape) { - final mins = (_timerRemainingSeconds ~/ 60).toString().padLeft(2, '0'); - final secs = (_timerRemainingSeconds % 60).toString().padLeft(2, '0'); - final progress = _timerTotalSeconds > 0 ? (_timerRemainingSeconds / _timerTotalSeconds) : 0.0; - - final glowEffects = _enableGlow && theme.glow.a > 0 - ? [Shadow(color: theme.glow, blurRadius: 25)] - : null; - - final numStyle = _getFontStyle( - TextStyle( - color: _isTimerRunning ? theme.primary : theme.accent, - fontSize: isLandscape ? 170 : 130, - fontWeight: FontWeight.w900, - height: 1.0, - shadows: glowEffects, - ), - ); - - return FittedBox( - fit: BoxFit.contain, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - _isTimerRunning ? 'FOCUS SESSION' : 'PAUSED (TAP TO START)', - style: _getFontStyle( - TextStyle( - color: theme.accent, - fontSize: 16, - letterSpacing: 4, - fontWeight: FontWeight.w800, - ), - ), - ), - const SizedBox(height: 10), - - // Giant Numerals - GestureDetector( - onTap: _toggleTimer, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text(mins, style: numStyle), - Text( - ':', - style: numStyle.copyWith( - color: _isTimerRunning ? theme.accent : theme.secondary, - ), - ), - Text(secs, style: numStyle), - ], - ), - ), - - const SizedBox(height: 16), - SizedBox( - width: isLandscape ? 500 : 300, - height: 4, - child: LinearProgressIndicator( - value: progress, - backgroundColor: theme.border.withValues(alpha: 0.3), - valueColor: AlwaysStoppedAnimation(theme.primary), - ), - ), - ], - ), - ); - } - - /// 4. STOPWATCH VIEW: Precision Millisecond Display - Widget _buildGiantStopwatch(ClockTheme theme, bool isLandscape) { - final totalSeconds = _stopwatchMilliseconds ~/ 1000; - final mins = (totalSeconds ~/ 60).toString().padLeft(2, '0'); - final secs = (totalSeconds % 60).toString().padLeft(2, '0'); - final millis = ((_stopwatchMilliseconds % 1000) ~/ 10).toString().padLeft(2, '0'); - - final glowEffects = _enableGlow && theme.glow.a > 0 - ? [Shadow(color: theme.glow, blurRadius: 25)] - : null; - - final numStyle = _getFontStyle( - TextStyle( - color: theme.primary, - fontSize: isLandscape ? 160 : 110, - fontWeight: FontWeight.w900, - height: 1.0, - shadows: glowEffects, - ), - ); - - final millisStyle = _getFontStyle( - TextStyle( - color: theme.accent, - fontSize: isLandscape ? 70 : 48, - fontWeight: FontWeight.w700, - height: 1.0, - shadows: glowEffects, - ), - ); - - return FittedBox( - fit: BoxFit.contain, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - _isStopwatchRunning ? 'PRECISION STOPWATCH' : 'STOPWATCH (TAP TO RUN)', - style: _getFontStyle( - TextStyle( - color: theme.accent, - fontSize: 16, - letterSpacing: 4, - fontWeight: FontWeight.w800, - ), - ), - ), - const SizedBox(height: 10), - - // Main Display - GestureDetector( - onTap: _toggleStopwatch, - child: Row( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.baseline, - textBaseline: TextBaseline.alphabetic, - children: [ - Text('$mins:$secs', style: numStyle), - Text('.$millis', style: millisStyle), - ], - ), - ), - - // Laps preview - if (_laps.isNotEmpty) - Padding( - padding: const EdgeInsets.only(top: 16.0), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - 'LAST LAP: ${(_laps.first ~/ 1000 ~/ 60).toString().padLeft(2, '0')}:${((_laps.first ~/ 1000) % 60).toString().padLeft(2, '0')}.${((_laps.first % 1000) ~/ 10).toString().padLeft(2, '0')}', - style: TextStyle( - color: theme.secondary, - fontSize: 13, - letterSpacing: 1.5, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(width: 12), - Text( - '(${_laps.length} LAPS)', - style: TextStyle( - color: theme.accent, - fontSize: 11, - letterSpacing: 1, - ), - ), - ], - ), - ), - ], - ), - ); - } -} - -/// DESIGN STUDIO MODAL SHEET -class _DesignStudioSheet extends StatefulWidget { - final int currentThemeIndex; - final int currentFontIndex; - final bool is12Hour; - final bool showSeconds; - final bool showDate; - final bool enableGlow; - final bool colonBlink; - final bool burnInProtection; - - final ValueChanged onThemeSelected; - final ValueChanged onFontSelected; - final ValueChanged on12HourChanged; - final ValueChanged onShowSecondsChanged; - final ValueChanged onShowDateChanged; - final ValueChanged onEnableGlowChanged; - final ValueChanged onColonBlinkChanged; - final ValueChanged onBurnInProtectionChanged; - final void Function(DateTime dt, String title) onSetCountdownTarget; - final ValueChanged onSetTimerDuration; - - const _DesignStudioSheet({ - required this.currentThemeIndex, - required this.currentFontIndex, - required this.is12Hour, - required this.showSeconds, - required this.showDate, - required this.enableGlow, - required this.colonBlink, - required this.burnInProtection, - required this.onThemeSelected, - required this.onFontSelected, - required this.on12HourChanged, - required this.onShowSecondsChanged, - required this.onShowDateChanged, - required this.onEnableGlowChanged, - required this.onColonBlinkChanged, - required this.onBurnInProtectionChanged, - required this.onSetCountdownTarget, - required this.onSetTimerDuration, - }); - - @override - State<_DesignStudioSheet> createState() => _DesignStudioSheetState(); -} - -class _DesignStudioSheetState extends State<_DesignStudioSheet> with SingleTickerProviderStateMixin { - late TabController _tabController; - - @override - void initState() { - super.initState(); - _tabController = TabController(length: 4, vsync: this); - } - - @override - void dispose() { - _tabController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final activeTheme = kThemes[widget.currentThemeIndex]; - - return Container( - height: MediaQuery.of(context).size.height * 0.75, - decoration: BoxDecoration( - color: activeTheme.surface, - border: Border(top: BorderSide(color: activeTheme.border, width: 2)), - ), - child: Column( - children: [ - // Drag handle & Header - Padding( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'DESIGN STUDIO', - style: TextStyle( - color: activeTheme.primary, - fontSize: 16, - fontWeight: FontWeight.w900, - letterSpacing: 2, - ), - ), - Text( - 'CUSTOMIZE PALETTE, TYPOGRAPHY & BEHAVIOR', - style: TextStyle( - color: activeTheme.accent, - fontSize: 10, - letterSpacing: 1, - ), - ), - ], - ), - IconButton( - onPressed: () => Navigator.pop(context), - icon: Icon(Icons.close, color: activeTheme.primary), - ), - ], - ), - ), - - // Tab Bar - TabBar( - controller: _tabController, - indicatorColor: activeTheme.primary, - indicatorWeight: 3, - labelColor: activeTheme.primary, - unselectedLabelColor: activeTheme.secondary, - labelStyle: const TextStyle(fontWeight: FontWeight.w900, fontSize: 11, letterSpacing: 1), - tabs: const [ - Tab(text: 'PALETTES'), - Tab(text: 'TYPOGRAPHY'), - Tab(text: 'DISPLAY'), - Tab(text: 'PRESETS'), - ], - ), - - // Tab Views - Expanded( - child: TabBarView( - controller: _tabController, - children: [ - // 1. PALETTES GRID - _buildPalettesTab(activeTheme), - - // 2. TYPOGRAPHY LIST - _buildTypographyTab(activeTheme), - - // 3. DISPLAY TOGGLES - _buildDisplayTab(activeTheme), - - // 4. PRESETS - _buildPresetsTab(activeTheme), - ], - ), - ), - ], - ), - ); - } - - Widget _buildPalettesTab(ClockTheme activeTheme) { - return GridView.builder( - padding: const EdgeInsets.all(16), - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 2, - crossAxisSpacing: 10, - mainAxisSpacing: 10, - childAspectRatio: 2.2, - ), - itemCount: kThemes.length, - itemBuilder: (context, index) { - final t = kThemes[index]; - final isSelected = index == widget.currentThemeIndex; - - return GestureDetector( - onTap: () { - widget.onThemeSelected(index); - setState(() {}); - }, - child: Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: t.background, - border: Border.all( - color: isSelected ? t.primary : t.border.withValues(alpha: 0.5), - width: isSelected ? 2 : 1, - ), - ), - child: Row( - children: [ - Container( - width: 14, - height: double.infinity, - color: t.primary, - ), - const SizedBox(width: 4), - Container( - width: 6, - height: double.infinity, - color: t.accent, - ), - const SizedBox(width: 10), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - t.name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: t.primary, - fontWeight: FontWeight.w900, - fontSize: 12, - ), - ), - Text( - t.category.toUpperCase(), - style: TextStyle( - color: t.accent, - fontSize: 8, - letterSpacing: 1, - ), - ), - ], - ), - ), - if (isSelected) - Icon(Icons.check_circle, color: t.primary, size: 16), - ], - ), - ), - ); - }, - ); - } - - Widget _buildTypographyTab(ClockTheme activeTheme) { - return ListView.builder( - padding: const EdgeInsets.all(16), - itemCount: kFonts.length, - itemBuilder: (context, index) { - final font = kFonts[index]; - final isSelected = index == widget.currentFontIndex; - - return Padding( - padding: const EdgeInsets.only(bottom: 8.0), - child: GestureDetector( - onTap: () { - widget.onFontSelected(index); - setState(() {}); - }, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - decoration: BoxDecoration( - color: isSelected ? activeTheme.border.withValues(alpha: 0.3) : Colors.transparent, - border: Border.all( - color: isSelected ? activeTheme.primary : activeTheme.border.withValues(alpha: 0.3), - ), - ), - child: Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - font.name, - style: TextStyle( - color: activeTheme.primary, - fontSize: 14, - fontWeight: FontWeight.w900, - ), - ), - Text( - font.style, - style: TextStyle( - color: activeTheme.accent, - fontSize: 10, - ), - ), - ], - ), - ), - Text( - '10:45:00', - style: font.builder( - TextStyle( - color: activeTheme.primary, - fontSize: 20, - fontWeight: FontWeight.bold, - ), - ), - ), - const SizedBox(width: 12), - if (isSelected) - Icon(Icons.check, color: activeTheme.primary, size: 18), - ], - ), - ), - ), - ); - }, - ); - } - - Widget _buildDisplayTab(ClockTheme activeTheme) { - return ListView( - padding: const EdgeInsets.all(16), - children: [ - _buildSwitchTile('12-HOUR FORMAT', 'Display AM/PM instead of 24h military time', widget.is12Hour, widget.on12HourChanged, activeTheme), - _buildSwitchTile('SHOW SECONDS', 'High-frequency seconds indicator', widget.showSeconds, widget.onShowSecondsChanged, activeTheme), - _buildSwitchTile('SHOW DATE', 'Full date & day of week above time', widget.showDate, widget.onShowDateChanged, activeTheme), - _buildSwitchTile('TEXT GLOW EFFECT', 'Atmospheric neon glow halo around numerals', widget.enableGlow, widget.onEnableGlowChanged, activeTheme), - _buildSwitchTile('PULSE COLON', 'Subtle 1Hz blinking colon separator', widget.colonBlink, widget.onColonBlinkChanged, activeTheme), - _buildSwitchTile('OLED BURN-IN GUARD', 'Imperceptible 1.5px micro-shift every 30 mins', widget.burnInProtection, widget.onBurnInProtectionChanged, activeTheme), - ], - ); - } - - Widget _buildSwitchTile(String title, String subtitle, bool val, ValueChanged onChanged, ClockTheme theme) { - return Container( - margin: const EdgeInsets.only(bottom: 10), - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - border: Border.all(color: theme.border.withValues(alpha: 0.4)), - ), - child: Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - style: TextStyle( - color: theme.primary, - fontWeight: FontWeight.w900, - fontSize: 12, - letterSpacing: 1, - ), - ), - Text( - subtitle, - style: TextStyle( - color: theme.accent, - fontSize: 10, - ), - ), - ], - ), - ), - Switch( - value: val, - onChanged: (v) { - onChanged(v); - setState(() {}); - }, - activeThumbColor: theme.primary, - activeTrackColor: theme.primary.withValues(alpha: 0.3), - inactiveThumbColor: theme.secondary, - inactiveTrackColor: theme.surface, - ), - ], - ), - ); - } - - Widget _buildPresetsTab(ClockTheme activeTheme) { - return ListView( - padding: const EdgeInsets.all(16), - children: [ - Text( - 'TIMER PRESETS', - style: TextStyle( - color: activeTheme.primary, - fontSize: 12, - fontWeight: FontWeight.w900, - letterSpacing: 2, - ), - ), - const SizedBox(height: 10), - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - _buildPresetChip('5m Break', 5 * 60, activeTheme), - _buildPresetChip('15m Recess', 15 * 60, activeTheme), - _buildPresetChip('25m Pomodoro', 25 * 60, activeTheme), - _buildPresetChip('45m Deep Work', 45 * 60, activeTheme), - _buildPresetChip('60m Sprint', 60 * 60, activeTheme), - _buildPresetChip('90m Ultradian', 90 * 60, activeTheme), - ], - ), - - const SizedBox(height: 24), - Text( - 'COUNTDOWN HORIZONS', - style: TextStyle( - color: activeTheme.primary, - fontSize: 12, - fontWeight: FontWeight.w900, - letterSpacing: 2, - ), - ), - const SizedBox(height: 10), - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - _buildCountdownChip('+24 Hours', DateTime.now().add(const Duration(hours: 24)), 'TOMORROW', activeTheme), - _buildCountdownChip('+7 Days', DateTime.now().add(const Duration(days: 7)), 'NEXT WEEK', activeTheme), - _buildCountdownChip('+30 Days', DateTime.now().add(const Duration(days: 30)), 'ONE MONTH', activeTheme), - _buildCountdownChip('New Year', DateTime(DateTime.now().year + 1, 1, 1), 'NEW YEAR', activeTheme), - ], - ), - ], - ); - } - - Widget _buildPresetChip(String label, int seconds, ClockTheme theme) { - return ActionChip( - label: Text(label), - backgroundColor: theme.surface, - side: BorderSide(color: theme.border), - labelStyle: TextStyle(color: theme.primary, fontWeight: FontWeight.bold, fontSize: 11), - onPressed: () { - widget.onSetTimerDuration(seconds); - Navigator.pop(context); - }, - ); - } - - Widget _buildCountdownChip(String label, DateTime dt, String title, ClockTheme theme) { - return ActionChip( - label: Text(label), - backgroundColor: theme.surface, - side: BorderSide(color: theme.border), - labelStyle: TextStyle(color: theme.primary, fontWeight: FontWeight.bold, fontSize: 11), - onPressed: () { - widget.onSetCountdownTarget(dt, title); - Navigator.pop(context); - }, - ); - } } diff --git a/lib/models/time_mode.dart b/lib/models/time_mode.dart new file mode 100644 index 0000000..0cef402 --- /dev/null +++ b/lib/models/time_mode.dart @@ -0,0 +1,2 @@ +/// Available clock operating modes +enum TimeMode { clock, countdown, timer, stopwatch } diff --git a/lib/services/device_services.dart b/lib/services/device_services.dart new file mode 100644 index 0000000..da5b84a --- /dev/null +++ b/lib/services/device_services.dart @@ -0,0 +1,27 @@ +import 'package:flutter/services.dart'; +import 'package:wakelock_plus/wakelock_plus.dart'; + +/// Keeps the display on while the clock is in the foreground. +class ScreenAwake { + static Future set(bool enabled) async { + try { + await WakelockPlus.toggle(enable: enabled); + } catch (_) { + // Platform channel unavailable (tests, unsupported platform). + } + } +} + +/// Audible and tactile alerts. +class Alerts { + static void tap() => HapticFeedback.selectionClick(); + + /// Plays the system alert sound and vibrates three times. + static Future timerFinished() async { + for (var i = 0; i < 3; i++) { + await SystemSound.play(SystemSoundType.alert); + await HapticFeedback.vibrate(); + await Future.delayed(const Duration(milliseconds: 700)); + } + } +} diff --git a/lib/services/settings_store.dart b/lib/services/settings_store.dart new file mode 100644 index 0000000..634a2c1 --- /dev/null +++ b/lib/services/settings_store.dart @@ -0,0 +1,151 @@ +import 'package:shared_preferences/shared_preferences.dart'; + +import '../models/time_mode.dart'; +import '../theme/clock_font.dart'; +import '../theme/clock_theme.dart'; + +/// User preferences and last-used values that survive app restarts. +class AppSettings { + final TimeMode mode; + final int themeIndex; + final int fontIndex; + final bool is12Hour; + final bool showSeconds; + final bool showDate; + final bool enableGlow; + final bool colonBlink; + final bool burnInProtection; + final bool keepAwake; + final DateTime countdownTarget; + final String countdownTitle; + final int timerTotalSeconds; + + const AppSettings({ + required this.mode, + required this.themeIndex, + required this.fontIndex, + required this.is12Hour, + required this.showSeconds, + required this.showDate, + required this.enableGlow, + required this.colonBlink, + required this.burnInProtection, + required this.keepAwake, + required this.countdownTarget, + required this.countdownTitle, + required this.timerTotalSeconds, + }); + + factory AppSettings.defaults([DateTime? now]) => AppSettings( + mode: TimeMode.clock, + themeIndex: 0, + fontIndex: 0, + is12Hour: false, + showSeconds: true, + showDate: true, + enableGlow: true, + colonBlink: true, + burnInProtection: true, + keepAwake: true, + countdownTarget: (now ?? DateTime.now()).add( + const Duration(days: 7, hours: 14), + ), + countdownTitle: 'PROJECT LAUNCH', + timerTotalSeconds: 25 * 60, + ); + + AppSettings copyWith({ + TimeMode? mode, + int? themeIndex, + int? fontIndex, + bool? is12Hour, + bool? showSeconds, + bool? showDate, + bool? enableGlow, + bool? colonBlink, + bool? burnInProtection, + bool? keepAwake, + DateTime? countdownTarget, + String? countdownTitle, + int? timerTotalSeconds, + }) => AppSettings( + mode: mode ?? this.mode, + themeIndex: themeIndex ?? this.themeIndex, + fontIndex: fontIndex ?? this.fontIndex, + is12Hour: is12Hour ?? this.is12Hour, + showSeconds: showSeconds ?? this.showSeconds, + showDate: showDate ?? this.showDate, + enableGlow: enableGlow ?? this.enableGlow, + colonBlink: colonBlink ?? this.colonBlink, + burnInProtection: burnInProtection ?? this.burnInProtection, + keepAwake: keepAwake ?? this.keepAwake, + countdownTarget: countdownTarget ?? this.countdownTarget, + countdownTitle: countdownTitle ?? this.countdownTitle, + timerTotalSeconds: timerTotalSeconds ?? this.timerTotalSeconds, + ); +} + +/// Reads and writes [AppSettings] through `shared_preferences`. +class SettingsStore { + SettingsStore(this._prefs); + + final SharedPreferences _prefs; + + static Future open() async => + SettingsStore(await SharedPreferences.getInstance()); + + /// Loads saved settings; missing or out-of-range values fall back to defaults. + AppSettings load([DateTime? now]) { + final d = AppSettings.defaults(now); + final modeIndex = _prefs.getInt('mode') ?? d.mode.index; + final targetMs = _prefs.getInt('countdownTarget'); + final timerSecs = _prefs.getInt('timerTotalSeconds') ?? d.timerTotalSeconds; + + return AppSettings( + mode: modeIndex >= 0 && modeIndex < TimeMode.values.length + ? TimeMode.values[modeIndex] + : d.mode, + themeIndex: _index('themeIndex', kThemes.length, d.themeIndex), + fontIndex: _index('fontIndex', kFonts.length, d.fontIndex), + is12Hour: _prefs.getBool('is12Hour') ?? d.is12Hour, + showSeconds: _prefs.getBool('showSeconds') ?? d.showSeconds, + showDate: _prefs.getBool('showDate') ?? d.showDate, + enableGlow: _prefs.getBool('enableGlow') ?? d.enableGlow, + colonBlink: _prefs.getBool('colonBlink') ?? d.colonBlink, + burnInProtection: + _prefs.getBool('burnInProtection') ?? d.burnInProtection, + keepAwake: _prefs.getBool('keepAwake') ?? d.keepAwake, + countdownTarget: targetMs == null + ? d.countdownTarget + : DateTime.fromMillisecondsSinceEpoch(targetMs), + countdownTitle: _prefs.getString('countdownTitle') ?? d.countdownTitle, + timerTotalSeconds: timerSecs > 0 ? timerSecs : d.timerTotalSeconds, + ); + } + + Future save(AppSettings s) async { + await Future.wait([ + _prefs.setInt('mode', s.mode.index), + _prefs.setInt('themeIndex', s.themeIndex), + _prefs.setInt('fontIndex', s.fontIndex), + _prefs.setBool('is12Hour', s.is12Hour), + _prefs.setBool('showSeconds', s.showSeconds), + _prefs.setBool('showDate', s.showDate), + _prefs.setBool('enableGlow', s.enableGlow), + _prefs.setBool('colonBlink', s.colonBlink), + _prefs.setBool('burnInProtection', s.burnInProtection), + _prefs.setBool('keepAwake', s.keepAwake), + _prefs.setInt( + 'countdownTarget', + s.countdownTarget.millisecondsSinceEpoch, + ), + _prefs.setString('countdownTitle', s.countdownTitle), + _prefs.setInt('timerTotalSeconds', s.timerTotalSeconds), + ]); + } + + int _index(String key, int length, int fallback) { + final v = _prefs.getInt(key); + return v != null && v >= 0 && v < length ? v : fallback; + } +} diff --git a/lib/theme/clock_font.dart b/lib/theme/clock_font.dart new file mode 100644 index 0000000..acd1189 --- /dev/null +++ b/lib/theme/clock_font.dart @@ -0,0 +1,98 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +/// Curated designer typography styles +class ClockFont { + final String name; + final String style; + final TextStyle Function(TextStyle base) builder; + + const ClockFont({ + required this.name, + required this.style, + required this.builder, + }); +} + +final List kFonts = [ + ClockFont( + name: 'Bebas Neue', + style: 'Bold Condensed Horology', + builder: (b) => GoogleFonts.bebasNeue(textStyle: b), + ), + ClockFont( + name: 'Orbitron', + style: 'Sci-Fi Cyberpunk', + builder: (b) => GoogleFonts.orbitron(textStyle: b), + ), + ClockFont( + name: 'Space Mono', + style: 'Hacker Terminal Monospace', + builder: (b) => GoogleFonts.spaceMono(textStyle: b), + ), + ClockFont( + name: 'Oswald', + style: 'Industrial Architecture', + builder: (b) => GoogleFonts.oswald(textStyle: b), + ), + ClockFont( + name: 'Montserrat', + style: 'Clean Geometric Bauhaus', + builder: (b) => GoogleFonts.montserrat(textStyle: b), + ), + ClockFont( + name: 'Playfair Display', + style: 'Luxury Swiss Horology Serif', + builder: (b) => GoogleFonts.playfairDisplay(textStyle: b), + ), + ClockFont( + name: 'Syne', + style: 'Radical Avant-Garde', + builder: (b) => GoogleFonts.syne(textStyle: b), + ), + ClockFont( + name: 'JetBrains Mono', + style: 'Precision Engineering', + builder: (b) => GoogleFonts.jetBrainsMono(textStyle: b), + ), + ClockFont( + name: 'Anton', + style: 'Ultra-Heavy Headline', + builder: (b) => GoogleFonts.anton(textStyle: b), + ), + ClockFont( + name: 'Cinzel', + style: 'Imperial Chiseled Stone', + builder: (b) => GoogleFonts.cinzel(textStyle: b), + ), + ClockFont( + name: 'Audiowide', + style: 'Digital Hypercar Dashboard', + builder: (b) => GoogleFonts.audiowide(textStyle: b), + ), + ClockFont( + name: 'Comfortaa', + style: 'Smooth Futuristic Rounded', + builder: (b) => GoogleFonts.comfortaa(textStyle: b), + ), + ClockFont( + name: 'Righteous', + style: 'Electric Art Deco', + builder: (b) => GoogleFonts.righteous(textStyle: b), + ), + ClockFont( + name: 'Silkscreen', + style: '8-Bit Retro Arcade', + builder: (b) => GoogleFonts.silkscreen(textStyle: b), + ), + ClockFont( + name: 'Major Mono', + style: 'Minimalist Abstract Mono', + builder: (b) => GoogleFonts.majorMonoDisplay(textStyle: b), + ), + ClockFont( + name: 'System Monospace', + style: 'Offline Hardware Terminal', + builder: (b) => b.copyWith(fontFamily: 'monospace'), + ), +]; diff --git a/lib/theme/clock_theme.dart b/lib/theme/clock_theme.dart new file mode 100644 index 0000000..5bff100 --- /dev/null +++ b/lib/theme/clock_theme.dart @@ -0,0 +1,343 @@ +import 'package:flutter/material.dart'; + +/// Curated high-performance horology themes +class ClockTheme { + final String name; + final String category; + final Color background; + final Color primary; + final Color accent; + final Color secondary; + final Color surface; + final Color border; + final Color glow; + final bool isDark; + + const ClockTheme({ + required this.name, + required this.category, + required this.background, + required this.primary, + required this.accent, + required this.secondary, + required this.surface, + required this.border, + required this.glow, + required this.isDark, + }); +} + +const List kThemes = [ + // 1. OLED Pitch Black + ClockTheme( + name: 'OLED Void', + category: 'OLED Minimal', + background: Color(0xFF000000), + primary: Color(0xFFFFFFFF), + accent: Color(0xFF9E9E9E), + secondary: Color(0xFF424242), + surface: Color(0xFF121212), + border: Color(0xFF262626), + glow: Color(0x33FFFFFF), + isDark: true, + ), + // 2. Cyberpunk Electric Cyan & Magenta + ClockTheme( + name: 'Cyberpunk Neon', + category: 'Cyberpunk', + background: Color(0xFF05050A), + primary: Color(0xFF00F0FF), + accent: Color(0xFFFF0055), + secondary: Color(0xFF7000FF), + surface: Color(0xFF0F0F1A), + border: Color(0xFF00F0FF), + glow: Color(0x8000F0FF), + isDark: true, + ), + // 3. Tokyo Midnight Slate + ClockTheme( + name: 'Tokyo Midnight', + category: 'Dark Studio', + background: Color(0xFF1A1B26), + primary: Color(0xFF7AA2F7), + accent: Color(0xFFBB9AF7), + secondary: Color(0xFF73DACA), + surface: Color(0xFF24283B), + border: Color(0xFF414868), + glow: Color(0x407AA2F7), + isDark: true, + ), + // 4. Solar Amber Cockpit + ClockTheme( + name: 'Solar Amber', + category: 'Aviation', + background: Color(0xFF070500), + primary: Color(0xFFFFB000), + accent: Color(0xFFFF5500), + secondary: Color(0xFFFFCC33), + surface: Color(0xFF140D00), + border: Color(0xFFFFB000), + glow: Color(0x66FFB000), + isDark: true, + ), + // 5. Matrix CRT Terminal + ClockTheme( + name: 'Matrix CRT', + category: 'Terminal', + background: Color(0xFF000F04), + primary: Color(0xFF00FF66), + accent: Color(0xFF33FF99), + secondary: Color(0xFF008833), + surface: Color(0xFF051C0A), + border: Color(0xFF00FF66), + glow: Color(0x7700FF66), + isDark: true, + ), + // 6. Synthwave 1984 + ClockTheme( + name: 'Synthwave 84', + category: 'Retro', + background: Color(0xFF150026), + primary: Color(0xFFFF2A85), + accent: Color(0xFF00FFF0), + secondary: Color(0xFFFFB800), + surface: Color(0xFF240040), + border: Color(0xFFFF2A85), + glow: Color(0x66FF2A85), + isDark: true, + ), + // 7. Nordic Frost Glacier + ClockTheme( + name: 'Nordic Frost', + category: 'Minimal', + background: Color(0xFF0B1017), + primary: Color(0xFF58A6FF), + accent: Color(0xFF79C0FF), + secondary: Color(0xFF388BFD), + surface: Color(0xFF161F2C), + border: Color(0xFF303E54), + glow: Color(0x4058A6FF), + isDark: true, + ), + // 8. Dieter Rams / Braun Bauhaus + ClockTheme( + name: 'Braun Bauhaus', + category: 'Industrial', + background: Color(0xFFECE8E1), + primary: Color(0xFF161616), + accent: Color(0xFFFF5500), + secondary: Color(0xFF5E5E5E), + surface: Color(0xFFDDD8D0), + border: Color(0xFF161616), + glow: Color(0x00000000), + isDark: false, + ), + // 9. Ferrari Scuderia Corsa + ClockTheme( + name: 'Ferrari Corsa', + category: 'Motorsport', + background: Color(0xFF080808), + primary: Color(0xFFFF1801), + accent: Color(0xFFFFFFFF), + secondary: Color(0xFFFF8000), + surface: Color(0xFF1A1A1A), + border: Color(0xFFFF1801), + glow: Color(0x66FF1801), + isDark: true, + ), + // 10. Royal Emerald & Brass + ClockTheme( + name: 'Emerald Royale', + category: 'Horology', + background: Color(0xFF04140C), + primary: Color(0xFFF3C761), + accent: Color(0xFF4ECCA3), + secondary: Color(0xFF2E8B57), + surface: Color(0xFF0C2417), + border: Color(0xFFF3C761), + glow: Color(0x40F3C761), + isDark: true, + ), + // 11. Dracula Twilight + ClockTheme( + name: 'Dracula', + category: 'Dark Studio', + background: Color(0xFF1E1F29), + primary: Color(0xFFBD93F9), + accent: Color(0xFFFF79C6), + secondary: Color(0xFF8BE9FD), + surface: Color(0xFF282A36), + border: Color(0xFF6272A4), + glow: Color(0x55BD93F9), + isDark: true, + ), + // 12. Monokai Pro + ClockTheme( + name: 'Monokai Pro', + category: 'Developer', + background: Color(0xFF222222), + primary: Color(0xFFFFD866), + accent: Color(0xFFA9DC76), + secondary: Color(0xFFFF6188), + surface: Color(0xFF2D2A2E), + border: Color(0xFF403E41), + glow: Color(0x40FFD866), + isDark: true, + ), + // 13. Japanese Sumi & Washi Paper + ClockTheme( + name: 'Sumi Paper', + category: 'Organic', + background: Color(0xFFF6F4EB), + primary: Color(0xFF1C1C1C), + accent: Color(0xFFBA1B1D), + secondary: Color(0xFF555555), + surface: Color(0xFFEBE7DB), + border: Color(0xFF1C1C1C), + glow: Color(0x00000000), + isDark: false, + ), + // 14. Deep Abyss Cobalt + ClockTheme( + name: 'Abyss Cobalt', + category: 'Sci-Fi', + background: Color(0xFF020B1A), + primary: Color(0xFF00D2FF), + accent: Color(0xFF3A7BD5), + secondary: Color(0xFF64FFDA), + surface: Color(0xFF0B1930), + border: Color(0xFF00D2FF), + glow: Color(0x6000D2FF), + isDark: true, + ), + // 15. Lilac Dusk + ClockTheme( + name: 'Lilac Dusk', + category: 'Dreamscape', + background: Color(0xFF13111C), + primary: Color(0xFFD8B4FE), + accent: Color(0xFFF472B6), + secondary: Color(0xFFA78BFA), + surface: Color(0xFF201B30), + border: Color(0xFFD8B4FE), + glow: Color(0x50D8B4FE), + isDark: true, + ), + // 16. Sunset Horizons + ClockTheme( + name: 'Sunset Horizons', + category: 'Atmospheric', + background: Color(0xFF0F0814), + primary: Color(0xFFFB923C), + accent: Color(0xFFF43F5E), + secondary: Color(0xFFFBBF24), + surface: Color(0xFF21132C), + border: Color(0xFFFB923C), + glow: Color(0x55FB923C), + isDark: true, + ), + // 17. Gruvbox Retro Basalt + ClockTheme( + name: 'Gruvbox Dark', + category: 'Retro', + background: Color(0xFF1D2021), + primary: Color(0xFFFABD2F), + accent: Color(0xFFFE8019), + secondary: Color(0xFFB8BB26), + surface: Color(0xFF282828), + border: Color(0xFF504945), + glow: Color(0x40FABD2F), + isDark: true, + ), + // 18. Acid Techno Radioactive + ClockTheme( + name: 'Acid Techno', + category: 'Brutalist', + background: Color(0xFF000000), + primary: Color(0xFFCCFF00), + accent: Color(0xFF00FFA3), + secondary: Color(0xFFFFFFFF), + surface: Color(0xFF12140A), + border: Color(0xFFCCFF00), + glow: Color(0x77CCFF00), + isDark: true, + ), + // 19. Arctic Clean Modern + ClockTheme( + name: 'Arctic Snow', + category: 'Minimal Light', + background: Color(0xFFF8FAFC), + primary: Color(0xFF0F172A), + accent: Color(0xFF0284C7), + secondary: Color(0xFF64748B), + surface: Color(0xFFE2E8F0), + border: Color(0xFF0F172A), + glow: Color(0x00000000), + isDark: false, + ), + // 20. Vaporwave Pastel Glow + ClockTheme( + name: 'Vaporwave', + category: 'Retro', + background: Color(0xFF1D0E36), + primary: Color(0xFFFF71CE), + accent: Color(0xFF01CDFE), + secondary: Color(0xFF05FFA1), + surface: Color(0xFF2D1850), + border: Color(0xFFFF71CE), + glow: Color(0x66FF71CE), + isDark: true, + ), + // 21. Zen Bamboo Garden + ClockTheme( + name: 'Zen Bamboo', + category: 'Organic', + background: Color(0xFF0C140E), + primary: Color(0xFFA3E635), + accent: Color(0xFF4ADE80), + secondary: Color(0xFF22C55E), + surface: Color(0xFF17241A), + border: Color(0xFFA3E635), + glow: Color(0x40A3E635), + isDark: true, + ), + // 22. Stealth Bomber Graphite + ClockTheme( + name: 'Stealth Bomber', + category: 'Tactical', + background: Color(0xFF121214), + primary: Color(0xFFEF4444), + accent: Color(0xFFF97316), + secondary: Color(0xFF71717A), + surface: Color(0xFF1C1C20), + border: Color(0xFFEF4444), + glow: Color(0x55EF4444), + isDark: true, + ), + // 23. Tiffany Diamond + ClockTheme( + name: 'Tiffany Cyan', + category: 'Luxury', + background: Color(0xFF060909), + primary: Color(0xFF0ABAB5), + accent: Color(0xFFE2F8F7), + secondary: Color(0xFF56DFDB), + surface: Color(0xFF0F1818), + border: Color(0xFF0ABAB5), + glow: Color(0x500ABAB5), + isDark: true, + ), + // 24. Vintage Gold Pocketwatch + ClockTheme( + name: 'Chronograph Gold', + category: 'Horology', + background: Color(0xFF110E0A), + primary: Color(0xFFFFD700), + accent: Color(0xFFFFA500), + secondary: Color(0xFFE6C280), + surface: Color(0xFF241C14), + border: Color(0xFFFFD700), + glow: Color(0x50FFD700), + isDark: true, + ), +]; diff --git a/lib/views/clock_view.dart b/lib/views/clock_view.dart new file mode 100644 index 0000000..4797f1e --- /dev/null +++ b/lib/views/clock_view.dart @@ -0,0 +1,144 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +import '../logic/time_logic.dart'; +import 'display_style.dart'; + +/// Giant digital clock filling the display. +class ClockView extends StatelessWidget { + const ClockView({ + super.key, + required this.style, + required this.now, + required this.is12Hour, + required this.showSeconds, + required this.showDate, + required this.colonBlink, + }); + + final DisplayStyle style; + final ValueListenable now; + final bool is12Hour; + final bool showSeconds; + final bool showDate; + final bool colonBlink; + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: now, + builder: (context, t, _) => _build(t), + ); + } + + Widget _build(DateTime t) { + final theme = style.theme; + final isLandscape = style.isLandscape; + final hours = DateFormat(is12Hour ? 'hh' : 'HH').format(t); + final minutes = DateFormat('mm').format(t); + final seconds = DateFormat('ss').format(t); + final ampm = is12Hour ? DateFormat('a').format(t) : ''; + final dateStr = DateFormat('EEE, MMM d, yyyy').format(t).toUpperCase(); + final showColon = !colonBlink || t.millisecond < 500; + final glow = style.glow(strong: true); + + final primaryStyle = style.text( + TextStyle( + color: theme.primary, + fontSize: 180, + fontWeight: FontWeight.w900, + letterSpacing: isLandscape ? 2 : 0, + height: 1.0, + shadows: glow, + ), + ); + final accentStyle = style.text( + TextStyle( + color: theme.accent, + fontSize: isLandscape ? 60 : 48, + fontWeight: FontWeight.w700, + letterSpacing: 2, + height: 1.0, + shadows: glow, + ), + ); + + final spoken = + '${DateFormat(is12Hour ? 'h:mm' : 'HH:mm').format(t)}' + '${showSeconds ? ' and $seconds seconds' : ''}' + '${is12Hour ? ' $ampm' : ''}' + '${showDate ? ', ${DateFormat('EEEE, MMMM d, y').format(t)}' : ''}'; + + return Semantics( + container: true, + label: 'Clock. $spoken', + child: ExcludeSemantics( + child: FittedBox( + fit: BoxFit.contain, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (showDate) + Padding( + padding: const EdgeInsets.only(bottom: 8.0), + child: Text( + dateStr, + style: style.text( + TextStyle( + color: theme.accent, + fontSize: isLandscape ? 18 : 22, + fontWeight: FontWeight.w700, + letterSpacing: 4, + ), + ), + ), + ), + Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + Text(hours, style: primaryStyle), + Text( + ':', + style: primaryStyle.copyWith( + color: showColon ? theme.accent : Colors.transparent, + ), + ), + Text(minutes, style: primaryStyle), + if (showSeconds) ...[ + const SizedBox(width: 8), + Text(':$seconds', style: accentStyle), + ], + if (is12Hour && ampm.isNotEmpty) ...[ + const SizedBox(width: 12), + Text( + ampm, + style: accentStyle.copyWith( + color: theme.secondary, + fontSize: isLandscape ? 40 : 32, + ), + ), + ], + ], + ), + Padding( + padding: const EdgeInsets.only(top: 12.0), + child: SizedBox( + width: isLandscape ? 600 : 320, + height: 3, + child: LinearProgressIndicator( + value: dayProgress(t), + backgroundColor: theme.border.withValues(alpha: 0.3), + valueColor: AlwaysStoppedAnimation(theme.accent), + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/views/countdown_view.dart b/lib/views/countdown_view.dart new file mode 100644 index 0000000..9c13635 --- /dev/null +++ b/lib/views/countdown_view.dart @@ -0,0 +1,145 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +import '../logic/time_logic.dart'; +import 'display_style.dart'; + +/// Days / hours / minutes / seconds until a target moment. +class CountdownView extends StatelessWidget { + const CountdownView({ + super.key, + required this.style, + required this.now, + required this.target, + required this.title, + }); + + final DisplayStyle style; + final ValueListenable now; + final DateTime target; + final String title; + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: now, + builder: (context, t, _) => _build(countdownParts(target, t)), + ); + } + + Widget _build(CountdownParts p) { + final theme = style.theme; + final isLandscape = style.isLandscape; + final glow = style.glow(); + + final numStyle = style.text( + TextStyle( + color: theme.primary, + fontSize: isLandscape ? 120 : 96, + fontWeight: FontWeight.w900, + height: 1.0, + shadows: glow, + ), + ); + final labelStyle = style.text( + TextStyle( + color: theme.accent, + fontSize: isLandscape ? 14 : 12, + fontWeight: FontWeight.w800, + letterSpacing: 2, + ), + ); + + final spoken = p.expired + ? '$title. Target reached.' + : '$title. ${p.days} days, ${p.hours} hours, ' + '${p.minutes} minutes, ${p.seconds} seconds remaining.'; + + return Semantics( + container: true, + label: 'Countdown. $spoken', + child: ExcludeSemantics( + child: FittedBox( + fit: BoxFit.contain, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.only(bottom: 16.0), + child: Text( + title.toUpperCase(), + style: style.text( + TextStyle( + color: theme.accent, + fontSize: isLandscape ? 20 : 18, + fontWeight: FontWeight.w900, + letterSpacing: 4, + ), + ), + ), + ), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + _block(two(p.days), 'DAYS', numStyle, labelStyle), + _separator(), + _block(two(p.hours), 'HOURS', numStyle, labelStyle), + _separator(), + _block(two(p.minutes), 'MINS', numStyle, labelStyle), + _separator(), + _block(two(p.seconds), 'SECS', numStyle, labelStyle), + ], + ), + const SizedBox(height: 16), + Text( + p.expired + ? 'HORIZON REACHED' + : 'TARGET: ${DateFormat('yyyy-MM-dd HH:mm').format(target)}', + style: TextStyle( + color: theme.secondary, + fontSize: 12, + letterSpacing: 2, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ), + ); + } + + Widget _block( + String val, + String label, + TextStyle numStyle, + TextStyle labelStyle, + ) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(val, style: numStyle), + const SizedBox(height: 4), + Text(label, style: labelStyle), + ], + ); + } + + Widget _separator() { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 12.0), + child: Text( + ':', + style: style.text( + TextStyle( + color: style.theme.accent, + fontSize: 70, + fontWeight: FontWeight.w300, + height: 1.0, + ), + ), + ), + ); + } +} diff --git a/lib/views/display_style.dart b/lib/views/display_style.dart new file mode 100644 index 0000000..7142419 --- /dev/null +++ b/lib/views/display_style.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; + +import '../theme/clock_font.dart'; +import '../theme/clock_theme.dart'; + +/// Theme, typography and layout context shared by every mode view. +class DisplayStyle { + final ClockTheme theme; + final ClockFont font; + final bool isLandscape; + final bool enableGlow; + + const DisplayStyle({ + required this.theme, + required this.font, + required this.isLandscape, + required this.enableGlow, + }); + + /// Applies the selected font with tabular figures so digits don't jitter. + TextStyle text(TextStyle base) { + try { + return font + .builder(base) + .copyWith(fontFeatures: const [FontFeature.tabularFigures()]); + } catch (_) { + return base.copyWith(fontFamily: 'monospace'); + } + } + + /// Neon glow halo, or null when disabled / the theme has none. + List? glow({bool strong = false}) { + if (!enableGlow || theme.glow.a <= 0) return null; + return [ + Shadow(color: theme.glow, blurRadius: 25), + if (strong) + Shadow(color: theme.glow.withValues(alpha: 0.5), blurRadius: 50), + ]; + } +} diff --git a/lib/views/stopwatch_view.dart b/lib/views/stopwatch_view.dart new file mode 100644 index 0000000..c3a34ba --- /dev/null +++ b/lib/views/stopwatch_view.dart @@ -0,0 +1,124 @@ +import 'package:flutter/material.dart'; + +import '../logic/time_logic.dart'; +import 'display_style.dart'; + +/// Millisecond-precision stopwatch with a last-lap summary. +class StopwatchView extends StatelessWidget { + const StopwatchView({ + super.key, + required this.style, + required this.controller, + }); + + final DisplayStyle style; + final StopwatchController controller; + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: Listenable.merge([ + controller.elapsedMs, + controller.running, + controller.laps, + ]), + builder: (context, _) => _build(), + ); + } + + Widget _build() { + final theme = style.theme; + final isLandscape = style.isLandscape; + final running = controller.running.value; + final laps = controller.laps.value; + final parts = splitStopwatch(controller.elapsedMs.value); + + final numStyle = style.text( + TextStyle( + color: theme.primary, + fontSize: isLandscape ? 160 : 110, + fontWeight: FontWeight.w900, + height: 1.0, + shadows: style.glow(), + ), + ); + final millisStyle = style.text( + TextStyle( + color: theme.accent, + fontSize: isLandscape ? 70 : 48, + fontWeight: FontWeight.w700, + height: 1.0, + shadows: style.glow(), + ), + ); + + return Semantics( + container: true, + label: + 'Stopwatch. ${parts.full}, ${running ? 'running' : 'stopped'}. ' + '${laps.length} laps recorded.', + onTap: controller.toggle, + child: ExcludeSemantics( + child: FittedBox( + fit: BoxFit.contain, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + running ? 'PRECISION STOPWATCH' : 'STOPWATCH (TAP TO RUN)', + style: style.text( + TextStyle( + color: theme.accent, + fontSize: 16, + letterSpacing: 4, + fontWeight: FontWeight.w800, + ), + ), + ), + const SizedBox(height: 10), + GestureDetector( + onTap: controller.toggle, + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + Text(parts.minutesSeconds, style: numStyle), + Text('.${parts.hundredths}', style: millisStyle), + ], + ), + ), + if (laps.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 16.0), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'LAST LAP: ${splitStopwatch(laps.first).full}', + style: TextStyle( + color: theme.secondary, + fontSize: 13, + letterSpacing: 1.5, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(width: 12), + Text( + '(${laps.length} LAPS)', + style: TextStyle( + color: theme.accent, + fontSize: 11, + letterSpacing: 1, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/views/timer_view.dart b/lib/views/timer_view.dart new file mode 100644 index 0000000..110d50c --- /dev/null +++ b/lib/views/timer_view.dart @@ -0,0 +1,96 @@ +import 'package:flutter/material.dart'; + +import '../logic/time_logic.dart'; +import 'display_style.dart'; + +/// Focus countdown timer (Pomodoro etc.). +class TimerView extends StatelessWidget { + const TimerView({super.key, required this.style, required this.controller}); + + final DisplayStyle style; + final FocusTimerController controller; + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: Listenable.merge([controller.remaining, controller.running]), + builder: (context, _) => _build(), + ); + } + + Widget _build() { + final theme = style.theme; + final isLandscape = style.isLandscape; + final running = controller.running.value; + final secs = controller.remaining.value; + + final numStyle = style.text( + TextStyle( + color: running ? theme.primary : theme.accent, + fontSize: isLandscape ? 170 : 130, + fontWeight: FontWeight.w900, + height: 1.0, + shadows: style.glow(), + ), + ); + + final minutesPart = two(secs ~/ 60); + final secondsPart = two(secs % 60); + + return Semantics( + container: true, + label: + 'Focus timer. $minutesPart minutes $secondsPart seconds remaining, ' + '${running ? 'running' : 'paused'}.', + onTap: controller.toggle, + child: ExcludeSemantics( + child: FittedBox( + fit: BoxFit.contain, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + running ? 'FOCUS SESSION' : 'PAUSED (TAP TO START)', + style: style.text( + TextStyle( + color: theme.accent, + fontSize: 16, + letterSpacing: 4, + fontWeight: FontWeight.w800, + ), + ), + ), + const SizedBox(height: 10), + GestureDetector( + onTap: controller.toggle, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(minutesPart, style: numStyle), + Text( + ':', + style: numStyle.copyWith( + color: running ? theme.accent : theme.secondary, + ), + ), + Text(secondsPart, style: numStyle), + ], + ), + ), + const SizedBox(height: 16), + SizedBox( + width: isLandscape ? 500 : 300, + height: 4, + child: LinearProgressIndicator( + value: controller.progress, + backgroundColor: theme.border.withValues(alpha: 0.3), + valueColor: AlwaysStoppedAnimation(theme.primary), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/widgets/design_studio_sheet.dart b/lib/widgets/design_studio_sheet.dart new file mode 100644 index 0000000..a4b5153 --- /dev/null +++ b/lib/widgets/design_studio_sheet.dart @@ -0,0 +1,505 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import '../services/settings_store.dart'; +import '../theme/clock_font.dart'; +import '../theme/clock_theme.dart'; + +/// DESIGN STUDIO MODAL SHEET +class DesignStudioSheet extends StatefulWidget { + /// Live settings; the sheet rebuilds whenever they change. + final ValueListenable settings; + + /// Applies a change to the app-wide settings. + final void Function(AppSettings Function(AppSettings current) change) + onUpdate; + final void Function(DateTime dt, String title) onSetCountdownTarget; + final ValueChanged onSetTimerDuration; + + const DesignStudioSheet({ + super.key, + required this.settings, + required this.onUpdate, + required this.onSetCountdownTarget, + required this.onSetTimerDuration, + }); + + @override + State createState() => _DesignStudioSheetState(); +} + +class _DesignStudioSheetState extends State + with SingleTickerProviderStateMixin { + late TabController _tabController; + + @override + void initState() { + super.initState(); + _tabController = TabController(length: 4, vsync: this); + } + + @override + void dispose() { + _tabController.dispose(); + super.dispose(); + } + + AppSettings get _s => widget.settings.value; + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: widget.settings, + builder: (context, _, _) => _buildSheet(context), + ); + } + + Widget _buildSheet(BuildContext context) { + final activeTheme = kThemes[_s.themeIndex]; + + return Container( + height: MediaQuery.of(context).size.height * 0.75, + decoration: BoxDecoration( + color: activeTheme.surface, + border: Border(top: BorderSide(color: activeTheme.border, width: 2)), + ), + child: Column( + children: [ + // Drag handle & Header + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'DESIGN STUDIO', + style: TextStyle( + color: activeTheme.primary, + fontSize: 16, + fontWeight: FontWeight.w900, + letterSpacing: 2, + ), + ), + Text( + 'CUSTOMIZE PALETTE, TYPOGRAPHY & BEHAVIOR', + style: TextStyle( + color: activeTheme.accent, + fontSize: 10, + letterSpacing: 1, + ), + ), + ], + ), + IconButton( + tooltip: 'Close', + onPressed: () => Navigator.pop(context), + icon: Icon(Icons.close, color: activeTheme.primary), + ), + ], + ), + ), + + // Tab Bar + TabBar( + controller: _tabController, + indicatorColor: activeTheme.primary, + indicatorWeight: 3, + labelColor: activeTheme.primary, + unselectedLabelColor: activeTheme.secondary, + labelStyle: const TextStyle( + fontWeight: FontWeight.w900, + fontSize: 11, + letterSpacing: 1, + ), + tabs: const [ + Tab(text: 'PALETTES'), + Tab(text: 'TYPOGRAPHY'), + Tab(text: 'DISPLAY'), + Tab(text: 'PRESETS'), + ], + ), + + // Tab Views + Expanded( + child: TabBarView( + controller: _tabController, + children: [ + // 1. PALETTES GRID + _buildPalettesTab(activeTheme), + + // 2. TYPOGRAPHY LIST + _buildTypographyTab(activeTheme), + + // 3. DISPLAY TOGGLES + _buildDisplayTab(activeTheme), + + // 4. PRESETS + _buildPresetsTab(activeTheme), + ], + ), + ), + ], + ), + ); + } + + Widget _buildPalettesTab(ClockTheme activeTheme) { + return GridView.builder( + padding: const EdgeInsets.all(16), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + crossAxisSpacing: 10, + mainAxisSpacing: 10, + childAspectRatio: 2.2, + ), + itemCount: kThemes.length, + itemBuilder: (context, index) { + final t = kThemes[index]; + final isSelected = index == _s.themeIndex; + + return GestureDetector( + onTap: () { + widget.onUpdate((s) => s.copyWith(themeIndex: index)); + }, + child: Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: t.background, + border: Border.all( + color: isSelected ? t.primary : t.border.withValues(alpha: 0.5), + width: isSelected ? 2 : 1, + ), + ), + child: Row( + children: [ + Container(width: 14, height: double.infinity, color: t.primary), + const SizedBox(width: 4), + Container(width: 6, height: double.infinity, color: t.accent), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + t.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: t.primary, + fontWeight: FontWeight.w900, + fontSize: 12, + ), + ), + Text( + t.category.toUpperCase(), + style: TextStyle( + color: t.accent, + fontSize: 8, + letterSpacing: 1, + ), + ), + ], + ), + ), + if (isSelected) + Icon(Icons.check_circle, color: t.primary, size: 16), + ], + ), + ), + ); + }, + ); + } + + Widget _buildTypographyTab(ClockTheme activeTheme) { + return ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: kFonts.length, + itemBuilder: (context, index) { + final font = kFonts[index]; + final isSelected = index == _s.fontIndex; + + return Padding( + padding: const EdgeInsets.only(bottom: 8.0), + child: GestureDetector( + onTap: () { + widget.onUpdate((s) => s.copyWith(fontIndex: index)); + }, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: isSelected + ? activeTheme.border.withValues(alpha: 0.3) + : Colors.transparent, + border: Border.all( + color: isSelected + ? activeTheme.primary + : activeTheme.border.withValues(alpha: 0.3), + ), + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + font.name, + style: TextStyle( + color: activeTheme.primary, + fontSize: 14, + fontWeight: FontWeight.w900, + ), + ), + Text( + font.style, + style: TextStyle( + color: activeTheme.accent, + fontSize: 10, + ), + ), + ], + ), + ), + Text( + '10:45:00', + style: font.builder( + TextStyle( + color: activeTheme.primary, + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + ), + const SizedBox(width: 12), + if (isSelected) + Icon(Icons.check, color: activeTheme.primary, size: 18), + ], + ), + ), + ), + ); + }, + ); + } + + Widget _buildDisplayTab(ClockTheme activeTheme) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + _buildSwitchTile( + '12-HOUR FORMAT', + 'Display AM/PM instead of 24h military time', + _s.is12Hour, + (v) => widget.onUpdate((s) => s.copyWith(is12Hour: v)), + activeTheme, + ), + _buildSwitchTile( + 'SHOW SECONDS', + 'High-frequency seconds indicator', + _s.showSeconds, + (v) => widget.onUpdate((s) => s.copyWith(showSeconds: v)), + activeTheme, + ), + _buildSwitchTile( + 'SHOW DATE', + 'Full date & day of week above time', + _s.showDate, + (v) => widget.onUpdate((s) => s.copyWith(showDate: v)), + activeTheme, + ), + _buildSwitchTile( + 'TEXT GLOW EFFECT', + 'Atmospheric neon glow halo around numerals', + _s.enableGlow, + (v) => widget.onUpdate((s) => s.copyWith(enableGlow: v)), + activeTheme, + ), + _buildSwitchTile( + 'PULSE COLON', + 'Subtle 1Hz blinking colon separator', + _s.colonBlink, + (v) => widget.onUpdate((s) => s.copyWith(colonBlink: v)), + activeTheme, + ), + _buildSwitchTile( + 'OLED BURN-IN GUARD', + 'Imperceptible 1.5px micro-shift every 3 mins', + _s.burnInProtection, + (v) => widget.onUpdate((s) => s.copyWith(burnInProtection: v)), + activeTheme, + ), + _buildSwitchTile( + 'KEEP SCREEN ON', + 'Prevent the display from sleeping while the clock is open', + _s.keepAwake, + (v) => widget.onUpdate((s) => s.copyWith(keepAwake: v)), + activeTheme, + ), + ], + ); + } + + Widget _buildSwitchTile( + String title, + String subtitle, + bool val, + ValueChanged onChanged, + ClockTheme theme, + ) { + return Container( + margin: const EdgeInsets.only(bottom: 10), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + border: Border.all(color: theme.border.withValues(alpha: 0.4)), + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle( + color: theme.primary, + fontWeight: FontWeight.w900, + fontSize: 12, + letterSpacing: 1, + ), + ), + Text( + subtitle, + style: TextStyle(color: theme.accent, fontSize: 10), + ), + ], + ), + ), + Switch( + value: val, + onChanged: onChanged, + activeThumbColor: theme.primary, + activeTrackColor: theme.primary.withValues(alpha: 0.3), + inactiveThumbColor: theme.secondary, + inactiveTrackColor: theme.surface, + ), + ], + ), + ); + } + + Widget _buildPresetsTab(ClockTheme activeTheme) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Text( + 'TIMER PRESETS', + style: TextStyle( + color: activeTheme.primary, + fontSize: 12, + fontWeight: FontWeight.w900, + letterSpacing: 2, + ), + ), + const SizedBox(height: 10), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _buildPresetChip('5m Break', 5 * 60, activeTheme), + _buildPresetChip('15m Recess', 15 * 60, activeTheme), + _buildPresetChip('25m Pomodoro', 25 * 60, activeTheme), + _buildPresetChip('45m Deep Work', 45 * 60, activeTheme), + _buildPresetChip('60m Sprint', 60 * 60, activeTheme), + _buildPresetChip('90m Ultradian', 90 * 60, activeTheme), + ], + ), + + const SizedBox(height: 24), + Text( + 'COUNTDOWN HORIZONS', + style: TextStyle( + color: activeTheme.primary, + fontSize: 12, + fontWeight: FontWeight.w900, + letterSpacing: 2, + ), + ), + const SizedBox(height: 10), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _buildCountdownChip( + '+24 Hours', + DateTime.now().add(const Duration(hours: 24)), + 'TOMORROW', + activeTheme, + ), + _buildCountdownChip( + '+7 Days', + DateTime.now().add(const Duration(days: 7)), + 'NEXT WEEK', + activeTheme, + ), + _buildCountdownChip( + '+30 Days', + DateTime.now().add(const Duration(days: 30)), + 'ONE MONTH', + activeTheme, + ), + _buildCountdownChip( + 'New Year', + DateTime(DateTime.now().year + 1, 1, 1), + 'NEW YEAR', + activeTheme, + ), + ], + ), + ], + ); + } + + Widget _buildPresetChip(String label, int seconds, ClockTheme theme) { + return ActionChip( + label: Text(label), + backgroundColor: theme.surface, + side: BorderSide(color: theme.border), + labelStyle: TextStyle( + color: theme.primary, + fontWeight: FontWeight.bold, + fontSize: 11, + ), + onPressed: () { + widget.onSetTimerDuration(seconds); + Navigator.pop(context); + }, + ); + } + + Widget _buildCountdownChip( + String label, + DateTime dt, + String title, + ClockTheme theme, + ) { + return ActionChip( + label: Text(label), + backgroundColor: theme.surface, + side: BorderSide(color: theme.border), + labelStyle: TextStyle( + color: theme.primary, + fontWeight: FontWeight.bold, + fontSize: 11, + ), + onPressed: () { + widget.onSetCountdownTarget(dt, title); + Navigator.pop(context); + }, + ); + } +} diff --git a/lib/widgets/hud.dart b/lib/widgets/hud.dart new file mode 100644 index 0000000..b9837ad --- /dev/null +++ b/lib/widgets/hud.dart @@ -0,0 +1,361 @@ +import 'package:flutter/material.dart'; + +import '../logic/time_logic.dart'; +import '../models/time_mode.dart'; +import '../theme/clock_theme.dart'; + +BoxDecoration _chipDecoration(ClockTheme theme) => BoxDecoration( + color: theme.surface.withValues(alpha: 0.85), + border: Border.all(color: theme.border.withValues(alpha: 0.5), width: 1), +); + +ButtonStyle _squareButton(ClockTheme theme, {bool filled = false}) => + IconButton.styleFrom( + backgroundColor: filled + ? theme.primary + : theme.surface.withValues(alpha: 0.85), + shape: const RoundedRectangleBorder(), + side: filled + ? null + : BorderSide(color: theme.border.withValues(alpha: 0.5)), + ); + +/// Header that slides in when the controls are revealed. +class HudTopBar extends StatelessWidget { + const HudTopBar({ + super.key, + required this.visible, + required this.theme, + required this.mode, + required this.fontName, + required this.onOpenStudio, + required this.onNextTheme, + required this.onHide, + }); + + final bool visible; + final ClockTheme theme; + final TimeMode mode; + final String fontName; + final VoidCallback onOpenStudio; + final VoidCallback onNextTheme; + final VoidCallback onHide; + + @override + Widget build(BuildContext context) { + return AnimatedPositioned( + duration: const Duration(milliseconds: 250), + curve: Curves.easeOutCubic, + top: visible ? 16 : -80, + left: 16, + right: 16, + child: ExcludeSemantics( + excluding: !visible, + child: SafeArea( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Flexible( + child: Semantics( + button: true, + label: + 'Open design studio. Mode ${mode.name}, font $fontName', + child: GestureDetector( + onTap: onOpenStudio, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), + decoration: _chipDecoration(theme), + child: ExcludeSemantics( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + mode.name.toUpperCase(), + style: TextStyle( + color: theme.primary, + fontSize: 11, + fontWeight: FontWeight.w900, + letterSpacing: 1.5, + ), + ), + const SizedBox(width: 6), + Container(width: 4, height: 4, color: theme.accent), + const SizedBox(width: 6), + Flexible( + child: Text( + fontName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: theme.accent, + fontSize: 11, + letterSpacing: 0.5, + ), + ), + ), + ], + ), + ), + ), + ), + ), + ), + const SizedBox(width: 8), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Semantics( + button: true, + label: 'Next theme. Current theme ${theme.name}', + child: GestureDetector( + onTap: onNextTheme, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 6, + ), + decoration: _chipDecoration(theme), + child: ExcludeSemantics( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 8, + height: 8, + color: theme.primary, + ), + const SizedBox(width: 6), + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 80), + child: Text( + theme.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: theme.primary, + fontSize: 10, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + ), + ), + ), + ), + const SizedBox(width: 6), + _iconButton( + tooltip: 'Design studio', + icon: Icons.tune, + color: theme.primary, + onPressed: onOpenStudio, + ), + const SizedBox(width: 4), + _iconButton( + tooltip: 'Hide controls', + icon: Icons.fullscreen_exit, + color: theme.accent, + onPressed: onHide, + ), + ], + ), + ], + ), + ), + ), + ); + } + + Widget _iconButton({ + required String tooltip, + required IconData icon, + required Color color, + required VoidCallback onPressed, + }) { + return IconButton( + tooltip: tooltip, + onPressed: onPressed, + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 40, minHeight: 40), + style: _squareButton(theme), + icon: Icon(icon, color: color, size: 16), + ); + } +} + +/// Mode switcher and transport buttons at the bottom of the screen. +class HudBottomBar extends StatelessWidget { + const HudBottomBar({ + super.key, + required this.visible, + required this.theme, + required this.mode, + required this.timer, + required this.stopwatch, + required this.onSetMode, + }); + + final bool visible; + final ClockTheme theme; + final TimeMode mode; + final FocusTimerController timer; + final StopwatchController stopwatch; + final ValueChanged onSetMode; + + @override + Widget build(BuildContext context) { + return AnimatedPositioned( + duration: const Duration(milliseconds: 250), + curve: Curves.easeOutCubic, + bottom: visible ? 20 : -100, + left: 20, + right: 20, + child: ExcludeSemantics( + excluding: !visible, + child: SafeArea( + child: Center( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: theme.surface.withValues(alpha: 0.92), + border: Border.all(color: theme.border, width: 1.5), + boxShadow: [ + BoxShadow( + color: theme.background.withValues(alpha: 0.7), + blurRadius: 16, + offset: const Offset(0, 4), + ), + ], + ), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + _modeButton(TimeMode.clock, 'CLOCK', Icons.schedule), + const SizedBox(width: 4), + _modeButton( + TimeMode.countdown, + 'COUNTDOWN', + Icons.hourglass_top, + ), + const SizedBox(width: 4), + _modeButton(TimeMode.timer, 'FOCUS', Icons.timer), + const SizedBox(width: 4), + _modeButton(TimeMode.stopwatch, 'STOPWATCH', Icons.speed), + if (mode == TimeMode.timer) ..._timerControls(), + if (mode == TimeMode.stopwatch) ..._stopwatchControls(), + ], + ), + ), + ), + ), + ), + ), + ); + } + + List _divider() => [ + const SizedBox(width: 10), + Container(width: 1, height: 24, color: theme.border), + const SizedBox(width: 10), + ]; + + List _timerControls() => [ + ..._divider(), + ValueListenableBuilder( + valueListenable: timer.running, + builder: (context, running, _) => IconButton( + tooltip: running ? 'Pause timer' : 'Start timer', + onPressed: timer.toggle, + style: _squareButton(theme, filled: true), + icon: Icon( + running ? Icons.pause : Icons.play_arrow, + color: theme.background, + size: 18, + ), + ), + ), + const SizedBox(width: 6), + IconButton( + tooltip: 'Reset timer', + onPressed: () => timer.reset(), + style: _squareButton(theme), + icon: Icon(Icons.refresh, color: theme.primary, size: 18), + ), + ]; + + List _stopwatchControls() => [ + ..._divider(), + ValueListenableBuilder( + valueListenable: stopwatch.running, + builder: (context, running, _) => IconButton( + tooltip: running ? 'Pause stopwatch' : 'Start stopwatch', + onPressed: stopwatch.toggle, + style: _squareButton(theme, filled: true), + icon: Icon( + running ? Icons.pause : Icons.play_arrow, + color: theme.background, + size: 18, + ), + ), + ), + const SizedBox(width: 6), + IconButton( + tooltip: 'Record lap', + onPressed: stopwatch.lap, + style: _squareButton(theme), + icon: Icon(Icons.flag, color: theme.primary, size: 18), + ), + const SizedBox(width: 6), + IconButton( + tooltip: 'Reset stopwatch', + onPressed: stopwatch.reset, + style: _squareButton(theme), + icon: Icon(Icons.refresh, color: theme.primary, size: 18), + ), + ]; + + Widget _modeButton(TimeMode m, String label, IconData icon) { + final selected = mode == m; + return Semantics( + button: true, + selected: selected, + label: '$label mode', + child: GestureDetector( + onTap: () => onSetMode(m), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 12), + color: selected ? theme.primary : Colors.transparent, + child: ExcludeSemantics( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + icon, + size: 14, + color: selected ? theme.background : theme.accent, + ), + const SizedBox(width: 6), + Text( + label, + style: TextStyle( + color: selected ? theme.background : theme.primary, + fontSize: 10, + fontWeight: FontWeight.w900, + letterSpacing: 1, + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index bfdb9e3..d60f185 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -81,6 +81,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.4" + dbus: + dependency: transitive + description: + name: dbus + sha256: f7db8504e715b835f5ebe5fe74604ccd961d504c73389a5d8e81bafc2e2f3612 + url: "https://pub.dev" + source: hosted + version: "0.8.0" fake_async: dependency: transitive description: @@ -97,6 +105,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + ffi_leak_tracker: + dependency: transitive + description: + name: ffi_leak_tracker + sha256: "4093d4ef9ca06ffe2786e73bfb25e22aa92112b9bb4ec941f11e3e6b61489a97" + url: "https://pub.dev" + source: hosted + version: "0.1.2" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" flutter: dependency: "direct main" description: flutter @@ -120,6 +144,11 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" google_fonts: dependency: "direct main" description: @@ -272,6 +301,22 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.0" + package_info_plus: + dependency: transitive + description: + name: package_info_plus + sha256: "73ef2cd5c11a7548f9b7f6fc2a4f6113de2e64aa2a70de63feede95548822908" + url: "https://pub.dev" + source: hosted + version: "10.2.2" + package_info_plus_platform_interface: + dependency: transitive + description: + name: package_info_plus_platform_interface + sha256: db762cb2f4f25ee60fb6359773861b0f199e00b90d237bd85a76a1e806b46ef4 + url: "https://pub.dev" + source: hosted + version: "4.1.0" path: dependency: transitive description: @@ -328,6 +373,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.0" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "2f1564aadb14215e478563b5406cb80729d022158c01dc50c96013c2f7eb4790" + url: "https://pub.dev" + source: hosted + version: "7.1.0" platform: dependency: transitive description: @@ -360,6 +413,62 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: "1e85e0dccf5b4a016077fea7dfbb4a7cb39c759ac1596fcf60c06deadb7da91f" + url: "https://pub.dev" + source: hosted + version: "2.5.6" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "33b38b07d9ac7d16aff0426c61418752fd3e7a8c7dc8be786c875d6ce673b002" + url: "https://pub.dev" + source: hosted + version: "2.5.4" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "4e396ac6f8751ab7d2d6e27f22f32c8350b07006028d534f5221503382f1fa69" + url: "https://pub.dev" + source: hosted + version: "2.6.1" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" sky_engine: dependency: transitive description: flutter @@ -437,6 +546,22 @@ packages: url: "https://pub.dev" source: hosted version: "15.4.0" + wakelock_plus: + dependency: "direct main" + description: + name: wakelock_plus + sha256: "39a1d99085cf3190d41305bca05f0c0d77522f355f7f99bb648ee231192d9d68" + url: "https://pub.dev" + source: hosted + version: "1.8.2" + wakelock_plus_platform_interface: + dependency: transitive + description: + name: wakelock_plus_platform_interface + sha256: "764c25504562abc8ac3406f5d175f126b276e6ba916c7c2675690b73368ca384" + url: "https://pub.dev" + source: hosted + version: "1.7.0" web: dependency: transitive description: @@ -445,6 +570,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + win32: + dependency: transitive + description: + name: win32 + sha256: a0b93865d5644f11cf6a8c3f6db909f1ec168958b5805f6cc684adea957cd63d + url: "https://pub.dev" + source: hosted + version: "6.4.0" xdg_directories: dependency: transitive description: @@ -453,6 +586,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: "0fe8cc98946def31cbcbc8734aa4067031e6f65ea3a78a508060bfed2200bfeb" + url: "https://pub.dev" + source: hosted + version: "7.1.0" yaml: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 2f6b5af..85456eb 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,91 +1,26 @@ name: time_app -description: "A new Flutter project." -# The following line prevents the package from being accidentally published to -# pub.dev using `flutter pub publish`. This is preferred for private packages. -publish_to: 'none' # Remove this line if you wish to publish to pub.dev +description: "ChronoCraft - a full-screen clock, countdown, focus timer and stopwatch for Android." +publish_to: 'none' -# The following defines the version and build number for your application. -# A version number is three numbers separated by dots, like 1.2.43 -# followed by an optional build number separated by a +. -# Both the version and the builder number may be overridden in flutter -# build by specifying --build-name and --build-number, respectively. -# In Android, build-name is used as versionName while build-number used as versionCode. -# Read more about Android versioning at https://developer.android.com/studio/publish/versioning -# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. -# Read more about iOS versioning at -# 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.0+1 +version: 2.1.0+3 environment: sdk: ^3.11.5 -# Dependencies specify other packages that your package needs in order to work. -# To automatically upgrade your package dependencies to the latest versions -# consider running `flutter pub upgrade --major-versions`. Alternatively, -# dependencies can be manually updated by changing the version numbers below to -# the latest version available on pub.dev. To see which dependencies have newer -# versions available, run `flutter pub outdated`. dependencies: flutter: sdk: flutter - # The following adds the Cupertino Icons font to your application. - # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.8 intl: ^0.20.3 google_fonts: ^9.0.0 + shared_preferences: ^2.5.6 + wakelock_plus: ^1.8.2 dev_dependencies: flutter_test: sdk: flutter - - # The "flutter_lints" package below contains a set of recommended lints to - # encourage good coding practices. The lint set provided by the package is - # activated in the `analysis_options.yaml` file located at the root of your - # package. See that file for information about deactivating specific lint - # rules and activating additional ones. flutter_lints: ^6.0.0 -# For information on the generic Dart part of this file, see the -# following page: https://dart.dev/tools/pub/pubspec - -# The following section is specific to Flutter packages. flutter: - - # The following line ensures that the Material Icons font is - # included with your application, so that you can use the icons in - # the material Icons class. uses-material-design: true - - # To add assets to your application, add an assets section, like this: - # assets: - # - images/a_dot_burr.jpeg - # - images/a_dot_ham.jpeg - - # An image asset can refer to one or more resolution-specific "variants", see - # https://flutter.dev/to/resolution-aware-images - - # For details regarding adding assets from package dependencies, see - # https://flutter.dev/to/asset-from-package - - # To add custom fonts to your application, add a fonts section here, - # in this "flutter" section. Each entry in this list should have a - # "family" key with the font family name, and a "fonts" key with a - # list giving the asset and other descriptors for the font. For - # example: - # fonts: - # - family: Schyler - # fonts: - # - asset: fonts/Schyler-Regular.ttf - # - asset: fonts/Schyler-Italic.ttf - # style: italic - # - family: Trajan Pro - # fonts: - # - asset: fonts/TrajanPro.ttf - # - asset: fonts/TrajanPro_Bold.ttf - # weight: 700 - # - # For details regarding fonts from package dependencies, - # see https://flutter.dev/to/font-from-package diff --git a/test/settings_store_test.dart b/test/settings_store_test.dart new file mode 100644 index 0000000..bf41619 --- /dev/null +++ b/test/settings_store_test.dart @@ -0,0 +1,70 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:time_app/models/time_mode.dart'; +import 'package:time_app/services/settings_store.dart'; +import 'package:time_app/theme/clock_theme.dart'; + +void main() { + Future storeWith(Map values) async { + SharedPreferences.setMockInitialValues(values); + return SettingsStore.open(); + } + + test('returns defaults when nothing is saved', () async { + final now = DateTime(2026, 1, 1); + final s = (await storeWith({})).load(now); + final d = AppSettings.defaults(now); + expect(s.mode, d.mode); + expect(s.themeIndex, 0); + expect(s.keepAwake, isTrue); + expect(s.countdownTarget, d.countdownTarget); + expect(s.timerTotalSeconds, 25 * 60); + }); + + test('round-trips every field', () async { + final store = await storeWith({}); + final saved = AppSettings.defaults().copyWith( + mode: TimeMode.stopwatch, + themeIndex: 3, + fontIndex: 2, + is12Hour: true, + showSeconds: false, + showDate: false, + enableGlow: false, + colonBlink: false, + burnInProtection: false, + keepAwake: false, + countdownTarget: DateTime(2030, 5, 6, 7, 8), + countdownTitle: 'EXAM', + timerTotalSeconds: 45 * 60, + ); + await store.save(saved); + final loaded = store.load(); + expect(loaded.mode, TimeMode.stopwatch); + expect(loaded.themeIndex, 3); + expect(loaded.fontIndex, 2); + expect(loaded.is12Hour, isTrue); + expect(loaded.showSeconds, isFalse); + expect(loaded.showDate, isFalse); + expect(loaded.enableGlow, isFalse); + expect(loaded.colonBlink, isFalse); + expect(loaded.burnInProtection, isFalse); + expect(loaded.keepAwake, isFalse); + expect(loaded.countdownTarget, DateTime(2030, 5, 6, 7, 8)); + expect(loaded.countdownTitle, 'EXAM'); + expect(loaded.timerTotalSeconds, 45 * 60); + }); + + test('ignores out-of-range indices and bad durations', () async { + final s = (await storeWith({ + 'themeIndex': kThemes.length + 5, + 'fontIndex': -1, + 'mode': 99, + 'timerTotalSeconds': 0, + })).load(); + expect(s.themeIndex, 0); + expect(s.fontIndex, 0); + expect(s.mode, TimeMode.clock); + expect(s.timerTotalSeconds, 25 * 60); + }); +} diff --git a/test/time_logic_test.dart b/test/time_logic_test.dart new file mode 100644 index 0000000..ddff2f4 --- /dev/null +++ b/test/time_logic_test.dart @@ -0,0 +1,167 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:time_app/logic/time_logic.dart'; + +void main() { + group('countdownParts', () { + final now = DateTime(2026, 1, 1, 12); + + test('splits a future target into units', () { + final p = countdownParts( + now.add(const Duration(days: 3, hours: 4, minutes: 5, seconds: 6)), + now, + ); + expect([p.days, p.hours, p.minutes, p.seconds], [3, 4, 5, 6]); + expect(p.expired, isFalse); + }); + + test('reports expired at and after the target', () { + expect(countdownParts(now, now).expired, isTrue); + final past = countdownParts(now.subtract(const Duration(hours: 1)), now); + expect(past.expired, isTrue); + expect([past.days, past.hours, past.minutes, past.seconds], [0, 0, 0, 0]); + }); + }); + + group('formatting', () { + test('formatTimer pads minutes and seconds', () { + expect(formatTimer(0), '00:00'); + expect(formatTimer(25 * 60), '25:00'); + expect(formatTimer(61), '01:01'); + }); + + test('splitStopwatch shows hundredths', () { + final p = splitStopwatch(61 * 1000 + 450); + expect(p.minutesSeconds, '01:01'); + expect(p.hundredths, '45'); + expect(p.full, '01:01.45'); + }); + + test('dayProgress spans the local day', () { + expect(dayProgress(DateTime(2026, 1, 1)), 0); + expect(dayProgress(DateTime(2026, 1, 1, 12)), 0.5); + }); + }); + + group('FocusTimerController', () { + late DateTime clock; + late int finished; + late FocusTimerController c; + + setUp(() { + clock = DateTime(2026, 1, 1, 9); + finished = 0; + c = FocusTimerController( + totalSeconds: 60, + now: () => clock, + onFinished: () => finished++, + ); + }); + tearDown(() => c.dispose()); + + test('counts down from the wall clock, not from tick count', () { + c.toggle(); + expect(c.running.value, isTrue); + clock = clock.add(const Duration(seconds: 10, milliseconds: 100)); + c.tick(); + expect(c.remaining.value, 50); + }); + + test('catches up after a long gap (app was backgrounded)', () { + c.toggle(); + clock = clock.add(const Duration(seconds: 45)); + c.tick(); + expect(c.remaining.value, 15); + }); + + test('pause keeps remaining time and resume continues from it', () { + c.toggle(); + clock = clock.add(const Duration(seconds: 20)); + c.tick(); + c.toggle(); + expect(c.running.value, isFalse); + clock = clock.add(const Duration(minutes: 5)); + c.tick(); + expect(c.remaining.value, 40); + c.toggle(); + clock = clock.add(const Duration(seconds: 10)); + c.tick(); + expect(c.remaining.value, 30); + }); + + test('finishes exactly once and stops', () { + c.toggle(); + clock = clock.add(const Duration(seconds: 61)); + c.tick(); + expect(c.remaining.value, 0); + expect(c.running.value, isFalse); + expect(finished, 1); + c.tick(); + expect(finished, 1); + }); + + test('starting a finished timer restarts from the full duration', () { + c.toggle(); + clock = clock.add(const Duration(seconds: 60)); + c.tick(); + c.toggle(); + expect(c.remaining.value, 60); + expect(c.running.value, isTrue); + }); + + test('reset with a new duration', () { + c.reset(300); + expect(c.totalSeconds, 300); + expect(c.remaining.value, 300); + expect(c.running.value, isFalse); + expect(c.progress, 1.0); + }); + }); + + group('StopwatchController', () { + late DateTime clock; + late StopwatchController c; + + setUp(() { + clock = DateTime(2026, 1, 1); + c = StopwatchController(now: () => clock); + }); + tearDown(() => c.dispose()); + + test('measures elapsed time and resumes after pause', () { + c.toggle(); + clock = clock.add(const Duration(milliseconds: 1500)); + c.tick(); + expect(c.elapsedMs.value, 1500); + c.toggle(); + clock = clock.add(const Duration(seconds: 30)); + c.toggle(); + clock = clock.add(const Duration(milliseconds: 500)); + c.tick(); + expect(c.elapsedMs.value, 2000); + }); + + test('records laps newest first, only while running', () { + c.lap(); + expect(c.laps.value, isEmpty); + c.toggle(); + clock = clock.add(const Duration(seconds: 1)); + c.tick(); + c.lap(); + clock = clock.add(const Duration(seconds: 1)); + c.tick(); + c.lap(); + expect(c.laps.value, [2000, 1000]); + }); + + test('reset clears time and laps', () { + c.toggle(); + clock = clock.add(const Duration(seconds: 1)); + c.tick(); + c.lap(); + c.reset(); + expect(c.running.value, isFalse); + expect(c.elapsedMs.value, 0); + expect(c.laps.value, isEmpty); + }); + }); +} diff --git a/test/widget_test.dart b/test/widget_test.dart index 915129e..9781d6c 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -1,12 +1,22 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import 'package:time_app/main.dart'; +import 'package:time_app/models/time_mode.dart'; +import 'package:time_app/services/settings_store.dart'; +import 'package:time_app/theme/clock_theme.dart'; void main() { - testWidgets('ChronoCraft App smoke test and HUD toggle test', (WidgetTester tester) async { + void usePhoneScreen(WidgetTester tester) { tester.view.physicalSize = const Size(1080, 1920); tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + } + + testWidgets('ChronoCraft App smoke test and mode cycling', (tester) async { + usePhoneScreen(tester); await tester.pumpWidget(const ChronoCraftApp()); await tester.pump(const Duration(milliseconds: 100)); @@ -19,17 +29,93 @@ void main() { await tester.pump(const Duration(milliseconds: 100)); expect(find.text('PROJECT LAUNCH'), findsOneWidget); - // Switch to TIMER mode await tester.sendKeyEvent(LogicalKeyboardKey.keyM); await tester.pump(const Duration(milliseconds: 100)); expect(find.textContaining('FOCUS'), findsWidgets); - // Switch to STOPWATCH mode await tester.sendKeyEvent(LogicalKeyboardKey.keyM); await tester.pump(const Duration(milliseconds: 100)); expect(find.textContaining('STOPWATCH'), findsWidgets); + }); - // Reset physical size - addTearDown(tester.view.resetPhysicalSize); + testWidgets('focus timer starts and pauses from the keyboard', ( + tester, + ) async { + usePhoneScreen(tester); + await tester.pumpWidget(const ChronoCraftApp()); + await tester.sendKeyEvent(LogicalKeyboardKey.keyM); + await tester.sendKeyEvent(LogicalKeyboardKey.keyM); + await tester.pump(); + expect(find.text('PAUSED (TAP TO START)'), findsOneWidget); + expect(find.text('25'), findsOneWidget); + + await tester.sendKeyEvent(LogicalKeyboardKey.space); + await tester.pump(); + expect(find.text('FOCUS SESSION'), findsOneWidget); + + await tester.sendKeyEvent(LogicalKeyboardKey.space); + await tester.pump(); + expect(find.text('PAUSED (TAP TO START)'), findsOneWidget); + }); + + testWidgets('exposes the time to screen readers', (tester) async { + usePhoneScreen(tester); + final handle = tester.ensureSemantics(); + await tester.pumpWidget(const ChronoCraftApp()); + await tester.pump(const Duration(milliseconds: 100)); + expect(find.bySemanticsLabel(RegExp(r'^Clock\. ')), findsOneWidget); + handle.dispose(); + }); + + testWidgets('long press opens the design studio and selection updates live', ( + tester, + ) async { + usePhoneScreen(tester); + await tester.pumpWidget(const ChronoCraftApp()); + await tester.longPress(find.byType(FittedBox).first); + await tester.pumpAndSettle(); + expect(find.text('DESIGN STUDIO'), findsOneWidget); + + // First palette is selected; choose the second and see the check move. + expect(find.byIcon(Icons.check_circle), findsOneWidget); + await tester.tap(find.text(kThemes[1].name)); + await tester.pumpAndSettle(); + expect(find.byIcon(Icons.check_circle), findsOneWidget); + final checkedTile = find.ancestor( + of: find.byIcon(Icons.check_circle), + matching: find.byType(Row), + ); + expect( + find.descendant( + of: checkedTile.first, + matching: find.text(kThemes[1].name), + ), + findsOneWidget, + ); + }); + + testWidgets('settings are restored on launch and saved on change', ( + tester, + ) async { + usePhoneScreen(tester); + SharedPreferences.setMockInitialValues({ + 'mode': TimeMode.countdown.index, + 'countdownTitle': 'EXAM DAY', + 'themeIndex': 2, + }); + final store = await SettingsStore.open(); + + await tester.pumpWidget( + ChronoCraftApp(store: store, initialSettings: store.load()), + ); + await tester.pump(); + expect(find.text('EXAM DAY'), findsOneWidget); + + await tester.sendKeyEvent(LogicalKeyboardKey.keyT); + await tester.pump(); + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 50)), + ); + expect(store.load().themeIndex, 3); }); }