diff --git a/lib/app.dart b/lib/app.dart index 570cb41c7..5a3eb58a2 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -10,6 +10,7 @@ import 'l10n/app_localizations.dart'; import 'notify/notification_service.dart'; import 'notify/tap_router.dart'; import 'state/app_state.dart'; +import 'state/clock_format.dart'; import 'state/locale_controller.dart'; import 'state/prefs.dart'; import 'telemetry/telemetry_service.dart'; @@ -122,6 +123,8 @@ class _OpenStrapAppState extends State with WidgetsBindingObserver Widget build(BuildContext context) { final theme = context.watch(); final locale = context.watch(); + final twelveHour = !context.watch().resolve24h( + MediaQuery.alwaysUse24HourFormatOf(context)); return MaterialApp( title: 'OpenStrap', debugShowCheckedModeBanner: false, @@ -130,7 +133,11 @@ class _OpenStrapAppState extends State with WidgetsBindingObserver darkTheme: buildTheme(Brightness.dark), themeMode: theme.materialThemeMode, locale: locale.locale, // null = follow the OS locale - localizationsDelegates: AppLocalizations.localizationsDelegates, + // First, so a 12-hour choice reaches the time pickers (see the delegate). + localizationsDelegates: [ + ClockMaterialLocalizationsDelegate(twelveHour: twelveHour), + ...AppLocalizations.localizationsDelegates, + ], supportedLocales: AppLocalizations.supportedLocales, // Runs even when `locale:` above has a user override — Flutter still // calls this callback, just with [locale.locale] as the sole @@ -152,14 +159,56 @@ class _OpenStrapAppState extends State with WidgetsBindingObserver } return const Locale('en'); }, - builder: (context, child) => - ThemeSwitchOverlay(key: themeSwitchKey, child: child!), + builder: (context, child) => _ClockScope( + child: ThemeSwitchOverlay(key: themeSwitchKey, child: child!)), navigatorObservers: [TelemetryNavigatorObserver()], home: const _Gate(), ); } } +/// Applies the 12/24-hour choice: overrides MediaQuery for time pickers (a +/// 12-hour dial in de/es/fr also needs [ClockMaterialLocalizationsDelegate]), +/// hands the locale's AM/PM text to `formatClock*`, and rebuilds everything +/// below once when either flips, since those context-free helpers can't +/// register a dependency. +class _ClockScope extends StatefulWidget { + const _ClockScope({required this.child}); + final Widget child; + + @override + State<_ClockScope> createState() => _ClockScopeState(); +} + +class _ClockScopeState extends State<_ClockScope> { + (bool, Locale)? _last; + + @override + Widget build(BuildContext context) { + final use24 = context + .watch() + .resolve24h(MediaQuery.alwaysUse24HourFormatOf(context)); + bindClockLocalizations(MaterialLocalizations.of(context)); + final now = (use24, Localizations.localeOf(context)); + if (_last != null && _last != now) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + void mark(Element e) { + e.markNeedsBuild(); + e.visitChildren(mark); + } + + (context as Element).visitChildren(mark); + }); + } + _last = now; + return MediaQuery( + data: MediaQuery.of(context).copyWith(alwaysUse24HourFormat: use24), + child: widget.child, + ); + } +} + // ══════════════════ THE ONBOARDING GATE ══════════════════ /// The gate, as a pure function of its inputs. Onboarding order bugs are diff --git a/lib/coach/coach_actions.dart b/lib/coach/coach_actions.dart index efe7e766e..0526e7eeb 100644 --- a/lib/coach/coach_actions.dart +++ b/lib/coach/coach_actions.dart @@ -443,7 +443,7 @@ class CoachActions { ], 'today': [ for (final s in slots) - {'name': s.def.label, 'time': s.timeLabel, 'state': s.state.name}, + {'name': s.def.label, 'time': s.timeMachine, 'state': s.state.name}, ], }); } diff --git a/lib/data/journal_fields.dart b/lib/data/journal_fields.dart index f1f3021a6..c9db701cf 100644 --- a/lib/data/journal_fields.dart +++ b/lib/data/journal_fields.dart @@ -15,6 +15,8 @@ import 'dart:math' as math; import 'package:flutter/foundation.dart'; +import '../state/clock_format.dart'; + /// What a field measures, which decides how it is entered and read back. enum JournalFieldKind { /// A subjective 1–5 self-report (mood, sleep quality). Ordinal: the gap @@ -305,11 +307,6 @@ String customJournalFieldKey(String label) { return 'custom_$slug'; } -/// Local minutes past midnight → "7:05 AM". -String formatMinuteOfDay(int minuteOfDay) { - final m = minuteOfDay % (24 * 60); - final h24 = m ~/ 60; - final mm = (m % 60).toString().padLeft(2, '0'); - final h = h24 % 12 == 0 ? 12 : h24 % 12; - return '$h:$mm ${h24 < 12 ? 'AM' : 'PM'}'; -} +/// Local minutes past midnight → "7:05 AM" or "07:05", per the user's clock +/// format ([formatClockMinute]). +String formatMinuteOfDay(int minuteOfDay) => formatClockMinute(minuteOfDay); diff --git a/lib/data/med_store.dart b/lib/data/med_store.dart index b94cf3591..673032c37 100644 --- a/lib/data/med_store.dart +++ b/lib/data/med_store.dart @@ -18,6 +18,7 @@ import 'dart:convert'; import 'package:sqflite/sqflite.dart'; +import '../state/clock_format.dart' show formatClockMinute; import 'day_label.dart'; // ══════════════════ SCHEMA ══════════════════ @@ -165,10 +166,14 @@ class MedSlot { final int slotMin; final DoseState state; - String get timeLabel { - final h = (slotMin ~/ 60).toString().padLeft(2, '0'); - final m = (slotMin % 60).toString().padLeft(2, '0'); - return '$h:$m'; + /// Display only, per the user's clock format — never a storage key. + String get timeLabel => formatClockMinute(slotMin); + + /// `HH:mm` for payloads and the coach, whatever the clock format. + String get timeMachine { + final h = slotMin ~/ 60; + final m = slotMin % 60; + return '${h.toString().padLeft(2, '0')}:${m.toString().padLeft(2, '0')}'; } /// A slot that has passed and was neither taken nor deliberately skipped. diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index e1c2e6125..8031361c4 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -2147,6 +2147,10 @@ "settingsGroupPreferences": "Einstellungen", "settingsUnitsRowTitle": "Einheiten", "settingsAppearanceRowTitle": "Erscheinungsbild", + "settingsClockFormatRowTitle": "Uhrzeitformat", + "settingsClockFormatSystem": "System", + "settingsClockFormat24h": "24 Stunden", + "settingsClockFormat12h": "12 Stunden", "settingsCycleTrackingRowTitle": "Zyklus-Tracking", "settingsCycleTrackingRowSub": "Fügt den Tab Zyklus zu Wellness hinzu. Aus blendet ihn aus und behält alles bereits Erfasste", "settingsGroupYourData": "Deine Daten", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index ae97e419e..ed145a1fe 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -11253,6 +11253,22 @@ "@settingsAppearanceRowTitle": { "description": "Settings row title cycling the app theme" }, + "settingsClockFormatRowTitle": "Time format", + "@settingsClockFormatRowTitle": { + "description": "Settings row title cycling the 12/24-hour clock format" + }, + "settingsClockFormatSystem": "System", + "@settingsClockFormatSystem": { + "description": "Clock format value: follow the operating system setting" + }, + "settingsClockFormat24h": "24-hour", + "@settingsClockFormat24h": { + "description": "Clock format value: 24-hour clock (e.g. 19:30)" + }, + "settingsClockFormat12h": "12-hour", + "@settingsClockFormat12h": { + "description": "Clock format value: 12-hour clock with AM/PM (e.g. 7:30 PM)" + }, "settingsCycleTrackingRowTitle": "Cycle tracking", "@settingsCycleTrackingRowTitle": { "description": "Settings row title toggling cycle tracking" diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 8612c4529..c4fa8e8f6 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -2159,6 +2159,10 @@ "settingsGroupPreferences": "Preferencias", "settingsUnitsRowTitle": "Unidades", "settingsAppearanceRowTitle": "Apariencia", + "settingsClockFormatRowTitle": "Formato de hora", + "settingsClockFormatSystem": "Sistema", + "settingsClockFormat24h": "24 horas", + "settingsClockFormat12h": "12 horas", "settingsCycleTrackingRowTitle": "Seguimiento del ciclo", "settingsCycleTrackingRowSub": "Agrega la pestaña Ciclo a Bienestar. Desactivado la oculta y conserva todo lo ya registrado", "settingsGroupYourData": "Tus datos", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index c760566d0..e85fbd106 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -2147,6 +2147,10 @@ "settingsGroupPreferences": "Préférences", "settingsUnitsRowTitle": "Unités", "settingsAppearanceRowTitle": "Apparence", + "settingsClockFormatRowTitle": "Format de l’heure", + "settingsClockFormatSystem": "Système", + "settingsClockFormat24h": "24 heures", + "settingsClockFormat12h": "12 heures", "settingsCycleTrackingRowTitle": "Suivi du cycle", "settingsCycleTrackingRowSub": "Ajoute l’onglet Cycle à Bien-être. Désactivé le masque et conserve tout ce qui est déjà enregistré", "settingsGroupYourData": "Vos données", diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index a41b9a60b..5baa5637c 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -2147,6 +2147,10 @@ "settingsGroupPreferences": "प्राथमिकताएं", "settingsUnitsRowTitle": "इकाइयां", "settingsAppearanceRowTitle": "रूप", + "settingsClockFormatRowTitle": "समय प्रारूप", + "settingsClockFormatSystem": "सिस्टम", + "settingsClockFormat24h": "24 घंटे", + "settingsClockFormat12h": "12 घंटे", "settingsCycleTrackingRowTitle": "चक्र ट्रैकिंग", "settingsCycleTrackingRowSub": "वेलनेस में साइकिल टैब जोड़ता है। बंद करने पर यह छिप जाता है और पहले से लॉग किया गया सब कुछ बना रहता है", "settingsGroupYourData": "आपका डेटा", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index f31d7ab86..46847d602 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -2147,6 +2147,10 @@ "settingsGroupPreferences": "偏好设置", "settingsUnitsRowTitle": "单位", "settingsAppearanceRowTitle": "外观", + "settingsClockFormatRowTitle": "时间格式", + "settingsClockFormatSystem": "跟随系统", + "settingsClockFormat24h": "24 小时制", + "settingsClockFormat12h": "12 小时制", "settingsCycleTrackingRowTitle": "周期追踪", "settingsCycleTrackingRowSub": "在“健康”中添加“周期”标签页。关闭后会隐藏该标签页,但已记录的内容仍会保留", "settingsGroupYourData": "你的数据", diff --git a/lib/main.dart b/lib/main.dart index 421ba5b50..7ffdd00f4 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -9,6 +9,7 @@ import 'notify/notification_service.dart'; import 'coach/coach_config.dart'; import 'state/app_state.dart'; import 'state/prefs.dart'; +import 'state/clock_format.dart'; import 'state/locale_controller.dart'; import 'state/units_controller.dart'; import 'sync/headless_boot.dart'; @@ -160,6 +161,16 @@ Future main() async { units = UnitsController.seed(UnitSystem.metric); } + // Local 12/24-hour clock preference. Best-effort; defaults to the OS setting. + // The timeout lives inside bootstrap() so a late load can't replace the seed. + ClockFormatController clockFormat; + try { + clockFormat = await ClockFormatController.bootstrap(timeout: _kStartupInitTimeout); + } catch (e, st) { + debugPrint('[main] ClockFormatController.bootstrap failed, using system: $e\n$st'); + clockFormat = ClockFormatController.seed(ClockFormat.system); + } + // Local language override (null = system default). LocaleController locale; try { @@ -189,6 +200,7 @@ Future main() async { ChangeNotifierProvider(create: (_) => AppState(), lazy: false), ChangeNotifierProvider.value(value: theme), ChangeNotifierProvider.value(value: units), + ChangeNotifierProvider.value(value: clockFormat), ChangeNotifierProvider.value(value: locale), ChangeNotifierProvider.value(value: coachConfig), ], diff --git a/lib/notify/battery_forecast.dart b/lib/notify/battery_forecast.dart index 0cf5fbdd1..77ee54ed3 100644 --- a/lib/notify/battery_forecast.dart +++ b/lib/notify/battery_forecast.dart @@ -25,6 +25,8 @@ import 'dart:math' as math; +import '../state/clock_format.dart' show formatClockOf; + /// One battery observation from `band_battery`. class BatterySample { const BatterySample({ @@ -355,9 +357,8 @@ class BatteryForecaster { final pct = f.currentPct; final empty = f.predictedEmptyAt; if (rate == null || pct == null || empty == null) return ''; - final h = empty.hour.toString().padLeft(2, '0'); - final m = empty.minute.toString().padLeft(2, '0'); - final head = 'At ${rate.toStringAsFixed(1)}%/h it runs out around $h:$m'; + final head = 'At ${rate.toStringAsFixed(1)}%/h it runs out around ' + '${formatClockOf(empty)}'; if (empty.isBefore(wakeAt)) { return '$head — before you wake. Charge it now to keep tonight\'s sleep.'; } diff --git a/lib/notify/notification_center.dart b/lib/notify/notification_center.dart index 210fcd64b..cc96b18f1 100644 --- a/lib/notify/notification_center.dart +++ b/lib/notify/notification_center.dart @@ -33,6 +33,7 @@ import '../ai/reminder_plan.dart'; import '../data/day_label.dart'; import '../data/journal_fields.dart'; import '../data/med_store.dart'; +import '../state/clock_format.dart' show formatClockMinute; import 'fired_keys.dart'; import 'notification_event.dart'; import 'notification_prefs.dart'; @@ -531,12 +532,8 @@ class NotificationCenter { return t; } - /// Two-digit HH:MM from minutes-past-midnight (notification bodies). - static String _hhmm(int minuteOfDay) { - final m = minuteOfDay % 1440; - return '${(m ~/ 60).toString().padLeft(2, '0')}:' - '${(m % 60).toString().padLeft(2, '0')}'; - } + /// Minutes-past-midnight → the user's clock format (notification bodies). + static String _hhmm(int minuteOfDay) => formatClockMinute(minuteOfDay); // ── the weekly lookback finding ───────────────────────────────────────── diff --git a/lib/state/clock_format.dart b/lib/state/clock_format.dart new file mode 100644 index 000000000..7e4dabae4 --- /dev/null +++ b/lib/state/clock_format.dart @@ -0,0 +1,180 @@ +// Clock format: a local display preference (system / 24-hour / 12-hour), +// persisted like UnitsController. Display only; stored, exported and coach +// values stay `HH:mm`. The formatters are context-free and read the last +// controller built, falling back to the OS setting when there is none. + +import 'dart:ui' show PlatformDispatcher; + +import 'package:flutter/foundation.dart' show SynchronousFuture; +import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:intl/intl.dart' as intl; +import 'package:shared_preferences/shared_preferences.dart'; + +enum ClockFormat { system, h24, h12 } + +extension ClockFormatLabel on ClockFormat { + String get label => switch (this) { + ClockFormat.system => 'System', + ClockFormat.h24 => '24-hour', + ClockFormat.h12 => '12-hour', + }; +} + +class ClockFormatController extends ChangeNotifier { + static const String _kClockFormat = 'clock_format'; // 'system'|'h24'|'h12' + + static ClockFormatController? _active; + + ClockFormat _format; + ClockFormatController._(this._format) { + _active = this; + } + + factory ClockFormatController.seed(ClockFormat f) => + ClockFormatController._(f); + + static Future bootstrap({ + Duration timeout = const Duration(seconds: 6), + }) async { + final prefs = await SharedPreferences.getInstance().timeout(timeout); + return ClockFormatController._(_parse(prefs.getString(_kClockFormat))); + } + + /// An unknown stored value (an older build, a hand-edited pref) is "system". + static ClockFormat _parse(String? s) => ClockFormat.values + .firstWhere((f) => f.name == s, orElse: () => ClockFormat.system); + + ClockFormat get format => _format; + + /// Whether [format] resolves to 24-hour, given the OS's own answer. + bool resolve24h(bool systemUse24h) => switch (_format) { + ClockFormat.system => systemUse24h, + ClockFormat.h24 => true, + ClockFormat.h12 => false, + }; + + Future setFormat(ClockFormat f) async { + if (_format == f) return; + _format = f; + notifyListeners(); + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_kClockFormat, f.name); + } + + /// Tapped through in place, like Units and Appearance. + Future cycle() => setFormat( + ClockFormat.values[(_format.index + 1) % ClockFormat.values.length]); + + /// Tests only: forget the active controller so the next test starts from + /// the OS setting again. + @visibleForTesting + static void debugReset() { + _active = null; + _localizations = null; + } +} + +/// The app locale's AM/PM text and 12-hour order, bound by the app's clock +/// scope. Null (no app yet, a background isolate) falls back to English. +MaterialLocalizations? _localizations; + +void bindClockLocalizations(MaterialLocalizations? l) => _localizations = l; + +/// Whether clock times render 24-hour right now: the user's choice, or the +/// OS's "use 24-hour format" when the choice is "system" (or no controller +/// exists in this isolate). +bool get use24HourClock { + final system = _systemUse24h(); + return ClockFormatController._active?.resolve24h(system) ?? system; +} + +/// The OS setting, via the binding's dispatcher when there is one (the one +/// MediaQuery reads). +bool _systemUse24h() { + try { + return WidgetsBinding.instance.platformDispatcher.alwaysUse24HourFormat; + } catch (_) { + return PlatformDispatcher.instance.alwaysUse24HourFormat; + } +} + +/// Hour + minute → "07:05" or "7:05 AM" (the locale's AM/PM), per +/// [use24HourClock]. +String formatClock(int hour, int minute) { + final mm = minute.toString().padLeft(2, '0'); + if (use24HourClock) return '${hour.toString().padLeft(2, '0')}:$mm'; + final h = hour % 12 == 0 ? 12 : hour % 12; + final l = _localizations; + final period = hour < 12 + ? (l?.anteMeridiemAbbreviation ?? 'AM') + : (l?.postMeridiemAbbreviation ?? 'PM'); + // A 24-hour-native locale (de, fr, es) has no 12-hour order of its own, so + // the period goes after, as in English. + return l?.timeOfDayFormat() == TimeOfDayFormat.a_space_h_colon_mm + ? '$period $h:$mm' + : '$h:$mm $period'; +} + +/// The time of day of [d], as [formatClock]. +String formatClockOf(DateTime d) => formatClock(d.hour, d.minute); + +/// Local minutes past midnight, as [formatClock]. Wraps past 24 h. +String formatClockMinute(int minuteOfDay) { + final m = minuteOfDay % 1440; // Dart's % is never negative here + return formatClock(m ~/ 60, m % 60); +} + +/// The time picker only takes `alwaysUse24HourFormat` as a way to force +/// 24-hour; `false` leaves a 24-hour-native locale (de, es, fr) on its 24-hour +/// dial. When the clock resolves to 12-hour this hands those locales the same +/// translations with a 12-hour order, so pickers match [formatClock]. +class ClockMaterialLocalizationsDelegate + extends LocalizationsDelegate { + const ClockMaterialLocalizationsDelegate({required this.twelveHour}); + final bool twelveHour; + + // ponytail: only the 24-hour-native languages the app ships; a new one + // falls through to the stock delegate (24-hour dial) until listed here. + static const _langs = {'de', 'es', 'fr'}; + + @override + bool isSupported(Locale locale) => + twelveHour && _langs.contains(locale.languageCode); + + @override + Future load(Locale locale) { + // Loads intl's date symbols (cached), which the formats below need. + GlobalMaterialLocalizations.delegate.load(locale); + final n = locale.languageCode; + final make = switch (n) { + 'de' => _De12.new, + 'es' => _Es12.new, + _ => _Fr12.new, + }; + return SynchronousFuture(make( + fullYearFormat: intl.DateFormat.y(n), + compactDateFormat: intl.DateFormat.yMd(n), + shortDateFormat: intl.DateFormat.yMMMd(n), + mediumDateFormat: intl.DateFormat.MMMEd(n), + longDateFormat: intl.DateFormat.yMMMMEEEEd(n), + yearMonthFormat: intl.DateFormat.yMMMM(n), + shortMonthDayFormat: intl.DateFormat.MMMd(n), + decimalFormat: intl.NumberFormat.decimalPattern(n), + twoDigitZeroPaddedFormat: intl.NumberFormat('00', n), + )); + } + + @override + bool shouldReload(ClockMaterialLocalizationsDelegate old) => + old.twelveHour != twelveHour; +} + +mixin _TwelveHour on GlobalMaterialLocalizations { + @override + TimeOfDayFormat get timeOfDayFormatRaw => TimeOfDayFormat.h_colon_mm_space_a; +} + +class _De12 = MaterialLocalizationDe with _TwelveHour; +class _Es12 = MaterialLocalizationEs with _TwelveHour; +class _Fr12 = MaterialLocalizationFr with _TwelveHour; diff --git a/lib/ui2/activity/poster.dart b/lib/ui2/activity/poster.dart index 24ca2e359..b28bab92b 100644 --- a/lib/ui2/activity/poster.dart +++ b/lib/ui2/activity/poster.dart @@ -36,6 +36,7 @@ import 'package:flutter/material.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../l10n/app_localizations.dart'; +import '../../state/clock_format.dart' show formatClockOf; import '../../state/units_controller.dart'; import '../screens/home_screen.dart' show unitsOf; import '../theme.dart'; @@ -920,16 +921,13 @@ void drawPin(Canvas canvas, Offset o, Color col) { ); } -/// `20 May 2026 • 7:15 AM`, in the reader's own clock terms. +/// `May 20, 2026 • 7:15 AM` (or `07:15`), in the reader's own clock terms. String posterDate(DateTime t) { const m = [ 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec', ]; - final h = t.hour % 12 == 0 ? 12 : t.hour % 12; - final min = t.minute.toString().padLeft(2, '0'); - return '${m[t.month - 1]} ${t.day}, ${t.year} • $h:$min ' - '${t.hour < 12 ? 'AM' : 'PM'}'; + return '${m[t.month - 1]} ${t.day}, ${t.year} • ${formatClockOf(t)}'; } /// Hero, unit, caption — distance when the session has one, time when it does diff --git a/lib/ui2/activity/summary.dart b/lib/ui2/activity/summary.dart index d92c8ede5..6743fc6df 100644 --- a/lib/ui2/activity/summary.dart +++ b/lib/ui2/activity/summary.dart @@ -30,6 +30,7 @@ import '../../data/db.dart'; import '../../gps/gpx_export.dart'; import '../../l10n/app_localizations.dart'; import '../../state/app_state.dart'; +import '../../state/clock_format.dart' show formatClockOf; import '../../state/prefs.dart'; import '../../state/units_controller.dart'; import '../charts.dart'; @@ -501,9 +502,7 @@ String _shortDate(DateTime t) { 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec', ]; - final h = t.hour % 12 == 0 ? 12 : t.hour % 12; - return '${months[t.month - 1]} ${t.day}, ${t.year} at $h:' - '${t.minute.toString().padLeft(2, '0')} ${t.hour < 12 ? 'AM' : 'PM'}'; + return '${months[t.month - 1]} ${t.day}, ${t.year} at ${formatClockOf(t)}'; } // ── THE SUPPORTING STATS ─────────────────────────────────────────────────── diff --git a/lib/ui2/profile/alarm.dart b/lib/ui2/profile/alarm.dart index 410bb9ac1..0e5405353 100644 --- a/lib/ui2/profile/alarm.dart +++ b/lib/ui2/profile/alarm.dart @@ -28,6 +28,7 @@ import 'package:provider/provider.dart'; import '../../l10n/app_localizations.dart'; import '../../state/alarm_schedule.dart'; import '../../state/app_state.dart'; +import '../../state/clock_format.dart' show formatClock; import '../screens/home_screen.dart' show weekdayShortName; import '../ui2.dart'; import 'profile.dart' show SetRow, settingsGroup; @@ -285,8 +286,7 @@ class AlarmScreenView extends StatelessWidget { static String _hhmm(DateTime d) => _hhmmOf(d.hour, d.minute); - static String _hhmmOf(int hour, int minute) => - '${hour.toString().padLeft(2, '0')}:${minute.toString().padLeft(2, '0')}'; + static String _hhmmOf(int hour, int minute) => formatClock(hour, minute); /// "Tue 07:30" — the weekday plus the time, both from the ARMED instant /// (not merely from the schedule row), so this never claims a day the band diff --git a/lib/ui2/profile/data.dart b/lib/ui2/profile/data.dart index 69db5fbc0..c077aab6f 100644 --- a/lib/ui2/profile/data.dart +++ b/lib/ui2/profile/data.dart @@ -27,6 +27,7 @@ import '../../data/db.dart'; import '../../import/backup_crypto.dart'; import '../../l10n/app_localizations.dart'; import '../../state/app_state.dart'; +import '../../state/clock_format.dart' show formatClockOf; import '../activity/share.dart' show shareOrigin; import '../onboarding/welcome.dart' show @@ -394,6 +395,5 @@ BackupCadence _nextCadence(BackupCadence c) => BackupCadence String _stamp(DateTime t) { String two(int v) => v.toString().padLeft(2, '0'); - return '${t.year}-${two(t.month)}-${two(t.day)} ' - '${two(t.hour)}:${two(t.minute)}'; + return '${t.year}-${two(t.month)}-${two(t.day)} ${formatClockOf(t)}'; } diff --git a/lib/ui2/profile/devices.dart b/lib/ui2/profile/devices.dart index 008ea5414..64ff39545 100644 --- a/lib/ui2/profile/devices.dart +++ b/lib/ui2/profile/devices.dart @@ -116,6 +116,7 @@ import '../../notify/battery_forecast.dart'; import '../../state/prefs.dart' show Prefs; import '../../sync/paired_device.dart' show cleanDeviceLabel; import '../../state/app_state.dart'; +import '../../state/clock_format.dart' show formatClockOf; import '../pairing/device_picker.dart' show DevicePickerScreen; import '../onboarding/profile_setup.dart' show formatDay; import '../ui2.dart'; @@ -2825,8 +2826,5 @@ String? _chargeHistory(Map? h) { /// /// It used to render `4/9, 07:12`, which a US reader reads as 9 April. The /// month name is the whole point; `formatDay` already writes one. -String formatDayTime(DateTime d, [AppLocalizations? l]) { - final t = '${d.hour.toString().padLeft(2, '0')}:' - '${d.minute.toString().padLeft(2, '0')}'; - return '${formatDay(d, l)}, $t'; -} +String formatDayTime(DateTime d, [AppLocalizations? l]) => + '${formatDay(d, l)}, ${formatClockOf(d)}'; diff --git a/lib/ui2/profile/settings.dart b/lib/ui2/profile/settings.dart index 2d24b6d13..cd355e639 100644 --- a/lib/ui2/profile/settings.dart +++ b/lib/ui2/profile/settings.dart @@ -29,6 +29,7 @@ import '../../notify/notification_prefs.dart'; import '../../notify/notification_service.dart'; import '../../platform/app_icon.dart'; import '../../state/app_state.dart'; +import '../../state/clock_format.dart'; import '../../state/prefs.dart'; import '../../state/units_controller.dart'; import '../../telemetry/health_uploader.dart'; @@ -148,6 +149,7 @@ class _MoreSettingsState extends State { final app = c.watch(); final units = c.watch(); final theme = c.watch(); + final clock = c.watch(); return MoreSettingsView( version: _version, devMode: _dev, @@ -156,6 +158,7 @@ class _MoreSettingsState extends State { onGallery: () => goto(c, const GalleryScreen()), units: units.system.label, appearance: theme.choice.label, + clockFormat: clock.format, cycleTracking: app.cycleTrackingEnabled, zoneAlertEnabled: app.zoneAlertEnabled, zoneAlertZone: app.zoneAlertTargetZone, @@ -187,6 +190,11 @@ class _MoreSettingsState extends State { : UnitSystem.imperial), onCycleAppearance: () => theme.setChoice(AppThemeChoice.values[ (theme.choice.index + 1) % AppThemeChoice.values.length]), + // the wind-down body bakes the time in when it's armed, re-arm it now + onCycleClockFormat: () async { + await clock.cycle(); + await app.refreshAiReminders(); + }, onToggleCycleTracking: () => app.setCycleTrackingEnabled(!app.cycleTrackingEnabled), onTogglePhoneSteps: () => app.phoneStepsEnabled @@ -481,6 +489,9 @@ Future _confirmReset(BuildContext c, AppState app) async { class MoreSettingsView extends StatelessWidget { final String units, appearance; + + /// 12- or 24-hour clock, or whatever the OS says ("System", the default). + final ClockFormat clockFormat; final bool phoneSteps, telemetry, barcodeLookup, cycleTracking; /// The live-workout HR-zone-crossing haptic. Off by default; [zoneAlertZone] @@ -530,6 +541,7 @@ class MoreSettingsView extends StatelessWidget { onAutomation, onCycleUnits, onCycleAppearance, + onCycleClockFormat, onTogglePhoneSteps, onToggleTelemetry, onToggleBarcodeLookup, @@ -545,6 +557,7 @@ class MoreSettingsView extends StatelessWidget { super.key, this.units = 'Metric', this.appearance = 'System', + this.clockFormat = ClockFormat.system, this.appIcon, this.onPickIcon, this.phoneSteps = false, @@ -574,6 +587,7 @@ class MoreSettingsView extends StatelessWidget { this.onAutomation, this.onCycleUnits, this.onCycleAppearance, + this.onCycleClockFormat, this.onTogglePhoneSteps, this.onToggleTelemetry, this.onToggleBarcodeLookup, @@ -666,6 +680,17 @@ class MoreSettingsView extends StatelessWidget { SetRow(LucideIcons.sun, C.yellow, l?.settingsAppearanceRowTitle ?? 'Appearance', value: appearance, onTap: onCycleAppearance), + SetRow(LucideIcons.clock, C.teal, + l?.settingsClockFormatRowTitle ?? 'Time format', + value: switch (clockFormat) { + ClockFormat.system => + l?.settingsClockFormatSystem ?? clockFormat.label, + ClockFormat.h24 => + l?.settingsClockFormat24h ?? clockFormat.label, + ClockFormat.h12 => + l?.settingsClockFormat12h ?? clockFormat.label, + }, + onTap: onCycleClockFormat), if (appIcon != null) _IconRow(chosen: appIcon!, onPick: onPickIcon), // Opt-in, and it says what it does rather than what it is @@ -1265,11 +1290,7 @@ class NotificationSettingsView extends StatelessWidget { return batteryChoices[(i + 1) % batteryChoices.length]; } - static String _hhmm(int minuteOfDay) { - final m = minuteOfDay % 1440; - return '${(m ~/ 60).toString().padLeft(2, '0')}:' - '${(m % 60).toString().padLeft(2, '0')}'; - } + static String _hhmm(int minuteOfDay) => formatClockMinute(minuteOfDay); static Future _pickMinute(BuildContext c, int current) async { final picked = await showTimePicker( diff --git a/lib/ui2/screens/coach_figures.dart b/lib/ui2/screens/coach_figures.dart index f3953487b..e4ebed452 100644 --- a/lib/ui2/screens/coach_figures.dart +++ b/lib/ui2/screens/coach_figures.dart @@ -17,6 +17,7 @@ import 'package:flutter/material.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../l10n/app_localizations.dart'; +import '../../state/clock_format.dart' show formatClockOf; import '../ui2.dart'; // ── loose parsing (the model is not a schema) ──────────────────────────────── @@ -354,8 +355,7 @@ class CoachFigure extends StatelessWidget { final d = DateTime.fromMillisecondsSinceEpoch( (epochSec * 1000).round(), ).toLocal(); - return '${d.hour.toString().padLeft(2, '0')}:' - '${d.minute.toString().padLeft(2, '0')}'; + return formatClockOf(d); } return _frame( diff --git a/lib/ui2/screens/ecg.dart b/lib/ui2/screens/ecg.dart index 8d4697594..b81685a0a 100644 --- a/lib/ui2/screens/ecg.dart +++ b/lib/ui2/screens/ecg.dart @@ -20,6 +20,7 @@ import '../../ecg/ecg_models.dart'; import '../../ecg/ecg_waveform_buffer.dart'; import '../../l10n/app_localizations.dart'; import '../../state/app_state.dart'; +import '../../state/clock_format.dart' show formatClockOf; import '../../theme/theme_switcher.dart' show themedRoute; import '../ui2.dart'; import 'coach.dart'; @@ -62,7 +63,7 @@ String _wristLabel(AppLocalizations? l, EcgWrist w) => w == EcgWrist.left String _fmtWhen(int epochS) { final d = DateTime.fromMillisecondsSinceEpoch(epochS * 1000); String two(int n) => n.toString().padLeft(2, '0'); - return '${d.year}-${two(d.month)}-${two(d.day)} ${two(d.hour)}:${two(d.minute)}'; + return '${d.year}-${two(d.month)}-${two(d.day)} ${formatClockOf(d)}'; } // ═══════════════════ entry card (Health overview) ═══════════════════ diff --git a/lib/ui2/screens/home_screen.dart b/lib/ui2/screens/home_screen.dart index dd6b1bbf9..c874c7012 100644 --- a/lib/ui2/screens/home_screen.dart +++ b/lib/ui2/screens/home_screen.dart @@ -42,6 +42,7 @@ import '../../l10n/app_localizations.dart'; import '../../models/metric.dart'; import '../../notify/notification_prefs.dart' show NotificationPrefs; import '../../state/app_state.dart'; +import '../../state/clock_format.dart' show formatClockOf; import '../../state/units_controller.dart'; import '../../theme/theme_switcher.dart' show themedRoute; import '../activity/day_strain.dart' show DayStrainDetail; @@ -178,9 +179,9 @@ DateTime? lastDataAtOf(BuildContext c) { /// directly above it, whatever the hour. Null (no day on screen yet) ⇒ always /// dated, which is the honest answer when we do not know what "today" is. /// -/// Bare `HH:mm` for the day on screen, the full "Fri 4 Sep, 07:12" otherwise — -/// a lone "07:12" against a strap not worn since Friday is the most misleading -/// thing this line could say. +/// Bare clock time for the day on screen, the full "Fri 4 Sep, 07:12" +/// otherwise — a lone "07:12" against a strap not worn since Friday is the +/// most misleading thing this line could say. String syncedThroughLabel(DateTime? at, String? todayId, [AppLocalizations? l]) { if (at == null) return l?.homeSyncedNever ?? 'No band data yet'; @@ -189,10 +190,7 @@ String syncedThroughLabel(DateTime? at, String? todayId, at.year == today.year && at.month == today.month && at.day == today.day; - final when = isToday - ? '${at.hour.toString().padLeft(2, '0')}:' - '${at.minute.toString().padLeft(2, '0')}' - : formatDayTime(at, l); + final when = isToday ? formatClockOf(at) : formatDayTime(at, l); return l?.homeSyncedThrough(when) ?? 'Synced through $when'; } @@ -596,17 +594,15 @@ String unitBeside(String unit) => unit == 'min' ? '' : unit; /// ONE clock format in the app. This used to render 24-hour while Wellness /// rendered the same field 12-hour, so a target bedtime read `22:40` on Home /// and `10:40 PM` two screens away. Both now go through the journal layer's -/// [formatMinuteOfDay], which is the format the rest of the app already uses -/// and the one that already has a test. +/// [formatMinuteOfDay], which follows the user's 12/24-hour choice +/// (`state/clock_format.dart`). String clock(num? minOfDay) => minOfDay == null ? '' : formatMinuteOfDay(minOfDay.round()); -/// Epoch seconds → "11:08 PM" in the device zone. +/// Epoch seconds → "11:08 PM" / "23:08" in the device zone. String clockOfTs(num? ts) { if (ts == null) return ''; - final d = DateTime.fromMillisecondsSinceEpoch(ts.round() * 1000); - final h = d.hour % 12 == 0 ? 12 : d.hour % 12; - return '$h:${d.minute.toString().padLeft(2, '0')} ${d.hour < 12 ? 'AM' : 'PM'}'; + return formatClockOf(DateTime.fromMillisecondsSinceEpoch(ts.round() * 1000)); } const _months = [ diff --git a/lib/ui2/screens/wellness_screen.dart b/lib/ui2/screens/wellness_screen.dart index 830f0e7cd..344bc5215 100644 --- a/lib/ui2/screens/wellness_screen.dart +++ b/lib/ui2/screens/wellness_screen.dart @@ -1242,8 +1242,7 @@ Future pickMedSchedule( const SizedBox(width: S.x3), Expanded( child: Text( - '${(minute ~/ 60).toString().padLeft(2, '0')}:' - '${(minute % 60).toString().padLeft(2, '0')}', + formatMinuteOfDay(minute), style: F.n17.copyWith(color: p.ink), ), ), diff --git a/lib/ui2/screens/workout_screen.dart b/lib/ui2/screens/workout_screen.dart index d0013a836..08e999eb8 100644 --- a/lib/ui2/screens/workout_screen.dart +++ b/lib/ui2/screens/workout_screen.dart @@ -27,6 +27,7 @@ import '../../health/health_workout_import.dart'; import '../../l10n/app_localizations.dart'; import '../../models/metric.dart'; import '../../state/app_state.dart'; +import '../../state/clock_format.dart' show formatClockOf; import '../../state/units_controller.dart'; import '../activity/catalogue.dart'; import '../activity/day_strain.dart'; @@ -1789,8 +1790,7 @@ class _PastWorkout { loc?.workoutWeekdayAbbrSat ?? 'Sat', loc?.workoutWeekdayAbbrSun ?? 'Sun', ]; - final t = '${start.hour.toString().padLeft(2, '0')}:' - '${start.minute.toString().padLeft(2, '0')}'; + final t = formatClockOf(start); if (days == 0) return loc?.workoutWhenToday(t) ?? 'Today, $t'; if (days == 1) return loc?.workoutWhenYesterday(t) ?? 'Yesterday, $t'; if (days < 7) return '${names[start.weekday - 1]}, $t'; diff --git a/test/clock_format_test.dart b/test/clock_format_test.dart new file mode 100644 index 000000000..d02762102 --- /dev/null +++ b/test/clock_format_test.dart @@ -0,0 +1,160 @@ +// The 12/24-hour display preference. One formatter for every clock time on +// screen, so a bedtime cannot read `22:40` on Home and `10:40 PM` two screens +// away — and so the choice the user makes in Settings reaches all of them. + +import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:openstrap_edge/data/journal_fields.dart'; +import 'package:openstrap_edge/state/clock_format.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() => SharedPreferences.setMockInitialValues({})); + tearDown(ClockFormatController.debugReset); + + group('formatClock', () { + test('24-hour pads the hour and has no suffix', () { + ClockFormatController.seed(ClockFormat.h24); + expect(formatClock(0, 0), '00:00'); + expect(formatClock(7, 5), '07:05'); + expect(formatClock(12, 0), '12:00'); + expect(formatClock(23, 59), '23:59'); + }); + + test('12-hour maps midnight and noon to 12', () { + ClockFormatController.seed(ClockFormat.h12); + expect(formatClock(0, 0), '12:00 AM'); + expect(formatClock(7, 5), '7:05 AM'); + expect(formatClock(12, 0), '12:00 PM'); + expect(formatClock(23, 59), '11:59 PM'); + }); + + test("12-hour uses the app locale's AM/PM and order", () async { + ClockFormatController.seed(ClockFormat.h12); + Future bind(String lang) async => bindClockLocalizations( + await GlobalMaterialLocalizations.delegate.load(Locale(lang))); + + await bind('zh'); + expect(formatClock(7, 30), '上午 7:30'); + expect(formatClock(19, 30), '下午 7:30'); + await bind('es'); + expect(formatClock(19, 30), '7:30 p. m.'); + await bind('en'); + expect(formatClock(19, 30), '7:30 PM'); + }); + + test('minute of day wraps rather than printing an impossible hour', () { + ClockFormatController.seed(ClockFormat.h24); + expect(formatClockMinute(24 * 60 + 30), '00:30'); + expect(formatClockMinute(-30), '23:30'); + }); + + test('the journal formatter follows the same choice', () { + final c = ClockFormatController.seed(ClockFormat.h12); + expect(formatMinuteOfDay(20 * 60 + 30), '8:30 PM'); + c.setFormat(ClockFormat.h24); + expect(formatMinuteOfDay(20 * 60 + 30), '20:30'); + }); + }); + + group('system', () { + test('follows the OS setting, with or without a controller', () { + final binding = TestWidgetsFlutterBinding.instance; + addTearDown(binding.platformDispatcher + .clearAlwaysUse24HourTestValue); + + binding.platformDispatcher.alwaysUse24HourFormatTestValue = true; + expect(formatClock(19, 30), '19:30', reason: 'no controller yet'); + ClockFormatController.seed(ClockFormat.system); + expect(formatClock(19, 30), '19:30'); + + binding.platformDispatcher.alwaysUse24HourFormatTestValue = false; + expect(formatClock(19, 30), '7:30 PM'); + }); + + test('an explicit choice overrides the OS', () { + final binding = TestWidgetsFlutterBinding.instance; + addTearDown(binding.platformDispatcher + .clearAlwaysUse24HourTestValue); + binding.platformDispatcher.alwaysUse24HourFormatTestValue = true; + + ClockFormatController.seed(ClockFormat.h12); + expect(formatClock(19, 30), '7:30 PM'); + }); + }); + + group('time picker', () { + Future openPicker(WidgetTester tester, {required bool h12}) async { + await tester.pumpWidget(MaterialApp( + locale: const Locale('de'), + supportedLocales: const [Locale('de')], + localizationsDelegates: [ + ClockMaterialLocalizationsDelegate(twelveHour: h12), + ...GlobalMaterialLocalizations.delegates, + ], + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context).copyWith(alwaysUse24HourFormat: !h12), + child: child!, + ), + home: Builder( + builder: (context) => TextButton( + onPressed: () => showTimePicker( + context: context, + initialTime: const TimeOfDay(hour: 22, minute: 0)), + child: const Text('open'), + ), + ), + )); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + } + + testWidgets('12-hour in German gets an AM/PM dial, still in German', + (tester) async { + await openPicker(tester, h12: true); + expect(find.text('PM'), findsOneWidget); + expect(find.text('10'), findsWidgets); + expect(find.text('Abbrechen'), findsOneWidget); + }); + + testWidgets('24-hour in German keeps the 24-hour dial', (tester) async { + await openPicker(tester, h12: false); + expect(find.text('PM'), findsNothing); + expect(find.text('22'), findsWidgets); + }); + }); + + group('ClockFormatController', () { + test('persists the choice and bootstraps it back', () async { + final c = await ClockFormatController.bootstrap(); + expect(c.format, ClockFormat.system, reason: 'nothing stored yet'); + await c.setFormat(ClockFormat.h12); + + final again = await ClockFormatController.bootstrap(); + expect(again.format, ClockFormat.h12); + }); + + test('an unknown stored value lands on system', () async { + SharedPreferences.setMockInitialValues({'clock_format': 'h36'}); + final c = await ClockFormatController.bootstrap(); + expect(c.format, ClockFormat.system); + }); + + test('cycles system → 24-hour → 12-hour → system and notifies', () async { + final c = ClockFormatController.seed(ClockFormat.system); + var notified = 0; + c.addListener(() => notified++); + await c.cycle(); + expect(c.format, ClockFormat.h24); + await c.cycle(); + expect(c.format, ClockFormat.h12); + await c.cycle(); + expect(c.format, ClockFormat.system); + expect(notified, 3); + }); + }); +} diff --git a/test/home_synced_through_test.dart b/test/home_synced_through_test.dart index db59b8b12..302081648 100644 --- a/test/home_synced_through_test.dart +++ b/test/home_synced_through_test.dart @@ -8,11 +8,16 @@ // read during build decides once and then goes stale across midnight. import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/state/clock_format.dart'; import 'package:openstrap_edge/ui2/screens/home_screen.dart'; void main() { const today = '2026-09-08'; + // The label follows the user's clock format; these pin the 24-hour one. + setUp(() => ClockFormatController.seed(ClockFormat.h24)); + tearDown(ClockFormatController.debugReset); + test('a record from today shows the bare time', () { expect( syncedThroughLabel(DateTime(2026, 9, 8, 11, 6), today), diff --git a/test/ui2_wellness_meds_not_due_test.dart b/test/ui2_wellness_meds_not_due_test.dart index b6269c548..9d68db95f 100644 --- a/test/ui2_wellness_meds_not_due_test.dart +++ b/test/ui2_wellness_meds_not_due_test.dart @@ -19,6 +19,7 @@ import 'package:sqflite_common_ffi/sqflite_ffi.dart'; import 'package:openstrap_edge/data/db.dart'; import 'package:openstrap_edge/data/med_store.dart'; import 'package:openstrap_edge/state/app_state.dart'; +import 'package:openstrap_edge/state/clock_format.dart'; import 'package:openstrap_edge/ui2/screens/screens.dart'; import 'package:openstrap_edge/ui2/ui2.dart'; @@ -29,7 +30,12 @@ void main() { sqfliteFfiInit(); databaseFactory = databaseFactoryFfi; }); - setUp(() => SharedPreferences.setMockInitialValues({})); + setUp(() { + SharedPreferences.setMockInitialValues({}); + // The slot time follows the user's clock format; pin the 24-hour one. + ClockFormatController.seed(ClockFormat.h24); + }); + tearDown(ClockFormatController.debugReset); testWidgets('a medication with nothing due today says so', (t) async { t.view.physicalSize = const Size(390 * 3, 844 * 3);