From a50d8bee86767e83cb88a8250829ecbbeb744d8f Mon Sep 17 00:00:00 2001 From: MikenRafter Date: Sun, 8 Nov 2026 20:21:10 -0700 Subject: [PATCH 01/17] test(home): red tests for the details card, spiral and stage rings, and Home labels --- lib/ui2/charts.dart | 35 +- lib/ui2/screens/home_screen.dart | 28 +- lib/ui2/sleep_ring_model.dart | 77 +++ lib/ui2/spiral_ring.dart | 39 ++ lib/ui2/ui2.dart | 2 + test/home_labels_test.dart | 329 +++++++++++ test/home_ring_card_test.dart | 557 ++++++++++++++++++ test/home_sleep_ring_widget_test.dart | 785 ++++++++++++++++++++++++++ test/home_tick_dst_test.dart | 72 +++ test/sleep_ring_model_test.dart | 630 +++++++++++++++++++++ test/ui2_spiral_ring_round2_test.dart | 137 +++++ test/ui2_spiral_ring_round3_test.dart | 171 ++++++ test/ui2_spiral_ring_test.dart | 605 ++++++++++++++++++++ 13 files changed, 3464 insertions(+), 3 deletions(-) create mode 100644 lib/ui2/sleep_ring_model.dart create mode 100644 lib/ui2/spiral_ring.dart create mode 100644 test/home_labels_test.dart create mode 100644 test/home_ring_card_test.dart create mode 100644 test/home_sleep_ring_widget_test.dart create mode 100644 test/home_tick_dst_test.dart create mode 100644 test/sleep_ring_model_test.dart create mode 100644 test/ui2_spiral_ring_round2_test.dart create mode 100644 test/ui2_spiral_ring_round3_test.dart create mode 100644 test/ui2_spiral_ring_test.dart diff --git a/lib/ui2/charts.dart b/lib/ui2/charts.dart index b26ded4d7..30a201975 100644 --- a/lib/ui2/charts.dart +++ b/lib/ui2/charts.dart @@ -41,6 +41,7 @@ import '../l10n/app_localizations.dart'; import '../l10n/decimal_text.dart'; import '../state/locale_controller.dart'; +import 'spiral_ring.dart'; import 'dart:math'; import 'dart:typed_data'; import 'dart:ui' show PointMode; @@ -611,8 +612,14 @@ class Ring extends CustomPainter { /// calibrating" and "this is missing" into one look. final bool solid; + /// RED-PHASE STUB: the surface under the ring (the lap separator). Unused. + final Color? background; + Ring(this.v, this.color, this.track, - {this.stroke = 10, this.t = 1, this.solid = false}); + {this.stroke = 10, this.t = 1, this.solid = false, this.background}); + + /// RED-PHASE STUB: the real getter is SpiralGeometry(stroke).pieces(...). + List get pieces => const []; @override void paint(Canvas cv, Size s) { @@ -655,6 +662,32 @@ class Ring extends CustomPainter { o.v != v || o.t != t || o.solid != solid || o.color != color; } +/// RED-PHASE STUB: one coloured arc of a [StageRing]. +class RingArc { + final Color color; + final double fraction; + const RingArc(this.color, this.fraction); +} + +/// RED-PHASE STUB: paints nothing and reports no pieces. +class StageRing extends CustomPainter { + final List arcs; + final Color track; + final double stroke, t; + final Color? background; + + StageRing(this.arcs, this.track, + {this.stroke = 10, this.t = 1, this.background}); + + List get pieces => const []; + + @override + void paint(Canvas cv, Size s) {} + + @override + bool shouldRepaint(covariant StageRing o) => true; +} + /// A ring made of discrete dashes rather than a continuous arc — for a value /// that is still filling (a baseline calibrating night by night), so "not /// solid yet" is literally true of the shape, not just a softer tint of the diff --git a/lib/ui2/screens/home_screen.dart b/lib/ui2/screens/home_screen.dart index 20a13feff..2c9defb21 100644 --- a/lib/ui2/screens/home_screen.dart +++ b/lib/ui2/screens/home_screen.dart @@ -42,6 +42,7 @@ import '../../compute/findings.dart' kReadinessGoodFrom, kReadinessRestBelow; import '../../data/day_label.dart' show todayLabel, calendarDaysBetween; +import '../sleep_ring_model.dart' show SleepStageMin; import '../../compute/onehz_pipeline.dart' show readinessInputShortfallNote, readinessUnstableBaselineNote; import '../../data/db.dart' show DbRebuild, LocalDb; @@ -843,11 +844,14 @@ class RingTrio extends StatelessWidget { /// worth pushing onto. final void Function(HomeRingKind)? onOpen; - const RingTrio({super.key, required this.d, this.onOpen}); + /// RED-PHASE STUB: the instant the sleep ring estimates from. Unused. + final DateTime? now; + + const RingTrio({super.key, required this.d, this.onOpen, this.now}); /// Whether ANY of the three has something to draw. When none do, the screen /// owes the user one written absence, not three empty circles. - static bool has(HomeData d) => + static bool has(HomeData d, {DateTime? now}) => HomeRingKind.values.any((k) => _ringOf(k, d, null).why == null); @override @@ -961,6 +965,15 @@ String? recoveryStateLine(Map head, AppLocalizations? l) => /// night gave back, what the day has cost, and what the night was made of. enum HomeRingKind { recovery, strain, sleep } +/// RED-PHASE STUB: the real function ranks the (at most two) rings the Home +/// card should explain. See edge.research/sol/home/scope-map.md. +List pressingRings(HomeData d, {DateTime? now}) => const []; + +/// RED-PHASE STUB: the real function is the delay to the next whole minute, +/// taken from the UTC instant (so the repeated fall-back hour is safe). +@visibleForTesting +Duration nextMinuteTickDelay(DateTime now) => Duration.zero; + /// One ring's resolved state — the only place a metric becomes a shape. class _RingState { final HomeRingKind kind; @@ -1346,6 +1359,12 @@ class HomeData { /// `{from, to, at}` when today's final recovery changed after it was shown. final Map? readinessUpdate; + // RED-PHASE STUBS (Home + labels): inputs the sleep ring needs. Not yet read + // by any loader or widget. + final SleepStageMin? sleepStages; + final DateTime? nextAlarm; + final int? learnedOnsetMin, learnedWakeMin, cycleLenMin; + const HomeData({ this.name, this.dayId, @@ -1369,6 +1388,11 @@ class HomeData { this.absentDiag, this.recoveryState, this.readinessUpdate, + this.sleepStages, + this.nextAlarm, + this.learnedOnsetMin, + this.learnedWakeMin, + this.cycleLenMin, }); /// The three illness fields, replaced together. Test-facing sugar, and they diff --git a/lib/ui2/sleep_ring_model.dart b/lib/ui2/sleep_ring_model.dart new file mode 100644 index 000000000..bee56eac3 --- /dev/null +++ b/lib/ui2/sleep_ring_model.dart @@ -0,0 +1,77 @@ +// RED-PHASE STUB (phase 0, Home + labels). Shapes only, no behaviour: every +// function returns the empty answer so the red tests fail on assertions. The +// green phase replaces this file with the model described in +// edge.research/sol/home/scope-map.md (fork origin/integrate/next: +// lib/ui2/sleep_ring_model.dart, minus ExpectedSleepSchedule). + +enum SleepRingPhase { slept, estimate, none } + +enum SleepArcKind { deep, rem, light, awake, solid, estimate } + +enum WindowStartSource { coach, learned } + +enum WakeSource { alarm, learned } + +/// STUB: the real constants are 480 and 90. +const int kDefaultSleepTargetMin = 0; +const int kDefaultCycleLenMin = 0; + +class SleepStageMin { + final int? deep, rem, light, awake; + const SleepStageMin({this.deep, this.rem, this.light, this.awake}); +} + +class SleepArc { + final SleepArcKind kind; + final double fraction; + const SleepArc(this.kind, this.fraction); +} + +class SleepRingModel { + final SleepRingPhase phase; + final List arcs; + final int targetMin; + final bool targetIsDefault; + final int? asleepMin, pct, estimateMin, cycles; + final int cycleLenMin; + final DateTime? windowStart, wakeAt; + final WindowStartSource? startSource; + final WakeSource? wakeSource; + + const SleepRingModel({ + required this.phase, + this.arcs = const [], + this.targetMin = 0, + this.targetIsDefault = false, + this.asleepMin, + this.pct, + this.estimateMin, + this.cycles, + this.cycleLenMin = 0, + this.windowStart, + this.wakeAt, + this.startSource, + this.wakeSource, + }); + + double get sweep => arcs.fold(0.0, (a, e) => a + e.fraction); +} + +SleepRingModel sleepRingModel({ + required DateTime now, + num? durationMin, + SleepStageMin? stages, + num? needMin, + num? coachBedtimeMinOfDay, + DateTime? nextAlarm, + int? learnedOnsetMin, + int? learnedWakeMin, + int? cycleLenMin, +}) => + const SleepRingModel(phase: SleepRingPhase.none); + +({int? onsetMin, int? wakeMin}) learnedClockMinutes( + List> windows, { + int minSamples = 3, +}) => + (onsetMin: null, wakeMin: null); diff --git a/lib/ui2/spiral_ring.dart b/lib/ui2/spiral_ring.dart new file mode 100644 index 000000000..beaf5d5a0 --- /dev/null +++ b/lib/ui2/spiral_ring.dart @@ -0,0 +1,39 @@ +// RED-PHASE STUB (phase 0, Home + labels). Shapes only, no behaviour. The +// green phase ports fork origin/integrate/next:lib/ui2/spiral_ring.dart. +import 'package:flutter/widgets.dart'; + +const double kSpiralSeparator = 0; + +double spiralSeparatorWidth(double stroke) => 0; + +class SpiralPiece { + final int seg, lap; + final double from, to; + final bool capEnd, capStart; + const SpiralPiece(this.seg, this.lap, this.from, this.to, + {this.capEnd = false, this.capStart = false}); +} + +class SpiralGeometry { + final double stroke, ramp, insetPerStroke; + const SpiralGeometry(this.stroke, {this.ramp = 0, this.insetPerStroke = 0}); + + double inset(double s) => 0; + double radiusAt(double s, double r0) => r0; + int saturationLaps(double r0) => 0; + static int lapsFor(double fraction) => 0; + List pieces(List fractions) => const []; + double? separatorFrom(int lap) => null; + static double gradientAt(double s, double total) => 0; +} + +void paintSpiral(Canvas canvas, Size size, + {required double stroke, + required Color track, + required List colors, + required List fractions, + required double t, + bool solid = true, + Color? background}) {} + +double gradientAt(double s, double total) => 0; diff --git a/lib/ui2/ui2.dart b/lib/ui2/ui2.dart index 91dd40ca6..edb597071 100644 --- a/lib/ui2/ui2.dart +++ b/lib/ui2/ui2.dart @@ -15,5 +15,7 @@ export 'nudges.dart'; export 'paint_activity.dart'; export 'revision.dart'; export 'scroll_hint.dart'; +export 'sleep_ring_model.dart'; +export 'spiral_ring.dart'; export 'theme.dart'; export 'trend.dart'; diff --git a/test/home_labels_test.dart b/test/home_labels_test.dart new file mode 100644 index 000000000..ba97c3560 --- /dev/null +++ b/test/home_labels_test.dart @@ -0,0 +1,329 @@ +// RED (Home + labels, phase 0). WHY: three label fixes that ship with the Home +// upgrade, each pinned where it can be checked without a screen. +// +// 1. NEW COPY, ALL LOCALES. The sleep ring's wording ("92% of 8h 00m +// (default)", "on track for 5 cycles ...", compact forms) and the why panels +// add 25 message keys. Every locale file (glob, so a newly added locale is +// covered; today de en es fr hi ru th zh) must carry each one with the +// SAME placeholders as English, a real ICU plural where English has one, +// and a translation (not English pasted in). Checklist "Copy and UI": +// meaning, not words, in every locale. Thai has no fork translation: it +// must be written for this PR. +// 2. PLAINER HOME COPY. Eleven existing Home strings lose their jargon (a +// "{z}x your normal night-to-night swing" multiple, "cross-day insights", +// "Nothing recorded says why"). The illness texts lose their {z} +// placeholder in EVERY locale, or the generated API and the locale files +// disagree. +// 3. GRAPH LABEL DATA. The relative axis label of a stored point reads "1 day +// ago" (not "1 days ago"; "1 night ago" for nights), counted against the +// injectable clock so the label does not depend on when the test runs. +// +// Getter-based checks go through `dynamic` so a missing getter is a test +// FAILURE with a message rather than a compile error in the red phase. + +import 'dart:convert'; +import 'dart:io'; +import 'dart:ui' show Locale; + +import 'package:clock/clock.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/l10n/app_localizations.dart'; +import 'package:openstrap_edge/ui2/screens/screens.dart'; + +/// The 25 keys this PR adds (sleep ring wording + why panels). +const _newKeys = [ + 'homeSleepPctOf', + 'homeSleepPctOfDefault', + 'homeSleepPctCompact', + 'homeSleepPctDefaultCompact', + 'homeSleepPctDefaultTight', + 'homeSleepOnTrack', + 'homeSleepOnTrackDefault', + 'homeSleepEstimateCompact', + 'homeSleepEstimateDefaultCompact', + 'homeSleepEstimateDefaultTight', + 'homeSleepEstimateDefaultMin', + 'homeSleepEstimateBare', + 'homeSleepNoEstimate', + 'homeSleepNoEstimateWhy', + 'homeWhyRecoveryTitle', + 'homeWhyRecoveryDrivers', + 'homeWhyRecoveryDriver', + 'homeWhyRecoveryPlain', + 'homeWhyRecoveryPullingTwo', + 'homeWhyRecoveryPullingOne', + 'homeWhySleepTitle', + 'homeWhySleepBody', + 'homeWhySleepDetail', + 'homeWhyStrainTitle', + 'homeWhyStrainBody', +]; + +/// Existing keys whose English is made plainer. +const _plainerKeys = [ + 'homeCoachSetUpChip', + 'homeDbRebuiltTitle', + 'homeGapNoReason', + 'homeIllnessBodyAbove', + 'homeIllnessBodyBelow', + 'homeIllnessBodyNoZ', + 'homeInsightsRebuildingAlgoVersion', + 'homeInsightsRebuildingTitle', + 'homeNothingTodayBody', + 'homeReadinessNoReason', + 'homeWorkoutHoldBody', +]; + +Map _arb(File f) => + jsonDecode(f.readAsStringSync()) as Map; + +Set _placeholders(String s) { + // Names inside {...}, ignoring the ICU plural/select heads and branch text. + final out = {}; + for (final m in RegExp(r'\{(\w+)(?=[},])').allMatches(s)) { + final n = m.group(1)!; + if (const {'one', 'other', 'few', 'many', 'two', 'zero'}.contains(n)) { + continue; + } + out.add(n); + } + return out; +} + +T _get(String what, T Function() f) { + try { + return f(); + } on NoSuchMethodError { + fail('$what is not generated yet (missing arb key)'); + } +} + +void main() { + final files = Directory('lib/l10n') + .listSync() + .whereType() + .where((f) => RegExp(r'app_[a-zA-Z_]+\.arb$').hasMatch(f.path)) + .toList() + ..sort((a, b) => a.path.compareTo(b.path)); + final en = _arb(File('lib/l10n/app_en.arb')); + + group('1. new keys exist in every locale', () { + test('WHY: the glob finds all eight locales', () { + final names = files.map((f) => f.uri.pathSegments.last).toSet(); + expect( + names, + containsAll([ + 'app_de.arb', + 'app_en.arb', + 'app_es.arb', + 'app_fr.arb', + 'app_hi.arb', + 'app_ru.arb', + 'app_th.arb', + 'app_zh.arb', + ])); + }); + + test('WHY: English documents every new key with a description', () { + for (final k in _newKeys) { + expect(en[k], isA(), reason: '$k missing in app_en.arb'); + expect((en['@$k'] as Map?)?['description'], isA(), + reason: '@$k needs a description for translators'); + } + }); + + for (final f in files) { + final name = f.uri.pathSegments.last; + test('WHY: $name carries every new key, same placeholders, translated', + () { + final j = _arb(f); + final missing = []; + for (final k in _newKeys) { + final v = j[k]; + if (v is! String || v.trim().isEmpty) { + missing.add(k); + continue; + } + final ref = en[k]; + if (ref is! String) continue; // reported by the English test + expect(_placeholders(v), _placeholders(ref), + reason: '$k placeholders in $name'); + if (ref.contains('plural,')) { + expect(v, contains('plural,'), + reason: '$k keeps its ICU plural in $name'); + } + if (name != 'app_en.arb') { + expect(v, isNot(ref), reason: '$k is still English in $name'); + } + } + expect(missing, isEmpty, reason: 'missing or empty in $name'); + }); + } + + test( + 'WHY: fr and hi classify 0 as "one", so a plural singular must show ' + 'the count, not a literal 1', () { + final pluralKeys = _newKeys + .where((k) => (en[k] as String?)?.contains('{cycles, plural') ?? false) + .toList(); + expect(pluralKeys, hasLength(7), + reason: 'English must define the seven cycle-plural keys first'); + for (final lang in ['fr', 'hi']) { + final j = _arb(File('lib/l10n/app_$lang.arb')); + for (final k in pluralKeys) { + final v = j[k]; + if (v is! String) fail('$k missing in $lang'); + final one = RegExp(r'one\{([^}]*)\}').firstMatch(v)?.group(1) ?? ''; + expect(one, contains('{cycles}'), + reason: '$k ($lang) one-branch hard-codes the count: "$one"'); + } + } + }); + }); + + group('1b. the English wording', () { + final l = lookupAppLocalizations(const Locale('en')); + test('WHY: percentages say when the target is the default', () { + final d = _get('homeSleepPctOfDefault', + () => (l as dynamic).homeSleepPctOfDefault(92, '8h 00m')); + expect(d, '92% of 8h 00m (default)'); + final n = + _get('homeSleepPctOf', () => (l as dynamic).homeSleepPctOf(96, '7h 42m')); + expect(n, '96% of 7h 42m'); + }); + + test('WHY: the estimate sentence has a real singular and plural', () { + String f(int c) => _get('homeSleepOnTrackDefault', + () => (l as dynamic).homeSleepOnTrackDefault(c, '8h 00m', '8h 00m')); + expect(f(1), 'on track for 1 cycle, 8h 00m · target 8h 00m (default)'); + expect(f(5), 'on track for 5 cycles, 8h 00m · target 8h 00m (default)'); + }); + + test('WHY: compact forms keep the estimate marker and the default label', + () { + expect( + _get('homeSleepEstimateDefaultCompact', + () => (l as dynamic).homeSleepEstimateDefaultCompact(4, '6h')), + '~4 cycles · 6h · default target'); + expect( + _get('homeSleepEstimateBare', + () => (l as dynamic).homeSleepEstimateBare(1)), + '~1 cycle'); + }); + + test('WHY: cycle plurals show the real count for 0, 1, 2 and 5', () { + for (final lang in ['en', 'fr', 'hi']) { + final loc = lookupAppLocalizations(Locale(lang)); + for (final n in [0, 1, 2, 5]) { + final s = _get('homeSleepEstimateBare', + () => (loc as dynamic).homeSleepEstimateBare(n) as String); + expect(RegExp('(? (l as dynamic).homeIllnessBodyAbove as Object); + final below = _get('homeIllnessBodyBelow', + () => (l as dynamic).homeIllnessBodyBelow as Object); + expect(above, isA(), + reason: 'still a method that wants {z}: the placeholder must go'); + expect(below, isA()); + expect(above, 'Recent nights put your resting heart rate above your usual. ' + 'That night was above it too. A pattern, not a cause.'); + }); + + for (final f in files) { + final name = f.uri.pathSegments.last; + test('WHY: $name drops the {z} placeholder and the x-multiple', () { + final j = _arb(f); + for (final k in ['homeIllnessBodyAbove', 'homeIllnessBodyBelow']) { + expect(j[k], isA(), reason: '$k in $name'); + expect(_placeholders(j[k] as String), isEmpty, + reason: '$k in $name still has a placeholder'); + expect(j[k], isNot(contains('×')), reason: '$k in $name'); + expect((j['@$k'] as Map?)?['placeholders'], isNull, + reason: '@$k in $name still declares placeholders'); + } + }); + } + + test('WHY: every locale keeps the same placeholders as English on all ' + 'eleven plainer keys', () { + for (final f in files) { + final j = _arb(f); + for (final k in _plainerKeys) { + if (j[k] is! String) continue; // a locale may lag; parity above + expect(_placeholders(j[k] as String), _placeholders(en[k] as String), + reason: '$k in ${f.uri.pathSegments.last}'); + } + } + }); + }); + + group('3. graph label data', () { + // Local noon of a calendar day, the way metric_series stamps points. + int noon(int day) => + DateTime(2026, 10, day, 12).millisecondsSinceEpoch ~/ 1000; + final today = DateTime(2026, 10, 10, 15); + + test('WHY: one day back reads "1 day ago", not "1 days ago"', () { + withClock(Clock.fixed(today), () { + expect(axisDay(noon(9)), '1 day ago'); + expect(axisDay(noon(8)), '2 days ago'); + expect(axisDay(noon(10)), 'Today'); + }); + }); + + test('WHY: the unit word agrees too ("1 night ago")', () { + withClock(Clock.fixed(today), () { + expect(axisDay(noon(9), unitWord: 'nights'), '1 night ago'); + expect(axisDay(noon(7), unitWord: 'nights'), '3 nights ago'); + }); + }); + + test( + 'WHY: the label counts against the injected clock, so it does not ' + 'change with the day the test runs', () { + withClock(Clock.fixed(today), () { + expect(daysBehind(noon(7)), 3); + expect(daysBehind(noon(10)), 0); + expect(axisDay(noon(10), todayWord: 'Last night'), 'Last night'); + }); + withClock(Clock.fixed(DateTime(2026, 10, 12, 0, 5)), () { + // Five minutes after midnight, two days on: still a whole-day count. + expect(daysBehind(noon(10)), 2); + expect(axisDay(noon(10)), '2 days ago'); + }); + }); + + test('WHY: no point, no label', () { + withClock(Clock.fixed(today), () { + expect(axisDay(null), ''); + expect(daysBehind(null), isNull); + }); + }); + }); +} diff --git a/test/home_ring_card_test.dart b/test/home_ring_card_test.dart new file mode 100644 index 000000000..9d67d677c --- /dev/null +++ b/test/home_ring_card_test.dart @@ -0,0 +1,557 @@ +// RED (Home + labels, phase 0). WHY: the Home ring card should explain the +// readings that need explaining INSIDE the card, so the user does not open +// three detail screens to learn why a ring is low. Today the card ends in a +// loose "Why? HRV · Resting heart rate · Breathing rate" footer, every absent +// ring's reason as its own divider row, and the recovery update as bare grey +// text. The upgrade replaces them with up to TWO inset rows in one fixed slot +// at the bottom of the card: +// +// 1. a "why" panel for each of the (at most two) most pressing rings, +// 2. then the recovery update note ("Updated 08:12 · 62 → 58"), +// 3. then an absent ring's reason (its title over the pipeline's reason). +// +// Pressing means: recovery under 37 (severity 3 under 26, 2 for 26-36), sleep +// under 85% of need (3 under 70%), strain above the top of today's target. A +// ring with no value, a provisional or in-progress recovery is NEVER pressing +// (never fabricate). The why copy states evidence or nothing: the biggest +// drags by weighted contribution, a number only when a real z is in the driver. +// +// Ported/adapted from the fork's ui2_home_card_test, ui2_home_card_fix_test, +// ui2_home_card_r3_test, ui2_home_update_note_test and home_help_strip_why_test. +// OUT of this file on purpose: the "since hh:mm" overlay and hold semantics +// (need dayRecordingsThrough / staleHold), the older-calculation row, the +// observation row and the review prompt (other PRs). +// +// Fixtures are plain constants: no clock, no timezone, no sqflite. The update +// note's time is formatted by the same formatter the app uses, from an epoch +// built with a LOCAL DateTime, so it reads the same on any host zone. +// +// Keys pinned (ValueKey): +// home-why: the panel (tappable, opens that ring) +// home-why-icon: 32 pt circle with the RING'S own icon +// home-why-title: / home-why-body: +// home-update-note / home-update-note-icon +// home-note:gap: an absent ring's reason row + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import 'package:openstrap_edge/data/local_repository.dart'; +import 'package:openstrap_edge/l10n/app_localizations.dart'; +import 'package:openstrap_edge/models/metric.dart'; +import 'package:openstrap_edge/state/clock_format.dart' show formatClockOf; +import 'package:openstrap_edge/ui2/screens/screens.dart'; +import 'package:openstrap_edge/ui2/ui2.dart'; + +final _now = DateTime(2026, 10, 4, 15); + +Metric _m(num v) => Metric(value: v, confidence: .8, tier: MetricTier.high); + +const _target = {'value': 10, 'low': 8, 'high': 12}; + +HomeData _data({ + num? recovery = 80, + num? sleep = 450, // minutes + num need = 480, + num? strain = 5.0, + Map? target, + List> drivers = const [], + String? recoveryState, + Map? update, +}) => + HomeData( + dayId: '2026-10-04', + recoveryState: recoveryState, + readiness: recovery == null ? Metric.empty : _m(recovery), + sleepMin: sleep == null ? Metric.empty : _m(sleep), + sleepNeedMin: _m(need), + strain: strain == null ? Metric.empty : _m(strain), + strainTarget: target, + drivers: drivers, + readinessUpdate: update, + ); + +// 10 -> recovery severity 3; 30 -> severity 2; 66% of 528 min is sleep sev 3. +HomeData get _healthy => _data(); +HomeData get _lowRecovery => _data(recovery: 10); +HomeData get _recAndSleep => _data(recovery: 10, sleep: 350, need: 528); +HomeData get _rec2Sleep3 => _data(recovery: 30, sleep: 350, need: 528); +HomeData get _allThree => + _data(recovery: 10, sleep: 350, need: 528, strain: 15, target: _target); + +final _updateAt = DateTime(2026, 10, 4, 8, 12).millisecondsSinceEpoch; +Map get _update => {'from': 62, 'to': 58, 'at': _updateAt}; +String get _updateLine => + 'Updated ${formatClockOf(DateTime.fromMillisecondsSinceEpoch(_updateAt))} · 62 → 58'; + +void _surface(WidgetTester t, double w, {double scale = 1.0}) { + t.view.devicePixelRatio = 1; + t.view.physicalSize = Size(w, 3200); + t.platformDispatcher.textScaleFactorTestValue = scale; + addTearDown(t.view.reset); + addTearDown(t.platformDispatcher.clearTextScaleFactorTestValue); +} + +Future _rings(WidgetTester t, HomeData d, + {void Function(HomeRingKind)? onOpen, + double w = 390, + double scale = 1.0, + Locale? locale}) async { + _surface(t, w, scale: scale); + await t.pumpWidget(MaterialApp( + locale: locale, + theme: buildTheme(Brightness.light), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: SingleChildScrollView( + child: RingTrio(d: d, now: _now, onOpen: onOpen))), + )); + await t.pump(); +} + +Finder _why(String k) => find.byKey(ValueKey('home-why:$k')); +final _anyWhy = find.byWidgetPredicate((w) => + w.key is ValueKey && + (w.key as ValueKey).value.startsWith('home-why:')); +final _note = find.byKey(const ValueKey('home-update-note')); +Finder _gap(String k) => find.byKey(ValueKey('home-note:gap:$k')); +Finder _body(String k) => find.byKey(ValueKey('home-why-body:$k')); + +/// The text in [f], failing as an assertion (not a StateError) when absent. +String? _txt(WidgetTester t, Finder f) { + expect(f, findsOneWidget); + return t.widget(f).data; +} + +List _whyOrder(WidgetTester t) { + final all = t.widgetList(_anyWhy).map((w) => (w.key as ValueKey).value); + return [for (final k in all) k.substring('home-why:'.length)]..sort((a, b) => + t.getTopLeft(_why(a)).dy.compareTo(t.getTopLeft(_why(b)).dy)); +} + +/// The top-left of each ring's label. The rings must not move when rows come +/// and go in the bottom slot (no layout shift above the slot). +Map _ringPos(WidgetTester t) => { + for (final l in ['RECOVERY', 'STRAIN', 'SLEEP']) + l: t.getTopLeft(find.text(l).first), + }; + +Map _drv(String label, double c, String detail) => + {'label': label, 'contribution': c, 'detail': detail}; +Map _z(String l, double c, double z) => + _drv(l, c, 'oriented robust-z (median+MAD)=$z'); +const _drag = 'dragging your score down'; +const _lift = 'lifting your score'; + +class _Repo extends LocalRepository { + _Repo(this.drivers); + final List> drivers; + @override + Future> getToday() async => { + 'daily': { + 'readiness': {'value': 22, 'confidence': .8, 'tier': 'HIGH'}, + }, + 'status': {'today_day': '2026-10-04', 'overnight_state': 'ready'}, + }; + @override + Future> getInsights() async => { + 'readiness_glassbox': { + 'score': 22.0, + 'breakdown': const [], + 'drivers': drivers, + 'narrative': '', + 'inputs_used': 3, + }, + }; + @override + Future> getProfile() async => const {}; +} + +void main() { + // ── 0. what "pressing" means (pure) ─────────────────────────────────────── + group('pressingRings', () { + test('WHY: a healthy day explains nothing', () { + expect(pressingRings(_healthy, now: _now), isEmpty); + }); + + test('WHY: recovery is pressing below 37; 36 yes, 37 no', () { + expect(pressingRings(_data(recovery: 10), now: _now), + [HomeRingKind.recovery]); + expect(pressingRings(_data(recovery: 36), now: _now), + [HomeRingKind.recovery]); + expect(pressingRings(_data(recovery: 37), now: _now), isEmpty); + }); + + test('WHY: sleep is pressing under 85% of need; 84% yes, 86% no', () { + expect(pressingRings(_data(sleep: 403, need: 480), now: _now), + [HomeRingKind.sleep]); + expect(pressingRings(_data(sleep: 413, need: 480), now: _now), isEmpty); + }); + + test('WHY: strain is pressing only above the top of the target range', () { + expect(pressingRings(_data(strain: 15, target: _target), now: _now), + [HomeRingKind.strain]); + expect(pressingRings(_data(strain: 11, target: _target), now: _now), + isEmpty); + // Low strain is not pressing, and with no target there is no range. + expect(pressingRings(_data(strain: 0.0, target: _target), now: _now), + isEmpty); + expect(pressingRings(_data(strain: 20), now: _now), isEmpty); + }); + + test('WHY: severity decides the order, ties go recovery then sleep', () { + expect(pressingRings(_rec2Sleep3, now: _now), + [HomeRingKind.sleep, HomeRingKind.recovery]); + expect(pressingRings(_recAndSleep, now: _now), + [HomeRingKind.recovery, HomeRingKind.sleep]); + }); + + test('WHY: at most two; the least pressing is dropped', () { + expect(pressingRings(_allThree, now: _now), + [HomeRingKind.recovery, HomeRingKind.sleep]); + }); + + test('WHY: an absent, provisional or in-progress value is never pressing', + () { + expect(pressingRings(_data(recovery: null, sleep: null), now: _now), + isEmpty); + expect( + pressingRings(_data(recovery: 10, recoveryState: 'provisional'), + now: _now), + isEmpty); + expect( + pressingRings( + _data(recovery: null, recoveryState: 'night_in_progress'), + now: _now), + isEmpty); + }); + }); + + // ── 1. the why panels ───────────────────────────────────────────────────── + group('why panels', () { + testWidgets('WHY: the loose "Why?" footer is gone, drivers or not', + (t) async { + await _rings( + t, + _data(drivers: const [ + {'label': 'hrv'}, + {'label': 'rhr'}, + {'label': 'resp'}, + ])); + expect(find.textContaining('Why?'), findsNothing); + expect(find.byIcon(LucideIcons.info), findsNothing); + }); + + testWidgets('WHY: nothing pressing means no panel at all', (t) async { + await _rings(t, _healthy); + expect(_anyWhy, findsNothing); + }); + + testWidgets('WHY: one pressing ring gets exactly its own panel', (t) async { + await _rings(t, _lowRecovery); + expect(_anyWhy, findsOneWidget); + expect(_why('recovery'), findsOneWidget); + expect(find.text('Recovery is low'), findsOneWidget); + }); + + testWidgets('WHY: two pressing show recovery then sleep, capped at two', + (t) async { + await _rings(t, _allThree); + expect(_whyOrder(t), ['recovery', 'sleep']); + expect(_why('strain'), findsNothing); + expect(find.text('Sleep is short of need'), findsOneWidget); + // Derived from the data: 5h 50m asleep against an 8h 48m need. + expect(find.text('5h 50m asleep, 2h 58m under your 8h 48m need.'), + findsOneWidget); + }); + + testWidgets('WHY: panels order by how pressing, not by ring order', + (t) async { + await _rings(t, _rec2Sleep3); + expect(_whyOrder(t), ['sleep', 'recovery']); + }); + + testWidgets('WHY: a pressing strain gets the strain panel', (t) async { + await _rings(t, _data(strain: 15.2, target: _target)); + expect(_whyOrder(t), ['strain']); + expect(find.text('Strain is above target'), findsOneWidget); + expect(_txt(t, _body('strain')), + 'Strain 15.2, above today’s target of 8–12.'); + }); + + testWidgets("WHY: the icon is the ring's own in a circle, never an (i)", + (t) async { + await _rings(t, _allThree); + expect(_anyWhy, findsNWidgets(2)); + Finder iconIn(String k) => find.descendant( + of: find.byKey(ValueKey('home-why-icon:$k')), + matching: find.byType(Icon)); + expect(t.widget(iconIn('recovery')).icon, + LucideIcons.batteryCharging); + expect(t.widget(iconIn('sleep')).icon, LucideIcons.moon); + final circle = t.widget( + find.byKey(const ValueKey('home-why-icon:recovery'))); + expect((circle.decoration! as BoxDecoration).shape, BoxShape.circle); + + await _rings(t, _data(strain: 15, target: _target)); + expect(iconIn('strain'), findsOneWidget); + expect(t.widget(iconIn('strain')).icon, LucideIcons.zap); + }); + + testWidgets('WHY: a panel opens its own ring', (t) async { + final opened = []; + await _rings(t, _recAndSleep, onOpen: opened.add); + expect(_anyWhy, findsNWidgets(2)); + await t.tap(_why('sleep')); + await t.tap(_why('recovery')); + expect(opened, [HomeRingKind.sleep, HomeRingKind.recovery]); + }); + + testWidgets( + 'WHY (a11y): a panel is ONE tappable node that says its ring, title ' + 'and reason, and is at least 48 dp tall', (t) async { + final h = t.ensureSemantics(); + await _rings(t, _data(recovery: 10, drivers: [ + _z('hrv', -6.0, -2.0), + ]), onOpen: (_) {}); + expect(_why('recovery'), findsOneWidget); + final node = t.getSemantics(_why('recovery')); + final data = node.getSemanticsData(); + expect(data.hasAction(SemanticsAction.tap), isTrue); + expect(data.label, contains('Recovery is low')); + expect(data.label, contains('HRV')); + // Context, not just the reason: which ring this is about. + expect(data.label.toLowerCase(), contains('recovery')); + expect(t.getSize(_why('recovery')).height, greaterThanOrEqualTo(48)); + // Title and body are not announced a second time by separate nodes. + expect(find.bySemanticsLabel(RegExp('Recovery is low')), findsOneWidget); + h.dispose(); + }); + }); + + // ── 2. recovery copy: evidence or nothing ───────────────────────────────── + group('recovery why copy', () { + testWidgets('WHY: ranked by weighted contribution, with the real z', + (t) async { + await _rings( + t, + _data(recovery: 22, drivers: [ + _z('temp', -1.0, -3.0), + _z('hrv', -6.0, -2.0), + _z('rhr', -3.0, -1.0), + ])); + // HRV z=-2 (the biggest drag) outranks temp z=-3 (a smaller drag). + expect(_txt(t, _body('recovery')), + 'Biggest drag: HRV -2.0, Resting heart rate -1.0 (how far each sits from your usual).'); + }); + + testWidgets('WHY: without a numeric z the same order holds, no number', + (t) async { + await _rings( + t, + _data(recovery: 22, drivers: [ + _drv('temp', -1.0, _drag), + _drv('hrv', -6.0, _drag), + _drv('rhr', -3.0, _drag), + ])); + expect(_txt(t, _body('recovery')), + 'Pulling it down: HRV, Resting heart rate.'); + }); + + testWidgets('WHY: one drag names just that one', (t) async { + await _rings( + t, + _data(recovery: 22, drivers: [ + _drv('rhr', -3.1, _drag), + _drv('hrv', 2.0, _lift), + ])); + expect(_txt(t, _body('recovery')), + 'Pulling it down: Resting heart rate.'); + }); + + testWidgets( + 'WHY: only lifts, or no drivers, say the plain score and claim ' + 'nothing about "usual"', (t) async { + await _rings(t, _data(recovery: 22, drivers: [_drv('hrv', 2.0, _lift)])); + expect(_txt(t, _body('recovery')), 'Recovery 22 of 100'); + await _rings(t, _data(recovery: 22)); + expect(_txt(t, _body('recovery')), 'Recovery 22 of 100'); + expect(find.textContaining('usual'), findsNothing); + }); + + testWidgets( + 'WHY: on the shape HomeData.load really reads (readiness_glassbox ' + 'drivers) the copy names the drags by label', (t) async { + final d = await t.runAsync(() => HomeData.load(_Repo([ + _drv('hrv', -6.2, _drag), + _drv('rhr', 3.1, _lift), + _drv('temp', -1.4, _drag), + ]))); + expect(d!.drivers, hasLength(3), reason: 'the loader passed them through'); + await _rings(t, d); + expect(_txt(t, _body('recovery')), + 'Pulling it down: HRV, Skin temperature.'); + }); + }); + + // ── 3. the bottom slot: update note, gap reasons, two rows at most ──────── + group('bottom slot', () { + testWidgets('WHY: the recovery update is a tappable row, said once', + (t) async { + final opened = []; + await _rings(t, _data(update: _update), onOpen: opened.add); + expect(_note, findsOneWidget); + expect(find.text(_updateLine), findsOneWidget); + await t.tap(_note); + expect(opened, [HomeRingKind.recovery]); + }); + + testWidgets('WHY: no update, no row', (t) async { + await _rings(t, _data()); + expect(_note, findsNothing); + expect(find.textContaining('Updated'), findsNothing); + }); + + testWidgets( + 'WHY: the note is a neutral inset row (refresh icon, chevron, ' + 'centred), not a ring-coloured panel', (t) async { + await _rings(t, _data(update: _update)); + expect(_note, findsOneWidget); + final p = P.of(t.element(_note)); + expect( + find.descendant( + of: _note, matching: find.byIcon(LucideIcons.refreshCw)), + findsOneWidget); + expect( + find.descendant( + of: _note, matching: find.byIcon(LucideIcons.chevronRight)), + findsOneWidget); + final circle = t.widget( + find.byKey(const ValueKey('home-update-note-icon'))); + expect((circle.decoration! as BoxDecoration).color, + p.ink3.withValues(alpha: .16)); + final insets = t + .widgetList( + find.descendant(of: _note, matching: find.byType(Container))) + .where((c) { + final d = c.decoration; + return d is BoxDecoration && + d.color == p.card2 && + d.borderRadius == R.rMd; + }); + expect(insets, isNotEmpty); + }); + + testWidgets('WHY: an absent ring gets a row: its name over its reason', + (t) async { + await _rings(t, _data(strain: null)); + expect(_gap('strain'), findsOneWidget); + expect(find.descendant(of: _gap('strain'), matching: find.text('Strain')), + findsOneWidget); + }); + + testWidgets('WHY: order is why, then the update note, then a gap reason', + (t) async { + // One pressing ring (recovery 10), an update, and an absent strain: room + // for two, so the note follows the why and the gap reason has no slot. + await _rings(t, _data(recovery: 10, strain: null, update: _update)); + expect(_why('recovery'), findsOneWidget); + expect(_note, findsOneWidget); + expect(_gap('strain'), findsNothing); + expect(t.getTopLeft(_why('recovery')).dy, + lessThan(t.getTopLeft(_note).dy)); + }); + + testWidgets( + 'WHY: the update note does not hide an absent ring\'s reason when ' + 'there is room', (t) async { + await _rings(t, _data(strain: null, update: _update)); + expect(_note, findsOneWidget); + expect(_gap('strain'), findsOneWidget); + expect(t.getTopLeft(_note).dy, lessThan(t.getTopLeft(_gap('strain')).dy)); + }); + + testWidgets('WHY: the slot holds two rows at most; two whys leave no note', + (t) async { + await _rings(t, _data(recovery: 10, sleep: 350, need: 528, update: _update)); + expect(_whyOrder(t), ['recovery', 'sleep']); + expect(_note, findsNothing); + }); + + testWidgets('WHY: a healthy card with an absent ring and an update shows ' + 'both, never a third', (t) async { + await _rings(t, _data(strain: null, sleep: null, update: _update)); + final rows = [ + _note, + _gap('strain'), + _gap('sleep'), + ].where((f) => f.evaluate().isNotEmpty).length; + expect(rows, 2); + }); + }); + + // ── 4. layout: nothing above the slot ever moves ────────────────────────── + group('layout', () { + for (final w in [320.0, 390.0]) { + for (final scale in [1.0, 1.5, 2.0]) { + testWidgets( + 'WHY: the rings sit at the same place with 0, 1 or 2 rows ' + '@${w.toInt()} pt x$scale, and no row overflows', (t) async { + await _rings(t, _healthy, w: w, scale: scale); + final rest = _ringPos(t); + final restHeight = t.getSize(find.byType(RingTrio)).height; + await _rings(t, _lowRecovery, w: w, scale: scale); + expect(_ringPos(t), rest, reason: 'one row'); + final one = t.getSize(find.byType(RingTrio)).height; + await _rings(t, _recAndSleep, w: w, scale: scale); + expect(_ringPos(t), rest, reason: 'two rows'); + final two = t.getSize(find.byType(RingTrio)).height; + expect(t.takeException(), isNull); + // Each row costs its own height; zero rows reserve nothing. + expect(one, greaterThan(restHeight)); + expect(two, greaterThan(one)); + for (final k in ['recovery', 'sleep']) { + final body = t.renderObject(_body(k)) as RenderParagraph; + final fs = body.text.style?.fontSize ?? 12; + expect((body.size.height / (fs * scale * 1.6)).round(), + lessThanOrEqualTo(2), + reason: '$k body stays within two lines'); + } + }); + } + } + }); + + // ── 5. the sleep caption in every language ──────────────────────────────── + group('sleep caption', () { + // A scored night against the 8 h default: 442 of 480 min is 92%. + HomeData slept() => HomeData( + dayId: '2026-10-04', + readiness: _m(80), + strain: _m(5), + sleepMin: _m(442), + sleepStages: const SleepStageMin(deep: 70, rem: 95, light: 277, awake: 30), + ); + + for (final lang in ['de', 'en', 'es', 'fr', 'hi', 'ru', 'th', 'zh']) { + testWidgets( + 'WHY: $lang at 320 pt x1.3 prints the 92% caption whole, within ' + 'the three reserved lines', (t) async { + await _rings(t, slept(), w: 320, scale: 1.3, locale: Locale(lang)); + expect(t.takeException(), isNull); + final caption = find.byWidgetPredicate((w) => + w is Text && (w.data ?? '').contains('92') && !(w.data ?? '').contains(':')); + expect(caption, findsOneWidget, reason: 'one caption carrying the 92%'); + final para = t.renderObject(caption); + expect(para.didExceedMaxLines, isFalse, + reason: '$lang caption is cut by its line limit'); + // Three lines is the reserved caption height; more would shift the card. + final fs = para.text.style?.fontSize ?? 12; + expect(para.size.height / (fs * 1.3 * 1.5), lessThanOrEqualTo(3.01)); + }); + } + }); +} diff --git a/test/home_sleep_ring_widget_test.dart b/test/home_sleep_ring_widget_test.dart new file mode 100644 index 000000000..be7407e6b --- /dev/null +++ b/test/home_sleep_ring_widget_test.dart @@ -0,0 +1,785 @@ +// RED (Home + labels, phase 0). WHY: the Home sleep ring must show a scored +// night as stage arcs (Deep, REM, Light, Awake) against a target that says when +// it is the 8 h default, show an unslept evening as ONE grey estimate arc with +// its sources honest (alarm > learned wake, coach bedtime > learned onset), keep +// a slept night from ever being an empty bar, and follow the clock while open. +// Ported from the fork's test/home_sleep_ring_widget_test.dart with the saved +// "expected sleep schedule" (fork-only) replaced by the learned typical times, +// and the AppState overlay reduced to the armed alarm (upstream has alarmEpoch). +// +// The Home sleep ring as rendered: stage arcs, the grey estimate arc, the +// words under them, and the data HomeData hands it. +// +// Every instant is injected (`RingTrio.now`, fixed dates). Nothing here reads +// the system clock. +// +// ROOT-CAUSE REGRESSIONS (owner saw "of 8h 10m" only BEFORE sleeping and an +// EMPTY bar AFTER sleeping): +// * the arc and the "of ..." text were both gated on `sleepNeedMin` alone, so +// a scored night with no learned need drew duration + "No target yet" + an +// empty bar, and +// * `HomeData.loadForDay` never loaded `sleepNeedMin` at all, so any dated +// view of a slept night did the same even when a need existed. +// Both are pinned below: a slept night is never an empty bar, and the need +// reaches the ring from both loaders. + +import 'package:clock/clock.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; + +import 'package:openstrap_edge/data/local_repository.dart'; +import 'package:openstrap_edge/l10n/app_localizations.dart'; +import 'package:openstrap_edge/models/metric.dart'; +import 'package:openstrap_edge/state/app_state.dart'; +import 'package:openstrap_edge/ui2/screens/screens.dart'; +import 'package:openstrap_edge/ui2/ui2.dart'; + +// ── fixtures ─────────────────────────────────────────────────────────────── + +final _evening = DateTime(2026, 10, 8, 22, 0); + +/// The unslept sub-line's tail when the target is the 8 h default. +const _dflt = ' · target 8h 00m (default)'; + +Finder _spokenCaption(String text) => find.byWidgetPredicate((widget) => + widget is Pressable && (widget.semanticLabel?.contains(text) ?? false)); + +/// The caption is BOTH spoken in full and VISIBLE: as the full text, or as one +/// of its measured compact wordings (which keep the default / estimate +/// qualifiers). Exactly one Text shows it. +void _caption(String full, List compact) { + expect(_spokenCaption(full), findsOneWidget); + expect( + find.byWidgetPredicate( + (w) => + w is Text && + (w.data == full || + compact.contains(w.data))), + findsOneWidget, + reason: 'visible caption is neither "$full" nor one of $compact; texts: ${find.byType(Text).evaluate().map((e) => (e.widget as Text).data).toList()}'); +} + +const _stages = SleepStageMin(deep: 70, rem: 95, light: 277, awake: 30); + +Metric _min(num v) => Metric( + value: v, + unit: 'min', + confidence: .8, + tier: MetricTier.estimate, + ); + +HomeData _slept({ + Metric? need, + SleepStageMin? stages = _stages, + num duration = 442, +}) => + HomeData( + dayId: '2026-10-09', + readiness: const Metric(value: 82, confidence: .8, tier: MetricTier.high), + strain: const Metric(value: 9.0, confidence: .6, tier: MetricTier.estimate), + sleepMin: _min(duration), + sleepNeedMin: need ?? Metric.empty, + sleepStages: stages, + ); + +HomeData _unslept({ + bool typical = false, // learned onset 23:00, learned wake 07:00 + DateTime? alarm, + Metric need = Metric.empty, + Metric bedtime = Metric.empty, + int? learnedOnset, + int? learnedWake, + Metric sleepMin = Metric.empty, +}) => + HomeData( + dayId: '2026-10-08', + readiness: const Metric(value: 82, confidence: .8, tier: MetricTier.high), + strain: const Metric(value: 9.0, confidence: .6, tier: MetricTier.estimate), + sleepMin: sleepMin, + sleepNeedMin: need, + bedtime: bedtime, + nextAlarm: alarm, + learnedOnsetMin: learnedOnset ?? (typical ? 1380 : null), + learnedWakeMin: learnedWake ?? (typical ? 420 : null), + ); + +Future _pump( + WidgetTester t, + HomeData d, { + DateTime? now, + double textScale = 1, +}) async { + t.view.physicalSize = const Size(390 * 3, 800 * 3); + t.view.devicePixelRatio = 3; + addTearDown(t.view.reset); + await t.pumpWidget(MaterialApp( + theme: buildTheme(Brightness.light), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: MediaQuery( + data: MediaQueryData( + size: const Size(390, 800), + textScaler: TextScaler.linear(textScale), + ), + child: Scaffold( + body: SingleChildScrollView( + child: RingTrio(d: d, now: now ?? _evening), + ), + ), + ), + )); + await t.pumpAndSettle(); +} + +/// Every ring painter on screen, in tree order: recovery, strain, sleep. +List _ringPainters(WidgetTester t) => [ + for (final e in find.byType(CustomPaint).evaluate()) + if ((e.widget as CustomPaint).painter case final CustomPainter p + when p is Ring || p is StageRing || p is DashedRing) + p, + ]; + +CustomPainter _sleepPainter(WidgetTester t) => _ringPainters(t).last; + +P _p(WidgetTester t) => P.of(t.element(find.byType(RingTrio))); + +// ── a fake repository, for the loaders ───────────────────────────────────── + +class _Repo extends LocalRepository { + _Repo({ + this.today = const {}, + this.insights = const {}, + this.night = const {}, + this.windows = const [], + }); + + final Map today, insights, night; + final List> windows; + + @override + Future> getToday() async => today; + @override + Future> getInsights() async => insights; + @override + Future> getProfile() async => const {}; + @override + Future> getDaySleepV2(String date) async => night; + @override + Future> getDayOverview(String date) async => const {}; + @override + Future> getDayStrain(String date) async => const {}; + @override + Future>> sleepWindows( + {int days = 60, String? before}) async => + windows; +} + +Map _env(num v) => { + 'value': v, + 'confidence': .8, + 'tier': 'ESTIMATE', + 'unit': 'min', + }; + +const _needInsights = { + 'sleep_coach': { + 'need': { + 'value': {'need_sec': 27720}, // 462 min + 'confidence': .7, + 'tier': 'ESTIMATE', + }, + }, +}; + +Map get _sleptToday => { + 'daily': const {}, + 'sleep': {'duration_min': _env(442)}, + 'status': { + 'today_day': '2026-10-09', + 'overnight_state': 'ready', + 'overnight_day': '2026-10-09', + 'showing_prior_overnight': false, + }, + }; + +const _nightMap = { + 'has_sleep': true, + 'duration_min': 442, + 'deep_min': 70, + 'rem_min': 95, + 'light_min': 277, + 'awake_min': 30, +}; + +/// Five nights, onset 23:00 the evening before and wake 07:00, as +/// `sleepWindows` returns them (epoch seconds). +List> _windows() => [ + for (var day = 1; day <= 5; day++) + { + 'date': '2026-10-0$day', + 'onset_ts': + DateTime(2026, 10, day - 1, 23, 0).millisecondsSinceEpoch ~/ 1000, + 'wake_ts': + DateTime(2026, 10, day, 7, 0).millisecondsSinceEpoch ~/ 1000, + }, + ]; + +void main() { + group('bounded scores are clamped at the call site', () { + testWidgets('recovery 130 and strain 30 draw one lap, not a spiral', + (t) async { + await _pump( + t, + HomeData( + dayId: '2026-10-09', + readiness: + const Metric(value: 130, confidence: .8, tier: MetricTier.high), + strain: const Metric( + value: 30, confidence: .6, tier: MetricTier.estimate), + sleepMin: _min(442), + sleepStages: _stages, + ), + ); + final rings = _ringPainters(t).whereType().toList(); + expect(rings, hasLength(2)); + for (final r in rings) { + expect(r.v, 1.0); + } + }); + }); + + group('slept: stage arcs', () { + testWidgets('Deep, REM, Light, Awake in the hypnogram colours, ' + 'contiguous, on the track', (t) async { + await _pump(t, _slept()); + final painter = _sleepPainter(t); + expect(painter, isA()); + final ring = painter as StageRing; + final cols = Hypnogram.cols(_p(t)); + expect([for (final a in ring.arcs) a.color], [ + cols[SleepStage.deep], + cols[SleepStage.rem], + cols[SleepStage.light], + cols[SleepStage.awake], + ]); + expect([for (final a in ring.arcs) a.fraction], [ + closeTo(70 / 480, 1e-9), + closeTo(95 / 480, 1e-9), + closeTo(277 / 480, 1e-9), + closeTo(30 / 480, 1e-9), + ]); + expect(ring.track, _p(t).track); + }); + + testWidgets('value is the duration; sub is "92% of 8h 00m (default)" when ' + 'the target is the default', (t) async { + await _pump(t, _slept()); + expect(find.text('7h 22m'), findsOneWidget); + _caption('92% of 8h 00m (default)', ['92% of 8h · default target', '92% of default 8h']); + }); + + testWidgets('a learned need is the target and is NOT labelled default', + (t) async { + await _pump(t, _slept(need: _min(462))); + expect(find.text('96% of 7h 42m'), findsOneWidget); + expect(find.textContaining('default'), findsNothing); + final ring = _sleepPainter(t) as StageRing; + expect(ring.arcs.first.fraction, closeTo(70 / 462, 1e-9)); + }); + + testWidgets('no stage totals => ONE solid arc in the sleep colour, never ' + 'a split', (t) async { + await _pump(t, _slept(stages: null)); + final painter = _sleepPainter(t); + expect(painter, isNot(isA())); + expect(painter, isA()); + final ring = painter as Ring; + expect(ring.color, _p(t).on(C.blue)); + expect(ring.v, closeTo(442 / 480, 1e-9)); + expect(ring.solid, isTrue); + }); + + testWidgets('a night longer than the target is drawn past a full circle', + (t) async { + await _pump( + t, + _slept( + duration: 540, + stages: const SleepStageMin(deep: 90, rem: 120, light: 330, awake: 20), + ), + ); + final ring = _sleepPainter(t) as StageRing; + final sweep = ring.arcs.fold(0, (s, a) => s + a.fraction); + expect(sweep, closeTo(540 / 480, 1e-9)); + _caption('113% of 8h 00m (default)', ['113% of 8h · default target', '113% of default 8h']); + }); + + testWidgets('stages still draw at the accessibility text size', + (t) async { + await _pump(t, _slept(), textScale: 2); + expect(_sleepPainter(t), isA()); + _caption('92% of 8h 00m (default)', ['92% of 8h · default target', '92% of default 8h']); + }); + + testWidgets('spoken text carries the percentage and the default label', + (t) async { + final h = t.ensureSemantics(); + await _pump(t, _slept()); + expect( + find.bySemanticsLabel( + RegExp(r'Sleep\. 7h 22m\. 92% of 8h 00m \(default\)')), + findsOneWidget, + ); + h.dispose(); + }); + }); + + group('regression: a slept night is never an empty bar', () { + testWidgets('scored night + NO learned need: arc is drawn against the ' + 'labelled default, not "No target yet" and an empty bar', (t) async { + await _pump(t, _slept(need: Metric.empty, stages: null)); + expect(find.text('No target yet'), findsNothing); + final ring = _sleepPainter(t) as Ring; + expect(ring.v, greaterThan(0)); + _caption('92% of 8h 00m (default)', ['92% of 8h · default target', '92% of default 8h']); + }); + + testWidgets('scored night + learned need present: the "of" target is ' + 'there AND the bar is filled', (t) async { + await _pump(t, _slept(need: _min(462), stages: null)); + expect(find.text('96% of 7h 42m'), findsOneWidget); + expect((_sleepPainter(t) as Ring).v, closeTo(442 / 462, 1e-9)); + }); + + test('HomeData.load carries the learned need and the stage totals of a ' + 'night that scored', () async { + final repo = _Repo( + today: _sleptToday, + insights: _needInsights, + night: _nightMap, + ); + final d = await HomeData.load(repo); + expect(d.sleepMin.value, 442); + expect(d.sleepNeedMin.value, 462); + expect(d.sleepStages?.deep, 70); + expect(d.sleepStages?.rem, 95); + expect(d.sleepStages?.light, 277); + expect(d.sleepStages?.awake, 30); + }); + + test('HomeData.loadForDay (a dated view of a slept night) carries the ' + 'learned need — it used to drop it and draw an empty bar', () async { + final repo = _Repo( + insights: _needInsights, + night: _nightMap, + ); + final d = await HomeData.loadForDay(repo, '2026-10-08'); + expect(d.sleepMin.value, 442); + expect(d.sleepNeedMin.value, 462); + expect(d.sleepStages?.deep, 70); + }); + + test('HomeData.loadForDay with no learned need leaves it absent (the ring ' + 'then shows the labelled default, never a made-up need)', () async { + final repo = _Repo(night: _nightMap); + final d = await HomeData.loadForDay(repo, '2026-10-08'); + expect(d.sleepNeedMin.value, isNull); + expect(d.sleepMin.value, 442); + }); + + testWidgets('a dated slept night with no need renders a filled bar and the ' + 'default label', (t) async { + final d = await t.runAsync(() => HomeData.loadForDay( + _Repo(night: _nightMap), '2026-10-08')); + await _pump(t, d!); + expect(find.text('No target yet'), findsNothing); + _caption('92% of 8h 00m (default)', ['92% of 8h · default target', '92% of default 8h']); + expect(_sleepPainter(t), isA()); + }); + }); + + group('loaders: learned times, no stale stages', () { + test('HomeData.load learns the typical onset and wake from recent nights', + () async { + final d = await HomeData.load(_Repo( + today: _sleptToday, + insights: _needInsights, + night: _nightMap, + windows: _windows(), + )); + expect(d.learnedOnsetMin, 23 * 60); + expect(d.learnedWakeMin, 7 * 60); + }); + + test('a night that is NOT today\'s never lends its stages to today', + () async { + final repo = _Repo( + today: { + 'daily': const {}, + 'sleep': const {}, + 'status': { + 'today_day': '2026-10-09', + 'overnight_state': 'building', + 'overnight_day': '2026-10-08', + 'showing_prior_overnight': true, + }, + }, + insights: _needInsights, + night: _nightMap, // last night's, which the ring must not borrow + ); + final d = await HomeData.load(repo); + expect(d.sleepMin.value, isNull); + expect(d.sleepStages, isNull); + expect(d.sleepNeedMin.value, 462); + }); + }); + + group('HomeScreen lays AppState\'s armed alarm over the loaded data', + () { + Future pumpHome(WidgetTester t, AppState app) async { + t.view.physicalSize = const Size(390 * 3, 1600 * 3); + t.view.devicePixelRatio = 3; + addTearDown(t.view.reset); + await t.pumpWidget(MaterialApp( + theme: buildTheme(Brightness.light), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: ChangeNotifierProvider.value( + value: app, + child: Scaffold( + body: HomeScreen( + hour: 22, + now: _evening, + data: _unslept(typical: true), + ), + ), + ), + )); + for (var i = 0; i < 10; i++) { + await t.pump(const Duration(milliseconds: 20)); + } + } + + testWidgets('an armed alarm beats the learned wake', (t) async { + final app = AppState.forTesting(); + addTearDown(app.dispose); + app.device.alarmEpoch = + DateTime(2026, 10, 9, 6, 30).millisecondsSinceEpoch ~/ 1000; + await pumpHome(t, app); + _caption('on track for 5 cycles, 7h 30m$_dflt', ['~5 cycles · 7h 30m · default target', '~5 cycles · 7h 30m · default goal', '~5 cycles · default goal']); + }); + + testWidgets('an alarm already past is ignored', (t) async { + final app = AppState.forTesting(); + addTearDown(app.dispose); + app.device.alarmEpoch = + DateTime(2026, 10, 8, 21, 0).millisecondsSinceEpoch ~/ 1000; + await pumpHome(t, app); + _caption('on track for 5 cycles, 8h 00m$_dflt', ['~5 cycles · 8h · default target', '~5 cycles · 8h · default goal', '~5 cycles · default goal']); + }); + + testWidgets('with nothing saved and nothing armed there is no estimate', + (t) async { + final app = AppState.forTesting(); + addTearDown(app.dispose); + await pumpHome(t, app); + // An absence is a dash in the ring; the reason is a row under the rings. + final gap = find.byKey(const ValueKey('home-note:gap:sleep')); + expect(find.descendant(of: gap, matching: find.text('Sleep')), findsOneWidget); + expect(find.descendant(of: gap, matching: find.textContaining('No sleep scored yet')), + findsOneWidget); + }); + }); + + group('a day with no measured data still shows the estimate', () { + Future pumpBare(WidgetTester t, AppState app, + {HomeData data = const HomeData()}) async { + t.view.physicalSize = const Size(390 * 3, 1600 * 3); + t.view.devicePixelRatio = 3; + addTearDown(t.view.reset); + await t.pumpWidget(MaterialApp( + theme: buildTheme(Brightness.light), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: ChangeNotifierProvider.value( + value: app, + child: Scaffold( + body: HomeScreen(hour: 22, now: _evening, data: data), + ), + ), + )); + for (var i = 0; i < 10; i++) { + await t.pump(const Duration(milliseconds: 20)); + } + } + + testWidgets('learned times and no readiness/strain/sleep: rings, not ' + 'the "Nothing derived yet" card', (t) async { + final app = AppState.forTesting(); + addTearDown(app.dispose); + await pumpBare(t, app, data: const HomeData(learnedOnsetMin: 1380, learnedWakeMin: 420)); + expect(find.textContaining('No nights scored yet'), findsNothing); + _caption('on track for 5 cycles, 8h 00m$_dflt', ['~5 cycles · 8h · default target', '~5 cycles · 8h · default goal', '~5 cycles · default goal']); + }); + + testWidgets('nothing to estimate from: the status card, as before', + (t) async { + final app = AppState.forTesting(); + addTearDown(app.dispose); + await pumpBare(t, app); + expect(find.textContaining('No nights scored yet'), findsOneWidget); + expect(find.text('No estimate'), findsNothing); + }); + }); + + group('the estimate follows the clock while the screen stays open', () { + final base = DateTime(2026, 10, 9, 2, 0, 30); + + testWidgets('it re-reads the clock on each minute boundary — no injected ' + 'now', (t) async { + var fake = base; + await withClock(Clock(() => fake), () async { + final app = AppState.forTesting(); + addTearDown(app.dispose); + t.view.physicalSize = const Size(390 * 3, 1600 * 3); + t.view.devicePixelRatio = 3; + addTearDown(t.view.reset); + await t.pumpWidget(MaterialApp( + theme: buildTheme(Brightness.light), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: ChangeNotifierProvider.value( + value: app, + child: Scaffold( + body: HomeScreen(hour: 2, data: _unslept(typical: true)), + ), + ), + )); + await t.pump(const Duration(milliseconds: 20)); + // 02:00:30 -> wake 07:00 is 4 h 59 m. + expect(find.text('4h 59m'), findsWidgets); + + // Half a minute later the next boundary has passed. + fake = base.add(const Duration(seconds: 30)); + await t.pump(const Duration(seconds: 30)); + // 02:01:00 -> 4 h 59 m still; one more minute is 4 h 58 m. + fake = base.add(const Duration(seconds: 90)); + await t.pump(const Duration(seconds: 60)); + expect(find.text('4h 58m'), findsWidgets); + expect(find.text('4h 59m'), findsNothing); + + // Half an hour on, with nothing but the passing minutes. + for (var i = 0; i < 30; i++) { + fake = fake.add(const Duration(minutes: 1)); + await t.pump(const Duration(minutes: 1)); + } + expect(find.text('4h 28m'), findsWidgets); + }); + }); + + testWidgets('it stops ticking once the screen is gone', (t) async { + var fake = base; + await withClock(Clock(() => fake), () async { + final app = AppState.forTesting(); + addTearDown(app.dispose); + await t.pumpWidget(MaterialApp( + theme: buildTheme(Brightness.light), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: ChangeNotifierProvider.value( + value: app, + child: Scaffold(body: HomeScreen(hour: 2, data: _unslept(typical: true))), + ), + )); + await t.pump(const Duration(milliseconds: 20)); + await t.pumpWidget(const SizedBox()); + // A timer left behind would be reported as pending by the framework. + fake = fake.add(const Duration(minutes: 5)); + await t.pump(const Duration(minutes: 5)); + }); + }); + }); + + group('unslept: the grey estimate', () { + testWidgets('ring is ONE grey arc (track ink, no stage colours) filled to ' + 'estimate / target', (t) async { + await _pump( + t, + _unslept(typical: true, need: _min(540)), + ); + final painter = _sleepPainter(t); + expect(painter, isNot(isA())); + final ring = painter as Ring; + expect(ring.color, _p(t).ink3); + expect(ring.v, closeTo(480 / 540, 1e-9)); + final stageColours = Hypnogram.cols(_p(t)).values; + expect(stageColours.contains(ring.color), isFalse); + }); + + testWidgets('value is the estimate; sub is "on track for n cycles, xxh ' + 'yym"', (t) async { + await _pump(t, _unslept(typical: true)); + expect(find.text('8h 00m'), findsNWidgets(1)); + _caption('on track for 5 cycles, 8h 00m$_dflt', ['~5 cycles · 8h · default target', '~5 cycles · 8h · default goal', '~5 cycles · default goal']); + }); + + testWidgets('one cycle is singular', (t) async { + await _pump( + t, + _unslept(typical: true), + now: DateTime(2026, 10, 9, 5, 20), + ); + _caption('on track for 1 cycle, 1h 40m$_dflt', ['~1 cycle · 1h 40m · default target', '~1 cycle · 1h 40m · default goal', '~1 cycle · default goal']); + }); + + testWidgets('a LEARNED need is the denominator: no "(default)" tail', + (t) async { + await _pump(t, _unslept(typical: true, need: _min(540))); + _caption('on track for 5 cycles, 8h 00m', ['~5 cycles · 8h', '~5 cycles']); + expect(find.textContaining('default'), findsNothing); + }); + + testWidgets('the DEFAULT denominator is visibly labelled on the unslept ' + 'sub-line too', (t) async { + await _pump(t, _unslept(typical: true)); + expect(find.textContaining('default'), findsOneWidget); + _caption('on track for 5 cycles, 8h 00m$_dflt', ['~5 cycles · 8h · default target', '~5 cycles · 8h · default goal', '~5 cycles · default goal']); + }); + + testWidgets('an estimate has no percentage and no "(default)" label ' + 'on screen', (t) async { + await _pump(t, _unslept(typical: true)); + expect(find.textContaining('%'), findsNothing); + }); + + testWidgets('next armed alarm sets the wake', (t) async { + await _pump( + t, + _unslept( + typical: true, + alarm: DateTime(2026, 10, 9, 6, 30), + ), + ); + _caption('on track for 5 cycles, 7h 30m$_dflt', ['~5 cycles · 7h 30m · default target', '~5 cycles · 7h 30m · default goal', '~5 cycles · default goal']); + }); + + testWidgets('coach bedtime beats the learned onset', (t) async { + await _pump( + t, + _unslept( + typical: true, + bedtime: const Metric( + value: 22 * 60 + 30, confidence: .7, tier: MetricTier.estimate), + ), + ); + _caption('on track for 5 cycles, 8h 30m$_dflt', ['~5 cycles · 8h 30m · default target', '~5 cycles · 8h 30m · default goal', '~5 cycles · default goal']); + }); + + testWidgets('learned onset and wake alone give the estimate', (t) async { + await _pump( + t, + _unslept(learnedOnset: 23 * 60 + 30, learnedWake: 7 * 60 + 30), + ); + _caption('on track for 5 cycles, 8h 00m$_dflt', ['~5 cycles · 8h · default target', '~5 cycles · 8h · default goal', '~5 cycles · default goal']); + }); + + testWidgets('a user cycle length (hook) changes the cycle count', + (t) async { + final d = HomeData( + learnedOnsetMin: 1380, learnedWakeMin: 420, + cycleLenMin: 100, + ); + await _pump(t, d); + _caption('on track for 4 cycles, 8h 00m$_dflt', ['~4 cycles · 8h · default target', '~4 cycles · 8h · default goal', '~4 cycles · default goal']); + }); + + testWidgets('the estimate depends only on the injected now — time passing ' + 'in the test changes nothing', (t) async { + await _pump(t, _unslept(typical: true)); + await t.pump(const Duration(hours: 3)); + _caption('on track for 5 cycles, 8h 00m$_dflt', ['~5 cycles · 8h · default target', '~5 cycles · 8h · default goal', '~5 cycles · default goal']); + }); + + testWidgets('spoken text carries the estimate sentence', (t) async { + final h = t.ensureSemantics(); + await _pump(t, _unslept(typical: true)); + expect( + find.bySemanticsLabel( + RegExp(r'Sleep\. 8h 00m\. on track for 5 cycles, 8h 00m · target 8h 00m \(default\)')), + findsOneWidget, + ); + h.dispose(); + }); + }); + + group('unslept: no wake, no estimate', () { + testWidgets('an honest absence and an empty ring — never a guessed time', + (t) async { + await _pump( + t, + _unslept( + bedtime: const Metric( + value: 1350, confidence: .7, tier: MetricTier.estimate), + learnedOnset: 1380, + ), + ); + // An absence is a dash in the ring; the reason is a row under the rings. + final gap = find.byKey(const ValueKey('home-note:gap:sleep')); + expect(find.descendant(of: gap, matching: find.text('Sleep')), findsOneWidget); + expect(find.descendant(of: gap, matching: find.textContaining('No sleep scored yet')), + findsOneWidget); + expect(find.textContaining('on track'), findsNothing); + final ring = _sleepPainter(t) as Ring; + expect(ring.v, 0); + expect( + find.textContaining('no alarm or typical wake time', + findRichText: true), + findsOneWidget, + ); + }); + + testWidgets('the pipeline\'s own reason comes first', (t) async { + await _pump( + t, + _unslept( + sleepMin: const Metric(note: 'Last night is still being processed.'), + ), + ); + expect( + find.textContaining('Last night is still being processed.', + findRichText: true), + findsOneWidget, + ); + }); + + testWidgets('RingTrio.has: an estimate is something to draw; nothing is ' + 'not', (t) async { + expect( + RingTrio.has( + HomeData(learnedOnsetMin: 1380, learnedWakeMin: 420), + now: _evening, + ), + isTrue, + ); + expect(RingTrio.has(const HomeData(), now: _evening), isFalse); + }); + }); + + group('calibrating is unchanged', () { + testWidgets('a baseline note still draws the dashed ring, even when an ' + 'estimate could be made', (t) async { + await _pump( + t, + _unslept( + typical: true, + sleepMin: const Metric(note: 'need_baseline:have=3,need=14'), + ), + ); + final p = _sleepPainter(t); + expect(p, isA()); + expect((p as DashedRing).segments, 14); + expect(find.textContaining('on track'), findsNothing); + }); + }); +} diff --git a/test/home_tick_dst_test.dart b/test/home_tick_dst_test.dart new file mode 100644 index 000000000..7fd3d997a --- /dev/null +++ b/test/home_tick_dst_test.dart @@ -0,0 +1,72 @@ +// RED (Home + labels, phase 0). WHY: the sleep ring's unslept estimate is +// "wake minus now", so Home re-reads the clock on every minute boundary. The +// delay to the next minute must come from the UTC instant: in the repeated +// fall-back hour `DateTime(y, m, d, h, min + 1)` resolves to the FIRST +// occurrence of that wall time, an hour in the past, which became a zero-delay +// Timer and a setState loop. Ported from the fork's test/home_tick_dst_test.dart +// (the pure delay function only; the screen-level ticking is pinned in +// home_sleep_ring_widget_test.dart). +// +// The process timezone is moved with the shared setProcessTz helper (POSIX +// setenv/tzset) and restored afterwards, so the repeated hour is real on any +// host. Every instant is built from UTC; nothing reads the system clock. + +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/ui2/screens/screens.dart'; + +import 'support/process_tz.dart'; + +void main() { + final originalTz = Platform.environment['TZ']; + + setUp(() { + if (Platform.isWindows) markTestSkipped('POSIX-only (libc setenv)'); + setProcessTz('America/Denver'); + }); + tearDown(() => setProcessTz(originalTz)); + + group('nextMinuteTickDelay', () { + test('fall back 2026-11-01, second 01:30:30 MST (08:30:30Z): 30 s', () { + final now = DateTime.utc(2026, 11, 1, 8, 30, 30).toLocal(); + expect(nextMinuteTickDelay(now), const Duration(seconds: 30)); + }); + + test('every second of the repeated hour waits (0, 60 s]', () { + final start = DateTime.utc(2026, 11, 1, 7, 55); + for (var s = 0; s < 15 * 60; s += 7) { + final now = start.add(Duration(seconds: s)).toLocal(); + final d = nextMinuteTickDelay(now); + expect(d > Duration.zero, isTrue, reason: '$now -> $d'); + expect(d <= const Duration(seconds: 60), isTrue, reason: '$now -> $d'); + } + }); + + test('on a whole minute the next tick is a full minute away', () { + final now = DateTime.utc(2026, 11, 1, 8, 30).toLocal(); + expect(nextMinuteTickDelay(now), const Duration(seconds: 60)); + }); + + test('one millisecond before the boundary', () { + final now = DateTime.utc(2026, 11, 1, 8, 30, 59, 999).toLocal(); + expect(nextMinuteTickDelay(now), const Duration(milliseconds: 1)); + }); + + test('spring forward 2026-03-08: 01:59:30 MST (08:59:30Z) is 30 s', () { + final now = DateTime.utc(2026, 3, 8, 8, 59, 30).toLocal(); + expect(nextMinuteTickDelay(now), const Duration(seconds: 30)); + }); + + test('spring forward: the first MDT second waits a full minute', () { + final now = DateTime.utc(2026, 3, 8, 9, 0).toLocal(); + expect(nextMinuteTickDelay(now), const Duration(seconds: 60)); + }); + + test('a half-hour zone has the same minute boundaries', () { + setProcessTz('Asia/Kolkata'); + final now = DateTime.utc(2026, 11, 1, 8, 30, 45).toLocal(); + expect(nextMinuteTickDelay(now), const Duration(seconds: 15)); + }); + }); +} diff --git a/test/sleep_ring_model_test.dart b/test/sleep_ring_model_test.dart new file mode 100644 index 000000000..be7914341 --- /dev/null +++ b/test/sleep_ring_model_test.dart @@ -0,0 +1,630 @@ +// RED (Home + labels, phase 0). WHY: the Home sleep ring must draw and word the +// night from one pure, fixed-date model, so the rules (default target flagged, +// stage arcs grouped Deep/REM/Light/Awake, no invented split, honest estimate +// sources, real elapsed time across DST) are pinned apart from any widget. +// Ported from the fork's test/sleep_ring_model_test.dart with the saved +// "expected sleep schedule" source removed (fork-only, state/control_operations); +// the learned typical times stand in for it. The fork's source-grep guard on the +// model file is dropped: not reading a clock is shown by the fixed-now tests. +// +// The Home sleep ring's rules, as a pure function of fixed dates. +// +// Two states, one function (`sleepRingModel`): +// +// SLEPT today has a scored night. The ring is that night's stages as +// contiguous arcs grouped Deep, REM, Light, then Awake; the total +// sweep of the asleep stages is asleep / target, NOT capped: above a full +// circle. Stage totals absent => ONE solid arc, never an invented +// split. Sub-line percentage is asleep / target. +// UNSLEPT no scored night yet. Value is an ESTIMATE = wake - max(start, now). +// start: coach bedtime > learned onset. +// wake : next armed alarm > learned wake. +// No wake => no estimate (phase `none`), ring empty. One grey arc. +// +// TARGET: the learned need when there is one, otherwise the owner's explicit +// default of 8 h (5 cycles + 30 min), flagged `targetIsDefault`. +// +// Every instant is injected. No test here reads the system clock. The DST +// cases move the PROCESS timezone with libc setenv/tzset (same idiom as +// day_window_dst_test.dart) and put it back afterwards; elapsed time across a +// transition is real elapsed time, not wall-clock subtraction. + +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/ui2/sleep_ring_model.dart'; + +import 'support/process_tz.dart'; + +const _h23 = 23 * 60; // 23:00 +const _h07 = 7 * 60; // 07:00 + +SleepRingModel _unslept({ + required DateTime now, + num? need, + num? bedtime, + DateTime? alarm, + int? learnedOnset, + int? learnedWake, + int? cycleLen, +}) => + sleepRingModel( + now: now, + needMin: need, + coachBedtimeMinOfDay: bedtime, + nextAlarm: alarm, + learnedOnsetMin: learnedOnset, + learnedWakeMin: learnedWake, + cycleLenMin: cycleLen, + ); + +// The evening before the night under test. +final _evening = DateTime(2026, 10, 8, 22, 0); + +SleepRingModel _slept({ + num duration = 442, + SleepStageMin? stages = const SleepStageMin( + deep: 70, + rem: 95, + light: 277, + awake: 30, + ), + num? need, + DateTime? now, +}) => + sleepRingModel( + now: now ?? DateTime(2026, 10, 9, 9, 0), + durationMin: duration, + stages: stages, + needMin: need, + ); + +void main() { + group('target', () { + test('no learned need => the 8 h default, flagged as a default', () { + final m = _slept(); + expect(m.targetMin, 480); + expect(m.targetIsDefault, isTrue); + expect(kDefaultSleepTargetMin, 480); + }); + + test('a learned need is the target and is NOT flagged', () { + final m = _slept(need: 462); + expect(m.targetMin, 462); + expect(m.targetIsDefault, isFalse); + }); + + test('a zero or negative need is no need at all (default, flagged)', () { + for (final bad in [0, -30]) { + final m = _slept(need: bad); + expect(m.targetMin, 480, reason: 'need $bad'); + expect(m.targetIsDefault, isTrue, reason: 'need $bad'); + } + }); + }); + + group('slept', () { + test('phase, asleep minutes and whole-percent of the default target', () { + final m = _slept(); + expect(m.phase, SleepRingPhase.slept); + expect(m.asleepMin, 442); + // 442 / 480 = 92.08 % + expect(m.pct, 92); + }); + + test('percent is against the LEARNED need when there is one', () { + // 442 / 462 = 95.67 % + expect(_slept(need: 462).pct, 96); + }); + + test('percent above 100 is also drawn above 100: the ring is a spiral', () { + final m = _slept( + duration: 540, + stages: const SleepStageMin(deep: 90, rem: 120, light: 330), + ); + expect(m.pct, 113); + expect(m.sweep, closeTo(540 / 480, 1e-9)); + }); + + test('arcs are grouped Deep, REM, Light, Awake — in that order', () { + final m = _slept(); + expect( + [for (final a in m.arcs) a.kind], + [ + SleepArcKind.deep, + SleepArcKind.rem, + SleepArcKind.light, + SleepArcKind.awake, + ], + ); + }); + + test('each stage is its minutes / target of the full circle', () { + final m = _slept(); + expect(m.arcs[0].fraction, closeTo(70 / 480, 1e-9)); + expect(m.arcs[1].fraction, closeTo(95 / 480, 1e-9)); + expect(m.arcs[2].fraction, closeTo(277 / 480, 1e-9)); + expect(m.arcs[3].fraction, closeTo(30 / 480, 1e-9)); + }); + + test('the ASLEEP arcs together sweep exactly asleep / target', () { + final m = _slept(); + final asleep = m.arcs + .where((a) => a.kind != SleepArcKind.awake) + .fold(0, (s, a) => s + a.fraction); + expect(asleep, closeTo(442 / 480, 1e-9)); + }); + + test('stage totals that disagree with the duration by rounding are ' + 'rescaled so the sweep still equals asleep / target', () { + // 70 + 95 + 270 = 435, but the night's duration is 442. + final m = _slept( + stages: const SleepStageMin(deep: 70, rem: 95, light: 270), + ); + final asleep = m.arcs.fold(0, (s, a) => s + a.fraction); + expect(asleep, closeTo(442 / 480, 1e-9)); + // Proportions between stages are untouched. + expect(m.arcs[0].fraction / m.arcs[1].fraction, closeTo(70 / 95, 1e-9)); + expect(m.arcs[2].fraction / m.arcs[1].fraction, closeTo(270 / 95, 1e-9)); + }); + + test('Awake is drawn after Light with its own length, and never pushes the ' + 'ring past a full circle', () { + // Asleep covers 0.9208 of the circle; 38 min awake would be 0.0792 + // more, which is exactly the room left. 60 min awake must be clipped. + final m = _slept( + stages: const SleepStageMin( + deep: 70, + rem: 95, + light: 277, + awake: 60, + ), + ); + expect(m.arcs.last.kind, SleepArcKind.awake); + expect(m.sweep, closeTo(1.0, 1e-9)); + expect(m.arcs.last.fraction, closeTo(1 - 442 / 480, 1e-9)); + }); + + test('asleep >= target: the circle is full of sleep and Awake gets no arc', + () { + final m = _slept( + duration: 540, + stages: const SleepStageMin( + deep: 90, + rem: 120, + light: 330, + awake: 20, + ), + ); + expect(m.arcs.any((a) => a.kind == SleepArcKind.awake), isFalse); + expect(m.sweep, closeTo(540 / 480, 1e-9)); + // Stage fractions are minutes / NEED, not rescaled to the circle. + expect(m.arcs[0].fraction, closeTo(90 / 480, 1e-9)); + expect(m.arcs[1].fraction, closeTo(120 / 480, 1e-9)); + expect(m.arcs[2].fraction, closeTo(330 / 480, 1e-9)); + }); + + test('no awake figure => no awake arc (not a zero-length one)', () { + final m = _slept( + stages: const SleepStageMin(deep: 70, rem: 95, light: 277), + ); + expect( + [for (final a in m.arcs) a.kind], + [SleepArcKind.deep, SleepArcKind.rem, SleepArcKind.light], + ); + }); + + test('NO stage totals => ONE solid arc, never an invented split', () { + final m = _slept(stages: null); + expect(m.phase, SleepRingPhase.slept); + expect(m.arcs, hasLength(1)); + expect(m.arcs.single.kind, SleepArcKind.solid); + expect(m.arcs.single.fraction, closeTo(442 / 480, 1e-9)); + }); + + test('a PARTIAL split (a stage missing) is also one solid arc', () { + final m = _slept( + stages: const SleepStageMin(deep: 70, light: 277, awake: 30), + ); + expect(m.arcs, hasLength(1)); + expect(m.arcs.single.kind, SleepArcKind.solid); + expect(m.arcs.single.fraction, closeTo(442 / 480, 1e-9)); + }); + + test('stage totals that are all zero are no split either', () { + final m = _slept( + stages: const SleepStageMin(deep: 0, rem: 0, light: 0, awake: 0), + ); + expect(m.arcs, hasLength(1)); + expect(m.arcs.single.kind, SleepArcKind.solid); + }); + + test('a solid arc is NOT capped: 600 min against 480 is 125%', () { + final m = _slept(duration: 600, stages: null); + expect(m.arcs.single.fraction, closeTo(600 / 480, 1e-9)); + expect(m.sweep, closeTo(600 / 480, 1e-9)); + }); + + test('a scored night wins over every alarm/learned input', () { + final m = sleepRingModel( + now: _evening, + durationMin: 442, + nextAlarm: DateTime(2026, 10, 9, 6, 30), + coachBedtimeMinOfDay: 1350, + learnedOnsetMin: _h23, + learnedWakeMin: _h07, + ); + expect(m.phase, SleepRingPhase.slept); + expect(m.estimateMin, isNull); + expect(m.cycles, isNull); + }); + }); + + group('unslept estimate', () { + test('wake - start from the learned typical times; cycles = floor(est / 90)', () { + final m = _unslept(now: _evening, learnedOnset: _h23, learnedWake: _h07); + expect(m.phase, SleepRingPhase.estimate); + expect(m.windowStart, DateTime(2026, 10, 8, 23, 0)); + expect(m.wakeAt, DateTime(2026, 10, 9, 7, 0)); + expect(m.estimateMin, 480); + expect(m.cycleLenMin, 90); + expect(m.cycles, 5); // 480 / 90 = 5.33 + expect(m.startSource, WindowStartSource.learned); + expect(m.wakeSource, WakeSource.learned); + expect(m.asleepMin, isNull); + expect(m.pct, isNull); + }); + + test('ONE grey (estimate) arc: estimate / target of the circle', () { + final m = _unslept(now: _evening, learnedOnset: _h23, learnedWake: _h07, need: 540); + expect(m.arcs, hasLength(1)); + expect(m.arcs.single.kind, SleepArcKind.estimate); + expect(m.arcs.single.fraction, closeTo(480 / 540, 1e-9)); + expect(m.targetMin, 540); + expect(m.targetIsDefault, isFalse); + }); + + test('the estimate arc is capped at a full circle', () { + final m = _unslept( + now: _evening, + learnedOnset: 22 * 60, + learnedWake: 9 * 60, + ); + expect(m.estimateMin, 11 * 60); // 22:00 -> 09:00, now is 22:00 + expect(m.arcs.single.fraction, closeTo(1.0, 1e-9)); + }); + + test('with no learned need the estimate is measured against the flagged ' + 'default', () { + final m = _unslept(now: _evening, learnedOnset: _h23, learnedWake: _h07); + expect(m.targetMin, 480); + expect(m.targetIsDefault, isTrue); + expect(m.arcs.single.fraction, closeTo(1.0, 1e-9)); + }); + + test('NEXT ARMED ALARM beats the learned wake', () { + final m = _unslept( + now: _evening, + learnedOnset: _h23, + learnedWake: _h07, + alarm: DateTime(2026, 10, 9, 6, 30), + ); + expect(m.wakeAt, DateTime(2026, 10, 9, 6, 30)); + expect(m.wakeSource, WakeSource.alarm); + expect(m.estimateMin, 450); // 23:00 -> 06:30 + expect(m.cycles, 5); + }); + + test('learned wake is used when there is no alarm', () { + final m = _unslept( + now: _evening, + learnedOnset: 23 * 60 + 30, + learnedWake: 7 * 60 + 30, + ); + expect(m.wakeAt, DateTime(2026, 10, 9, 7, 30)); + expect(m.wakeSource, WakeSource.learned); + expect(m.windowStart, DateTime(2026, 10, 8, 23, 30)); + expect(m.startSource, WindowStartSource.learned); + expect(m.estimateMin, 480); + }); + + test('COACH BEDTIME beats the learned onset', () { + final m = _unslept( + now: _evening, + bedtime: 22 * 60 + 30, + learnedOnset: _h23, + learnedWake: _h07, + ); + expect(m.windowStart, DateTime(2026, 10, 8, 22, 30)); + expect(m.startSource, WindowStartSource.coach); + expect(m.estimateMin, 510); // 22:30 -> 07:00 + expect(m.cycles, 5); + }); + + test('the coach bedtime is a clock minute, so it can fall after midnight', + () { + final m = _unslept( + now: DateTime(2026, 10, 8, 21, 0), + bedtime: 30, // 00:30 + learnedOnset: _h23, + learnedWake: _h07, + ); + expect(m.windowStart, DateTime(2026, 10, 9, 0, 30)); + expect(m.estimateMin, 390); // 00:30 -> 07:00 + expect(m.cycles, 4); // 390 / 90 = 4.33 + }); + + test('a fractional coach bedtime minute still resolves to a clock time', + () { + final m = _unslept( + now: _evening, + bedtime: 1350.4, + learnedOnset: _h23, + learnedWake: _h07, + ); + expect(m.windowStart, DateTime(2026, 10, 8, 22, 30)); + }); + + test('estimate runs from NOW once the window has started (max(start, now))', + () { + final now = DateTime(2026, 10, 9, 2, 0); + final m = _unslept(now: now, learnedOnset: _h23, learnedWake: _h07); + expect(m.windowStart, now); + expect(m.wakeAt, DateTime(2026, 10, 9, 7, 0)); + expect(m.estimateMin, 300); + expect(m.cycles, 3); + expect(m.startSource, WindowStartSource.learned); + }); + + test('estimate runs from the window start while it is still in the future', + () { + final m = _unslept(now: DateTime(2026, 10, 8, 18, 0), learnedOnset: _h23, learnedWake: _h07); + expect(m.windowStart, DateTime(2026, 10, 8, 23, 0)); + expect(m.estimateMin, 480); + }); + + test('no onset from any source => the estimate runs from now, saying so', + () { + final m = _unslept( + now: _evening, + alarm: DateTime(2026, 10, 9, 7, 0), + ); + expect(m.phase, SleepRingPhase.estimate); + expect(m.windowStart, _evening); + expect(m.startSource, isNull); + expect(m.estimateMin, 540); + expect(m.cycles, 6); + }); + + test('an alarm that has already passed is not a wake', () { + final m = _unslept( + now: _evening, + learnedOnset: _h23, + learnedWake: _h07, + alarm: _evening.subtract(const Duration(minutes: 1)), + ); + expect(m.wakeSource, WakeSource.learned); + expect(m.wakeAt, DateTime(2026, 10, 9, 7, 0)); + }); + + test('an alarm more than 24 h away is not tonight\'s wake', () { + final m = _unslept( + now: _evening, + learnedOnset: _h23, + learnedWake: _h07, + alarm: DateTime(2026, 10, 10, 7, 0), // a weekday alarm, 33 h away + ); + expect(m.wakeSource, WakeSource.learned); + expect(m.wakeAt, DateTime(2026, 10, 9, 7, 0)); + }); + + test('the 24 h horizon is compared in full: exactly 24 h is accepted', () { + final m = _unslept( + now: _evening, + learnedOnset: _h23, + learnedWake: _h07, + alarm: _evening.add(const Duration(hours: 24)), + ); + expect(m.wakeSource, WakeSource.alarm); + }); + + test('24 h and one second away is rejected', () { + final m = _unslept( + now: _evening, + learnedOnset: _h23, + learnedWake: _h07, + alarm: _evening.add(const Duration(hours: 24, seconds: 1)), + ); + expect(m.wakeSource, WakeSource.learned); + }); + + test('24 h 0 m 59 s away is rejected (not truncated to 24 h 0 m)', () { + final m = _unslept( + now: _evening, + learnedOnset: _h23, + learnedWake: _h07, + alarm: _evening.add(const Duration(hours: 24, seconds: 59)), + ); + expect(m.wakeSource, WakeSource.learned); + }); + + test('a wake at exactly now rolls to the next day\'s wake', () { + final now = DateTime(2026, 10, 9, 7, 0); + final m = _unslept(now: now, learnedOnset: _h23, learnedWake: _h07); + expect(m.wakeAt, DateTime(2026, 10, 10, 7, 0)); + expect(m.windowStart, DateTime(2026, 10, 9, 23, 0)); + expect(m.estimateMin, 480); + }); + + test('NO WAKE FROM ANY SOURCE => no estimate, empty ring, never a guess', + () { + final m = _unslept( + now: _evening, + bedtime: 1350, + learnedOnset: _h23, // an onset alone is not a wake + ); + expect(m.phase, SleepRingPhase.none); + expect(m.estimateMin, isNull); + expect(m.cycles, isNull); + expect(m.wakeAt, isNull); + expect(m.arcs, isEmpty); + expect(m.sweep, 0); + }); + + test('nothing at all is also no estimate', () { + final m = _unslept(now: _evening); + expect(m.phase, SleepRingPhase.none); + expect(m.arcs, isEmpty); + }); + + test('cycle length is injectable: 100 min => floor(480 / 100) = 4', () { + final m = _unslept(now: _evening, learnedOnset: _h23, learnedWake: _h07, cycleLen: 100); + expect(m.cycleLenMin, 100); + expect(m.cycles, 4); + }); + + test('an implausible cycle length falls back to 90', () { + for (final bad in [0, -5, 20, 400]) { + final m = _unslept(now: _evening, learnedOnset: _h23, learnedWake: _h07, cycleLen: bad); + expect(m.cycleLenMin, 90, reason: 'cycleLen $bad'); + expect(m.cycles, 5, reason: 'cycleLen $bad'); + } + }); + + test('under one cycle is zero cycles, not one', () { + final m = _unslept( + now: DateTime(2026, 10, 9, 6, 0), + learnedOnset: _h23, + learnedWake: _h07, + ); + expect(m.estimateMin, 60); + expect(m.cycles, 0); + }); + }); + + group('DST: elapsed time, not wall-clock subtraction', () { + final originalTz = Platform.environment['TZ']; + + setUp(() { + if (Platform.isWindows) markTestSkipped('POSIX-only (libc setenv)'); + setProcessTz('America/Denver'); + }); + tearDown(() => setProcessTz(originalTz)); + + test('spring forward 2026-03-08: 23:00 -> 07:00 is 7 h, not 8', () { + final now = DateTime(2026, 3, 7, 22, 0); + final m = _unslept(now: now, learnedOnset: _h23, learnedWake: _h07); + expect(m.windowStart, DateTime(2026, 3, 7, 23, 0)); + expect(m.wakeAt, DateTime(2026, 3, 8, 7, 0)); + expect(m.estimateMin, 420); + expect(m.cycles, 4); // 420 / 90 = 4.67 + }); + + test('spring forward, wake given as a UTC alarm instant', () { + final now = DateTime(2026, 3, 7, 22, 0); + final m = _unslept( + now: now, + learnedOnset: _h23, + learnedWake: _h07, + alarm: DateTime.utc(2026, 3, 8, 13, 0), // 07:00 MDT + ); + expect(m.wakeSource, WakeSource.alarm); + expect(m.estimateMin, 420); + }); + + test('fall back 2026-11-01: 23:00 -> 07:00 is 9 h, not 8', () { + final now = DateTime(2026, 10, 31, 22, 0); + final m = _unslept(now: now, learnedOnset: _h23, learnedWake: _h07); + expect(m.windowStart, DateTime(2026, 10, 31, 23, 0)); + expect(m.wakeAt, DateTime(2026, 11, 1, 7, 0)); + expect(m.estimateMin, 540); + expect(m.cycles, 6); + }); + + test('now inside the transition night still measures real elapsed time', + () { + // 00:30 MDT on spring-forward day, before the 02:00 jump: 7:00 MDT is + // 5.5 real hours away, not 6.5. + final now = DateTime(2026, 3, 8, 0, 30); + final m = _unslept(now: now, learnedOnset: _h23, learnedWake: _h07); + expect(m.windowStart, now); + expect(m.estimateMin, 330); + }); + }); + + group('learnedClockMinutes', () { + int sec(int y, int mo, int d, int h, int mi) => + DateTime(y, mo, d, h, mi).millisecondsSinceEpoch ~/ 1000; + + Map night(int day, (int, int)? onset, (int, int)? wake) => + { + 'date': '2026-09-${day.toString().padLeft(2, '0')}', + 'onset_ts': onset == null + ? null + : (onset.$1 >= 12 + ? sec(2026, 9, day - 1, onset.$1, onset.$2) + : sec(2026, 9, day, onset.$1, onset.$2)), + 'wake_ts': wake == null ? null : sec(2026, 9, day, wake.$1, wake.$2), + }; + + test('median local clock minute of onsets and of wakes', () { + final r = learnedClockMinutes([ + night(10, (23, 0), (7, 0)), + night(11, (23, 0), (7, 0)), + night(12, (23, 0), (7, 30)), + night(13, (23, 30), (6, 45)), + night(14, (0, 10), (7, 15)), + ]); + expect(r.onsetMin, 23 * 60); + expect(r.wakeMin, 7 * 60); + }); + + test('onsets straddling midnight do not average to midday', () { + final r = learnedClockMinutes([ + night(10, (23, 10), (7, 0)), + night(11, (23, 30), (7, 0)), + night(12, (23, 50), (7, 0)), + night(13, (0, 10), (7, 0)), + night(14, (0, 30), (7, 0)), + ]); + expect(r.onsetMin, 23 * 60 + 50); + }); + + test('all-after-midnight onsets stay after midnight', () { + final r = learnedClockMinutes([ + night(10, (0, 10), (8, 0)), + night(11, (0, 20), (8, 0)), + night(12, (0, 30), (8, 0)), + ]); + expect(r.onsetMin, 20); + }); + + test('fewer than the minimum usable nights => null, not a guess', () { + final r = learnedClockMinutes([ + night(10, (23, 0), (7, 0)), + night(11, (23, 0), (7, 0)), + ]); + expect(r.onsetMin, isNull); + expect(r.wakeMin, isNull); + }); + + test('nights with no window are skipped, and each side counts its own', + () { + final r = learnedClockMinutes([ + night(10, null, null), + night(11, (23, 0), (7, 0)), + night(12, (23, 0), null), + night(13, (23, 0), (7, 0)), + night(14, null, (7, 0)), + ]); + expect(r.onsetMin, 23 * 60); // 3 onsets + expect(r.wakeMin, 7 * 60); // 3 wakes + }); + + test('empty input is null on both sides', () { + final r = learnedClockMinutes(const []); + expect(r.onsetMin, isNull); + expect(r.wakeMin, isNull); + }); + }); +} diff --git a/test/ui2_spiral_ring_round2_test.dart b/test/ui2_spiral_ring_round2_test.dart new file mode 100644 index 000000000..08a92da47 --- /dev/null +++ b/test/ui2_spiral_ring_round2_test.dart @@ -0,0 +1,137 @@ +// Round 2 on the spiral ring: what is VISIBLE at stage joins, lap boundaries +// and on small rings at high ratios. Pixel probes, not call counts. + +import 'dart:math'; +import 'dart:typed_data'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/ui2/charts.dart'; +import 'package:openstrap_edge/ui2/spiral_ring.dart'; + +const _a = Color(0xFF0000FF); +const _b = Color(0xFFFF0000); +const _bg = Color(0xFFFF00FF); +const _none = Color(0x00000000); +const _side = 200; +const _w = 20.0; +const _r0 = 100 - _w / 2; + +Future _raster(CustomPainter p, {int px = _side}) async { + final rec = ui.PictureRecorder(); + p.paint(Canvas(rec), Size(px.toDouble(), px.toDouble())); + final img = await rec.endRecording().toImage(px, px); + return (await img.toByteData())!; +} + +List _px(ByteData d, double x, double y, {int px = _side}) { + final i = (y.floor() * px + x.floor()) * 4; + return [for (var k = 0; k < 4; k++) d.getUint8(i + k)]; +} + +List _polar(ByteData d, double u, double r) => + _px(d, 100 + r * sin(2 * pi * u), 100 - r * cos(2 * pi * u)); + +bool _is(List c, Color k) => + c[3] > 200 && + (c[0] - (k.r * 255)).abs() < 40 && + (c[1] - (k.g * 255)).abs() < 40 && + (c[2] - (k.b * 255)).abs() < 40; + +class _Count implements Canvas { + int paths = 0; + @override + void drawPath(Path p, Paint paint) => paths++; + @override + dynamic noSuchMethod(Invocation invocation) => null; +} + +void main() { + const g = SpiralGeometry(_w); + + testWidgets('separator halos never cut a hole in the clockwise neighbour', + (tester) async { + await tester.runAsync(() async { + final d = await _raster(StageRing(const [ + RingArc(_a, 1.5), + RingArc(_b, .5), + ], _none, stroke: _w, background: _bg)); + // B's centre-line just clockwise of A's end (inside A's old halo). + for (final u in [.5228, .53, .56, .6, .8]) { + final c = _polar(d, u, g.radiusAt(1 + u, _r0)); + expect(_is(c, _b), isTrue, reason: 'u=$u px=$c'); + } + }); + }); + + testWidgets('a segment ending exactly on a lap boundary keeps its round cap ' + 'over the neighbour', (tester) async { + await tester.runAsync(() async { + final d = await _raster(StageRing(const [ + RingArc(_a, 1.0), + RingArc(_b, .5), + ], _none, stroke: _w, background: _bg)); + // A ends at 12 o'clock. Its disc (r 10) covers (105,12), which B's lap-1 + // band starts under; the cap must be on top. + expect(_is(_px(d, 105, 12), _a), isTrue, reason: _px(d, 105, 12).toString()); + expect(_is(_px(d, 100, 16), _a), isTrue); + // Away from the cap B is on top. + expect(_is(_polar(d, .3, g.radiusAt(1.3, _r0)), _b), isTrue); + }); + }); + + testWidgets('full-circle seam: the last segment\'s round end is visible over ' + 'the first segment\'s start', (tester) async { + await tester.runAsync(() async { + final d = await _raster(StageRing(const [ + RingArc(_a, .5), + RingArc(_b, .5), + ], _none, stroke: _w, background: _bg)); + // B ends at 12 o'clock; its disc covers (105,12) clockwise of the seam. + expect(_is(_px(d, 105, 12), _b), isTrue, reason: _px(d, 105, 12).toString()); + // The first segment's own start is square: well clockwise it is A. + expect(_is(_polar(d, .1, _r0), _a), isTrue); + }); + }); + + group('small rings saturate', () { + const small = SpiralGeometry(3); + const r0 = 10 - 1.5; + + test('saturationLaps: the lap after which every lap looks the same', () { + // Hold radius of lap L is r0 - 2.25 L; the floor is 4.5: lap 2 sits on it. + expect(small.saturationLaps(r0), 3); + expect(g.saturationLaps(_r0), 5); // 90 - 15 L <= 30 -> L = 4 -> 5 laps + // A ring too small to inset at all is one lap. + expect(const SpiralGeometry(10).saturationLaps(12), 1); + }); + + testWidgets('1000% builds a bounded number of paths, same as the ' + 'saturation point', (tester) async { + Ring ring(double v) => Ring(v, _a, _none, + stroke: 3, solid: true, background: _bg); + int paths(double v) { + final c = _Count(); + ring(v).paint(c, const Size(20, 20)); + return c.paths; + } + + expect(paths(200), lessThanOrEqualTo(paths(3.0))); + expect(paths(200), lessThan(30)); + expect(paths(10), paths(3.0)); + await tester.runAsync(() async { + final sat = await _raster(ring(3.0), px: 20); + for (final v in [5.0, 10.0, 200.0]) { + final d = await _raster(ring(v), px: 20); + expect(d.buffer.asUint8List(), sat.buffer.asUint8List(), + reason: 'v=$v'); + } + }); + }); + + test('the value itself is still reported unclamped by pieces', () { + expect(Ring(10, _a, _none, stroke: 3).pieces.last.to, 10.0); + }); + }); +} diff --git a/test/ui2_spiral_ring_round3_test.dart b/test/ui2_spiral_ring_round3_test.dart new file mode 100644 index 000000000..fe831accd --- /dev/null +++ b/test/ui2_spiral_ring_round3_test.dart @@ -0,0 +1,171 @@ +// Round 3 on the spiral ring: the separator is a thin line, not an eraser, and +// a translucent (gradient) ring composites each coloured region once. + +import 'dart:math'; +import 'dart:typed_data'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/ui2/charts.dart'; +import 'package:openstrap_edge/ui2/spiral_ring.dart'; + +const _green = Color(0xFF00AA00); +const _bg = Color(0xFFFF00FF); +const _none = Color(0x00000000); + +Future _raster(CustomPainter p, int px) async { + final rec = ui.PictureRecorder(); + p.paint(Canvas(rec), Size(px.toDouble(), px.toDouble())); + final img = await rec.endRecording().toImage(px, px); + return (await img.toByteData())!; +} + +List _at(ByteData d, int px, double u, double r) { + final x = (px / 2 + r * sin(2 * pi * u)).floor().clamp(0, px - 1); + final y = (px / 2 - r * cos(2 * pi * u)).floor().clamp(0, px - 1); + final i = (y * px + x) * 4; + return [for (var k = 0; k < 4; k++) d.getUint8(i + k)]; +} + +bool _magenta(List c) => + c[3] > 100 && c[0] > 200 && c[2] > 200 && c[1] < 80; + +void main() { + testWidgets('20 px / stroke 3 ring at 500%: no empty or background pixel ' + 'between the outer edge and the innermost lap', (tester) async { + await tester.runAsync(() async { + final d = await _raster( + Ring(5.0, _green, _none, stroke: 3, solid: true, background: _bg), + 20); + for (var k = 0; k < 12; k++) { + final u = k / 12 + .02; + double? inner; + for (var r = 1.5; r < 10; r += .25) { + if (_at(d, 20, u, r)[3] > 200) { + inner = r; + break; + } + } + expect(inner, isNotNull, reason: 'u=$u'); + for (var r = inner! + .5; r < 8.8; r += .25) { + final c = _at(d, 20, u, r); + expect(c[3], greaterThan(200), reason: 'gap at u=$u r=$r $c'); + expect(_magenta(c), isFalse, reason: 'separator at u=$u r=$r'); + } + } + }); + }); + + testWidgets('200 px / stroke 20: the separator is present and at most ' + '1.5 px wide', (tester) async { + await tester.runAsync(() async { + const g = SpiralGeometry(20); + final d = await _raster( + Ring(1.3, _green, _none, stroke: 20, solid: true, background: _bg), + 200); + for (final u in [.15, .2, .25]) { + final edge = 90 - g.inset(1 + u) + 10; + // Width = sum of the magenta share (the red channel; the ring is + // green) along the ray, so antialiased edge pixels count fractionally. + var w = 0.0; + for (var r = edge - 4; r < edge + 4; r += .1) { + w += _at(d, 200, u, r)[0] / 255 * .1; + } + expect(w, greaterThan(.6), reason: 'u=$u: no separator'); + expect(w, lessThanOrEqualTo(1.8), reason: 'u=$u width $w'); + } + }); + }); + + test('separator width scales with stroke and vanishes on tiny rings', () { + expect(kSpiralSeparator, 1.5); + expect(spiralSeparatorWidth(20), closeTo(1.5, 1e-9)); + expect(spiralSeparatorWidth(40), closeTo(1.5, 1e-9)); + expect(spiralSeparatorWidth(14), closeTo(1.05, 1e-9)); + expect(spiralSeparatorWidth(10), closeTo(.75, 1e-9)); + expect(spiralSeparatorWidth(3), 0); + expect(spiralSeparatorWidth(6), 0); + }); + + // Round 5: a gradient ring is pre-blended against `background` and drawn + // fully opaque, with ordinary discs and bands. No cap subtraction, so no + // crack at the join and the 100% end cap is back. + + testWidgets('gradient ring at 66%: start and end caps blend into the band ' + 'with the same colour (once)', (tester) async { + await tester.runAsync(() async { + final d = await _raster( + Ring(.66, _green, _none, stroke: 20, background: _bg), 200); + int red(double u, double r) => _at(d, 200, u, r)[0]; + expect((red(.008, 90) - red(.05, 90)).abs(), lessThan(16), + reason: 'start cap overlap vs band'); + expect((red(.99, 90) - red(.05, 90)).abs(), lessThan(24), + reason: 'cap-only side vs band'); + expect((red(.655, 90) - red(.6, 90)).abs(), lessThan(16), + reason: 'end cap overlap vs band'); + }); + }); + + for (final v in [.66, 1.3]) { + testWidgets('gradient ring at ${(v * 100).round()}%: no crack along the ' + 'cap/band joins', (tester) async { + await tester.runAsync(() async { + const g = SpiralGeometry(20); + final d = await _raster( + Ring(v, _green, _none, stroke: 20, background: _bg), 200); + // Points that are inside the band or its end caps, 3 px clear of the + // edges: every one must be fully opaque and match its horizontal + // neighbours (a crack is a lighter or more transparent hairline). + final end = v % 1; + final probes = <(double, double)>[ + for (var k = -6; k <= 6; k++) (k / 200, 90.0), // start cap region + for (var k = -6; k <= 6; k++) + (end + k / 200, g.radiusAt(v, 90).toDouble()), // end cap region + ]; + for (final (u, r) in probes) { + for (var dr = -6.0; dr <= 6; dr += 1) { + final rr = r + dr; + if (v > 1 && u > .5 && u < end - .5) continue; + final c = _at(d, 200, u, rr); + if (c[3] < 255) { + // Allowed only outside the painted shape (track is transparent). + continue; + } + final x = (100 + rr * sin(2 * pi * u)).floor(); + final y = (100 - rr * cos(2 * pi * u)).floor(); + for (final nx in [x - 1, x + 1]) { + final i = (y * 200 + nx) * 4; + if (d.getUint8(i + 3) < 255) continue; // shape boundary + for (var ch = 0; ch < 3; ch++) { + expect((d.getUint8(i + ch) - c[ch]).abs(), lessThan(40), + reason: 'crack at u=$u r=$rr ch=$ch'); + } + } + } + } + // Inside both band and cap the pixel is opaque and not background. + for (final (u, r) in [(.02, 90.0), (end - .02, g.radiusAt(v, 90))]) { + final c = _at(d, 200, u, r); + expect(c[3], 255, reason: 'u=$u'); + expect(c[0] < 200, isTrue, reason: 'background shows at u=$u $c'); + } + }); + }); + } + + testWidgets('gradient ring at 100% keeps its round end cap over the start', + (tester) async { + await tester.runAsync(() async { + final d = await _raster( + Ring(1.0, _green, _none, stroke: 20, background: _bg), 200); + // The end disc (full colour) covers (105,12); the start band there is + // faint (mostly background pink), so the disc must be on top. + final c = _at(d, 200, 5 / (2 * pi * 90), 88); + expect(c[0], lessThan(40), reason: 'end cap missing: $c'); + // And it is round: the disc reaches just past the straight seam but not + // beyond its radius. + expect(_at(d, 200, 14 / (2 * pi * 90), 90)[0], greaterThan(60)); + }); + }); +} diff --git a/test/ui2_spiral_ring_test.dart b/test/ui2_spiral_ring_test.dart new file mode 100644 index 000000000..9a0be6186 --- /dev/null +++ b/test/ui2_spiral_ring_test.dart @@ -0,0 +1,605 @@ +// RED: rings are spirals past 100%. +// +// Owner decision: one spiral ring in the ring component, used by StageRing, +// Ring (recovery, strain). v3 "T30% inset 75%": lap 0 is a true circle; at +// each lap boundary n the band insets inward over 30% of a lap (cosine eased, +// no corners), then holds; inset per lap = 0.75 x stroke; the outer edge never +// leaves the lap-0 circle; the gradient spans the whole bar; each segment's +// clockwise end is a round cap painted over its clockwise neighbour. +// +// Honesty: the ring draws exactly the fraction it is given. <= 1 is one lap +// and no inset; nothing is drawn past the value; nothing is clamped to 100%. +// +// Geometry is asserted through [SpiralGeometry] (lib/ui2/spiral_ring.dart), +// then through Ring.pieces / StageRing.pieces, then a pixel smoke test. + +import 'dart:math'; +import 'dart:typed_data'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/ui2/charts.dart'; +import 'package:openstrap_edge/ui2/spiral_ring.dart'; + +const _g = SpiralGeometry(10); +const _w = 10.0; +const _eps = 1e-9; + +double _sweep(List p) => p.fold(0.0, (a, e) => a + (e.to - e.from)); + +void main() { + group('inset profile', () { + test('lap 0 is a true circle: no inset anywhere in [0, 1]', () { + for (var i = 0; i <= 100; i++) { + expect(_g.inset(i / 100), closeTo(0, _eps), reason: 's=${i / 100}'); + expect(_g.radiusAt(i / 100, 45), closeTo(45, _eps)); + } + }); + + test('inset per lap is 0.75 x stroke once each ramp is done', () { + for (var n = 1; n <= 4; n++) { + expect(_g.inset(n + .3), closeTo(.75 * _w * n, 1e-6), reason: 'n=$n'); + expect(_g.radiusAt(n + .3, 45), closeTo(45 - .75 * _w * n, 1e-6)); + } + expect(const SpiralGeometry(14).inset(1.5), closeTo(.75 * 14, 1e-6)); + }); + + test('holds flat for the rest of the lap after the 30% ramp', () { + for (var n = 1; n <= 3; n++) { + final held = _g.inset(n + .3); + expect(held, greaterThan(0)); + for (var x = .3; x < 1.0; x += .05) { + expect(_g.inset(n + x), closeTo(held, 1e-6), reason: 'n=$n x=$x'); + } + } + }); + + test('ramp is monotone and cosine-eased: halfway is half, ends are flat', + () { + expect(_g.inset(1.15), closeTo(.5 * .75 * _w, 1e-6)); + var prev = _g.inset(1.0); + for (var i = 1; i <= 300; i++) { + final v = _g.inset(1 + .3 * i / 300); + expect(v, greaterThanOrEqualTo(prev - _eps)); + prev = v; + } + // Derivative ~ 0 at both ends of the transition (no corners). + const h = 1e-4; + double d(double s) => (_g.inset(s + h) - _g.inset(s - h)) / (2 * h); + expect(d(1.0 + 2 * h).abs(), lessThan(.01)); + expect(d(1.3 - 2 * h).abs(), lessThan(.01)); + expect(d(2.0 + 2 * h).abs(), lessThan(.01)); + // ...and clearly nonzero mid-ramp (it is a ramp, not a step). + expect(d(1.15), greaterThan(10)); + }); + + test('no jump at any lap boundary (continuous)', () { + for (var n = 1; n <= 3; n++) { + expect(_g.inset(n + .5), greaterThan(0)); + expect((_g.inset(n + 1e-7) - _g.inset(n - 1e-7)).abs(), lessThan(1e-3)); + } + }); + + test('the outer edge never exceeds the lap-0 circle', () { + const r0 = 45.0; + expect(_g.radiusAt(2, r0), lessThan(r0)); // it does spiral inward + expect(_g.radiusAt(0, r0), r0); + for (var s = 0.0; s <= 5; s += .01) { + expect(_g.radiusAt(s, r0) + _w / 2, lessThanOrEqualTo(r0 + _w / 2 + _eps), + reason: 's=$s'); + } + }); + }); + + group('laps', () { + test('lapsFor', () { + expect(SpiralGeometry.lapsFor(0), 0); + expect(SpiralGeometry.lapsFor(-1), 0); + expect(SpiralGeometry.lapsFor(double.nan), 0); + expect(SpiralGeometry.lapsFor(.0001), 1); + expect(SpiralGeometry.lapsFor(.66), 1); + expect(SpiralGeometry.lapsFor(1), 1); + expect(SpiralGeometry.lapsFor(1.0001), 2); + expect(SpiralGeometry.lapsFor(2), 2); + expect(SpiralGeometry.lapsFor(3), 3); + expect(SpiralGeometry.lapsFor(3.5), 4); + }); + + test('a fraction <= 1 is one lap and no inset; 2.0 is two full laps', () { + final one = _g.pieces([.66]); + expect(one, hasLength(1)); + expect(one.single.lap, 0); + expect(_sweep(one), closeTo(.66, _eps)); + for (final p in one) { + expect(_g.inset(p.to), closeTo(0, _eps)); + } + final two = _g.pieces([2.0]); + expect(two.map((p) => p.lap), [0, 1]); + expect(two[0].from, 0); + expect(two[0].to, 1); + expect(two[1].from, 1); + expect(two[1].to, 2); + expect(_sweep(two), closeTo(2.0, _eps)); + }); + + test('exactly 100% is one lap, not a second sliver', () { + final p = _g.pieces([1.0]); + expect(p, hasLength(1)); + expect(p.single.lap, 0); + }); + + test('values are not clamped: 300% draws 3 laps, nothing beyond', () { + final p = _g.pieces([3.0]); + expect(p.map((e) => e.lap), [0, 1, 2]); + expect(_sweep(p), closeTo(3.0, _eps)); + expect(p.every((e) => e.to <= 3.0 + _eps), isTrue); + }); + + test('empty, zero, negative and NaN fractions draw nothing', () { + expect(_g.pieces([]), isEmpty); + expect(_g.pieces([0]), isEmpty); + expect(_g.pieces([-.5]), isEmpty); + expect(_g.pieces([double.nan]), isEmpty); + expect(_g.pieces([0, .4]).map((e) => e.seg), [1]); + }); + }); + + group('segments, order and caps', () { + test('segments split at the lap boundary; cut ends are not capped', () { + // a .3 | b .5 | c .4 -> c is cut at 1.0. + final p = _g.pieces([.3, .5, .4]); + expect(_sweep(p), closeTo(1.2, _eps)); + final c0 = p.singleWhere((e) => e.seg == 2 && e.lap == 0); + final c1 = p.singleWhere((e) => e.seg == 2 && e.lap == 1); + expect(c0.from, closeTo(.8, _eps)); + expect(c0.to, closeTo(1.0, _eps)); + expect(c0.capEnd, isFalse, reason: 'a lap cut is not an end'); + expect(c1.from, closeTo(1.0, _eps)); + expect(c1.to, closeTo(1.2, _eps)); + expect(c1.capEnd, isTrue); + }); + + test('paint order: lower laps first; in a lap the LAST segment first', () { + final p = _g.pieces([.3, .5, .4]); + expect(p.map((e) => (e.lap, e.seg)).toList(), + [(0, 2), (0, 1), (0, 0), (1, 2)]); + }); + + test('every segment end is round (over its clockwise neighbour)', () { + final p = _g.pieces([.3, .2, .1]); + expect(p.map((e) => e.capEnd), everyElement(isTrue)); + }); + + test('first start is round only while a gap precedes it (under 100%)', () { + final under = _g.pieces([.3, .2]); + expect(under.singleWhere((e) => e.seg == 0).capStart, isTrue); + expect(under.singleWhere((e) => e.seg == 1).capStart, isFalse); + for (final total in [[1.0], [.6, .4], [1.5], [.7, .8]]) { + final p = _g.pieces(total); + expect(p.every((e) => !e.capStart), isTrue, reason: '$total'); + } + }); + + test('single-colour ring under 100% has round caps at both ends', () { + final p = _g.pieces([.66]).single; + expect(p.capStart, isTrue); + expect(p.capEnd, isTrue); + }); + + test('a segment spanning laps is capped only at its true end', () { + final p = _g.pieces([2.0]); + expect(p.map((e) => e.capEnd), [false, true]); + expect(p.map((e) => e.capStart), [false, false]); + }); + }); + + group('separator and gradient', () { + test('separator is 1.5px and starts where the lap leaves the one below', + () { + expect(kSpiralSeparator, 1.5); + expect(_g.separatorFrom(0), isNull); + final s1 = _g.separatorFrom(1)!; + expect(s1, greaterThan(1.0)); + expect(s1, lessThan(1.3)); + expect(_g.inset(s1), closeTo(.5, .05)); + expect(_g.separatorFrom(2), 2.0); + }); + + test('gradient position is over the whole bar and never restarts', () { + expect(SpiralGeometry.gradientAt(0, 2), 0); + expect(SpiralGeometry.gradientAt(1, 2), closeTo(.5, _eps)); + expect(SpiralGeometry.gradientAt(2, 2), closeTo(1, _eps)); + expect(SpiralGeometry.gradientAt(.5, 1), closeTo(.5, _eps)); + var prev = -1.0; + for (var s = 0.0; s <= 3; s += .01) { + final g = SpiralGeometry.gradientAt(s, 3); + expect(g, greaterThanOrEqualTo(prev)); + prev = g; + } + expect(SpiralGeometry.gradientAt(1.0 + 1e-6, 2), + greaterThan(SpiralGeometry.gradientAt(1.0 - 1e-6, 2))); + }); + }); + + group('one implementation behind both painters', () { + const track = Color(0x00000000); + + test('Ring draws exactly the fraction given, unclamped', () { + for (final v in [.66, 1.0, 1.5, 2.0, 3.0]) { + final r = Ring(v, Colors.green, track); + expect(_sweep(r.pieces), closeTo(v, 1e-9), reason: 'v=$v'); + } + }); + + test('Ring scales by the draw-in progress t, never past the value', () { + expect(_sweep(Ring(1.5, Colors.green, track, t: .5).pieces), + closeTo(.75, 1e-9)); + expect(Ring(1.5, Colors.green, track, t: 0).pieces, isEmpty); + expect(_sweep(Ring(1.5, Colors.green, track, t: 2).pieces), + closeTo(1.5, 1e-9)); + }); + + test('Ring draws nothing for zero, negative or NaN (no invented overflow)', + () { + for (final v in [0.0, -1.0, double.nan]) { + expect(Ring(v, Colors.green, track).pieces, isEmpty, reason: '$v'); + } + }); + + test('Ring <= 100% never produces a second lap', () { + for (final v in [.01, .5, 1.0]) { + expect(Ring(v, Colors.green, track).pieces, isNotEmpty); + expect(Ring(v, Colors.green, track).pieces.every((p) => p.lap == 0), + isTrue); + } + }); + + test('StageRing no longer clips: arcs summing to 1.5 draw 1.5 laps', () { + final r = StageRing(const [ + RingArc(Colors.blue, .6), + RingArc(Colors.red, .5), + RingArc(Colors.green, .4), + ], track); + expect(_sweep(r.pieces), closeTo(1.5, 1e-9)); + expect(r.pieces.map((p) => p.lap).toSet(), {0, 1}); + }); + + test('StageRing under 100%: one lap, round caps, same rules as Ring', () { + final r = StageRing(const [ + RingArc(Colors.blue, .3), + RingArc(Colors.red, .2), + ], track); + expect(_sweep(r.pieces), closeTo(.5, 1e-9)); + expect(r.pieces.every((p) => p.lap == 0 && p.capEnd), isTrue); + expect(r.pieces.singleWhere((p) => p.seg == 0).capStart, isTrue); + }); + + test('StageRing scales by t and keeps segment order', () { + final r = StageRing(const [ + RingArc(Colors.blue, .8), + RingArc(Colors.red, .8), + ], track, t: .5); + expect(_sweep(r.pieces), closeTo(.8, 1e-9)); + }); + }); + + group('render smoke', () { + const side = 100.0; + + Future> raster(CustomPainter p) async { + final rec = ui.PictureRecorder(); + p.paint(Canvas(rec), const Size(side, side)); + final img = await rec.endRecording().toImage(side.toInt(), side.toInt()); + return (await img.toByteData())!.buffer.asUint8List().toList(); + } + + testWidgets('Ring at 66/100/150/200% pumps clean and the painter keeps ' + 'the unclamped value', (tester) async { + for (final v in [.66, 1.0, 1.5, 2.0]) { + await tester.pumpWidget(MaterialApp( + home: Center( + child: SizedBox( + width: side, + height: side, + child: CustomPaint( + size: Size.infinite, + painter: Ring(v, Colors.green, const Color(0x22000000))), + ), + ), + )); + expect(tester.takeException(), isNull, reason: 'v=$v'); + final painter = tester + .widgetList(find.byType(CustomPaint)) + .map((e) => e.painter) + .whereType() + .single; + expect(painter.v, v); + expect(_sweep(painter.pieces), closeTo(v, 1e-9), reason: 'v=$v'); + } + }); + + testWidgets('StageRing at 66/100/150/200% pumps clean and keeps every lap', + (tester) async { + for (final v in [.66, 1.0, 1.5, 2.0]) { + await tester.pumpWidget(MaterialApp( + home: Center( + child: SizedBox( + width: side, + height: side, + child: CustomPaint( + size: Size.infinite, + painter: StageRing([ + RingArc(Colors.blue, v * .5), + RingArc(Colors.red, v * .5), + ], const Color(0x22000000))), + ), + ), + )); + expect(tester.takeException(), isNull, reason: 'v=$v'); + final painter = tester + .widgetList(find.byType(CustomPaint)) + .map((e) => e.painter) + .whereType() + .single; + expect(_sweep(painter.pieces), closeTo(v, 1e-9), reason: 'v=$v'); + } + }); + + testWidgets('pixels: each extra lap changes the picture (no clamp at 100%)', + (tester) async { + await tester.runAsync(() async { + const tr = Color(0x00000000); + final imgs = >{ + for (final v in [1.0, 1.5, 2.0, 3.0]) + v: await raster(Ring(v, Colors.green, tr, stroke: 10, solid: true)), + }; + expect(imgs[1.5], isNot(imgs[1.0]), reason: '150% must differ from 100%'); + expect(imgs[2.0], isNot(imgs[1.5])); + expect(imgs[3.0], isNot(imgs[2.0])); + }); + }); + + testWidgets('pixels: nothing is drawn past the value, and t=0 is empty', + (tester) async { + await tester.runAsync(() async { + const tr = Color(0x00000000); + int alphaAt(List px, int x, int y) => px[(y * side.toInt() + x) * 4 + 3]; + // 66%: the arc ends at ~238 degrees clockwise from 12; 9 o'clock + // (270 degrees) must be untouched, 3 o'clock (90) painted. + final a = await raster(Ring(.66, Colors.green, tr, stroke: 10, solid: true)); + expect(alphaAt(a, 5, 50), 0); + expect(alphaAt(a, 95, 50), greaterThan(200)); + // Nothing beyond a 150% value inside the lap-0 circle's outer edge. + final empty = await raster(Ring(1.5, Colors.green, tr, t: 0, solid: true)); + expect(empty.every((b) => b == 0), isTrue); + // The outer edge never leaves the lap-0 circle: the corners stay clear. + final big = await raster(Ring(3.0, Colors.green, tr, stroke: 10, solid: true)); + for (final (x, y) in [(1, 1), (98, 1), (1, 98), (98, 98)]) { + expect(alphaAt(big, x, y), 0); + } + expect(sqrt(pow(50 - 1, 2) * 2), greaterThan(50)); // sanity: corner is outside + }); + }); + }); + + // ── fix round: the RENDERED shape, not just the helper maths ────────────── + + group('rendered spiral (pixel probes)', () { + const side = 200, w = 20.0, r0 = 100 - w / 2; + const green = Color(0xFF00AA00); + const magenta = Color(0xFFFF00FF); + final g = SpiralGeometry(w); + + Future raster(CustomPainter p, {int px = side}) async { + final rec = ui.PictureRecorder(); + p.paint(Canvas(rec), Size(px.toDouble(), px.toDouble())); + final img = await rec.endRecording().toImage(px, px); + return (await img.toByteData())!; + } + + // RGBA at the pixel under polar (u laps clockwise from 12, radius r). + List at(ByteData d, double u, double r, {int px = side}) { + final x = (px / 2 + r * sin(2 * pi * u)).floor().clamp(0, px - 1); + final y = (px / 2 - r * cos(2 * pi * u)).floor().clamp(0, px - 1); + final i = (y * px + x) * 4; + return [for (var k = 0; k < 4; k++) d.getUint8(i + k)]; + } + + bool isGreen(List c) => c[3] > 200 && c[1] > 120 && c[0] < 60; + + // First radius (scanning outward from the centre) that is the ring colour. + double innerEdge(ByteData d, double u) { + for (var r = 5.0; r < 100; r += .25) { + if (isGreen(at(d, u, r))) return r; + } + return double.nan; + } + + // The innermost painted edge at angle u when laps 0..top overlap there. + double expectedInner(double sTop) => r0 - g.inset(sTop) - w / 2; + + testWidgets('130% ring: the inner edge follows the eased ramp r0 -> ' + 'r0 - 0.75 x stroke, then holds', (tester) async { + await tester.runAsync(() async { + final d = await raster(Ring(1.3, green, const Color(0x00000000), + stroke: w, solid: true, background: magenta)); + for (final u in [.03, .06, .1, .15, .2, .25, .28]) { + expect(innerEdge(d, u), closeTo(expectedInner(1 + u), 1.6), + reason: 'u=$u inset=${g.inset(1 + u)}'); + } + // Past the value (u > .3) only lap 0 is there: edge back at r0 - w/2. + for (final u in [.4, .6, .9]) { + expect(innerEdge(d, u), closeTo(r0 - w / 2, 1.6), reason: 'u=$u'); + } + }); + }); + + testWidgets('300% ring: the innermost edge follows the profile on lap 2', + (tester) async { + await tester.runAsync(() async { + final d = await raster(Ring(3.0, green, const Color(0x00000000), + stroke: w, solid: true, background: magenta)); + for (final u in [.05, .1, .2, .5, .9]) { + expect(innerEdge(d, u), closeTo(expectedInner(2 + u), 1.6), + reason: 'u=$u'); + } + }); + }); + + testWidgets('stage ring: segment joins and lap cuts have no radial jump', + (tester) async { + await tester.runAsync(() async { + // Joins at s = .5, .9, 1.3 (inside lap 1) and the lap cut at 1.0. + final d = await raster(StageRing(const [ + RingArc(green, .5), + RingArc(green, .4), + RingArc(green, .4), + RingArc(green, .4), + ], const Color(0x00000000), stroke: w, background: magenta)); + for (final u in [.1, .2, .28, .29, .31, .32, .4, .6]) { + expect(innerEdge(d, u), closeTo(expectedInner(1 + u), 2.0), + reason: 'u=$u'); + } + }); + }); + + testWidgets('the separator is the BACKGROUND colour, above lap 0, only ' + 'along the lap\'s real sweep', (tester) async { + await tester.runAsync(() async { + final d = await raster(Ring(1.3, green, const Color(0xFFFFFF00), + stroke: w, solid: true, background: magenta)); + // Just outside lap 1's outer edge, on top of lap 0's band. + // A thin LINE centred on the upper lap's outer edge. + double rim(double s) => r0 - g.inset(s) + w / 2 + .75; + double edge(double s) => r0 - g.inset(s) + w / 2; + for (final u in [.15, .2, .25]) { + final c = at(d, u, edge(1 + u)); + expect(c[0] > 100 && c[2] > 100, isTrue, reason: 'u=$u px=$c'); + } + // Past the value: no separator, lap 0 colour is intact. + for (final u in [.4, .5]) { + expect(isGreen(at(d, u, rim(1.25))), isTrue, reason: 'u=$u'); + } + // And never the track colour. + expect(at(d, .2, rim(1.2)), isNot(equals([255, 255, 0, 255]))); + }); + }); + + testWidgets('default gradient Ring is visible (solid: false)', + (tester) async { + await tester.runAsync(() async { + final d = await raster(Ring(.66, green, const Color(0x00000000), + stroke: w, background: magenta)); + expect(at(d, .3, r0)[3], greaterThan(120)); + expect(at(d, .05, r0)[3], greaterThan(60)); + expect(at(d, .8, r0)[3], 0, reason: 'past .66 nothing is drawn'); + }); + }); + + testWidgets('gradient spans the WHOLE bar: lap 1 continues where lap 0 ' + 'ended', (tester) async { + await tester.runAsync(() async { + final d = await raster(Ring(2.0, green, const Color(0x00000000), + stroke: w, background: magenta)); + // At u=.2 lap 1 has an inner sliver lap 0 does not cover. Its colour + // is pre-blended against the background: bar position 1.2 / 2 = .6 -> + // 82% green, 18% background, so red is about 46. A lap that restarted + // faint would be about 92. + final px = at(d, .2, r0 - w / 2 - .25 * g.inset(1.2)); + expect(px[3], 255); + expect(px[0], lessThan(65), reason: 'restarted per lap? $px'); + // Start of the bar is faint (a one-lap ring, nothing under it). + final one = await raster(Ring(1.0, green, const Color(0x00000000), + stroke: w, background: magenta)); + expect(at(one, .02, r0)[0], greaterThan(95)); + }); + }); + + testWidgets('20 px / stroke 3 ring survives 500% and 1000%', + (tester) async { + await tester.runAsync(() async { + for (final v in [5.0, 10.0]) { + final d = await raster( + Ring(v, green, const Color(0x00000000), + stroke: 3, solid: true, background: magenta), + px: 20); + // The ring is painted, nothing is drawn through the centre. + expect(at(d, .3, 8.5, px: 20)[3], greaterThan(200), reason: 'v=$v'); + expect(at(d, .3, 0.5, px: 20)[3], 0, reason: 'centre stays empty'); + } + }); + }); + }); + + group('small rings never run out of centre', () { + test('centre-line radius floors at 1.5 x stroke (20 px ring, stroke 3)', () { + const small = SpiralGeometry(3); + const r0 = 10 - 1.5; + for (var s = 0.0; s <= 20; s += .05) { + expect(small.radiusAt(s, r0), greaterThanOrEqualTo(1.5 * 3 - 1e-9), + reason: 's=$s'); + } + expect(small.radiusAt(5.3, r0), closeTo(4.5, 1e-9)); // 500% floored + expect(small.radiusAt(1.3, r0), closeTo(r0 - 2.25, 1e-9)); // untouched + }); + }); + + group('caps are applied independently', () { + int discs(CustomPainter p) { + final rec = _Discs(); + p.paint(rec, const Size(100, 100)); + return rec.n; + } + + const none = Color(0x00000000); + + test('150% single colour: one disc (the end); the lap-cut start is square', + () { + expect(discs(Ring(1.5, Colors.green, none, solid: true)), 1); + }); + + test('66% single colour: discs at both ends', () { + expect(discs(Ring(.66, Colors.green, none, solid: true)), 2); + }); + + test('a full segmented circle: no start disc over the last segment\'s end', + () { + expect( + discs(StageRing(const [ + RingArc(Colors.blue, .5), + RingArc(Colors.red, .5), + ], none)), + 2); + }); + }); + + group('shouldRepaint covers every input', () { + test('Ring: track, stroke, v, t, solid, color, background', () { + final base = Ring(.5, Colors.green, Colors.grey); + expect(Ring(.5, Colors.green, Colors.grey).shouldRepaint(base), isFalse); + expect(Ring(.5, Colors.green, Colors.black).shouldRepaint(base), isTrue); + expect(Ring(.5, Colors.green, Colors.grey, stroke: 4).shouldRepaint(base), + isTrue); + expect(Ring(.5, Colors.green, Colors.grey, background: Colors.white) + .shouldRepaint(base), isTrue); + }); + }); +} + +class _Discs implements Canvas { + int n = 0; + var _first = true; + @override + void drawCircle(Offset c, double r, Paint p) { + if (_first) { + _first = false; // the track + return; + } + // Separator halos are background-coloured (here transparent). + if (p.style == PaintingStyle.fill && p.color.a > 0) n++; + } + + @override + dynamic noSuchMethod(Invocation invocation) => null; +} From bcad8450084a9dd83eef56c8b0f3300d80a6d709 Mon Sep 17 00:00:00 2001 From: MikenRafter Date: Sun, 8 Nov 2026 20:24:59 -0700 Subject: [PATCH 02/17] feat(home): spiral and sleep-stage rings Rings past 100% draw as an eased spiral with one separator per lap; the sleep ring model groups a scored night into stage arcs against the person's need (or a labelled 8 h default) and estimates tonight before sleep. The pixel probes now fail against a flat ring. --- lib/ui2/charts.dart | 82 ++++----- lib/ui2/screens/home_screen.dart | 1 - lib/ui2/sleep_ring_model.dart | 230 +++++++++++++++++++++-- lib/ui2/spiral_ring.dart | 254 ++++++++++++++++++++++++-- test/sleep_ring_model_test.dart | 21 +++ test/ui2_spiral_ring_round2_test.dart | 13 ++ test/ui2_spiral_ring_round3_test.dart | 4 +- test/ui2_spiral_ring_test.dart | 21 +++ 8 files changed, 550 insertions(+), 76 deletions(-) diff --git a/lib/ui2/charts.dart b/lib/ui2/charts.dart index 30a201975..06acfc874 100644 --- a/lib/ui2/charts.dart +++ b/lib/ui2/charts.dart @@ -612,80 +612,80 @@ class Ring extends CustomPainter { /// calibrating" and "this is missing" into one look. final bool solid; - /// RED-PHASE STUB: the surface under the ring (the lap separator). Unused. + /// The surface the ring sits on: the 1.5 px separator between laps. final Color? background; Ring(this.v, this.color, this.track, {this.stroke = 10, this.t = 1, this.solid = false, this.background}); - /// RED-PHASE STUB: the real getter is SpiralGeometry(stroke).pieces(...). - List get pieces => const []; + List get pieces => SpiralGeometry(stroke).pieces([v * t.clamp(0.0, 1.0)]); @override void paint(Canvas cv, Size s) { - final c = Offset(s.width / 2, s.height / 2); - final r = min(s.width, s.height) / 2 - stroke / 2; - if (r <= 0) return; - cv.drawCircle( - c, - r, - Paint() - ..style = PaintingStyle.stroke - ..strokeWidth = stroke - ..color = track, - ); - final sweep = 2 * pi * v.clamp(0, 1) * t.clamp(0, 1); - if (sweep <= 0) return; - cv.drawArc( - Rect.fromCircle(center: c, radius: r), - -pi / 2, - sweep, - false, - Paint() - ..style = PaintingStyle.stroke - ..strokeWidth = stroke - ..strokeCap = StrokeCap.round - ..color = solid ? color : const Color(0x00000000) - ..shader = solid - ? null - : SweepGradient( - startAngle: -pi / 2, - endAngle: 3 * pi / 2, - colors: [color.withValues(alpha: .55), color], - transform: const GradientRotation(-pi / 2), - ).createShader(Rect.fromCircle(center: c, radius: r)), - ); + paintSpiral(cv, s, stroke: stroke, track: track, colors: [color], + fractions: [v], t: t, solid: solid, background: background); } @override bool shouldRepaint(covariant Ring o) => - o.v != v || o.t != t || o.solid != solid || o.color != color; + o.v != v || + o.t != t || + o.solid != solid || + o.color != color || + o.track != track || + o.stroke != stroke || + o.background != background; } -/// RED-PHASE STUB: one coloured arc of a [StageRing]. +/// One coloured arc of a [StageRing]: [fraction] of the FULL circle. class RingArc { final Color color; final double fraction; const RingArc(this.color, this.fraction); + + @override + bool operator ==(Object other) => + other is RingArc && other.color == color && other.fraction == fraction; + + @override + int get hashCode => Object.hash(color, fraction); } -/// RED-PHASE STUB: paints nothing and reports no pieces. +/// The sleep dial once a night is scored, with one shared spiral geometry. class StageRing extends CustomPainter { final List arcs; final Color track; final double stroke, t; + final Color? background; StageRing(this.arcs, this.track, {this.stroke = 10, this.t = 1, this.background}); - List get pieces => const []; + List get pieces => SpiralGeometry(stroke).pieces(arcs.map((a) => a.fraction * t.clamp(0.0, 1.0)).toList()); @override - void paint(Canvas cv, Size s) {} + void paint(Canvas cv, Size s) { + paintSpiral(cv, s, stroke: stroke, track: track, + colors: arcs.map((a) => a.color).toList(), fractions: arcs.map((a) => a.fraction).toList(), t: t, + background: background); + } @override - bool shouldRepaint(covariant StageRing o) => true; + bool shouldRepaint(covariant StageRing o) => + o.track != track || + o.background != background || + o.stroke != stroke || + o.t != t || + o.arcs.length != arcs.length || + !_sameArcs(o.arcs); + + bool _sameArcs(List other) { + for (var i = 0; i < arcs.length; i++) { + if (other[i] != arcs[i]) return false; + } + return true; + } } /// A ring made of discrete dashes rather than a continuous arc — for a value diff --git a/lib/ui2/screens/home_screen.dart b/lib/ui2/screens/home_screen.dart index 2c9defb21..6a6701c60 100644 --- a/lib/ui2/screens/home_screen.dart +++ b/lib/ui2/screens/home_screen.dart @@ -42,7 +42,6 @@ import '../../compute/findings.dart' kReadinessGoodFrom, kReadinessRestBelow; import '../../data/day_label.dart' show todayLabel, calendarDaysBetween; -import '../sleep_ring_model.dart' show SleepStageMin; import '../../compute/onehz_pipeline.dart' show readinessInputShortfallNote, readinessUnstableBaselineNote; import '../../data/db.dart' show DbRebuild, LocalDb; diff --git a/lib/ui2/sleep_ring_model.dart b/lib/ui2/sleep_ring_model.dart index bee56eac3..351dab219 100644 --- a/lib/ui2/sleep_ring_model.dart +++ b/lib/ui2/sleep_ring_model.dart @@ -1,8 +1,17 @@ -// RED-PHASE STUB (phase 0, Home + labels). Shapes only, no behaviour: every -// function returns the empty answer so the red tests fail on assertions. The -// green phase replaces this file with the model described in -// edge.research/sol/home/scope-map.md (fork origin/integrate/next: -// lib/ui2/sleep_ring_model.dart, minus ExpectedSleepSchedule). +// sleep_ring_model.dart — what the Home sleep ring says and draws, as data. +// +// PURE. No clock (the caller passes `now`), no I/O, no widgets, no l10n: the +// ring turns this into shapes and strings. Kept apart so every rule below is a +// fixed-date unit test, including the DST night. +// +import 'dart:math' as math; + +/// The owner's explicit default target: 5 cycles + 30 min. Used ONLY when no +/// learned need exists, and always labelled as a default where it is shown. +const int kDefaultSleepTargetMin = 480; + +/// Assumed length of one sleep cycle, until a per-user value exists. +const int kDefaultCycleLenMin = 90; enum SleepRingPhase { slept, estimate, none } @@ -12,15 +21,13 @@ enum WindowStartSource { coach, learned } enum WakeSource { alarm, learned } -/// STUB: the real constants are 480 and 90. -const int kDefaultSleepTargetMin = 0; -const int kDefaultCycleLenMin = 0; - +/// One night's stage totals, minutes. Null means the pipeline had no figure. class SleepStageMin { final int? deep, rem, light, awake; const SleepStageMin({this.deep, this.rem, this.light, this.awake}); } +/// One contiguous arc of the ring: [fraction] of the FULL circle. class SleepArc { final SleepArcKind kind; final double fraction; @@ -32,8 +39,22 @@ class SleepRingModel { final List arcs; final int targetMin; final bool targetIsDefault; - final int? asleepMin, pct, estimateMin, cycles; + + /// Slept: minutes asleep. Null otherwise. + final int? asleepMin; + + /// Slept: whole percent of [targetMin]. Null otherwise. + final int? pct; + + /// Estimate: minutes between the window start and the wake. Null otherwise. + final int? estimateMin; + + /// Estimate: whole cycles in [estimateMin]. Null otherwise. + final int? cycles; final int cycleLenMin; + + /// Estimate: the instants the estimate was measured between, and where each + /// came from. [startSource] is null when the estimate runs from `now`. final DateTime? windowStart, wakeAt; final WindowStartSource? startSource; final WakeSource? wakeSource; @@ -41,22 +62,31 @@ class SleepRingModel { const SleepRingModel({ required this.phase, this.arcs = const [], - this.targetMin = 0, - this.targetIsDefault = false, + this.targetMin = kDefaultSleepTargetMin, + this.targetIsDefault = true, this.asleepMin, this.pct, this.estimateMin, this.cycles, - this.cycleLenMin = 0, + this.cycleLenMin = kDefaultCycleLenMin, this.windowStart, this.wakeAt, this.startSource, this.wakeSource, }); + /// Total sweep of the ring in laps: 1.0 is a full circle, above 1 a spiral. double get sweep => arcs.fold(0.0, (a, e) => a + e.fraction); } +/// Cycle lengths outside this are not a sleep cycle; the 90 min assumption +/// stands instead. +const int _minCycleLenMin = 60, _maxCycleLenMin = 150; + +/// An alarm further away than this is not tonight's wake (a weekday alarm seen +/// from Friday evening), so the learned wake answers instead. +const int _maxAlarmHorizonMicros = 24 * 3600 * 1000000; + SleepRingModel sleepRingModel({ required DateTime now, num? durationMin, @@ -67,11 +97,177 @@ SleepRingModel sleepRingModel({ int? learnedOnsetMin, int? learnedWakeMin, int? cycleLenMin, -}) => - const SleepRingModel(phase: SleepRingPhase.none); +}) { + final learnedNeed = needMin != null && needMin > 0; + final target = learnedNeed ? needMin.round() : kDefaultSleepTargetMin; + final isDefault = !learnedNeed; + final cycleLen = cycleLenMin != null && + cycleLenMin >= _minCycleLenMin && + cycleLenMin <= _maxCycleLenMin + ? cycleLenMin + : kDefaultCycleLenMin; + + if (durationMin != null) { + return _slept(durationMin, stages, target, isDefault, cycleLen); + } + + // ── unslept: wake ──────────────────────────────────────────────────────── + final n = now.toLocal(); + DateTime? wake; + WakeSource? wakeSource; + if (nextAlarm != null && + nextAlarm.isAfter(now) && + nextAlarm.difference(now).inMicroseconds <= _maxAlarmHorizonMicros) { + wake = nextAlarm.toLocal(); + wakeSource = WakeSource.alarm; + } else if (learnedWakeMin != null) { + wake = _nextClock(n, learnedWakeMin); + wakeSource = WakeSource.learned; + } + if (wake == null) { + return SleepRingModel( + phase: SleepRingPhase.none, + targetMin: target, + targetIsDefault: isDefault, + cycleLenMin: cycleLen, + ); + } + + // ── unslept: window start ──────────────────────────────────────────────── + int? onsetMinute; + WindowStartSource? startSource; + if (coachBedtimeMinOfDay != null) { + onsetMinute = coachBedtimeMinOfDay.round() % 1440; + startSource = WindowStartSource.coach; + } else if (learnedOnsetMin != null) { + onsetMinute = learnedOnsetMin; + startSource = WindowStartSource.learned; + } + DateTime start = n; + if (onsetMinute != null) { + final onset = _clockBefore(wake, onsetMinute); + if (onset.isAfter(n)) start = onset; + } + // Real elapsed time between two instants, so a DST night is 7 h or 9 h. + final est = wake.difference(start).inMinutes; + if (est <= 0) { + return SleepRingModel( + phase: SleepRingPhase.none, + targetMin: target, + targetIsDefault: isDefault, + cycleLenMin: cycleLen, + ); + } + return SleepRingModel( + phase: SleepRingPhase.estimate, + arcs: [SleepArc(SleepArcKind.estimate, math.min(1.0, est / target))], + targetMin: target, + targetIsDefault: isDefault, + estimateMin: est, + cycles: est ~/ cycleLen, + cycleLenMin: cycleLen, + windowStart: start, + wakeAt: wake, + startSource: startSource, + wakeSource: wakeSource, + ); +} + +SleepRingModel _slept(num durationMin, SleepStageMin? stages, int target, + bool isDefault, int cycleLen) { + final asleep = durationMin.round(); + final asleepFrac = asleep / target; + final pct = (asleepFrac * 100).round(); + + final d = stages?.deep, r = stages?.rem, l = stages?.light; + final split = d != null && r != null && l != null && d >= 0 && r >= 0 && l >= 0 + ? d + r + l + : 0; + + final arcs = []; + if (split > 0 && asleep > 0) { + // Rescale to the night's duration so rounding in the stage totals cannot + // move the sweep. Fractions are minutes / NEED and stay unscaled past a + // full circle: 12 h against an 8 h need is 150%, a second lap, as the + // label says. + final k = asleepFrac / split; + arcs.addAll([ + SleepArc(SleepArcKind.deep, d! * k), + SleepArc(SleepArcKind.rem, r! * k), + SleepArc(SleepArcKind.light, l! * k), + ]); + final awake = stages!.awake; + final room = 1 - math.min(1.0, asleepFrac); + if (awake != null && awake > 0) { + final f = math.min(awake / target, room).toDouble(); + if (f > 1e-12) arcs.add(SleepArc(SleepArcKind.awake, f)); + } + } else { + arcs.add(SleepArc(SleepArcKind.solid, asleepFrac)); + } + return SleepRingModel( + phase: SleepRingPhase.slept, + arcs: arcs, + targetMin: target, + targetIsDefault: isDefault, + asleepMin: asleep, + pct: pct, + cycleLenMin: cycleLen, + ); +} + +/// The first local [minuteOfDay] strictly after [from]. Calendar arithmetic, +/// not `+ 24 h`, so the wall-clock time survives a DST change. +DateTime _nextClock(DateTime from, int minuteOfDay) { + final today = DateTime( + from.year, from.month, from.day, minuteOfDay ~/ 60, minuteOfDay % 60); + return today.isAfter(from) + ? today + : DateTime(from.year, from.month, from.day + 1, minuteOfDay ~/ 60, + minuteOfDay % 60); +} + +/// The latest local [minuteOfDay] strictly before [wake]. +DateTime _clockBefore(DateTime wake, int minuteOfDay) { + final same = DateTime( + wake.year, wake.month, wake.day, minuteOfDay ~/ 60, minuteOfDay % 60); + return same.isBefore(wake) + ? same + : DateTime(wake.year, wake.month, wake.day - 1, minuteOfDay ~/ 60, + minuteOfDay % 60); +} + +/// Median local clock minute-of-day of the onsets and of the wakes in +/// [windows] (`LocalRepository.sleepWindows` rows: epoch-second `onset_ts` / +/// `wake_ts`, either may be null). A side is null with fewer than +/// [minSamples] usable nights. Onsets are medianed on a noon-to-noon axis so +/// 23:30 and 00:10 average to ~23:50, not 11:50. ({int? onsetMin, int? wakeMin}) learnedClockMinutes( List> windows, { int minSamples = 3, -}) => - (onsetMin: null, wakeMin: null); +}) { + int minuteOf(num sec) { + final t = DateTime.fromMillisecondsSinceEpoch(sec.toInt() * 1000); + return t.hour * 60 + t.minute; + } + + int? median(List v) { + if (v.length < minSamples || v.isEmpty) return null; + v.sort(); + final mid = v.length ~/ 2; + return v.length.isOdd ? v[mid] : ((v[mid - 1] + v[mid]) / 2).round(); + } + + final onsets = [], wakes = []; + for (final w in windows) { + final o = w['onset_ts'], k = w['wake_ts']; + if (o is num) { + final m = minuteOf(o); + onsets.add(m < 720 ? m + 1440 : m); + } + if (k is num) wakes.add(minuteOf(k)); + } + final on = median(onsets); + return (onsetMin: on == null ? null : on % 1440, wakeMin: median(wakes)); +} diff --git a/lib/ui2/spiral_ring.dart b/lib/ui2/spiral_ring.dart index beaf5d5a0..95f886aee 100644 --- a/lib/ui2/spiral_ring.dart +++ b/lib/ui2/spiral_ring.dart @@ -1,10 +1,17 @@ -// RED-PHASE STUB (phase 0, Home + labels). Shapes only, no behaviour. The -// green phase ports fork origin/integrate/next:lib/ui2/spiral_ring.dart. -import 'package:flutter/widgets.dart'; +import 'dart:math'; -const double kSpiralSeparator = 0; +import 'package:flutter/material.dart'; -double spiralSeparatorWidth(double stroke) => 0; +const double kSpiralSeparator = 1.5; + +/// Width of the lap separator: a LINE centred on the upper lap's outer edge, +/// 1.5 px at stroke 20 and up, scaled down with the stroke, and none below +/// 0.5 px. A tiny ring has no room to spend on a separator: the laps overlap +/// by a quarter stroke and an erased gap would leave empty rings. +double spiralSeparatorWidth(double stroke) { + final w = kSpiralSeparator * min(1.0, stroke / 20); + return w < .5 ? 0 : w; +} class SpiralPiece { final int seg, lap; @@ -16,17 +23,93 @@ class SpiralPiece { class SpiralGeometry { final double stroke, ramp, insetPerStroke; - const SpiralGeometry(this.stroke, {this.ramp = 0, this.insetPerStroke = 0}); - - double inset(double s) => 0; - double radiusAt(double s, double r0) => r0; - int saturationLaps(double r0) => 0; - static int lapsFor(double fraction) => 0; - List pieces(List fractions) => const []; - double? separatorFrom(int lap) => null; - static double gradientAt(double s, double total) => 0; + const SpiralGeometry(this.stroke, {this.ramp = .3, this.insetPerStroke = .75}); + + double inset(double s) { + if (!s.isFinite || s <= 1 || ramp <= 0) return 0; + final lap = s.floor(); + final x = ((s - lap) / ramp).clamp(0.0, 1.0); + if (x < .002) return insetPerStroke * stroke * (lap - 1).clamp(0, 1000000); + if (1 - x < .002) return insetPerStroke * stroke * lap; + return insetPerStroke * stroke * ((lap - 1) + (1 - cos(pi * x)) / 2); + } + + /// Centre-line radius at [s]. Floored at 1.5 x stroke so a small ring at a + /// high ratio (a 20 px ring at 500%) keeps its laps on the dial instead of + /// running through the centre; beyond that laps overlap at the innermost + /// radius and the value label carries the number. + double radiusAt(double s, double r0) => + max(r0 - inset(s), min(r0, 1.5 * stroke)); + /// Laps after which a further lap would be drawn exactly on the previous + /// one: the first lap whose held radius is at the floor, plus lap 0. + /// + /// DECISION (saturation): on a small ring at a high ratio the laps run out + /// of room, so the picture stops growing here and the value label carries + /// the true number. It also bounds the work per paint: a 200-lap input + /// builds the same few paths as the saturation point. [Ring.pieces] still + /// reports the full value. + int saturationLaps(double r0) { + final floor = min(r0, 1.5 * stroke); + final step = insetPerStroke * stroke; + if (r0 <= floor || step <= 0) return 1; + return (r0 - floor) ~/ step + ((r0 - floor) % step > 1e-9 ? 1 : 0) + 1; + } + + static int lapsFor(double fraction) => + !fraction.isFinite || fraction <= 0 ? 0 : fraction.ceil(); + + List pieces(List fractions) { + final valid = <(int, double)>[]; + for (var i = 0; i < fractions.length; i++) { + final f = fractions[i]; + if (f.isFinite && f > 0) valid.add((i, f)); + } + final total = valid.fold(0, (a, b) => a + b.$2); + if (!total.isFinite || total <= 0) return const []; + final result = []; + var at = 0.0; + final starts = []; + for (final e in valid) { + starts.add(at); + at += e.$2; + } + for (var lap = 0; lap < lapsFor(total); lap++) { + final pieces = []; + for (var i = 0; i < valid.length; i++) { + final start = starts[i], end = start + valid[i].$2; + final from = max(start, lap.toDouble()), to = min(end, min(total, lap + 1.0)); + if (to <= from) continue; + final trueStart = from == start, trueEnd = to == end; + pieces.add(SpiralPiece(valid[i].$1, lap, from, to, + capStart: trueStart && start == 0 && total < 1, + capEnd: trueEnd)); + } + result.addAll(pieces.reversed); + } + if (valid.length == 1 && total < 1 && result.isNotEmpty) { + final p = result.single; + result[0] = SpiralPiece(p.seg, p.lap, p.from, p.to, capStart: true, capEnd: true); + } + return result; + } + + double? separatorFrom(int lap) { + if (lap <= 0) return null; + if (lap > 1) return lap.toDouble(); + final target = .5 / (insetPerStroke * stroke); + return 1 + ramp * acos(1 - 2 * target.clamp(0.0, 1.0)) / pi; + } + static double gradientAt(double s, double total) => + total <= 0 || !total.isFinite ? 0 : (s / total).clamp(0.0, 1.0); } +/// Shared rendering path for score and stage rings. +/// +/// Every piece is a FILLED band whose radius follows [SpiralGeometry.radiusAt] +/// along its whole sweep, so the eased inset is drawn, not sampled once per +/// piece. Caps are separate discs: ends and starts are decided independently +/// by [SpiralPiece.capEnd] / [SpiralPiece.capStart]. [background] is the +/// surface under the ring (the lap separator); it falls back to [track]. void paintSpiral(Canvas canvas, Size size, {required double stroke, required Color track, @@ -34,6 +117,145 @@ void paintSpiral(Canvas canvas, Size size, required List fractions, required double t, bool solid = true, - Color? background}) {} + Color? background}) { + final center = Offset(size.width / 2, size.height / 2); + final r0 = min(size.width, size.height) / 2 - stroke / 2; + if (r0 <= 0) return; + final half = stroke / 2; + canvas.drawCircle( + center, + r0, + Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = stroke + ..color = track); + final progress = t.clamp(0.0, 1.0); + final geometry = SpiralGeometry(stroke); + // Saturation: laps past the one that reaches the floor would repaint it. + var room = geometry.saturationLaps(r0).toDouble(); + final scaled = []; + for (final f in fractions) { + final v = f.isFinite && f > 0 ? f * progress : 0.0; + final take = min(v, room); + scaled.add(take); + room -= take; + } + final total = scaled.fold(0, (a, b) => a + b); + final pieces = geometry.pieces(scaled); + if (pieces.isEmpty) return; + + Offset pt(double s, double radius) { + final a = -pi / 2 + 2 * pi * s; + return center + Offset(cos(a), sin(a)) * radius; + } + + // A closed band from lap position [a] to [b], [grow] px wider each side. + Path band(double a, double b, double grow) { + final n = max(2, ((b - a) * 360).ceil()); + final path = Path(); + for (var i = 0; i <= n; i++) { + final s = a + (b - a) * i / n; + final o = pt(s, geometry.radiusAt(s, r0) + half + grow); + i == 0 ? path.moveTo(o.dx, o.dy) : path.lineTo(o.dx, o.dy); + } + for (var i = n; i >= 0; i--) { + final s = a + (b - a) * i / n; + final o = pt(s, geometry.radiusAt(s, r0) - half - grow); + path.lineTo(o.dx, o.dy); + } + return path..close(); + } + + // A gradient ring fades from 55% to full colour along the bar. That fade is + // PRE-BLENDED against [background] and everything is drawn fully opaque, so + // bands and cap discs overlap without compositing twice (a double-alpha + // patch) and without any subtraction (which cracked the join and removed the + // 100% end cap). With no [background] the translucent colour is used as is, + // and a cap overlap shows a slightly denser patch: callers should pass it. + Color colourAt(SpiralPiece p, double s) { + final c = colors[p.seg]; + if (solid) return c; + final a = .55 + .45 * gradientAt(s, total); + final faded = c.withValues(alpha: c.a * a); + return background == null ? faded : Color.alphaBlend(faded, background); + } + + final sepColour = background ?? track; + canvas.save(); + // The outer edge never leaves the lap-0 circle. + canvas.clipPath( + Path()..addOval(Rect.fromCircle(center: center, radius: r0 + half))); + + final laps = >{}; + for (final p in pieces) { + (laps[p.lap] ??= []).add(p); + } + // A cap on a segment end that lands exactly on a lap boundary rounds over + // the NEXT lap's first band, so it is drawn with that lap. + final lateDiscs = >{}; + for (final p in pieces) { + if (p.capEnd && p.to == p.lap + 1 && laps.containsKey(p.lap + 1)) { + (lateDiscs[p.lap + 1] ??= []).add(p); + } + } + void disc(SpiralPiece p, double s) => canvas.drawCircle( + pt(s, geometry.radiusAt(s, r0)), half, Paint()..color = colourAt(p, s)); + + for (final lap in laps.keys.toList()..sort()) { + final group = laps[lap]!; // already last segment first + final bandsFrom = group.map((p) => p.from).reduce(min); + final bandsTo = group.map((p) => p.to).reduce(max); + for (final p in group) { + // A lap cut overlaps the next lap's start slightly: no hairline seam. + final to = p.capEnd ? p.to : p.to + .003; + final paint = Paint()..style = PaintingStyle.fill; + final ua = p.from - p.lap, ub = p.to - p.lap; + if (solid || ub - ua < 1e-6) { + paint.color = colourAt(p, (p.from + p.to) / 2); + } else { + paint.shader = SweepGradient( + startAngle: 0, + endAngle: 2 * pi, + colors: [colourAt(p, p.from), colourAt(p, p.to)], + stops: [ua, ub], + transform: const GradientRotation(-pi / 2), + ).createShader(Rect.fromCircle(center: center, radius: r0)); + } + canvas.drawPath(band(p.from, to, 0), paint); + } + // One thin line along the upper lap's outer edge, laid after the lap's own + // bands (so stage joins stay contiguous) and before its caps. It starts + // where the lap leaves the one below. + final sepW = spiralSeparatorWidth(stroke); + if (lap >= 1 && sepW > 0) { + final from = max(bandsFrom, geometry.separatorFrom(lap)!); + if (from < bandsTo) { + final n = max(2, ((bandsTo - from) * 360).ceil()); + final line = Path(); + for (var i = 0; i <= n; i++) { + final s = from + (bandsTo - from) * i / n; + final o = pt(s, geometry.radiusAt(s, r0) + half); + i == 0 ? line.moveTo(o.dx, o.dy) : line.lineTo(o.dx, o.dy); + } + canvas.drawPath( + line, + Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = sepW + ..color = sepColour); + } + } + // Caps after every band of the lap, so each segment's round end sits over + // its clockwise neighbour (and the last one over the first at the seam). + for (final p in [...?lateDiscs[lap], ...group]) { + final late = lateDiscs[lap]?.contains(p) ?? false; + if (p.capEnd && (late || !(lateDiscs[lap + 1]?.contains(p) ?? false))) { + disc(p, p.to); + } + if (p.capStart && !late) disc(p, p.from); + } + } + canvas.restore(); +} -double gradientAt(double s, double total) => 0; +double gradientAt(double s, double total) => SpiralGeometry.gradientAt(s, total); diff --git a/test/sleep_ring_model_test.dart b/test/sleep_ring_model_test.dart index be7914341..97fb464ec 100644 --- a/test/sleep_ring_model_test.dart +++ b/test/sleep_ring_model_test.dart @@ -469,12 +469,20 @@ void main() { expect(m.wakeAt, isNull); expect(m.arcs, isEmpty); expect(m.sweep, 0); + // Control: the same inputs plus a wake DO give an estimate, so the + // refusal above is the model's rule, not an inert model. + final withWake = _unslept( + now: _evening, bedtime: 1350, learnedOnset: _h23, learnedWake: _h07); + expect(withWake.phase, SleepRingPhase.estimate); }); test('nothing at all is also no estimate', () { final m = _unslept(now: _evening); expect(m.phase, SleepRingPhase.none); expect(m.arcs, isEmpty); + // Control: the default target is still reported (a real model). + expect(m.targetMin, kDefaultSleepTargetMin); + expect(kDefaultSleepTargetMin, 480); }); test('cycle length is injectable: 100 min => floor(480 / 100) = 4', () { @@ -606,6 +614,14 @@ void main() { ]); expect(r.onsetMin, isNull); expect(r.wakeMin, isNull); + // Control: one more night and both sides answer. + final three = learnedClockMinutes([ + night(10, (23, 0), (7, 0)), + night(11, (23, 0), (7, 0)), + night(12, (23, 0), (7, 0)), + ]); + expect(three.onsetMin, 23 * 60); + expect(three.wakeMin, 7 * 60); }); test('nights with no window are skipped, and each side counts its own', @@ -625,6 +641,11 @@ void main() { final r = learnedClockMinutes(const []); expect(r.onsetMin, isNull); expect(r.wakeMin, isNull); + // Control: minSamples is honoured (a single night answers at 1). + expect( + learnedClockMinutes([night(10, (23, 0), (7, 0))], minSamples: 1) + .wakeMin, + 7 * 60); }); }); } diff --git a/test/ui2_spiral_ring_round2_test.dart b/test/ui2_spiral_ring_round2_test.dart index 08a92da47..2a5ff82cc 100644 --- a/test/ui2_spiral_ring_round2_test.dart +++ b/test/ui2_spiral_ring_round2_test.dart @@ -5,6 +5,7 @@ import 'dart:math'; import 'dart:typed_data'; import 'dart:ui' as ui; +import 'package:flutter/foundation.dart' show listEquals; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:openstrap_edge/ui2/charts.dart'; @@ -117,11 +118,23 @@ void main() { return c.paths; } + expect(paths(3.0), greaterThan(paths(1.0)), + reason: 'extra laps are extra bands'); expect(paths(200), lessThanOrEqualTo(paths(3.0))); expect(paths(200), lessThan(30)); expect(paths(10), paths(3.0)); await tester.runAsync(() async { final sat = await _raster(ring(3.0), px: 20); + final one = await _raster(ring(1.0), px: 20); + expect(listEquals(sat.buffer.asUint8List(), one.buffer.asUint8List()), + isFalse, + reason: 'the saturated picture is a spiral, not one flat lap'); + // Radius 5 (u = .3, 20 px) is inside the saturated laps and outside + // lap 0's band (7..10): only a spiral paints it. + int a(ByteData d) => _px(d, 10 + 5 * sin(2 * pi * .3), + 10 - 5 * cos(2 * pi * .3), px: 20)[3]; + expect(a(one), 0); + expect(a(sat), greaterThan(200)); for (final v in [5.0, 10.0, 200.0]) { final d = await _raster(ring(v), px: 20); expect(d.buffer.asUint8List(), sat.buffer.asUint8List(), diff --git a/test/ui2_spiral_ring_round3_test.dart b/test/ui2_spiral_ring_round3_test.dart index fe831accd..f81f26618 100644 --- a/test/ui2_spiral_ring_round3_test.dart +++ b/test/ui2_spiral_ring_round3_test.dart @@ -48,7 +48,9 @@ void main() { } } expect(inner, isNotNull, reason: 'u=$u'); - for (var r = inner! + .5; r < 8.8; r += .25) { + // A flat ring's innermost pixel is lap 0's inner edge (~7). + expect(inner!, lessThan(6), reason: 'u=$u: no inner laps'); + for (var r = inner + .5; r < 8.8; r += .25) { final c = _at(d, 20, u, r); expect(c[3], greaterThan(200), reason: 'gap at u=$u r=$r $c'); expect(_magenta(c), isFalse, reason: 'separator at u=$u r=$r'); diff --git a/test/ui2_spiral_ring_test.dart b/test/ui2_spiral_ring_test.dart index 9a0be6186..c59dc2b12 100644 --- a/test/ui2_spiral_ring_test.dart +++ b/test/ui2_spiral_ring_test.dart @@ -357,6 +357,18 @@ void main() { expect(imgs[1.5], isNot(imgs[1.0]), reason: '150% must differ from 100%'); expect(imgs[2.0], isNot(imgs[1.5])); expect(imgs[3.0], isNot(imgs[2.0])); + // A flat ring would put the extra lap exactly on lap 0. The spiral + // puts it inside: at 12 o'clock + 72 degrees the 150% ring is painted + // at radius 37 (lap 0's inner edge is 40, lap 1's is ~34), the 100% + // ring is not. + int alpha(List px, double u, double r) { + final x = (50 + r * sin(2 * pi * u)).floor(); + final y = (50 - r * cos(2 * pi * u)).floor(); + return px[(y * side.toInt() + x) * 4 + 3]; + } + expect(alpha(imgs[1.0]!, .2, 37), 0); + expect(alpha(imgs[1.5]!, .2, 37), greaterThan(200), + reason: 'the second lap must sit inside the first'); }); }); @@ -379,6 +391,10 @@ void main() { expect(alphaAt(big, x, y), 0); } expect(sqrt(pow(50 - 1, 2) * 2), greaterThan(50)); // sanity: corner is outside + // ...and the laps that are drawn sit inside lap 0 (radius 30 at 108 + // degrees is painted by lap 2 only; a flat ring leaves it empty). + expect(alphaAt(big, (50 + 30 * sin(2 * pi * .3)).floor(), + (50 - 30 * cos(2 * pi * .3)).floor()), greaterThan(200)); }); }); }); @@ -527,6 +543,11 @@ void main() { // The ring is painted, nothing is drawn through the centre. expect(at(d, .3, 8.5, px: 20)[3], greaterThan(200), reason: 'v=$v'); expect(at(d, .3, 0.5, px: 20)[3], 0, reason: 'centre stays empty'); + // The extra laps are on the dial: radius 5 lies inside lap 0's + // band (7..10) and inside the saturated lap's (3..6). A flat ring + // leaves it empty. + expect(at(d, .3, 5.0, px: 20)[3], greaterThan(200), + reason: 'v=$v: inner laps missing'); } }); }); From 3cb3b262d2fc141a1c17958858cb9125b25eea52 Mon Sep 17 00:00:00 2001 From: MikenRafter Date: Sun, 8 Nov 2026 20:46:52 -0700 Subject: [PATCH 03/17] feat(home): details in the ring card and plainer Home labels The ring card ends in up to two inset rows: why the most pressing rings are low (recovery under 37, sleep under 85% of need, strain above target), the recovery update note, then an absent ring's reason. The sleep ring shows a scored night as stage arcs against its target ('92% of 8h 00m (default)') and, before sleep, an estimate that follows the clock each minute. Eleven Home strings lose their jargon, the illness texts no longer print a z multiple, and 25 new keys are added in all eight locales. axisDay says '1 day ago' and daysBehind counts on the injectable clock. --- lib/l10n/app_de.arb | 202 ++++- lib/l10n/app_en.arb | 295 ++++++- lib/l10n/app_es.arb | 200 ++++- lib/l10n/app_fr.arb | 200 ++++- lib/l10n/app_hi.arb | 200 ++++- lib/l10n/app_ru.arb | 266 +++++- lib/l10n/app_th.arb | 295 ++++++- lib/l10n/app_zh.arb | 200 ++++- lib/ui2/profile/gallery.dart | 37 + lib/ui2/screens/home_screen.dart | 960 +++++++++++++++++---- test/home_day_switch_decode_once_test.dart | 82 ++ test/home_labels_test.dart | 2 +- test/home_ring_card_test.dart | 27 + test/home_sleep_ring_widget_test.dart | 13 +- test/ui2_home_day_switcher_test.dart | 60 ++ test/ui2_wiring_r2_test.dart | 4 +- 16 files changed, 2746 insertions(+), 297 deletions(-) diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 32efbb4eb..f2d1596ef 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -1475,15 +1475,15 @@ "homeDriverRhr": "Ruhepuls", "homeDriverResp": "Atemfrequenz", "homeDriverTemp": "Hauttemperatur", - "homeDbRebuiltTitle": "Die Datenbank musste für den App-Start neu erstellt werden", + "homeDbRebuiltTitle": "Deine Datenbank wurde neu aufgebaut", "homeDbRebuiltNothingRecovered": "Es konnte nichts wiederhergestellt werden.", "homeDbRebuiltRecovered": "Wiederhergestellt: {list}.", "homeDbRebuiltEmpty": "Leer: {list}.", "homeDbRebuiltKept": "Die ursprüngliche Datei bleibt unter {path} erhalten. Es wurde nichts gelöscht.", "homeWorkoutHoldTitle": "Ein Training läuft noch", - "homeWorkoutHoldBody": "Der heutige Tag wartet, solange ein Training läuft: Das Band zeichnet weiter auf, berechnet wird aber erst nach dem Ende. Beende das Training über die Leiste unten, dann wird der Tag ausgewertet – Synchronisieren allein reicht nicht.", - "homeInsightsRebuildingTitle": "Deine tagesübergreifenden Auswertungen werden neu berechnet", - "homeInsightsRebuildingAlgoVersion": "Die Berechnung hat sich mit dem letzten Update geändert.", + "homeWorkoutHoldBody": "Heute füllt sich, sobald du das Training beendest.", + "homeInsightsRebuildingTitle": "Deine Trends werden aktualisiert", + "homeInsightsRebuildingAlgoVersion": "Wird nach dem letzten App-Update aktualisiert.", "homeInsightsStaleOverWeek": "Die letzte Auswertung wurde vor über einer Woche erstellt – zu alt, um sich darauf zu verlassen.", "homeInsightsStaleOnDay": "Die letzte Auswertung wurde am {day} erstellt – zu alt, um sich darauf zu verlassen.", "homeInsightsNoVersionStamp": "Die gespeicherte Auswertung hat keine Versionsangabe.", @@ -1492,13 +1492,193 @@ "homeCalibrating": "Wird kalibriert", "homeCalibratingNights": "{have} von {need} Nächten", "homeCalibratingDays": "{have} von {need} Tagen", - "homeGapNoReason": "Aus den gespeicherten Daten geht nicht hervor, warum das fehlt.", + "homeGapNoReason": "Kein Messwert.", "homeRingRecovery": "Erholung", "homeRingStrain": "Strain", "homeRingSleep": "Schlaf", "homeRingNoStrain": "Kein Strain", "homeRingNoSleep": "Kein Schlaf", "homeSleepGapFallback": "Es wurde keine Nacht aufgezeichnet, die lang genug für eine Auswertung war.", + "homeSleepPctOf": "{pct} % von {target}", + "homeSleepPctOfDefault": "{pct} % von {target} (Standard)", + "homeSleepPctCompact": "{pct} % von {target}", + "@homeSleepPctCompact": { + "description": "Short sleep caption against a learned target.", + "placeholders": { + "pct": { + "type": "int" + }, + "target": { + "type": "String" + } + } + }, + "homeSleepPctDefaultCompact": "{pct} % von {target} · Standardziel", + "@homeSleepPctDefaultCompact": { + "description": "Short sleep caption that keeps an explicit default-target qualifier.", + "placeholders": { + "pct": { + "type": "int" + }, + "target": { + "type": "String" + } + } + }, + "homeSleepPctDefaultTight": "{pct} % von {target} (Standard)", + "@homeSleepPctDefaultTight": { + "description": "Tightest sleep caption when the target is the default: still names the target as the default.", + "placeholders": { + "pct": { + "type": "int" + }, + "target": { + "type": "String" + } + } + }, + "homeSleepOnTrack": "auf Kurs für {cycles, plural, =1{1 Zyklus} other{{cycles} Zyklen}}, {duration}", + "homeSleepOnTrackDefault": "auf Kurs für {cycles, plural, =1{1 Zyklus} other{{cycles} Zyklen}}, {duration} · Ziel {target} (Standard)", + "homeSleepEstimateCompact": "~{cycles, plural, =1{1 Zyklus} other{{cycles} Zyklen}} · {duration}", + "@homeSleepEstimateCompact": { + "description": "Short sleep estimate: \"~\" marks it as an estimate; keeps cycle count and duration.", + "placeholders": { + "cycles": { + "type": "int" + }, + "duration": { + "type": "String" + } + } + }, + "homeSleepEstimateDefaultCompact": "~{cycles, plural, =1{1 Zyklus} other{{cycles} Zyklen}} · {duration} · Standardziel", + "@homeSleepEstimateDefaultCompact": { + "description": "Short sleep estimate keeping the \"~\" estimate marker and an explicit default-target qualifier.", + "placeholders": { + "cycles": { + "type": "int" + }, + "duration": { + "type": "String" + } + } + }, + "homeSleepEstimateDefaultTight": "~{cycles, plural, =1{1 Zyklus} other{{cycles} Zyklen}} · {duration} · Standardziel", + "@homeSleepEstimateDefaultTight": { + "description": "Tightest sleep estimate caption: keeps the estimate marker, cycles, duration and the default-target qualifier.", + "placeholders": { + "cycles": { + "type": "int" + }, + "duration": { + "type": "String" + } + } + }, + "homeSleepEstimateDefaultMin": "~{cycles, plural, =1{1 Zyklus} other{{cycles} Zyklen}} · Standardziel", + "@homeSleepEstimateDefaultMin": { + "description": "Smallest default-target estimate caption: drops the duration, keeps the estimate marker, cycles and the default goal.", + "placeholders": { + "cycles": { + "type": "int" + } + } + }, + "homeSleepEstimateBare": "~{cycles, plural, =1{1 Zyklus} other{{cycles} Zyklen}}", + "@homeSleepEstimateBare": { + "description": "Smallest estimate caption: the estimate marker and the cycle count.", + "placeholders": { + "cycles": { + "type": "int" + } + } + }, + "homeSleepNoEstimate": "Keine Schätzung", + "homeSleepNoEstimateWhy": "Noch keine Nacht gewertet, und es gibt weder Wecker noch übliche Aufwachzeit, um zu schätzen.", + "homeWhyRecoveryTitle": "Erholung ist niedrig", + "homeWhyRecoveryDrivers": "Größte Bremse: {a} {za}, {b} {zb} (Streuungen von deiner Basislinie).", + "@homeWhyRecoveryDrivers": { + "placeholders": { + "a": { + "type": "String" + }, + "za": { + "type": "String" + }, + "b": { + "type": "String" + }, + "zb": { + "type": "String" + } + } + }, + "homeWhyRecoveryDriver": "Größte Bremse: {a} {za} (Streuungen von deiner Basislinie).", + "@homeWhyRecoveryDriver": { + "placeholders": { + "a": { + "type": "String" + }, + "za": { + "type": "String" + } + } + }, + "homeWhyRecoveryPlain": "Erholung {score} von 100", + "@homeWhyRecoveryPlain": { + "placeholders": { + "score": { + "type": "int" + } + } + }, + "homeWhyRecoveryPullingTwo": "Zieht ihn nach unten: {a}, {b}.", + "@homeWhyRecoveryPullingTwo": { + "placeholders": { + "a": { + "type": "String" + }, + "b": { + "type": "String" + } + } + }, + "homeWhyRecoveryPullingOne": "Zieht ihn nach unten: {a}.", + "@homeWhyRecoveryPullingOne": { + "placeholders": { + "a": { + "type": "String" + } + } + }, + "homeWhySleepTitle": "Schlaf reicht nicht aus", + "homeWhySleepBody": "Dein bewerteter Schlaf lag unter deinem Bedarf.", + "homeWhySleepDetail": "{asleep} geschlafen, {gap} unter deinem Bedarf von {need}.", + "@homeWhySleepDetail": { + "placeholders": { + "asleep": { + "type": "String" + }, + "gap": { + "type": "String" + }, + "need": { + "type": "String" + } + } + }, + "homeWhyStrainTitle": "Belastung über dem Ziel", + "homeWhyStrainBody": "Strain {strain}, über dem heutigen Ziel von {range}.", + "@homeWhyStrainBody": { + "placeholders": { + "strain": { + "type": "String" + }, + "range": { + "type": "String" + } + } + }, "homeStrainOf21": "von 21", "homeSleepNoTarget": "Noch kein Ziel", "homeOfSpan": "von {duration}", @@ -1521,13 +1701,13 @@ "homeAskCoach": "Coach fragen", "homeProfileSettings": "Profil und Einstellungen", "homeNothingTodayTitle": "Für heute nichts aufgezeichnet", - "homeNothingTodayBody": "Die zuletzt ausgewertete Nacht war {day}. Seitdem ist nichts Neues angekommen.", + "homeNothingTodayBody": "Zuletzt ausgewertete Nacht: {day}.", "homeNoDataThisDayTitle": "Keine Daten für diesen Tag", "homeNoDataThisDayBody": "An diesem Tag wurde nichts aufgezeichnet.", "homeSomeDayDataMissingTitle": "Einige Daten dieses Tages fehlen", "homeReadinessNotScoredTitle": "Heute keine Erholung berechnet", "homeReadinessNeedBody": "{need}, um zu wissen, was für dich normal ist.", - "homeReadinessNoReason": "Aus den gespeicherten Daten geht nicht hervor, warum.", + "homeReadinessNoReason": "Kein Messwert.", "homeSeeWhatWasMissing": "Anzeigen, was gefehlt hat", "homeAtAGlance": "Auf einen Blick", "homeTodaysPlan": "Plan für heute", @@ -1538,9 +1718,9 @@ "homeIllnessRedTitle": "Die letzten Nächte ergeben zusammen einen erhöhten Ruhepuls", "homeIllnessAmberSameNight": "Letzte Nacht lag außerhalb deines Normalbereichs", "homeIllnessAmberOtherNight": "{day} lag außerhalb deines Normalbereichs", - "homeIllnessBodyNoZ": "Deine nächtlichen Ruhepulswerte der letzten Nächte ergeben zusammen einen Anstieg über deine Baseline. Hier wird nur ein einziges Signal ausgewertet. Es zeigt ein Muster, aber keine Ursache.", - "homeIllnessBodyAbove": "Deine nächtlichen Ruhepulswerte der letzten Nächte ergeben zusammen einen Anstieg über deine Baseline; in dieser Nacht lag der Wert {z} Standardabweichungen darüber. Hier wird nur ein einziges Signal ausgewertet. Es zeigt ein Muster, aber keine Ursache.", - "homeIllnessBodyBelow": "Deine nächtlichen Ruhepulswerte der letzten Nächte ergeben zusammen einen Anstieg über deine Baseline; in dieser Nacht lag der Wert {z} Standardabweichungen darunter. Hier wird nur ein einziges Signal ausgewertet. Es zeigt ein Muster, aber keine Ursache.", + "homeIllnessBodyNoZ": "Dein Ruhepuls lag in den letzten Nächten über deinem Üblichen. Ein Muster, keine Ursache.", + "homeIllnessBodyAbove": "Die letzten Nächte haben deinen Ruhepuls über dein Übliches gebracht. Auch diese Nacht lag darüber. Ein Muster, keine Ursache.", + "homeIllnessBodyBelow": "Die letzten Nächte zusammen haben deinen Ruhepuls über dein Übliches gebracht, auch wenn diese Nacht allein darunter lag. Ein Muster, keine Ursache.", "homeIllnessAdvice": "Behalte es im Blick, falls es länger als ein paar Tage anhält.", "homeHeartRate": "Herzfrequenz", "homeRestingSub": "In Ruhe", @@ -3280,7 +3460,7 @@ "homeFirstWeekRecovery": "Erholung: ab Nacht {night}, sobald dein Normalwert bekannt ist", "homeFirstWeekSoFar": "{count, plural, =1{Bisher 1 Tag aufgezeichnet} other{Bisher {count} Tage aufgezeichnet}}", "homeFirstWeekDone": "Erledigt: {step}", - "homeCoachSetUpChip": "Stell Fragen zu deinen Daten: Coach einrichten", + "homeCoachSetUpChip": "Coach einrichten", "profileSetupCycleSub": "Trag deine Periode unter „Gesundheit“ ein. Das lässt sich später ändern.", "homeRecoverySleeping": "Schläft…", "homeRecoveryAfterWake": "Die Erholung wird nach dem Aufwachen bewertet.", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 7899a26db..1e810d085 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -7219,7 +7219,7 @@ "@homeDriverTemp": { "description": "Readiness driver name: skin temperature, shown in the Why? row on Home." }, - "homeDbRebuiltTitle": "Your database was rebuilt to start the app", + "homeDbRebuiltTitle": "Your database was rebuilt", "@homeDbRebuiltTitle": { "description": "Title of the card shown when the local database had to be rebuilt to start the app." }, @@ -7261,15 +7261,15 @@ "@homeWorkoutHoldTitle": { "description": "Title of the card shown on Home while a workout session is live and derivation is paused." }, - "homeWorkoutHoldBody": "Today is paused while a workout runs. The band keeps recording; finish the workout from the bar below and today fills in.", + "homeWorkoutHoldBody": "Today fills in when you finish the workout.", "@homeWorkoutHoldBody": { "description": "Body of the card shown on Home while a workout session is live and derivation is paused." }, - "homeInsightsRebuildingTitle": "Your cross-day insights are being rebuilt", + "homeInsightsRebuildingTitle": "Updating your trends", "@homeInsightsRebuildingTitle": { "description": "Title of the card shown when the cross-day insights rollup was withheld rather than shown." }, - "homeInsightsRebuildingAlgoVersion": "How these are computed changed with the last update.", + "homeInsightsRebuildingAlgoVersion": "Updating after the latest app update.", "@homeInsightsRebuildingAlgoVersion": { "description": "Reason the cross-day rollup is being rebuilt: the algorithm version changed." }, @@ -7327,7 +7327,7 @@ } } }, - "homeGapNoReason": "Nothing recorded says why this is missing.", + "homeGapNoReason": "No reading.", "@homeGapNoReason": { "description": "Fallback reason shown when a ring is empty and nothing explains why." }, @@ -7355,6 +7355,263 @@ "@homeSleepGapFallback": { "description": "Fallback reason the sleep ring is empty: no night long enough to score." }, + "homeSleepPctOf": "{pct}% of {target}", + "@homeSleepPctOf": { + "description": "Sleep ring subtitle once a night is scored: percent of the learned sleep need.", + "placeholders": { + "pct": { + "type": "int" + }, + "target": { + "type": "String", + "example": "8h 00m" + } + } + }, + "homeSleepPctOfDefault": "{pct}% of {target} (default)", + "@homeSleepPctOfDefault": { + "description": "Sleep ring subtitle once a night is scored, when no need has been learned yet: percent of the 8 h default target, labelled as a default.", + "placeholders": { + "pct": { + "type": "int" + }, + "target": { + "type": "String", + "example": "8h 00m" + } + } + }, + "homeSleepPctCompact": "{pct}% of {target}", + "@homeSleepPctCompact": { + "description": "Short sleep caption against a learned target.", + "placeholders": { + "pct": { + "type": "int" + }, + "target": { + "type": "String" + } + } + }, + "homeSleepPctDefaultCompact": "{pct}% of {target} · default target", + "@homeSleepPctDefaultCompact": { + "description": "Short sleep caption that keeps an explicit default-target qualifier.", + "placeholders": { + "pct": { + "type": "int" + }, + "target": { + "type": "String" + } + } + }, + "homeSleepPctDefaultTight": "{pct}% of default {target}", + "@homeSleepPctDefaultTight": { + "description": "Tightest sleep caption when the target is the default: still names the target as the default.", + "placeholders": { + "pct": { + "type": "int" + }, + "target": { + "type": "String" + } + } + }, + "homeSleepOnTrack": "on track for {cycles, plural, =1{1 cycle} other{{cycles} cycles}}, {duration}", + "@homeSleepOnTrack": { + "description": "Sleep ring subtitle before a night is scored: whole sleep cycles the estimate covers, then the estimated time asleep.", + "placeholders": { + "cycles": { + "type": "int" + }, + "duration": { + "type": "String", + "example": "7h 30m" + } + } + }, + "homeSleepOnTrackDefault": "on track for {cycles, plural, =1{1 cycle} other{{cycles} cycles}}, {duration} · target {target} (default)", + "@homeSleepOnTrackDefault": { + "description": "Sleep ring subtitle before a night is scored when the target is the 8 h default (no need learned yet): the estimate, then the default target, labelled as a default.", + "placeholders": { + "cycles": { + "type": "int" + }, + "duration": { + "type": "String", + "example": "7h 30m" + }, + "target": { + "type": "String", + "example": "8h 00m" + } + } + }, + "homeSleepEstimateCompact": "~{cycles, plural, one{{cycles} cycle} other{{cycles} cycles}} · {duration}", + "@homeSleepEstimateCompact": { + "description": "Short sleep estimate: \"~\" marks it as an estimate; keeps cycle count and duration.", + "placeholders": { + "cycles": { + "type": "int" + }, + "duration": { + "type": "String" + } + } + }, + "homeSleepEstimateDefaultCompact": "~{cycles, plural, one{{cycles} cycle} other{{cycles} cycles}} · {duration} · default target", + "@homeSleepEstimateDefaultCompact": { + "description": "Short sleep estimate keeping the \"~\" estimate marker and an explicit default-target qualifier.", + "placeholders": { + "cycles": { + "type": "int" + }, + "duration": { + "type": "String" + } + } + }, + "homeSleepEstimateDefaultTight": "~{cycles, plural, one{{cycles} cycle} other{{cycles} cycles}} · {duration} · default goal", + "@homeSleepEstimateDefaultTight": { + "description": "Tightest sleep estimate caption: keeps the estimate marker, cycles, duration and the default-target qualifier.", + "placeholders": { + "cycles": { + "type": "int" + }, + "duration": { + "type": "String" + } + } + }, + "homeSleepEstimateDefaultMin": "~{cycles, plural, one{{cycles} cycle} other{{cycles} cycles}} · default goal", + "@homeSleepEstimateDefaultMin": { + "description": "Smallest default-target estimate caption: drops the duration, keeps the estimate marker, cycles and the default goal.", + "placeholders": { + "cycles": { + "type": "int" + } + } + }, + "homeSleepEstimateBare": "~{cycles, plural, one{{cycles} cycle} other{{cycles} cycles}}", + "@homeSleepEstimateBare": { + "description": "Smallest estimate caption: the estimate marker and the cycle count.", + "placeholders": { + "cycles": { + "type": "int" + } + } + }, + "homeSleepNoEstimate": "No estimate", + "@homeSleepNoEstimate": { + "description": "Sleep ring value when no night is scored and there is no wake time to estimate to." + }, + "homeSleepNoEstimateWhy": "No sleep scored yet, and no alarm or typical wake time to estimate from.", + "@homeSleepNoEstimateWhy": { + "description": "Why the sleep ring has no estimate." + }, + "homeWhyRecoveryTitle": "Recovery is low", + "@homeWhyRecoveryTitle": { + "description": "Title of the Home card row that explains a low recovery score." + }, + "homeWhyRecoveryDrivers": "Biggest drag: {a} {za}, {b} {zb} (how far each sits from your usual).", + "@homeWhyRecoveryDrivers": { + "description": "Body of the low-recovery row naming the two biggest drags with their distance from the person's own usual (z-scores).", + "placeholders": { + "a": { + "type": "String" + }, + "za": { + "type": "String" + }, + "b": { + "type": "String" + }, + "zb": { + "type": "String" + } + } + }, + "homeWhyRecoveryDriver": "Biggest drag: {a} {za} (how far each sits from your usual).", + "@homeWhyRecoveryDriver": { + "description": "Body of the low-recovery row naming the one biggest drag with its distance from the person's own usual (z-score).", + "placeholders": { + "a": { + "type": "String" + }, + "za": { + "type": "String" + } + } + }, + "homeWhyRecoveryPlain": "Recovery {score} of 100", + "@homeWhyRecoveryPlain": { + "description": "Body of the low-recovery row when no input is known to drag the score: just the score out of 100.", + "placeholders": { + "score": { + "type": "int" + } + } + }, + "homeWhyRecoveryPullingTwo": "Pulling it down: {a}, {b}.", + "@homeWhyRecoveryPullingTwo": { + "description": "Body of the low-recovery row naming the two inputs pulling the score down, without numbers.", + "placeholders": { + "a": { + "type": "String" + }, + "b": { + "type": "String" + } + } + }, + "homeWhyRecoveryPullingOne": "Pulling it down: {a}.", + "@homeWhyRecoveryPullingOne": { + "description": "Body of the low-recovery row naming the one input pulling the score down, without numbers.", + "placeholders": { + "a": { + "type": "String" + } + } + }, + "homeWhySleepTitle": "Sleep is short of need", + "@homeWhySleepTitle": { + "description": "Title of the Home card row shown when sleep is well short of the person's need." + }, + "homeWhySleepBody": "Your scored sleep was below your need.", + "@homeWhySleepBody": { + "description": "Body of the short-sleep row when the time asleep or the need is not available to quote." + }, + "homeWhySleepDetail": "{asleep} asleep, {gap} under your {need} need.", + "@homeWhySleepDetail": { + "description": "Body of the short-sleep row: time asleep and how far under the need it was.", + "placeholders": { + "asleep": { + "type": "String" + }, + "gap": { + "type": "String" + }, + "need": { + "type": "String" + } + } + }, + "homeWhyStrainTitle": "Strain is above target", + "@homeWhyStrainTitle": { + "description": "Title of the Home card row shown when strain is above today's target range." + }, + "homeWhyStrainBody": "Strain {strain}, above today’s target of {range}.", + "@homeWhyStrainBody": { + "description": "Body of the high-strain row: today's strain and the target range.", + "placeholders": { + "strain": { + "type": "String" + }, + "range": { + "type": "String" + } + } + }, "homeStrainOf21": "of 21", "@homeStrainOf21": { "description": "Strain ring subtitle: out of the fixed 21-point scale." @@ -7461,7 +7718,7 @@ "@homeNothingTodayTitle": { "description": "Title shown when there is history but nothing recorded for today." }, - "homeNothingTodayBody": "The last night this app scored was {day}. Nothing has reached it since.", + "homeNothingTodayBody": "Last scored night: {day}.", "@homeNothingTodayBody": { "description": "Body shown when there is history but nothing recorded for today, naming the last scored night.", "placeholders": { @@ -7497,7 +7754,7 @@ } } }, - "homeReadinessNoReason": "Nothing recorded says why.", + "homeReadinessNoReason": "No reading.", "@homeReadinessNoReason": { "description": "Fallback body of the readiness-not-scored card when no reason is recorded." }, @@ -7547,29 +7804,17 @@ } } }, - "homeIllnessBodyNoZ": "Your resting heart rate has run above your usual over recent nights. This tracks one signal, so it shows a pattern, not a cause.", + "homeIllnessBodyNoZ": "Your resting heart rate has been above your usual over recent nights. A pattern, not a cause.", "@homeIllnessBodyNoZ": { "description": "Illness watch body text when no standardised-deviation figure is available." }, - "homeIllnessBodyAbove": "Recent nights put your resting heart rate above your usual. That night was {z}× your normal night-to-night swing above it. A pattern, not a cause.", + "homeIllnessBodyAbove": "Recent nights put your resting heart rate above your usual. That night was above it too. A pattern, not a cause.", "@homeIllnessBodyAbove": { - "description": "Illness watch body text when the flagged night's heart rate sat above baseline, with the deviation amount.", - "placeholders": { - "z": { - "type": "String", - "example": "1.8" - } - } + "description": "Illness watch body text when the flagged night's heart rate sat above baseline, with the deviation amount." }, - "homeIllnessBodyBelow": "Recent nights together put your resting heart rate above your usual, even though that night on its own was {z}× your normal night-to-night swing below it. A pattern, not a cause.", + "homeIllnessBodyBelow": "Recent nights together put your resting heart rate above your usual, though that night alone was below it. A pattern, not a cause.", "@homeIllnessBodyBelow": { - "description": "Illness watch body text when the flagged night's heart rate sat below baseline, with the deviation amount.", - "placeholders": { - "z": { - "type": "String", - "example": "1.8" - } - } + "description": "Illness watch body text when the flagged night's heart rate sat below baseline, with the deviation amount." }, "homeIllnessAdvice": "Worth noting if it continues past a couple of days.", "@homeIllnessAdvice": { @@ -15547,7 +15792,7 @@ } } }, - "homeCoachSetUpChip": "Ask questions about your data: set up the coach", + "homeCoachSetUpChip": "Set up the coach", "@homeCoachSetUpChip": { "description": "Today tab chip when no coach model is configured." }, diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index a1c22eb7e..db37eb83c 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -1428,15 +1428,15 @@ "homeDriverRhr": "Frecuencia cardíaca en reposo", "homeDriverResp": "Frecuencia respiratoria", "homeDriverTemp": "Temperatura de la piel", - "homeDbRebuiltTitle": "Tu base de datos se reconstruyó para iniciar la app", + "homeDbRebuiltTitle": "Tu base de datos se reconstruyó", "homeDbRebuiltNothingRecovered": "No se pudo recuperar nada.", "homeDbRebuiltRecovered": "Recuperado: {list}.", "homeDbRebuiltEmpty": "Vacío: {list}.", "homeDbRebuiltKept": "El archivo original se conserva en {path}; no se eliminó nada.", "homeWorkoutHoldTitle": "Todavía hay un entrenamiento en curso", - "homeWorkoutHoldBody": "Hoy está en espera mientras un entrenamiento está activo: la correa sigue grabando, pero los números se calculan al terminar la sesión. Finaliza el entrenamiento con la barra de abajo y hoy se completará; sincronizar no lo hará.", - "homeInsightsRebuildingTitle": "Tus datos entre días se están reconstruyendo", - "homeInsightsRebuildingAlgoVersion": "La forma de calcular esto cambió con la última actualización.", + "homeWorkoutHoldBody": "Hoy se completa cuando termines el entrenamiento.", + "homeInsightsRebuildingTitle": "Actualizando tus tendencias", + "homeInsightsRebuildingAlgoVersion": "Actualizando tras la última actualización de la app.", "homeInsightsStaleOverWeek": "El último resumen se generó hace más de una semana, lo cual es demasiado antiguo para confiar en él.", "homeInsightsStaleOnDay": "El último resumen se generó el {day}, lo cual es demasiado antiguo para confiar en él.", "homeInsightsNoVersionStamp": "El resumen guardado no tiene marca de versión.", @@ -1445,13 +1445,193 @@ "homeCalibrating": "Calibrando", "homeCalibratingNights": "{have} de {need} noches", "homeCalibratingDays": "{have} de {need} días", - "homeGapNoReason": "No hay ningún dato registrado que explique por qué falta esto.", + "homeGapNoReason": "Sin lectura.", "homeRingRecovery": "Recuperación", "homeRingStrain": "Esfuerzo", "homeRingSleep": "Sueño", "homeRingNoStrain": "Sin esfuerzo", "homeRingNoSleep": "Sin sueño", "homeSleepGapFallback": "No se registró ninguna noche lo bastante larga para puntuar.", + "homeSleepPctOf": "{pct} % de {target}", + "homeSleepPctOfDefault": "{pct} % de {target} (predeterminado)", + "homeSleepPctCompact": "{pct} % de {target}", + "@homeSleepPctCompact": { + "description": "Short sleep caption against a learned target.", + "placeholders": { + "pct": { + "type": "int" + }, + "target": { + "type": "String" + } + } + }, + "homeSleepPctDefaultCompact": "{pct} % de {target} · objetivo predeterminado", + "@homeSleepPctDefaultCompact": { + "description": "Short sleep caption that keeps an explicit default-target qualifier.", + "placeholders": { + "pct": { + "type": "int" + }, + "target": { + "type": "String" + } + } + }, + "homeSleepPctDefaultTight": "{pct} % de {target} por defecto", + "@homeSleepPctDefaultTight": { + "description": "Tightest sleep caption when the target is the default: still names the target as the default.", + "placeholders": { + "pct": { + "type": "int" + }, + "target": { + "type": "String" + } + } + }, + "homeSleepOnTrack": "en camino a {cycles, plural, =1{1 ciclo} other{{cycles} ciclos}}, {duration}", + "homeSleepOnTrackDefault": "en camino a {cycles, plural, =1{1 ciclo} other{{cycles} ciclos}}, {duration} · objetivo {target} (predeterminado)", + "homeSleepEstimateCompact": "~{cycles, plural, =1{1 ciclo} other{{cycles} ciclos}} · {duration}", + "@homeSleepEstimateCompact": { + "description": "Short sleep estimate: \"~\" marks it as an estimate; keeps cycle count and duration.", + "placeholders": { + "cycles": { + "type": "int" + }, + "duration": { + "type": "String" + } + } + }, + "homeSleepEstimateDefaultCompact": "~{cycles, plural, =1{1 ciclo} other{{cycles} ciclos}} · {duration} · objetivo predeterminado", + "@homeSleepEstimateDefaultCompact": { + "description": "Short sleep estimate keeping the \"~\" estimate marker and an explicit default-target qualifier.", + "placeholders": { + "cycles": { + "type": "int" + }, + "duration": { + "type": "String" + } + } + }, + "homeSleepEstimateDefaultTight": "~{cycles, plural, =1{1 ciclo} other{{cycles} ciclos}} · {duration} · meta por defecto", + "@homeSleepEstimateDefaultTight": { + "description": "Tightest sleep estimate caption: keeps the estimate marker, cycles, duration and the default-target qualifier.", + "placeholders": { + "cycles": { + "type": "int" + }, + "duration": { + "type": "String" + } + } + }, + "homeSleepEstimateDefaultMin": "~{cycles, plural, =1{1 ciclo} other{{cycles} ciclos}} · meta por defecto", + "@homeSleepEstimateDefaultMin": { + "description": "Smallest default-target estimate caption: drops the duration, keeps the estimate marker, cycles and the default goal.", + "placeholders": { + "cycles": { + "type": "int" + } + } + }, + "homeSleepEstimateBare": "~{cycles, plural, =1{1 ciclo} other{{cycles} ciclos}}", + "@homeSleepEstimateBare": { + "description": "Smallest estimate caption: the estimate marker and the cycle count.", + "placeholders": { + "cycles": { + "type": "int" + } + } + }, + "homeSleepNoEstimate": "Sin estimación", + "homeSleepNoEstimateWhy": "Aún no hay una noche puntuada y no hay alarma ni hora habitual de despertar para estimar.", + "homeWhyRecoveryTitle": "La recuperación es baja", + "homeWhyRecoveryDrivers": "Mayor lastre: {a} {za}, {b} {zb} (desviaciones respecto a tu referencia).", + "@homeWhyRecoveryDrivers": { + "placeholders": { + "a": { + "type": "String" + }, + "za": { + "type": "String" + }, + "b": { + "type": "String" + }, + "zb": { + "type": "String" + } + } + }, + "homeWhyRecoveryDriver": "Mayor lastre: {a} {za} (desviaciones respecto a tu referencia).", + "@homeWhyRecoveryDriver": { + "placeholders": { + "a": { + "type": "String" + }, + "za": { + "type": "String" + } + } + }, + "homeWhyRecoveryPlain": "Recuperación {score} de 100", + "@homeWhyRecoveryPlain": { + "placeholders": { + "score": { + "type": "int" + } + } + }, + "homeWhyRecoveryPullingTwo": "Lo reduce: {a}, {b}.", + "@homeWhyRecoveryPullingTwo": { + "placeholders": { + "a": { + "type": "String" + }, + "b": { + "type": "String" + } + } + }, + "homeWhyRecoveryPullingOne": "Lo reduce: {a}.", + "@homeWhyRecoveryPullingOne": { + "placeholders": { + "a": { + "type": "String" + } + } + }, + "homeWhySleepTitle": "Falta sueño", + "homeWhySleepBody": "El sueño evaluado quedó por debajo de tu necesidad.", + "homeWhySleepDetail": "{asleep} dormidos, {gap} menos de las {need} que necesitas.", + "@homeWhySleepDetail": { + "placeholders": { + "asleep": { + "type": "String" + }, + "gap": { + "type": "String" + }, + "need": { + "type": "String" + } + } + }, + "homeWhyStrainTitle": "Esfuerzo por encima del objetivo", + "homeWhyStrainBody": "Esfuerzo {strain}, por encima del objetivo de hoy de {range}.", + "@homeWhyStrainBody": { + "placeholders": { + "strain": { + "type": "String" + }, + "range": { + "type": "String" + } + } + }, "homeStrainOf21": "de 21", "homeSleepNoTarget": "Aún sin objetivo", "homeOfSpan": "de {duration}", @@ -1472,13 +1652,13 @@ "homeAskCoach": "Preguntar al entrenador", "homeProfileSettings": "Perfil y ajustes", "homeNothingTodayTitle": "Nada registrado hoy", - "homeNothingTodayBody": "La última noche que la app puntuó fue el {day}. No ha llegado nada desde entonces.", + "homeNothingTodayBody": "Última noche puntuada: {day}.", "homeNoDataThisDayTitle": "No hay datos de este día", "homeNoDataThisDayBody": "No se registró nada este día.", "homeSomeDayDataMissingTitle": "Faltan algunos datos de este día", "homeReadinessNotScoredTitle": "La recuperación no tiene puntuación hoy", "homeReadinessNeedBody": "{need} para saber qué es lo normal en tu caso.", - "homeReadinessNoReason": "No hay ningún dato registrado que lo explique.", + "homeReadinessNoReason": "Sin lectura.", "homeSeeWhatWasMissing": "Ver qué faltaba", "homeAtAGlance": "De un vistazo", "homeTodaysPlan": "Plan de hoy", @@ -1487,9 +1667,9 @@ "homeIllnessRedTitle": "Las noches recientes, en conjunto, muestran una frecuencia cardíaca en reposo elevada", "homeIllnessAmberSameNight": "Anoche estuvo fuera de tu rango normal", "homeIllnessAmberOtherNight": "El {day} estuvo fuera de tu rango normal", - "homeIllnessBodyNoZ": "En conjunto, tus frecuencias cardíacas nocturnas en reposo recientes muestran una subida por encima de tu propio valor de referencia. Esto refleja una sola señal. Indica un patrón, pero no indica una causa.", - "homeIllnessBodyAbove": "En conjunto, tus frecuencias cardíacas nocturnas en reposo recientes muestran una subida por encima de tu propio valor de referencia; esa noche se situó {z} desviaciones estándar por encima. Esto refleja una sola señal. Indica un patrón, pero no indica una causa.", - "homeIllnessBodyBelow": "En conjunto, tus frecuencias cardíacas nocturnas en reposo recientes muestran una subida por encima de tu propio valor de referencia; esa noche se situó {z} desviaciones estándar por debajo. Esto refleja una sola señal. Indica un patrón, pero no indica una causa.", + "homeIllnessBodyNoZ": "Tu frecuencia cardíaca en reposo ha superado lo habitual en las últimas noches. Un patrón, no una causa.", + "homeIllnessBodyAbove": "Las noches recientes han subido tu frecuencia cardíaca en reposo por encima de lo habitual. Esa noche también estuvo por encima. Un patrón, no una causa.", + "homeIllnessBodyBelow": "Juntas, las noches recientes han subido tu frecuencia cardíaca en reposo por encima de lo habitual, aunque esa noche por sí sola estuvo por debajo. Un patrón, no una causa.", "homeIllnessAdvice": "Vale la pena prestarle atención si continúa varios días.", "homeHeartRate": "Frecuencia cardíaca", "homeRestingSub": "En reposo", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index bc8505b67..4d63c674d 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -1428,15 +1428,15 @@ "homeDriverRhr": "Fréquence cardiaque au repos", "homeDriverResp": "Fréquence respiratoire", "homeDriverTemp": "Température cutanée", - "homeDbRebuiltTitle": "Ta base de données a été reconstruite pour démarrer l'application", + "homeDbRebuiltTitle": "Ta base de données a été reconstruite", "homeDbRebuiltNothingRecovered": "Rien n'a pu être récupéré.", "homeDbRebuiltRecovered": "Récupéré : {list}.", "homeDbRebuiltEmpty": "Vide : {list}.", "homeDbRebuiltKept": "Le fichier d'origine est conservé à {path} ; rien n'a été supprimé.", "homeWorkoutHoldTitle": "Un entraînement est encore en cours", - "homeWorkoutHoldBody": "Aujourd'hui est en attente pendant qu'un entraînement est en cours : le bracelet continue d'enregistrer, mais les chiffres sont calculés une fois la séance terminée. Termine l'entraînement depuis la barre ci-dessous et la journée se complètera ; synchroniser ne suffira pas.", - "homeInsightsRebuildingTitle": "Tes données inter-journalières sont en cours de reconstruction", - "homeInsightsRebuildingAlgoVersion": "La façon dont c'est calculé a changé avec la dernière mise à jour.", + "homeWorkoutHoldBody": "Aujourd’hui se remplit quand tu termines l’entraînement.", + "homeInsightsRebuildingTitle": "Mise à jour de tes tendances", + "homeInsightsRebuildingAlgoVersion": "Mise à jour après la dernière mise à jour de l’app.", "homeInsightsStaleOverWeek": "Le dernier récapitulatif date de plus d'une semaine, ce qui est trop ancien pour s'y fier.", "homeInsightsStaleOnDay": "Le dernier récapitulatif a été généré le {day}, ce qui est trop ancien pour s'y fier.", "homeInsightsNoVersionStamp": "Le récapitulatif enregistré ne porte aucune marque de version.", @@ -1445,13 +1445,193 @@ "homeCalibrating": "Étalonnage", "homeCalibratingNights": "{have, plural, one{{have} nuit sur {need}} other{{have} nuits sur {need}}}", "homeCalibratingDays": "{have, plural, one{{have} jour sur {need}} other{{have} jours sur {need}}}", - "homeGapNoReason": "Rien n'a été enregistré pour expliquer cette absence.", + "homeGapNoReason": "Aucune mesure.", "homeRingRecovery": "Récupération", "homeRingStrain": "Effort", "homeRingSleep": "Sommeil", "homeRingNoStrain": "Pas d'effort", "homeRingNoSleep": "Pas de sommeil", "homeSleepGapFallback": "Aucune nuit assez longue pour être évaluée n'a été enregistrée.", + "homeSleepPctOf": "{pct} % de {target}", + "homeSleepPctOfDefault": "{pct} % de {target} (par défaut)", + "homeSleepPctCompact": "{pct} % de {target}", + "@homeSleepPctCompact": { + "description": "Short sleep caption against a learned target.", + "placeholders": { + "pct": { + "type": "int" + }, + "target": { + "type": "String" + } + } + }, + "homeSleepPctDefaultCompact": "{pct} % de {target} · objectif par défaut", + "@homeSleepPctDefaultCompact": { + "description": "Short sleep caption that keeps an explicit default-target qualifier.", + "placeholders": { + "pct": { + "type": "int" + }, + "target": { + "type": "String" + } + } + }, + "homeSleepPctDefaultTight": "{pct} % de {target} par défaut", + "@homeSleepPctDefaultTight": { + "description": "Tightest sleep caption when the target is the default: still names the target as the default.", + "placeholders": { + "pct": { + "type": "int" + }, + "target": { + "type": "String" + } + } + }, + "homeSleepOnTrack": "sur la bonne voie pour {cycles, plural, =0{{cycles} cycles} one{{cycles} cycle} other{{cycles} cycles}}, {duration}", + "homeSleepOnTrackDefault": "sur la bonne voie pour {cycles, plural, =0{{cycles} cycles} one{{cycles} cycle} other{{cycles} cycles}}, {duration} · objectif {target} (par défaut)", + "homeSleepEstimateCompact": "~{cycles, plural, =0{{cycles} cycles} one{{cycles} cycle} other{{cycles} cycles}} · {duration}", + "@homeSleepEstimateCompact": { + "description": "Short sleep estimate: \"~\" marks it as an estimate; keeps cycle count and duration.", + "placeholders": { + "cycles": { + "type": "int" + }, + "duration": { + "type": "String" + } + } + }, + "homeSleepEstimateDefaultCompact": "~{cycles, plural, =0{{cycles} cycles} one{{cycles} cycle} other{{cycles} cycles}} · {duration} · objectif par défaut", + "@homeSleepEstimateDefaultCompact": { + "description": "Short sleep estimate keeping the \"~\" estimate marker and an explicit default-target qualifier.", + "placeholders": { + "cycles": { + "type": "int" + }, + "duration": { + "type": "String" + } + } + }, + "homeSleepEstimateDefaultTight": "~{cycles, plural, =0{{cycles} cycles} one{{cycles} cycle} other{{cycles} cycles}} · {duration} · cible par défaut", + "@homeSleepEstimateDefaultTight": { + "description": "Tightest sleep estimate caption: keeps the estimate marker, cycles, duration and the default-target qualifier.", + "placeholders": { + "cycles": { + "type": "int" + }, + "duration": { + "type": "String" + } + } + }, + "homeSleepEstimateDefaultMin": "~{cycles, plural, =0{{cycles} cycles} one{{cycles} cycle} other{{cycles} cycles}} · cible par défaut", + "@homeSleepEstimateDefaultMin": { + "description": "Smallest default-target estimate caption: drops the duration, keeps the estimate marker, cycles and the default goal.", + "placeholders": { + "cycles": { + "type": "int" + } + } + }, + "homeSleepEstimateBare": "~{cycles, plural, =0{{cycles} cycles} one{{cycles} cycle} other{{cycles} cycles}}", + "@homeSleepEstimateBare": { + "description": "Smallest estimate caption: the estimate marker and the cycle count.", + "placeholders": { + "cycles": { + "type": "int" + } + } + }, + "homeSleepNoEstimate": "Pas d'estimation", + "homeSleepNoEstimateWhy": "Aucune nuit n'est encore évaluée, et il n'y a ni alarme ni heure de réveil habituelle pour estimer.", + "homeWhyRecoveryTitle": "Récupération faible", + "homeWhyRecoveryDrivers": "Principal frein : {a} {za}, {b} {zb} (écarts par rapport à votre référence).", + "@homeWhyRecoveryDrivers": { + "placeholders": { + "a": { + "type": "String" + }, + "za": { + "type": "String" + }, + "b": { + "type": "String" + }, + "zb": { + "type": "String" + } + } + }, + "homeWhyRecoveryDriver": "Principal frein : {a} {za} (écarts par rapport à votre référence).", + "@homeWhyRecoveryDriver": { + "placeholders": { + "a": { + "type": "String" + }, + "za": { + "type": "String" + } + } + }, + "homeWhyRecoveryPlain": "Récupération {score} sur 100", + "@homeWhyRecoveryPlain": { + "placeholders": { + "score": { + "type": "int" + } + } + }, + "homeWhyRecoveryPullingTwo": "Ce qui la tire vers le bas : {a}, {b}.", + "@homeWhyRecoveryPullingTwo": { + "placeholders": { + "a": { + "type": "String" + }, + "b": { + "type": "String" + } + } + }, + "homeWhyRecoveryPullingOne": "Ce qui la tire vers le bas : {a}.", + "@homeWhyRecoveryPullingOne": { + "placeholders": { + "a": { + "type": "String" + } + } + }, + "homeWhySleepTitle": "Sommeil insuffisant", + "homeWhySleepBody": "Votre sommeil évalué était inférieur à votre besoin.", + "homeWhySleepDetail": "{asleep} de sommeil, {gap} de moins que vos {need} nécessaires.", + "@homeWhySleepDetail": { + "placeholders": { + "asleep": { + "type": "String" + }, + "gap": { + "type": "String" + }, + "need": { + "type": "String" + } + } + }, + "homeWhyStrainTitle": "Effort au-dessus de l’objectif", + "homeWhyStrainBody": "Effort {strain}, au-dessus de l’objectif du jour de {range}.", + "@homeWhyStrainBody": { + "placeholders": { + "strain": { + "type": "String" + }, + "range": { + "type": "String" + } + } + }, "homeStrainOf21": "sur 21", "homeSleepNoTarget": "Pas encore d'objectif", "homeOfSpan": "sur {duration}", @@ -1466,13 +1646,13 @@ "homeAskCoach": "Demander au coach", "homeProfileSettings": "Profil et réglages", "homeNothingTodayTitle": "Rien d'enregistré aujourd'hui", - "homeNothingTodayBody": "La dernière nuit évaluée par l'application est le {day}. Rien n'est arrivé depuis.", + "homeNothingTodayBody": "Dernière nuit évaluée : {day}.", "homeNoDataThisDayTitle": "Aucune donnée pour ce jour", "homeNoDataThisDayBody": "Rien n'a été enregistré ce jour-là.", "homeSomeDayDataMissingTitle": "Certaines données de ce jour manquent", "homeReadinessNotScoredTitle": "La récupération n'est pas évaluée aujourd'hui", "homeReadinessNeedBody": "{need} pour savoir ce qui est normal pour toi.", - "homeReadinessNoReason": "Rien n'a été enregistré pour l'expliquer.", + "homeReadinessNoReason": "Aucune mesure.", "homeSeeWhatWasMissing": "Voir ce qui manquait", "homeAtAGlance": "En un coup d'œil", "homeTodaysPlan": "Plan du jour", @@ -1481,9 +1661,9 @@ "homeIllnessRedTitle": "Prises ensemble, tes dernières nuits indiquent une fréquence cardiaque au repos élevée", "homeIllnessAmberSameNight": "Cette nuit se situait hors de ta plage normale", "homeIllnessAmberOtherNight": "Le {day} se situait hors de ta plage normale", - "homeIllnessBodyNoZ": "Prises ensemble, tes fréquences cardiaques nocturnes au repos récentes indiquent une hausse au-dessus de ta propre référence. Ceci reflète un seul signal. Cela indique une tendance, pas une cause.", - "homeIllnessBodyAbove": "Prises ensemble, tes fréquences cardiaques nocturnes au repos récentes indiquent une hausse au-dessus de ta propre référence ; cette nuit-là, la valeur se situait {z} écarts-types au-dessus. Ceci reflète un seul signal. Cela indique une tendance, pas une cause.", - "homeIllnessBodyBelow": "Prises ensemble, tes fréquences cardiaques nocturnes au repos récentes indiquent une hausse au-dessus de ta propre référence ; cette nuit-là, la valeur se situait {z} écarts-types en dessous. Ceci reflète un seul signal. Cela indique une tendance, pas une cause.", + "homeIllnessBodyNoZ": "Ta fréquence cardiaque au repos a été au-dessus de l’habituel ces dernières nuits. Une tendance, pas une cause.", + "homeIllnessBodyAbove": "Les nuits récentes ont fait monter ta fréquence cardiaque au repos au-dessus de l’habituel. Cette nuit-là aussi était au-dessus. Une tendance, pas une cause.", + "homeIllnessBodyBelow": "Ensemble, les nuits récentes ont fait monter ta fréquence cardiaque au repos au-dessus de l’habituel, même si cette nuit-là seule était en dessous. Une tendance, pas une cause.", "homeIllnessAdvice": "À surveiller si cela continue plusieurs jours.", "homeHeartRate": "Fréquence cardiaque", "homeRestingSub": "Au repos", diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index 46039c5f9..c42b2eec4 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -1428,15 +1428,15 @@ "homeDriverRhr": "आराम की हृदय गति", "homeDriverResp": "श्वसन दर", "homeDriverTemp": "त्वचा का तापमान", - "homeDbRebuiltTitle": "ऐप शुरू करने के लिए आपका डेटाबेस फिर से बनाया गया", + "homeDbRebuiltTitle": "आपका डेटाबेस दोबारा बनाया गया", "homeDbRebuiltNothingRecovered": "कुछ भी वापस नहीं पढ़ा जा सका।", "homeDbRebuiltRecovered": "पुनर्प्राप्त: {list}।", "homeDbRebuiltEmpty": "खाली: {list}।", "homeDbRebuiltKept": "मूल फ़ाइल {path} पर सुरक्षित रखी गई है — कुछ भी हटाया नहीं गया।", "homeWorkoutHoldTitle": "एक वर्कआउट अभी भी चल रहा है", - "homeWorkoutHoldBody": "वर्कआउट चलते समय आज का दिन रुका हुआ है: स्ट्रैप रिकॉर्ड करना जारी रखता है, लेकिन आंकड़े सेशन खत्म होने पर ही गणना होते हैं। नीचे दिए गए बार से वर्कआउट पूरा करें, तभी आज का डेटा भरेगा — केवल सिंक करने से नहीं होगा।", - "homeInsightsRebuildingTitle": "आपकी क्रॉस-डे इनसाइट्स फिर से बनाई जा रही हैं", - "homeInsightsRebuildingAlgoVersion": "इसकी गणना का तरीका पिछले अपडेट के साथ बदल गया है।", + "homeWorkoutHoldBody": "वर्कआउट खत्म करने पर आज का दिन भर जाएगा।", + "homeInsightsRebuildingTitle": "आपके ट्रेंड अपडेट हो रहे हैं", + "homeInsightsRebuildingAlgoVersion": "ऐप के ताज़ा अपडेट के बाद अपडेट हो रहा है।", "homeInsightsStaleOverWeek": "आखिरी रोलअप एक हफ़्ते से भी पहले बना था, जो भरोसा करने के लिए बहुत पुराना है।", "homeInsightsStaleOnDay": "आखिरी रोलअप {day} को बना था, जो भरोसा करने के लिए बहुत पुराना है।", "homeInsightsNoVersionStamp": "सहेजे गए रोलअप पर कोई वर्शन स्टैंप नहीं है।", @@ -1445,13 +1445,193 @@ "homeCalibrating": "कैलिब्रेट हो रहा है", "homeCalibratingNights": "{need} में से {have} रातें", "homeCalibratingDays": "{need} में से {have} दिन", - "homeGapNoReason": "यह क्यों गायब है, इसकी कोई वजह दर्ज नहीं है।", + "homeGapNoReason": "कोई रीडिंग नहीं।", "homeRingRecovery": "रिकवरी", "homeRingStrain": "स्ट्रेन", "homeRingSleep": "नींद", "homeRingNoStrain": "कोई स्ट्रेन नहीं", "homeRingNoSleep": "कोई नींद नहीं", "homeSleepGapFallback": "स्कोर करने लायक लंबी रात दर्ज नहीं हुई।", + "homeSleepPctOf": "{target} का {pct}%", + "homeSleepPctOfDefault": "{target} का {pct}% (डिफ़ॉल्ट)", + "homeSleepPctCompact": "{target} का {pct}%", + "@homeSleepPctCompact": { + "description": "Short sleep caption against a learned target.", + "placeholders": { + "pct": { + "type": "int" + }, + "target": { + "type": "String" + } + } + }, + "homeSleepPctDefaultCompact": "{target} का {pct}% · डिफ़ॉल्ट लक्ष्य", + "@homeSleepPctDefaultCompact": { + "description": "Short sleep caption that keeps an explicit default-target qualifier.", + "placeholders": { + "pct": { + "type": "int" + }, + "target": { + "type": "String" + } + } + }, + "homeSleepPctDefaultTight": "डिफ़ॉल्ट {target} का {pct}%", + "@homeSleepPctDefaultTight": { + "description": "Tightest sleep caption when the target is the default: still names the target as the default.", + "placeholders": { + "pct": { + "type": "int" + }, + "target": { + "type": "String" + } + } + }, + "homeSleepOnTrack": "{cycles, plural, =0{{cycles} चक्र} one{{cycles} चक्र} other{{cycles} चक्र}} की राह पर, {duration}", + "homeSleepOnTrackDefault": "{cycles, plural, =0{{cycles} चक्र} one{{cycles} चक्र} other{{cycles} चक्र}} की राह पर, {duration} · लक्ष्य {target} (डिफ़ॉल्ट)", + "homeSleepEstimateCompact": "~{cycles, plural, =0{{cycles} चक्र} one{{cycles} चक्र} other{{cycles} चक्र}} · {duration}", + "@homeSleepEstimateCompact": { + "description": "Short sleep estimate: \"~\" marks it as an estimate; keeps cycle count and duration.", + "placeholders": { + "cycles": { + "type": "int" + }, + "duration": { + "type": "String" + } + } + }, + "homeSleepEstimateDefaultCompact": "~{cycles, plural, =0{{cycles} चक्र} one{{cycles} चक्र} other{{cycles} चक्र}} · {duration} · डिफ़ॉल्ट लक्ष्य", + "@homeSleepEstimateDefaultCompact": { + "description": "Short sleep estimate keeping the \"~\" estimate marker and an explicit default-target qualifier.", + "placeholders": { + "cycles": { + "type": "int" + }, + "duration": { + "type": "String" + } + } + }, + "homeSleepEstimateDefaultTight": "~{cycles, plural, =0{{cycles} चक्र} one{{cycles} चक्र} other{{cycles} चक्र}} · {duration} · डिफ़ॉल्ट लक्ष्य", + "@homeSleepEstimateDefaultTight": { + "description": "Tightest sleep estimate caption: keeps the estimate marker, cycles, duration and the default-target qualifier.", + "placeholders": { + "cycles": { + "type": "int" + }, + "duration": { + "type": "String" + } + } + }, + "homeSleepEstimateDefaultMin": "~{cycles, plural, =0{{cycles} चक्र} one{{cycles} चक्र} other{{cycles} चक्र}} · डिफ़ॉल्ट लक्ष्य", + "@homeSleepEstimateDefaultMin": { + "description": "Smallest default-target estimate caption: drops the duration, keeps the estimate marker, cycles and the default goal.", + "placeholders": { + "cycles": { + "type": "int" + } + } + }, + "homeSleepEstimateBare": "~{cycles, plural, =0{{cycles} चक्र} one{{cycles} चक्र} other{{cycles} चक्र}}", + "@homeSleepEstimateBare": { + "description": "Smallest estimate caption: the estimate marker and the cycle count.", + "placeholders": { + "cycles": { + "type": "int" + } + } + }, + "homeSleepNoEstimate": "कोई अनुमान नहीं", + "homeSleepNoEstimateWhy": "अभी तक कोई रात स्कोर नहीं हुई है, और अनुमान के लिए कोई अलार्म या सामान्य जागने का समय नहीं है।", + "homeWhyRecoveryTitle": "रिकवरी कम है", + "homeWhyRecoveryDrivers": "सबसे बड़ा असर: {a} {za}, {b} {zb} (आपकी बेसलाइन से अंतर)।", + "@homeWhyRecoveryDrivers": { + "placeholders": { + "a": { + "type": "String" + }, + "za": { + "type": "String" + }, + "b": { + "type": "String" + }, + "zb": { + "type": "String" + } + } + }, + "homeWhyRecoveryDriver": "सबसे बड़ा असर: {a} {za} (आपकी बेसलाइन से अंतर)।", + "@homeWhyRecoveryDriver": { + "placeholders": { + "a": { + "type": "String" + }, + "za": { + "type": "String" + } + } + }, + "homeWhyRecoveryPlain": "रिकवरी {score}/100", + "@homeWhyRecoveryPlain": { + "placeholders": { + "score": { + "type": "int" + } + } + }, + "homeWhyRecoveryPullingTwo": "इसे नीचे खींच रहे हैं: {a}, {b}।", + "@homeWhyRecoveryPullingTwo": { + "placeholders": { + "a": { + "type": "String" + }, + "b": { + "type": "String" + } + } + }, + "homeWhyRecoveryPullingOne": "इसे नीचे खींच रहा है: {a}।", + "@homeWhyRecoveryPullingOne": { + "placeholders": { + "a": { + "type": "String" + } + } + }, + "homeWhySleepTitle": "नींद की कमी", + "homeWhySleepBody": "आपकी मापी गई नींद ज़रूरत से कम थी।", + "homeWhySleepDetail": "{asleep} सोए, आपकी {need} की ज़रूरत से {gap} कम।", + "@homeWhySleepDetail": { + "placeholders": { + "asleep": { + "type": "String" + }, + "gap": { + "type": "String" + }, + "need": { + "type": "String" + } + } + }, + "homeWhyStrainTitle": "लक्ष्य से अधिक स्ट्रेन", + "homeWhyStrainBody": "स्ट्रेन {strain}, आज के लक्ष्य {range} से ऊपर।", + "@homeWhyStrainBody": { + "placeholders": { + "strain": { + "type": "String" + }, + "range": { + "type": "String" + } + } + }, "homeStrainOf21": "21 में से", "homeSleepNoTarget": "अभी कोई लक्ष्य नहीं", "homeOfSpan": "{duration} में से", @@ -1466,13 +1646,13 @@ "homeAskCoach": "कोच से पूछें", "homeProfileSettings": "प्रोफ़ाइल और सेटिंग्स", "homeNothingTodayTitle": "आज के लिए कुछ भी दर्ज नहीं", - "homeNothingTodayBody": "इस ऐप ने जिस आखिरी रात को स्कोर किया वह {day} थी। तब से कुछ भी नहीं पहुँचा है।", + "homeNothingTodayBody": "आखिरी स्कोर की गई रात: {day}।", "homeNoDataThisDayTitle": "इस दिन का कोई डेटा नहीं", "homeNoDataThisDayBody": "इस दिन कुछ भी दर्ज नहीं हुआ।", "homeSomeDayDataMissingTitle": "इस दिन का कुछ डेटा मौजूद नहीं है", "homeReadinessNotScoredTitle": "आज रिकवरी का स्कोर नहीं है", "homeReadinessNeedBody": "आपके लिए सामान्य क्या है, यह जानने के लिए {need}।", - "homeReadinessNoReason": "इसे समझाने के लिए कुछ भी दर्ज नहीं है।", + "homeReadinessNoReason": "कोई रीडिंग नहीं।", "homeSeeWhatWasMissing": "देखें क्या कमी थी", "homeAtAGlance": "एक नज़र में", "homeTodaysPlan": "आज की योजना", @@ -1481,9 +1661,9 @@ "homeIllnessRedTitle": "हाल की रातें मिलकर बढ़ी हुई आराम-हृदय गति दिखाती हैं", "homeIllnessAmberSameNight": "पिछली रात आपकी सामान्य सीमा से बाहर रही", "homeIllnessAmberOtherNight": "{day} आपकी सामान्य सीमा से बाहर रहा", - "homeIllnessBodyNoZ": "हाल की रातों की आपकी आराम-हृदय गति मिलकर आपके अपने आधार-स्तर से ऊपर की बढ़त दिखाती है। यह केवल एक संकेत दर्शाता है। यह एक पैटर्न बताता है, कोई कारण नहीं।", - "homeIllnessBodyAbove": "हाल की रातों की आपकी आराम-हृदय गति मिलकर आपके अपने आधार-स्तर से ऊपर की बढ़त दिखाती है; उस रात यह आधार-स्तर से {z} मानक विचलन ऊपर रही। यह केवल एक संकेत दर्शाता है। यह एक पैटर्न बताता है, कोई कारण नहीं।", - "homeIllnessBodyBelow": "हाल की रातों की आपकी आराम-हृदय गति मिलकर आपके अपने आधार-स्तर से ऊपर की बढ़त दिखाती है; उस रात यह आधार-स्तर से {z} मानक विचलन नीचे रही। यह केवल एक संकेत दर्शाता है। यह एक पैटर्न बताता है, कोई कारण नहीं।", + "homeIllnessBodyNoZ": "हाल की रातों में आपकी आराम की हृदय गति सामान्य से ऊपर रही है। यह एक पैटर्न है, कारण नहीं।", + "homeIllnessBodyAbove": "हाल की रातों ने आपकी आराम की हृदय गति को सामान्य से ऊपर कर दिया है। वह रात भी उससे ऊपर थी। यह एक पैटर्न है, कारण नहीं।", + "homeIllnessBodyBelow": "हाल की रातों ने मिलकर आपकी आराम की हृदय गति को सामान्य से ऊपर कर दिया है, हालाँकि वह रात अकेली उससे नीचे थी। यह एक पैटर्न है, कारण नहीं।", "homeIllnessAdvice": "अगर यह कुछ दिन और जारी रहे तो ध्यान देने लायक है।", "homeHeartRate": "हृदय गति", "homeRestingSub": "आराम", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 99839c0f7..a2a991e7b 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -3282,7 +3282,7 @@ "homeDriverRhr": "Пульс в покое", "homeDriverResp": "Частота дыхания", "homeDriverTemp": "Температура кожи", - "homeDbRebuiltTitle": "Для запуска приложения база восстановлена заново", + "homeDbRebuiltTitle": "Ваша база данных пересоздана", "homeDbRebuiltNothingRecovered": "Ничего не удалось прочитать.", "homeDbRebuiltRecovered": "Восстановлено: {list}.", "@homeDbRebuiltRecovered": { @@ -3312,9 +3312,9 @@ } }, "homeWorkoutHoldTitle": "Тренировка ещё идёт", - "homeWorkoutHoldBody": "Пока идёт тренировка, расчёт дня на паузе. Браслет продолжает запись; завершите тренировку на панели ниже, и день заполнится.", - "homeInsightsRebuildingTitle": "Выводы за несколько дней пересчитываются", - "homeInsightsRebuildingAlgoVersion": "После обновления способ расчёта изменился.", + "homeWorkoutHoldBody": "Сегодняшний день заполнится, когда вы завершите тренировку.", + "homeInsightsRebuildingTitle": "Обновляем ваши тренды", + "homeInsightsRebuildingAlgoVersion": "Обновляется после последнего обновления приложения.", "homeInsightsStaleOverWeek": "Тренды обновлялись больше недели назад. Синхронизируйте браслет, чтобы обновить их.", "homeInsightsStaleOnDay": "Тренды обновлялись {day}. Синхронизируйте браслет, чтобы обновить их.", "@homeInsightsStaleOnDay": { @@ -3351,13 +3351,241 @@ } } }, - "homeGapNoReason": "Причина отсутствия данных не записана.", + "homeGapNoReason": "Нет показания.", "homeRingRecovery": "Восстановление", "homeRingStrain": "Нагрузка", "homeRingSleep": "Сон", "homeRingNoStrain": "Нет нагрузки", "homeRingNoSleep": "Нет сна", "homeSleepGapFallback": "Не записана ночь с достаточной длительностью сна для оценки.", + "homeSleepPctOf": "{pct}% от {target}", + "@homeSleepPctOf": { + "placeholders": { + "pct": { + "type": "int" + }, + "target": { + "type": "String", + "example": "8h 00m" + } + } + }, + "homeSleepPctOfDefault": "{pct}% от {target} (по умолчанию)", + "@homeSleepPctOfDefault": { + "placeholders": { + "pct": { + "type": "int" + }, + "target": { + "type": "String", + "example": "8h 00m" + } + } + }, + "homeSleepPctCompact": "{pct}% от {target}", + "@homeSleepPctCompact": { + "description": "Short sleep caption against a learned target.", + "placeholders": { + "pct": { + "type": "int" + }, + "target": { + "type": "String" + } + } + }, + "homeSleepPctDefaultCompact": "{pct}% от {target} · цель по умолчанию", + "@homeSleepPctDefaultCompact": { + "description": "Short sleep caption that keeps an explicit default-target qualifier.", + "placeholders": { + "pct": { + "type": "int" + }, + "target": { + "type": "String" + } + } + }, + "homeSleepPctDefaultTight": "{pct}% от {target} по умолч.", + "@homeSleepPctDefaultTight": { + "description": "Tightest sleep caption when the target is the default: still names the target as the default.", + "placeholders": { + "pct": { + "type": "int" + }, + "target": { + "type": "String" + } + } + }, + "homeSleepOnTrack": "успеваете на {cycles, plural, one{{cycles} цикл} few{{cycles} цикла} many{{cycles} циклов} other{{cycles} цикла}}, {duration}", + "@homeSleepOnTrack": { + "placeholders": { + "cycles": { + "type": "int" + }, + "duration": { + "type": "String", + "example": "7h 30m" + } + } + }, + "homeSleepOnTrackDefault": "успеваете на {cycles, plural, one{{cycles} цикл} few{{cycles} цикла} many{{cycles} циклов} other{{cycles} цикла}}, {duration} · цель {target} (по умолчанию)", + "@homeSleepOnTrackDefault": { + "placeholders": { + "cycles": { + "type": "int" + }, + "duration": { + "type": "String", + "example": "7h 30m" + }, + "target": { + "type": "String", + "example": "8h 00m" + } + } + }, + "homeSleepEstimateCompact": "~{cycles, plural, one{{cycles} цикл} few{{cycles} цикла} many{{cycles} циклов} other{{cycles} цикла}} · {duration}", + "@homeSleepEstimateCompact": { + "description": "Short sleep estimate: \"~\" marks it as an estimate; keeps cycle count and duration.", + "placeholders": { + "cycles": { + "type": "int" + }, + "duration": { + "type": "String" + } + } + }, + "homeSleepEstimateDefaultCompact": "~{cycles, plural, one{{cycles} цикл} few{{cycles} цикла} many{{cycles} циклов} other{{cycles} цикла}} · {duration} · цель по умолчанию", + "@homeSleepEstimateDefaultCompact": { + "description": "Short sleep estimate keeping the \"~\" estimate marker and an explicit default-target qualifier.", + "placeholders": { + "cycles": { + "type": "int" + }, + "duration": { + "type": "String" + } + } + }, + "homeSleepEstimateDefaultTight": "~{cycles, plural, one{{cycles} цикл} few{{cycles} цикла} many{{cycles} циклов} other{{cycles} цикла}} · {duration} · цель по умолч.", + "@homeSleepEstimateDefaultTight": { + "description": "Tightest sleep estimate caption: keeps the estimate marker, cycles, duration and the default-target qualifier.", + "placeholders": { + "cycles": { + "type": "int" + }, + "duration": { + "type": "String" + } + } + }, + "homeSleepEstimateDefaultMin": "~{cycles, plural, one{{cycles} цикл} few{{cycles} цикла} many{{cycles} циклов} other{{cycles} цикла}} · цель по умолч.", + "@homeSleepEstimateDefaultMin": { + "description": "Smallest default-target estimate caption: drops the duration, keeps the estimate marker, cycles and the default goal.", + "placeholders": { + "cycles": { + "type": "int" + } + } + }, + "homeSleepEstimateBare": "~{cycles, plural, one{{cycles} цикл} few{{cycles} цикла} many{{cycles} циклов} other{{cycles} цикла}}", + "@homeSleepEstimateBare": { + "description": "Smallest estimate caption: the estimate marker and the cycle count.", + "placeholders": { + "cycles": { + "type": "int" + } + } + }, + "homeSleepNoEstimate": "Нет оценки", + "homeSleepNoEstimateWhy": "Сон ещё не оценён, и нет будильника или обычного времени подъёма для оценки.", + "homeWhyRecoveryTitle": "Низкое восстановление", + "homeWhyRecoveryDrivers": "Сильнее всего тянут вниз: {a} {za}, {b} {zb} (отклонения от вашей нормы).", + "@homeWhyRecoveryDrivers": { + "placeholders": { + "a": { + "type": "String" + }, + "za": { + "type": "String" + }, + "b": { + "type": "String" + }, + "zb": { + "type": "String" + } + } + }, + "homeWhyRecoveryDriver": "Сильнее всего тянет вниз: {a} {za} (отклонения от вашей нормы).", + "@homeWhyRecoveryDriver": { + "placeholders": { + "a": { + "type": "String" + }, + "za": { + "type": "String" + } + } + }, + "homeWhyRecoveryPlain": "Восстановление {score} из 100", + "@homeWhyRecoveryPlain": { + "placeholders": { + "score": { + "type": "int" + } + } + }, + "homeWhyRecoveryPullingTwo": "Снижают показатель: {a}, {b}.", + "@homeWhyRecoveryPullingTwo": { + "placeholders": { + "a": { + "type": "String" + }, + "b": { + "type": "String" + } + } + }, + "homeWhyRecoveryPullingOne": "Снижает показатель: {a}.", + "@homeWhyRecoveryPullingOne": { + "placeholders": { + "a": { + "type": "String" + } + } + }, + "homeWhySleepTitle": "Недостаточно сна", + "homeWhySleepBody": "Оценённый сон был короче вашей потребности.", + "homeWhySleepDetail": "Сон {asleep}, на {gap} меньше вашей потребности {need}.", + "@homeWhySleepDetail": { + "placeholders": { + "asleep": { + "type": "String" + }, + "gap": { + "type": "String" + }, + "need": { + "type": "String" + } + } + }, + "homeWhyStrainTitle": "Нагрузка выше цели", + "homeWhyStrainBody": "Нагрузка {strain}, выше сегодняшней цели {range}.", + "@homeWhyStrainBody": { + "placeholders": { + "strain": { + "type": "String" + }, + "range": { + "type": "String" + } + } + }, "homeStrainOf21": "из 21", "homeSleepNoTarget": "Цель пока не задана", "homeOfSpan": "из {duration}", @@ -3404,7 +3632,7 @@ "homeAskCoach": "Спросить тренера", "homeProfileSettings": "Профиль и настройки", "homeNothingTodayTitle": "За сегодня ничего не записано", - "homeNothingTodayBody": "Последняя оценённая ночь: {day}. С тех пор данные не поступали.", + "homeNothingTodayBody": "Последняя оценённая ночь: {day}.", "homeNoDataThisDayTitle": "Нет данных за этот день", "homeNoDataThisDayBody": "В этот день ничего не было записано.", "homeSomeDayDataMissingTitle": "Часть данных за этот день отсутствует", @@ -3426,7 +3654,7 @@ } } }, - "homeReadinessNoReason": "Причина не записана.", + "homeReadinessNoReason": "Нет показания.", "homeSeeWhatWasMissing": "Посмотреть, чего не хватило", "homeAtAGlance": "Краткий обзор", "homeTodaysPlan": "План на сегодня", @@ -3445,25 +3673,9 @@ } } }, - "homeIllnessBodyNoZ": "В последние ночи пульс в покое выше обычного. Отслеживается один сигнал, поэтому это закономерность, а не причина.", - "homeIllnessBodyAbove": "В последние ночи пульс в покое выше обычного. В ту ночь он был выше на {z} вашего обычного разброса. Это закономерность, а не причина.", - "@homeIllnessBodyAbove": { - "placeholders": { - "z": { - "type": "String", - "example": "1.8" - } - } - }, - "homeIllnessBodyBelow": "В сумме за последние ночи пульс в покое выше обычного, хотя сама по себе та ночь была ниже нормы на {z} вашего обычного разброса. Это закономерность, а не причина.", - "@homeIllnessBodyBelow": { - "placeholders": { - "z": { - "type": "String", - "example": "1.8" - } - } - }, + "homeIllnessBodyNoZ": "В последние ночи ваш пульс в покое был выше обычного. Это закономерность, а не причина.", + "homeIllnessBodyAbove": "Последние ночи подняли ваш пульс в покое выше обычного. Та ночь тоже была выше. Это закономерность, а не причина.", + "homeIllnessBodyBelow": "В сумме последние ночи подняли ваш пульс в покое выше обычного, хотя сама та ночь была ниже. Это закономерность, а не причина.", "homeIllnessAdvice": "Обратите внимание, если это продолжается больше пары дней.", "homeHeartRate": "Пульс", "homeRestingSub": "В покое", @@ -7437,7 +7649,7 @@ } } }, - "homeCoachSetUpChip": "Задавайте вопросы о своих данных: настройте тренера", + "homeCoachSetUpChip": "Настроить коуча", "profileSetupCycleSub": "Отмечайте менструацию в разделе «Здоровье». Это можно изменить позже.", "homeRecoverySleeping": "Сон…", "homeRecoveryAfterWake": "Восстановление оценивается после пробуждения.", diff --git a/lib/l10n/app_th.arb b/lib/l10n/app_th.arb index cdb48da1e..d7de901bb 100644 --- a/lib/l10n/app_th.arb +++ b/lib/l10n/app_th.arb @@ -7207,7 +7207,7 @@ "@homeDriverTemp": { "description": "Readiness driver name: skin temperature, shown in the Why? row on Home." }, - "homeDbRebuiltTitle": "สร้างฐานข้อมูลใหม่เพื่อให้แอปเริ่มทำงาน", + "homeDbRebuiltTitle": "สร้างฐานข้อมูลของคุณใหม่แล้ว", "@homeDbRebuiltTitle": { "description": "Title of the card shown when the local database had to be rebuilt to start the app." }, @@ -7249,15 +7249,15 @@ "@homeWorkoutHoldTitle": { "description": "Title of the card shown on Home while a workout session is live and derivation is paused." }, - "homeWorkoutHoldBody": "พักการประมวลผลวันนี้ไว้ขณะออกกำลังกาย สายรัดยังบันทึกอยู่ จบการออกกำลังกายจากแถบด้านล่างเพื่อให้ข้อมูลวันนี้เติมเข้ามา", + "homeWorkoutHoldBody": "ข้อมูลวันนี้จะเติมเข้ามาเมื่อคุณจบการออกกำลังกาย", "@homeWorkoutHoldBody": { "description": "Body of the card shown on Home while a workout session is live and derivation is paused." }, - "homeInsightsRebuildingTitle": "กำลังสร้างข้อสังเกตข้ามวันใหม่", + "homeInsightsRebuildingTitle": "กำลังอัปเดตแนวโน้มของคุณ", "@homeInsightsRebuildingTitle": { "description": "Title of the card shown when the cross-day insights rollup was withheld rather than shown." }, - "homeInsightsRebuildingAlgoVersion": "อัปเดตล่าสุดเปลี่ยนวิธีคำนวณข้อมูลเหล่านี้", + "homeInsightsRebuildingAlgoVersion": "กำลังอัปเดตหลังแอปอัปเดตล่าสุด", "@homeInsightsRebuildingAlgoVersion": { "description": "Reason the cross-day rollup is being rebuilt: the algorithm version changed." }, @@ -7315,7 +7315,7 @@ } } }, - "homeGapNoReason": "ไม่มีข้อมูลระบุว่าเหตุใดจึงไม่มีค่านี้", + "homeGapNoReason": "ไม่มีค่าที่อ่านได้", "@homeGapNoReason": { "description": "Fallback reason shown when a ring is empty and nothing explains why." }, @@ -7343,6 +7343,263 @@ "@homeSleepGapFallback": { "description": "Fallback reason the sleep ring is empty: no night long enough to score." }, + "homeSleepPctOf": "{pct}% ของ {target}", + "@homeSleepPctOf": { + "description": "Sleep ring subtitle once a night is scored: percent of the learned sleep need.", + "placeholders": { + "pct": { + "type": "int" + }, + "target": { + "type": "String", + "example": "8h 00m" + } + } + }, + "homeSleepPctOfDefault": "{pct}% ของ {target} (เป้าหมายเริ่มต้น)", + "@homeSleepPctOfDefault": { + "description": "Sleep ring subtitle once a night is scored, when no need has been learned yet: percent of the 8 h default target, labelled as a default.", + "placeholders": { + "pct": { + "type": "int" + }, + "target": { + "type": "String", + "example": "8h 00m" + } + } + }, + "homeSleepPctCompact": "{pct}% ของ {target}", + "@homeSleepPctCompact": { + "description": "Short sleep caption against a learned target.", + "placeholders": { + "pct": { + "type": "int" + }, + "target": { + "type": "String" + } + } + }, + "homeSleepPctDefaultCompact": "{pct}% ของ {target} · เป้าหมายเริ่มต้น", + "@homeSleepPctDefaultCompact": { + "description": "Short sleep caption that keeps an explicit default-target qualifier.", + "placeholders": { + "pct": { + "type": "int" + }, + "target": { + "type": "String" + } + } + }, + "homeSleepPctDefaultTight": "{pct}% ของเป้าหมายเริ่มต้น {target}", + "@homeSleepPctDefaultTight": { + "description": "Tightest sleep caption when the target is the default: still names the target as the default.", + "placeholders": { + "pct": { + "type": "int" + }, + "target": { + "type": "String" + } + } + }, + "homeSleepOnTrack": "คาดว่าจะนอนได้ {cycles, plural, other{{cycles} รอบการนอน}} รวม {duration}", + "@homeSleepOnTrack": { + "description": "Sleep ring subtitle before a night is scored: whole sleep cycles the estimate covers, then the estimated time asleep.", + "placeholders": { + "cycles": { + "type": "int" + }, + "duration": { + "type": "String", + "example": "7h 30m" + } + } + }, + "homeSleepOnTrackDefault": "คาดว่าจะนอนได้ {cycles, plural, other{{cycles} รอบการนอน}} รวม {duration} · เป้าหมาย {target} (เริ่มต้น)", + "@homeSleepOnTrackDefault": { + "description": "Sleep ring subtitle before a night is scored when the target is the 8 h default (no need learned yet): the estimate, then the default target, labelled as a default.", + "placeholders": { + "cycles": { + "type": "int" + }, + "duration": { + "type": "String", + "example": "7h 30m" + }, + "target": { + "type": "String", + "example": "8h 00m" + } + } + }, + "homeSleepEstimateCompact": "~{cycles, plural, other{{cycles} รอบการนอน}} · {duration}", + "@homeSleepEstimateCompact": { + "description": "Short sleep estimate: \"~\" marks it as an estimate; keeps cycle count and duration.", + "placeholders": { + "cycles": { + "type": "int" + }, + "duration": { + "type": "String" + } + } + }, + "homeSleepEstimateDefaultCompact": "~{cycles, plural, other{{cycles} รอบการนอน}} · {duration} · เป้าหมายเริ่มต้น", + "@homeSleepEstimateDefaultCompact": { + "description": "Short sleep estimate keeping the \"~\" estimate marker and an explicit default-target qualifier.", + "placeholders": { + "cycles": { + "type": "int" + }, + "duration": { + "type": "String" + } + } + }, + "homeSleepEstimateDefaultTight": "~{cycles, plural, other{{cycles} รอบการนอน}} · {duration} · เป้าเริ่มต้น", + "@homeSleepEstimateDefaultTight": { + "description": "Tightest sleep estimate caption: keeps the estimate marker, cycles, duration and the default-target qualifier.", + "placeholders": { + "cycles": { + "type": "int" + }, + "duration": { + "type": "String" + } + } + }, + "homeSleepEstimateDefaultMin": "~{cycles, plural, other{{cycles} รอบการนอน}} · เป้าเริ่มต้น", + "@homeSleepEstimateDefaultMin": { + "description": "Smallest default-target estimate caption: drops the duration, keeps the estimate marker, cycles and the default goal.", + "placeholders": { + "cycles": { + "type": "int" + } + } + }, + "homeSleepEstimateBare": "~{cycles, plural, other{{cycles} รอบการนอน}}", + "@homeSleepEstimateBare": { + "description": "Smallest estimate caption: the estimate marker and the cycle count.", + "placeholders": { + "cycles": { + "type": "int" + } + } + }, + "homeSleepNoEstimate": "ยังประมาณไม่ได้", + "@homeSleepNoEstimate": { + "description": "Sleep ring value when no night is scored and there is no wake time to estimate to." + }, + "homeSleepNoEstimateWhy": "ยังไม่มีการนอนที่คำนวณคะแนน และไม่มีเวลาปลุกหรือเวลาตื่นปกติให้ใช้ประมาณ", + "@homeSleepNoEstimateWhy": { + "description": "Why the sleep ring has no estimate." + }, + "homeWhyRecoveryTitle": "การฟื้นตัวต่ำ", + "@homeWhyRecoveryTitle": { + "description": "Title of the Home card row that explains a low recovery score." + }, + "homeWhyRecoveryDrivers": "ตัวฉุดหลัก: {a} {za}, {b} {zb} (แต่ละค่าห่างจากค่าปกติของคุณเท่าไร)", + "@homeWhyRecoveryDrivers": { + "description": "Body of the low-recovery row naming the two biggest drags with their distance from the person's own usual (z-scores).", + "placeholders": { + "a": { + "type": "String" + }, + "za": { + "type": "String" + }, + "b": { + "type": "String" + }, + "zb": { + "type": "String" + } + } + }, + "homeWhyRecoveryDriver": "ตัวฉุดหลัก: {a} {za} (ห่างจากค่าปกติของคุณเท่าไร)", + "@homeWhyRecoveryDriver": { + "description": "Body of the low-recovery row naming the one biggest drag with its distance from the person's own usual (z-score).", + "placeholders": { + "a": { + "type": "String" + }, + "za": { + "type": "String" + } + } + }, + "homeWhyRecoveryPlain": "การฟื้นตัว {score} จาก 100", + "@homeWhyRecoveryPlain": { + "description": "Body of the low-recovery row when no input is known to drag the score: just the score out of 100.", + "placeholders": { + "score": { + "type": "int" + } + } + }, + "homeWhyRecoveryPullingTwo": "ที่ฉุดให้ต่ำลง: {a}, {b}", + "@homeWhyRecoveryPullingTwo": { + "description": "Body of the low-recovery row naming the two inputs pulling the score down, without numbers.", + "placeholders": { + "a": { + "type": "String" + }, + "b": { + "type": "String" + } + } + }, + "homeWhyRecoveryPullingOne": "ที่ฉุดให้ต่ำลง: {a}", + "@homeWhyRecoveryPullingOne": { + "description": "Body of the low-recovery row naming the one input pulling the score down, without numbers.", + "placeholders": { + "a": { + "type": "String" + } + } + }, + "homeWhySleepTitle": "นอนน้อยกว่าที่ต้องการ", + "@homeWhySleepTitle": { + "description": "Title of the Home card row shown when sleep is well short of the person's need." + }, + "homeWhySleepBody": "การนอนที่คำนวณคะแนนได้ต่ำกว่าที่คุณต้องการ", + "@homeWhySleepBody": { + "description": "Body of the short-sleep row when the time asleep or the need is not available to quote." + }, + "homeWhySleepDetail": "นอนหลับ {asleep} น้อยกว่าที่คุณต้องการ {need} อยู่ {gap}", + "@homeWhySleepDetail": { + "description": "Body of the short-sleep row: time asleep and how far under the need it was.", + "placeholders": { + "asleep": { + "type": "String" + }, + "gap": { + "type": "String" + }, + "need": { + "type": "String" + } + } + }, + "homeWhyStrainTitle": "ความหนักเกินเป้าหมาย", + "@homeWhyStrainTitle": { + "description": "Title of the Home card row shown when strain is above today's target range." + }, + "homeWhyStrainBody": "ความหนัก {strain} เกินเป้าหมายของวันนี้ที่ {range}", + "@homeWhyStrainBody": { + "description": "Body of the high-strain row: today's strain and the target range.", + "placeholders": { + "strain": { + "type": "String" + }, + "range": { + "type": "String" + } + } + }, "homeStrainOf21": "จาก 21", "@homeStrainOf21": { "description": "Strain ring subtitle: out of the fixed 21-point scale." @@ -7449,7 +7706,7 @@ "@homeNothingTodayTitle": { "description": "Title shown when there is history but nothing recorded for today." }, - "homeNothingTodayBody": "คืนล่าสุดที่แอปคำนวณคะแนนคือ {day} ยังไม่มีข้อมูลเข้ามาหลังจากนั้น", + "homeNothingTodayBody": "คืนล่าสุดที่คำนวณคะแนน: {day}", "@homeNothingTodayBody": { "description": "Body shown when there is history but nothing recorded for today, naming the last scored night.", "placeholders": { @@ -7485,7 +7742,7 @@ } } }, - "homeReadinessNoReason": "ไม่มีข้อมูลระบุเหตุผล", + "homeReadinessNoReason": "ไม่มีค่าที่อ่านได้", "@homeReadinessNoReason": { "description": "Fallback body of the readiness-not-scored card when no reason is recorded." }, @@ -7535,29 +7792,17 @@ } } }, - "homeIllnessBodyNoZ": "อัตราการเต้นหัวใจขณะพักสูงกว่าปกติในหลายคืนล่าสุด ติดตามสัญญาณเดียว จึงแสดงรูปแบบ ไม่ใช่สาเหตุ", + "homeIllnessBodyNoZ": "อัตราการเต้นหัวใจขณะพักสูงกว่าปกติในหลายคืนล่าสุด เป็นรูปแบบ ไม่ใช่สาเหตุ", "@homeIllnessBodyNoZ": { "description": "Illness watch body text when no standardised-deviation figure is available." }, - "homeIllnessBodyAbove": "หลายคืนล่าสุดแสดงอัตราการเต้นหัวใจขณะพักสูงกว่าปกติ คืนนั้นสูงกว่าอยู่ {z} เท่าของความผันผวนระหว่างคืน เป็นรูปแบบ ไม่ใช่สาเหตุ", + "homeIllnessBodyAbove": "หลายคืนล่าสุดแสดงอัตราการเต้นหัวใจขณะพักสูงกว่าปกติ คืนนั้นก็สูงกว่าเช่นกัน เป็นรูปแบบ ไม่ใช่สาเหตุ", "@homeIllnessBodyAbove": { - "description": "Illness watch body text when the flagged night's heart rate sat above baseline, with the deviation amount.", - "placeholders": { - "z": { - "type": "String", - "example": "1.8" - } - } + "description": "Illness watch body text when the flagged night's heart rate sat above baseline, with the deviation amount." }, - "homeIllnessBodyBelow": "หลายคืนล่าสุดรวมกันแสดงอัตราการเต้นหัวใจขณะพักสูงกว่าปกติ แม้คืนนั้นเพียงคืนเดียวจะต่ำกว่าอยู่ {z} เท่าของความผันผวนระหว่างคืน เป็นรูปแบบ ไม่ใช่สาเหตุ", + "homeIllnessBodyBelow": "หลายคืนล่าสุดรวมกันแสดงอัตราการเต้นหัวใจขณะพักสูงกว่าปกติ แม้คืนนั้นเพียงคืนเดียวจะต่ำกว่า เป็นรูปแบบ ไม่ใช่สาเหตุ", "@homeIllnessBodyBelow": { - "description": "Illness watch body text when the flagged night's heart rate sat below baseline, with the deviation amount.", - "placeholders": { - "z": { - "type": "String", - "example": "1.8" - } - } + "description": "Illness watch body text when the flagged night's heart rate sat below baseline, with the deviation amount." }, "homeIllnessAdvice": "ควรสังเกตหากเป็นต่อเนื่องเกินสองสามวัน", "@homeIllnessAdvice": { @@ -15535,7 +15780,7 @@ } } }, - "homeCoachSetUpChip": "ถามเกี่ยวกับข้อมูลของคุณ: ตั้งค่าโค้ช", + "homeCoachSetUpChip": "ตั้งค่าโค้ช", "@homeCoachSetUpChip": { "description": "Today tab chip when no coach model is configured." }, diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 19599d7bc..d29b35b52 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -1428,15 +1428,15 @@ "homeDriverRhr": "静息心率", "homeDriverResp": "呼吸频率", "homeDriverTemp": "皮肤温度", - "homeDbRebuiltTitle": "为启动应用,你的数据库已被重建", + "homeDbRebuiltTitle": "你的数据库已重建", "homeDbRebuiltNothingRecovered": "没有任何数据可以恢复。", "homeDbRebuiltRecovered": "已恢复:{list}。", "homeDbRebuiltEmpty": "为空:{list}。", "homeDbRebuiltKept": "原始文件仍保存在 {path},没有任何内容被删除。", "homeWorkoutHoldTitle": "有一项运动仍在进行中", - "homeWorkoutHoldBody": "运动进行期间,今日数据处于暂停状态:腕带仍在记录,但数值要等到运动结束后才会计算。请在下方的进度条中结束运动,今日数据便会补全——仅同步是不够的。", - "homeInsightsRebuildingTitle": "你的跨日汇总数据正在重建", - "homeInsightsRebuildingAlgoVersion": "计算方式已随最新更新发生变化。", + "homeWorkoutHoldBody": "结束运动后,今天的数据就会补全。", + "homeInsightsRebuildingTitle": "正在更新你的趋势", + "homeInsightsRebuildingAlgoVersion": "正在根据最近的应用更新进行更新。", "homeInsightsStaleOverWeek": "上次汇总是一周多以前生成的,太旧了,不宜依赖。", "homeInsightsStaleOnDay": "上次汇总是在 {day} 生成的,太旧了,不宜依赖。", "homeInsightsNoVersionStamp": "已保存的汇总没有版本标记。", @@ -1445,13 +1445,193 @@ "homeCalibrating": "校准中", "homeCalibratingNights": "已有 {have}/{need} 晚", "homeCalibratingDays": "已有 {have}/{need} 天", - "homeGapNoReason": "没有记录说明缺失的原因。", + "homeGapNoReason": "没有读数。", "homeRingRecovery": "恢复", "homeRingStrain": "消耗", "homeRingSleep": "睡眠", "homeRingNoStrain": "暂无消耗数据", "homeRingNoSleep": "暂无睡眠数据", "homeSleepGapFallback": "没有记录到时长足以评分的夜晚。", + "homeSleepPctOf": "{target} 的 {pct}%", + "homeSleepPctOfDefault": "{target} 的 {pct}%(默认)", + "homeSleepPctCompact": "{target} 的 {pct}%", + "@homeSleepPctCompact": { + "description": "Short sleep caption against a learned target.", + "placeholders": { + "pct": { + "type": "int" + }, + "target": { + "type": "String" + } + } + }, + "homeSleepPctDefaultCompact": "{target} 的 {pct}% · 默认目标", + "@homeSleepPctDefaultCompact": { + "description": "Short sleep caption that keeps an explicit default-target qualifier.", + "placeholders": { + "pct": { + "type": "int" + }, + "target": { + "type": "String" + } + } + }, + "homeSleepPctDefaultTight": "默认 {target} 的 {pct}%", + "@homeSleepPctDefaultTight": { + "description": "Tightest sleep caption when the target is the default: still names the target as the default.", + "placeholders": { + "pct": { + "type": "int" + }, + "target": { + "type": "String" + } + } + }, + "homeSleepOnTrack": "有望达到 {cycles, plural, other{{cycles} 个周期}},{duration}", + "homeSleepOnTrackDefault": "有望达到 {cycles, plural, other{{cycles} 个周期}},{duration} · 目标 {target}(默认)", + "homeSleepEstimateCompact": "~{cycles, plural, other{{cycles} 个周期}} · {duration}", + "@homeSleepEstimateCompact": { + "description": "Short sleep estimate: \"~\" marks it as an estimate; keeps cycle count and duration.", + "placeholders": { + "cycles": { + "type": "int" + }, + "duration": { + "type": "String" + } + } + }, + "homeSleepEstimateDefaultCompact": "~{cycles, plural, other{{cycles} 个周期}} · {duration} · 默认目标", + "@homeSleepEstimateDefaultCompact": { + "description": "Short sleep estimate keeping the \"~\" estimate marker and an explicit default-target qualifier.", + "placeholders": { + "cycles": { + "type": "int" + }, + "duration": { + "type": "String" + } + } + }, + "homeSleepEstimateDefaultTight": "~{cycles, plural, other{{cycles} 个周期}} · {duration} · 默认目标", + "@homeSleepEstimateDefaultTight": { + "description": "Tightest sleep estimate caption: keeps the estimate marker, cycles, duration and the default-target qualifier.", + "placeholders": { + "cycles": { + "type": "int" + }, + "duration": { + "type": "String" + } + } + }, + "homeSleepEstimateDefaultMin": "~{cycles, plural, other{{cycles} 个周期}} · 默认目标", + "@homeSleepEstimateDefaultMin": { + "description": "Smallest default-target estimate caption: drops the duration, keeps the estimate marker, cycles and the default goal.", + "placeholders": { + "cycles": { + "type": "int" + } + } + }, + "homeSleepEstimateBare": "~{cycles, plural, other{{cycles} 个周期}}", + "@homeSleepEstimateBare": { + "description": "Smallest estimate caption: the estimate marker and the cycle count.", + "placeholders": { + "cycles": { + "type": "int" + } + } + }, + "homeSleepNoEstimate": "暂无估算", + "homeSleepNoEstimateWhy": "尚无已评分的夜晚,也没有闹钟或常规起床时间可供估算。", + "homeWhyRecoveryTitle": "恢复状态偏低", + "homeWhyRecoveryDrivers": "拖累最大:{a} {za}、{b} {zb}(与你的基线的偏差)。", + "@homeWhyRecoveryDrivers": { + "placeholders": { + "a": { + "type": "String" + }, + "za": { + "type": "String" + }, + "b": { + "type": "String" + }, + "zb": { + "type": "String" + } + } + }, + "homeWhyRecoveryDriver": "拖累最大:{a} {za}(与你的基线的偏差)。", + "@homeWhyRecoveryDriver": { + "placeholders": { + "a": { + "type": "String" + }, + "za": { + "type": "String" + } + } + }, + "homeWhyRecoveryPlain": "恢复 {score}/100", + "@homeWhyRecoveryPlain": { + "placeholders": { + "score": { + "type": "int" + } + } + }, + "homeWhyRecoveryPullingTwo": "拉低恢复评分的因素:{a}、{b}。", + "@homeWhyRecoveryPullingTwo": { + "placeholders": { + "a": { + "type": "String" + }, + "b": { + "type": "String" + } + } + }, + "homeWhyRecoveryPullingOne": "拉低恢复评分的因素:{a}。", + "@homeWhyRecoveryPullingOne": { + "placeholders": { + "a": { + "type": "String" + } + } + }, + "homeWhySleepTitle": "睡眠不足", + "homeWhySleepBody": "已评分的睡眠时长低于你的需求。", + "homeWhySleepDetail": "睡了 {asleep},比所需的 {need} 少 {gap}。", + "@homeWhySleepDetail": { + "placeholders": { + "asleep": { + "type": "String" + }, + "gap": { + "type": "String" + }, + "need": { + "type": "String" + } + } + }, + "homeWhyStrainTitle": "负荷超过目标", + "homeWhyStrainBody": "消耗 {strain},高于今天的目标 {range}。", + "@homeWhyStrainBody": { + "placeholders": { + "strain": { + "type": "String" + }, + "range": { + "type": "String" + } + } + }, "homeStrainOf21": "满分 21", "homeSleepNoTarget": "暂无目标", "homeOfSpan": "共 {duration}", @@ -1466,13 +1646,13 @@ "homeAskCoach": "咨询教练", "homeProfileSettings": "个人资料与设置", "homeNothingTodayTitle": "今天没有记录到数据", - "homeNothingTodayBody": "应用评分的最后一晚是 {day}。此后没有收到任何新数据。", + "homeNothingTodayBody": "最近一次评分的夜晚:{day}。", "homeNoDataThisDayTitle": "这一天没有数据", "homeNoDataThisDayBody": "这一天没有记录到任何数据。", "homeSomeDayDataMissingTitle": "这一天的部分数据缺失", "homeReadinessNotScoredTitle": "今天的恢复度未评分", "homeReadinessNeedBody": "{need},才能了解你的正常水平。", - "homeReadinessNoReason": "没有记录说明原因。", + "homeReadinessNoReason": "没有读数。", "homeSeeWhatWasMissing": "查看缺失的内容", "homeAtAGlance": "概览", "homeTodaysPlan": "今日计划", @@ -1481,9 +1661,9 @@ "homeIllnessRedTitle": "最近几晚累计显示静息心率偏高", "homeIllnessAmberSameNight": "昨晚超出了你的正常范围", "homeIllnessAmberOtherNight": "{day} 超出了你的正常范围", - "homeIllnessBodyNoZ": "你最近几晚的夜间静息心率累计显示高于自身基线。这只反映了单一信号,说明存在某种趋势,但并不能说明原因。", - "homeIllnessBodyAbove": "你最近几晚的夜间静息心率累计显示高于自身基线;那一晚高出基线 {z} 个标准差。这只反映了单一信号,说明存在某种趋势,但并不能说明原因。", - "homeIllnessBodyBelow": "你最近几晚的夜间静息心率累计显示高于自身基线;那一晚低于基线 {z} 个标准差。这只反映了单一信号,说明存在某种趋势,但并不能说明原因。", + "homeIllnessBodyNoZ": "最近几晚你的静息心率高于平时。这是一种趋势,不是原因。", + "homeIllnessBodyAbove": "最近几晚你的静息心率高于平时,那一晚也高于平时。这是一种趋势,不是原因。", + "homeIllnessBodyBelow": "最近几晚合起来使你的静息心率高于平时,尽管那一晚单独看低于平时。这是一种趋势,不是原因。", "homeIllnessAdvice": "如果持续几天,值得留意。", "homeHeartRate": "心率", "homeRestingSub": "静息", diff --git a/lib/ui2/profile/gallery.dart b/lib/ui2/profile/gallery.dart index 04fe408ae..3c77a674e 100644 --- a/lib/ui2/profile/gallery.dart +++ b/lib/ui2/profile/gallery.dart @@ -985,6 +985,43 @@ Map extraCases() => { ), ); }), + // Past 100% a ring is an eased spiral, one lap inside the last with a + // thin separator; the sleep ring is the same spiral split into stages. + 'chart_ring_spiral': Builder(builder: (c) { + final p = P.of(c); + return Surface( + child: ChartFrame( + title: 'Sleep', + unit: '113% of 8h 00m (default)', + height: 120, + child: CustomPaint( + size: Size.infinite, + painter: StageRing( + [ + RingArc(Hypnogram.cols(p)[SleepStage.deep]!, .15), + RingArc(Hypnogram.cols(p)[SleepStage.rem]!, .2), + RingArc(Hypnogram.cols(p)[SleepStage.light]!, .7), + RingArc(Hypnogram.cols(p)[SleepStage.awake]!, .08), + ], + p.track, + background: p.card)), + ), + ); + }), + 'chart_ring_overflow': Builder(builder: (c) { + final p = P.of(c); + return Surface( + child: ChartFrame( + title: 'Strain', + unit: '150% of target', + height: 120, + child: CustomPaint( + size: Size.infinite, + painter: Ring(1.5, p.on(C.purple), p.track, + solid: true, background: p.card)), + ), + ); + }), 'chart_macro_ring': Builder(builder: (c) { final p = P.of(c); return Surface( diff --git a/lib/ui2/screens/home_screen.dart b/lib/ui2/screens/home_screen.dart index 6a6701c60..65c08987c 100644 --- a/lib/ui2/screens/home_screen.dart +++ b/lib/ui2/screens/home_screen.dart @@ -28,7 +28,9 @@ import '../../l10n/date_text.dart'; // only three of them and they are read together. import 'dart:async'; +import 'dart:ui' as ui show TextDirection; +import 'package:clock/clock.dart' as pc; import 'package:flutter/material.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:provider/provider.dart'; @@ -315,6 +317,17 @@ Widget syncedThroughLine(BuildContext c, String? todayId, ); } +/// The next armed alarm, or null with none held (or in a golden). May be an +/// instant already past: the sleep ring ignores those. +DateTime? nextAlarmOf(BuildContext c) { + try { + final e = c.select((a) => a.alarmEpoch); + return e == null ? null : DateTime.fromMillisecondsSinceEpoch(e * 1000); + } catch (_) { + return null; + } +} + bool derivingOf(BuildContext c) { try { return c.select((a) => a.deriving || a.derivePending); @@ -506,7 +519,12 @@ String axisDay(int? epochSec, final behind = daysBehind(epochSec); if (behind == null) return ''; if (behind <= 0) return todayWord; - return '$behind $unitWord ago'; + // "1 days ago" read as a typo on the card that says "as of ...". The words + // passed in are English plurals ("days", "nights"), so one drops the s. + final unit = behind == 1 && unitWord.endsWith('s') + ? unitWord.substring(0, unitWord.length - 1) + : unitWord; + return '$behind $unit ago'; } /// Whole calendar days between a stored point and today, or null when there is @@ -515,7 +533,7 @@ String axisDay(int? epochSec, int? daysBehind(int? epochSec) { if (epochSec == null) return null; return calendarDaysBetween( - DateTime.fromMillisecondsSinceEpoch(epochSec * 1000), DateTime.now()); + DateTime.fromMillisecondsSinceEpoch(epochSec * 1000), pc.clock.now()); } /// The withheld-rollup reason inside a `getInsights()` result, or null when the @@ -550,7 +568,7 @@ StatusCard? dbRebuiltCard(DbRebuild? r, [AppLocalizations? l]) { final savedList = saved.map((e) => '${e.key} ${thousands(e.value)}').join(' · '); final lostList = lost.map((e) => e.key).join(' · '); return StatusCard( - l?.homeDbRebuiltTitle ?? 'Your database was rebuilt to start the app', + l?.homeDbRebuiltTitle ?? 'Your database was rebuilt', '${r.cause}\n\n' '${saved.isEmpty ? (l?.homeDbRebuiltNothingRecovered ?? 'Nothing could be read back.') : (l?.homeDbRebuiltRecovered(savedList) ?? 'Recovered: $savedList.')}' '${lost.isEmpty ? '' : ' ${l?.homeDbRebuiltEmpty(lostList) ?? 'Empty: $lostList.'}'}' @@ -569,7 +587,7 @@ StatusCard? dbRebuiltCard(DbRebuild? r, [AppLocalizations? l]) { StatusCard workoutHoldCard([AppLocalizations? l]) => StatusCard( l?.homeWorkoutHoldTitle ?? 'A workout is still running', l?.homeWorkoutHoldBody ?? - 'Today is paused while a workout runs. The band keeps recording; finish the workout from the bar below and today fills in.', + 'Today fills in when you finish the workout.', icon: LucideIcons.timer, ); @@ -579,10 +597,10 @@ StatusCard? staleInsightsCard( if (s == null) return null; final built = s['built_for_day']?.toString(); return StatusCard( - l?.homeInsightsRebuildingTitle ?? 'Your cross-day insights are being rebuilt', + l?.homeInsightsRebuildingTitle ?? 'Updating your trends', switch (s['kind']) { 'algo_version' => l?.homeInsightsRebuildingAlgoVersion ?? - 'How these are computed changed with the last update.', + 'Updating after the latest app update.', 'stale' => built == null || built.isEmpty ? (l?.homeInsightsStaleOverWeek ?? 'Your trends were last updated over a week ago. Sync the band to bring them up to date.') @@ -612,6 +630,15 @@ String hm(num? minutes) { ); } +/// "8h" for a whole number of hours, else [hm]: the caption's compact forms. +String _compactHm(num? minutes, AppLocalizations? l) { + if (minutes == null) return ''; + final value = minutes.round(); + return value % 60 == 0 + ? l?.supplementHoursValue('${value ~/ 60}') ?? '${value ~/ 60}h' + : hm(value); +} + String thousands(num? v) => v == null ? '' : NumberFormat.decimalPattern( @@ -843,21 +870,29 @@ class RingTrio extends StatelessWidget { /// worth pushing onto. final void Function(HomeRingKind)? onOpen; - /// RED-PHASE STUB: the instant the sleep ring estimates from. Unused. + /// The instant the sleep ring measures an unslept night's estimate from. + /// Injected by tests; production reads `clock.now()`. final DateTime? now; const RingTrio({super.key, required this.d, this.onOpen, this.now}); /// Whether ANY of the three has something to draw. When none do, the screen /// owes the user one written absence, not three empty circles. - static bool has(HomeData d, {DateTime? now}) => - HomeRingKind.values.any((k) => _ringOf(k, d, null).why == null); + static bool has(HomeData d, {DateTime? now}) { + final at = now ?? pc.clock.now(); + return HomeRingKind.values.any((k) => _ringOf(k, d, null, at).why == null); + } + + /// Whether the sleep ring can estimate tonight's sleep, and so has something + /// to draw even when nothing else on the day does. + static bool hasSleepEstimate(HomeData d, {DateTime? now}) => + _ringOf(HomeRingKind.sleep, d, null, now ?? pc.clock.now()).estimated; @override Widget build(BuildContext c) { - final p = P.of(c); final l = AppLocalizations.of(c); - final rings = [for (final k in HomeRingKind.values) _ringOf(k, d, l)]; + final at = now ?? pc.clock.now(); + final rings = [for (final k in HomeRingKind.values) _ringOf(k, d, l, at)]; final gaps = rings.where((r) => r.why != null).toList(); // THERE IS NO "THESE TWO ARE FROM SATURDAY" LINE ANY MORE, and there is // nothing left for one to explain. Recovery and sleep used to be served @@ -866,13 +901,39 @@ class RingTrio extends StatelessWidget { // sentence lost: a number inside a ring is today's, and a caption under // three rings does not undo it. The loader refuses the older night now // ([overnightMetric]), so a ring with no night behind it is a gap row with - // the reason in it — same place every other absence on this screen goes. + // the reason in it, in the same place every other absence on this screen + // goes. + + // THE CARD'S BOTTOM SLOT: up to two info rows in one style, and nothing at + // all (no reserved band) when there is nothing to say. The most useful two + // win, in this order: why a ring is low, what changed the recovery number, + // then an absent ring's reason. + final rows = [ + for (final k in pressingRings(d, now: at)) + _HomeWhyPanel(kind: k, data: d, now: at, onTap: _open(k)), + if (recoveryUpdatedLine(d.readinessUpdate, l) case final note?) + _HomeUpdateNote(line: note, onTap: _open(HomeRingKind.recovery)), + for (final r in gaps) + _HomeNoteRow( + id: 'gap:${r.kind.name}', + title: r.label, + text: presentationText(l, r.why ?? ''), + onTap: _open(r.kind)), + ]; + final slot = [ + for (final (i, row) in rows.take(2).indexed) ...[ + SizedBox(height: i == 0 ? S.x3 : S.x2), + row, + ], + ]; return Surface( elevation: 2, child: Column(children: [ + // Rows replace columns by locale, text scale and width only, never by + // the day's data: an empty card and a loaded one are the same layout. if (bigText(c)) - // Past ~1.3× a 100 pt column cannot hold the word "Recovery" on one + // Past ~1.3x a 100 pt column cannot hold the word "Recovery" on one // line and there is nowhere for it to wrap to. The ring keeps its // size and the type gets the width instead. for (var i = 0; i < rings.length; i++) ...[ @@ -890,41 +951,8 @@ class RingTrio extends StatelessWidget { ], ], ), - for (final r in gaps) ...[ - const SizedBox(height: S.x2), - Divider(color: p.line, height: 1), - _GapRow(r, onTap: _open(r.kind)), - ], - if (recoveryUpdatedLine(d.readinessUpdate, l) case final note?) ...[ - const SizedBox(height: S.x2), - Text(note, style: F.cap.copyWith(color: p.ink3)), - ], - if (d.readiness.value != null && d.drivers.isNotEmpty) ...[ - const SizedBox(height: S.x3), - Divider(color: p.line, height: 1), - const SizedBox(height: S.x3), - Pressable( - onTap: _open(HomeRingKind.recovery), - // Top-aligned: at an accessibility size the driver list is three - // lines and "Why?" was centred against the middle of them. - child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(l?.homeWhyLabel ?? 'Why?', style: F.cap.copyWith(color: p.ink3)), - const SizedBox(width: S.x2), - Expanded( - child: Text( - d.drivers - .take(3) - .map((e) => driverLabel(e['label'], l)) - .join(' · '), - style: F.cap.copyWith(color: p.ink2), - ), - ), - Icon(LucideIcons.chevronRight, size: 15, color: p.ink3), - ]), - ), - ], - ], - ), + ...slot, + ]), ); } @@ -934,6 +962,248 @@ class RingTrio extends StatelessWidget { } } +class _HomeWhyPanel extends StatelessWidget { + final HomeRingKind kind; + final HomeData data; + final DateTime now; + final VoidCallback? onTap; + const _HomeWhyPanel( + {required this.kind, required this.data, required this.now, this.onTap}); + + @override + Widget build(BuildContext c) { + final l = AppLocalizations.of(c); + final state = _ringOf(kind, data, l, now); + final title = switch (kind) { + HomeRingKind.recovery => l?.homeWhyRecoveryTitle ?? 'Recovery is low', + HomeRingKind.sleep => l?.homeWhySleepTitle ?? 'Sleep is short of need', + HomeRingKind.strain => l?.homeWhyStrainTitle ?? 'Strain is above target', + }; + final body = switch (kind) { + HomeRingKind.recovery => _recoveryWhy(data, l), + HomeRingKind.sleep => _sleepWhy(data, l), + HomeRingKind.strain => _strainWhy(data, l), + }; + // One tappable node (Pressable merges the texts): the title names the + // ring, the body gives the reason, each announced once. + return _HomeWhyRow( + id: kind.name, + title: title, + body: body, + color: state.color, + icon: state.icon, + onTap: onTap); + } +} + +class _HomeWhyRow extends StatelessWidget { + final String id, title, body; + final Color color; + final IconData icon; + final VoidCallback? onTap; + const _HomeWhyRow( + {required this.id, + required this.title, + required this.body, + required this.color, + required this.icon, + this.onTap}); + + @override + Widget build(BuildContext c) { + final p = P.of(c); + return _HomeInsetRow( + key: ValueKey('home-why:$id'), + onTap: onTap, + child: Row(crossAxisAlignment: CrossAxisAlignment.center, children: [ + Container( + key: ValueKey('home-why-icon:$id'), + width: 32, + height: 32, + decoration: BoxDecoration( + color: p.on(color).withValues(alpha: .14), + shape: BoxShape.circle), + child: Icon(icon, size: 17, color: p.on(color))), + const SizedBox(width: S.x2), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text(title, + key: ValueKey('home-why-title:$id'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: F.body + .copyWith(fontWeight: FontWeight.w600, color: p.ink)), + const SizedBox(height: 2), + Text(body, + key: ValueKey('home-why-body:$id'), + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: F.cap.copyWith(color: p.ink2)), + ])), + const SizedBox(width: S.x1), + Icon(LucideIcons.chevronRight, size: 17, color: p.ink3), + ])); + } +} + +/// Evidence or nothing. Built from what BOTH driver shapes carry (label and the +/// sign/size of the weighted `contribution`; the production `detail` is prose): +/// the biggest drags first. A number is added only when a numeric z is really in +/// `detail` (`...=`) for every driver named; otherwise none is invented. +String _recoveryWhy(HomeData d, AppLocalizations? l) { + final drags = <(String, double, double?)>[]; + for (final e in d.drivers) { + final c = e['contribution']; + if (c is! num || c >= 0) continue; + final detail = '${e['detail']}'; + final z = + detail.contains('=') ? double.tryParse(detail.split('=').last) : null; + drags.add((driverLabel(e['label'], l), c.toDouble(), z)); + } + drags.sort((a, b) => a.$2.compareTo(b.$2)); // largest drag first + final top = drags.take(2).toList(); + String f(double? z) => z!.toStringAsFixed(1); + final numeric = top.isNotEmpty && top.every((x) => x.$3 != null); + if (top.length == 2) { + final (a, b) = (top[0], top[1]); + return numeric + ? (l?.homeWhyRecoveryDrivers(a.$1, f(a.$3), b.$1, f(b.$3)) ?? + 'Biggest drag: ${a.$1} ${f(a.$3)}, ${b.$1} ${f(b.$3)} (how far each sits from your usual).') + : (l?.homeWhyRecoveryPullingTwo(a.$1, b.$1) ?? + 'Pulling it down: ${a.$1}, ${b.$1}.'); + } + if (top.length == 1) { + final a = top[0]; + return numeric + ? (l?.homeWhyRecoveryDriver(a.$1, f(a.$3)) ?? + 'Biggest drag: ${a.$1} ${f(a.$3)} (how far each sits from your usual).') + : (l?.homeWhyRecoveryPullingOne(a.$1) ?? 'Pulling it down: ${a.$1}.'); + } + final v = (d.readiness.value ?? 0).round(); + return l?.homeWhyRecoveryPlain(v) ?? 'Recovery $v of 100'; +} + +String _strainWhy(HomeData d, AppLocalizations? l) { + String n(num v) => v == v.round() ? '${v.round()}' : v.toStringAsFixed(1); + final t = d.strainTarget; + final low = t?['low'], high = t?['high']; + final range = + low is num && high is num ? '${n(low)}–${n(high)}' : n(high as num); + final s = displayFixed(d.strain.value ?? 0, 1); + return l?.homeWhyStrainBody(s, range) ?? + 'Strain $s, above today’s target of $range.'; +} + +String _sleepWhy(HomeData d, AppLocalizations? l) { + final asleep = d.sleepMin.value, need = d.sleepNeedMin.value; + if (asleep == null || need == null) { + return l?.homeWhySleepBody ?? 'Your scored sleep was below your need.'; + } + final gap = (need - asleep).clamp(0, need); + return l?.homeWhySleepDetail(hm(asleep), hm(gap), hm(need)) ?? + '${hm(asleep)} asleep, ${hm(gap)} under your ${hm(need)} need.'; +} + +/// An absent ring's reason in the pressing rows' style: a neutral icon, [title] +/// over [text], and a chevron when it opens something. +class _HomeNoteRow extends StatelessWidget { + final String id, text; + final String? title; + final VoidCallback? onTap; + const _HomeNoteRow( + {required this.id, required this.text, this.title, this.onTap}); + @override + Widget build(BuildContext c) { + final p = P.of(c); + return _HomeInsetRow( + key: ValueKey('home-note:$id'), + onTap: onTap, + child: Row(crossAxisAlignment: CrossAxisAlignment.center, children: [ + Container( + key: ValueKey('home-note-icon:$id'), + width: 32, + height: 32, + decoration: BoxDecoration( + color: p.ink3.withValues(alpha: .16), + shape: BoxShape.circle), + child: Icon(LucideIcons.info, size: 17, color: p.ink2)), + const SizedBox(width: S.x2), + Expanded( + child: title == null + ? Text(text, + style: F.body + .copyWith(color: p.ink, fontWeight: FontWeight.w600), + maxLines: 3, + overflow: TextOverflow.ellipsis) + : Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text(title!, + style: F.body.copyWith( + color: p.ink, fontWeight: FontWeight.w600)), + const SizedBox(height: 2), + Text(text, + style: F.cap.copyWith(color: p.ink2), + maxLines: 2, + overflow: TextOverflow.ellipsis), + ])), + if (onTap != null) + Icon(LucideIcons.chevronRight, size: 17, color: p.ink3), + ])); + } +} + +/// The recovery update note as a neutral row in the pressing style. +class _HomeUpdateNote extends StatelessWidget { + final String line; + final VoidCallback? onTap; + const _HomeUpdateNote({required this.line, this.onTap}); + @override + Widget build(BuildContext c) { + final p = P.of(c); + return _HomeInsetRow( + key: const ValueKey('home-update-note'), + onTap: onTap, + child: Row(crossAxisAlignment: CrossAxisAlignment.center, children: [ + Container( + key: const ValueKey('home-update-note-icon'), + width: 32, + height: 32, + decoration: BoxDecoration( + color: p.ink3.withValues(alpha: .16), + shape: BoxShape.circle), + child: Icon(LucideIcons.refreshCw, size: 17, color: p.ink2)), + const SizedBox(width: S.x2), + Expanded( + child: Text(line, + style: + F.body.copyWith(color: p.ink, fontWeight: FontWeight.w600), + maxLines: 2, + overflow: TextOverflow.ellipsis)), + Icon(LucideIcons.chevronRight, size: 17, color: p.ink3), + ])); + } +} + +class _HomeInsetRow extends StatelessWidget { + final Widget child; + final VoidCallback? onTap; + const _HomeInsetRow({super.key, required this.child, this.onTap}); + @override + Widget build(BuildContext c) { + final p = P.of(c); + final panel = Container( + padding: const EdgeInsets.fromLTRB(12, 12, 10, 12), + decoration: BoxDecoration(color: p.card2, borderRadius: R.rMd), + child: child); + return onTap == null ? panel : Pressable(onTap: onTap, child: panel); + } +} + String _fixed1OrEmpty(Object? v) => v is num ? displayFixed(v, 1) : ''; /// "Updated 8:12 · 2 → 28" off a `todayHeadlineOf` `recovery_update`, or null. @@ -964,19 +1234,68 @@ String? recoveryStateLine(Map head, AppLocalizations? l) => /// night gave back, what the day has cost, and what the night was made of. enum HomeRingKind { recovery, strain, sleep } -/// RED-PHASE STUB: the real function ranks the (at most two) rings the Home -/// card should explain. See edge.research/sol/home/scope-map.md. -List pressingRings(HomeData d, {DateTime? now}) => const []; +/// The (at most two) rings the Home card should explain, most pressing first: +/// recovery under 37, sleep under 85% of need, strain above the top of today's +/// target range. A value that is absent, provisional or still being scored is +/// never pressing (never fabricate). +List pressingRings(HomeData d, {DateTime? now}) { + final ranked = <(HomeRingKind, int)>[]; + final recovery = d.readiness.value; + if (recovery != null && + d.recoveryState != 'provisional' && + d.recoveryState != 'night_in_progress') { + if (recovery < 26) { + ranked.add((HomeRingKind.recovery, 3)); + } else if (recovery <= 36) { + ranked.add((HomeRingKind.recovery, 2)); + } + } + final sleep = d.sleepMin.value, need = d.sleepNeedMin.value; + if (sleep != null && need != null && need > 0 && sleep / need < .85) { + ranked.add((HomeRingKind.sleep, sleep / need < .70 ? 3 : 2)); + } + final strain = d.strain.value; + final high = d.strainTarget?['high']; + if (strain != null && high is num && strain > high) { + ranked.add((HomeRingKind.strain, 1)); + } + const order = { + HomeRingKind.recovery: 0, + HomeRingKind.sleep: 1, + HomeRingKind.strain: 2 + }; + ranked.sort((a, b) => b.$2.compareTo(a.$2) != 0 + ? b.$2.compareTo(a.$2) + : order[a.$1]!.compareTo(order[b.$1]!)); + return [for (final item in ranked.take(2)) item.$1]; +} -/// RED-PHASE STUB: the real function is the delay to the next whole minute, -/// taken from the UTC instant (so the repeated fall-back hour is safe). +/// How long until the next whole minute after [now], in (0, 60 s]. +/// +/// Taken from the UTC instant, never from local wall-clock fields: a minute +/// boundary is the same instant in every whole-, half- and quarter-hour zone, +/// but `DateTime(y, m, d, h, min + 1)` in the repeated fall-back hour resolves +/// to the FIRST occurrence of that wall time, an hour in the past: a negative +/// delay, a zero-delay Timer, and a setState loop. @visibleForTesting -Duration nextMinuteTickDelay(DateTime now) => Duration.zero; +Duration nextMinuteTickDelay(DateTime now) { + const minuteUs = 60 * 1000000; + final us = now.toUtc().microsecondsSinceEpoch; + final next = DateTime.fromMicrosecondsSinceEpoch( + us - us % minuteUs + minuteUs, + isUtc: true); + return next.difference(now.toUtc()); +} /// One ring's resolved state — the only place a metric becomes a shape. class _RingState { final HomeRingKind kind; final String label, value, sub; + + /// Shorter wordings of [sub], widest first, that keep its meaning (the + /// estimate marker, the default-target qualifier). The caption tries + /// [sub], then each of these, and takes the first that fits. + final List alts; final IconData icon; final Color color; @@ -995,6 +1314,14 @@ class _RingState { /// ring is [absent]. final String? why; + /// The sleep ring's arcs when a night is scored: stage arcs, or one solid + /// arc. Null for every other ring and state. + final List? arcs; + + /// An ESTIMATE of tonight's sleep, not a reading: drawn in the muted ink, + /// never a stage colour, and never solid. + final bool estimated; + const _RingState( this.kind, this.label, @@ -1002,12 +1329,15 @@ class _RingState { this.color, { required this.value, this.sub = '', + this.alts = const [], this.frac, this.calibrating = false, this.have, this.need, this.why, this.provisional = false, + this.arcs, + this.estimated = false, }); /// A number that may still move (the night is still draining), drawn grey. @@ -1015,20 +1345,26 @@ class _RingState { /// A number the ring is actually reporting. Calibration is progress, not a /// reading, so it is not one. - bool get measured => why == null && !calibrating; + bool get measured => why == null && !calibrating && !estimated; + + /// Whether the value is a number to set in numerals (a reading or an + /// estimate) rather than an absence in words. + bool get showsNumber => why == null && !calibrating; - Color arc(P p) => calibrating || provisional ? p.ink3 : p.on(color); + Color arc(P p) => + calibrating || estimated || provisional ? p.ink3 : p.on(color); Color ink(P p) => measured && !provisional ? p.on(color) : p.ink3; String get spoken => [ label, - measured ? value : value.toLowerCase(), + showsNumber ? value : value.toLowerCase(), if (sub.isNotEmpty) sub, ?why, ].join('. '); } -_RingState _ringOf(HomeRingKind k, HomeData d, AppLocalizations? l) { +_RingState _ringOf( + HomeRingKind k, HomeData d, AppLocalizations? l, DateTime now) { switch (k) { case HomeRingKind.recovery: final v = d.readiness.value; @@ -1045,7 +1381,9 @@ _RingState _ringOf(HomeRingKind k, HomeData d, AppLocalizations? l) { LucideIcons.batteryCharging, band.color, value: '${v.round()}', sub: l?.homeRecoveryFinishingUp ?? 'Finishing up', - frac: v / 100, + // Recovery is a bounded 0-100 score: out of range is bad data, + // not overflow, so it never spirals. + frac: (v / 100).clamp(0.0, 1.0), provisional: true); } return v == null @@ -1069,7 +1407,7 @@ _RingState _ringOf(HomeRingKind k, HomeData d, AppLocalizations? l) { '') : _RingState(k, l?.homeRingRecovery ?? 'Recovery', LucideIcons.batteryCharging, band.color, - value: '${v.round()}', sub: band.label, frac: v / 100); + value: '${v.round()}', sub: band.label, frac: (v / 100).clamp(0.0, 1.0)); case HomeRingKind.strain: final v = d.strain.value; // 0–21 is the scale's own ceiling, not a target invented here. @@ -1077,24 +1415,102 @@ _RingState _ringOf(HomeRingKind k, HomeData d, AppLocalizations? l) { ? _gap(k, l?.homeRingStrain ?? 'Strain', LucideIcons.zap, C.purple, d.strain, l?.homeRingNoStrain ?? 'No strain', l, unit: 'days') : _RingState(k, l?.homeRingStrain ?? 'Strain', LucideIcons.zap, C.purple, - value: displayFixed(v, 1), sub: l?.homeStrainOf21 ?? 'of 21', frac: v / 21); + value: displayFixed(v, 1), sub: l?.homeStrainOf21 ?? 'of 21', + // Bounded 0-21 scale: out of range is bad data, not overflow. + frac: (v / 21).clamp(0.0, 1.0), + ); case HomeRingKind.sleep: - final v = d.sleepMin.value; - final need = d.sleepNeedMin.value; - return v == null - ? _gap(k, l?.homeRingSleep ?? 'Sleep', LucideIcons.moon, C.blue, - d.sleepMin, l?.homeRingNoSleep ?? 'No sleep', l, - fallbackWhy: l?.homeSleepGapFallback ?? - 'No night long enough to score was recorded.') - : _RingState(k, l?.homeRingSleep ?? 'Sleep', LucideIcons.moon, C.blue, - value: hm(v), - // No computed need means no denominator. The hardcoded 480 in - // the sleep bundle is not this user's need and must never be - // shown as one, so the ring stays open and says so. - sub: need == null - ? (l?.homeSleepNoTarget ?? 'No target yet') - : (l?.homeOfSpan(hm(need)) ?? 'of ${hm(need)}'), - frac: need == null || need <= 0 ? null : v / need); + final label = l?.homeRingSleep ?? 'Sleep'; + // A baseline still filling is progress, not an absence, and is drawn + // exactly as before: dashed, ahead of any estimate. + if (d.sleepMin.value == null && + baselineCountsFromNote(d.sleepMin.note) != null) { + return _gap(k, label, LucideIcons.moon, C.blue, d.sleepMin, + l?.homeRingNoSleep ?? 'No sleep', l); + } + final m = sleepRingModel( + now: now, + durationMin: d.sleepMin.value, + stages: d.sleepStages, + needMin: d.sleepNeedMin.value, + coachBedtimeMinOfDay: d.bedtime.value, + nextAlarm: d.nextAlarm, + learnedOnsetMin: d.learnedOnsetMin, + learnedWakeMin: d.learnedWakeMin, + cycleLenMin: d.cycleLenMin, + ); + switch (m.phase) { + case SleepRingPhase.slept: + final target = hm(m.targetMin); + return _RingState(k, label, LucideIcons.moon, C.blue, + value: hm(m.asleepMin), + // The target is the learned need, or the 8 h default — and the + // default says so. It is never passed off as this user's need. + sub: m.targetIsDefault + ? (l?.homeSleepPctOfDefault(m.pct!, target) ?? + '${m.pct}% of $target (default)') + : (l?.homeSleepPctOf(m.pct!, target) ?? + '${m.pct}% of $target'), + alts: m.targetIsDefault + ? [ + l?.homeSleepPctDefaultCompact( + m.pct!, _compactHm(m.targetMin, l)) ?? + '${m.pct}% of ${_compactHm(m.targetMin, l)} · default target', + l?.homeSleepPctDefaultTight( + m.pct!, _compactHm(m.targetMin, l)) ?? + '${m.pct}% of default ${_compactHm(m.targetMin, l)}', + ] + : [ + l?.homeSleepPctCompact( + m.pct!, _compactHm(m.targetMin, l)) ?? + '${m.pct}% of ${_compactHm(m.targetMin, l)}', + ], + frac: m.sweep, + arcs: m.arcs); + case SleepRingPhase.estimate: + final est = hm(m.estimateMin); + return _RingState(k, label, LucideIcons.moon, C.blue, + value: est, + // The ring is filled against the target, so the default says + // so here too — it is never passed off as this user's need. + sub: m.targetIsDefault + ? (l?.homeSleepOnTrackDefault( + m.cycles!, est, hm(m.targetMin)) ?? + 'on track for ${m.cycles} ' + '${m.cycles == 1 ? 'cycle' : 'cycles'}, $est' + ' · target ${hm(m.targetMin)} (default)') + : (l?.homeSleepOnTrack(m.cycles!, est) ?? + 'on track for ${m.cycles} ' + '${m.cycles == 1 ? 'cycle' : 'cycles'}, $est'), + // "~" marks an estimate in every compact form. + alts: m.targetIsDefault + ? [ + l?.homeSleepEstimateDefaultCompact( + m.cycles!, _compactHm(m.estimateMin, l)) ?? + '~${m.cycles} cycles · ${_compactHm(m.estimateMin, l)} · default target', + l?.homeSleepEstimateDefaultTight( + m.cycles!, _compactHm(m.estimateMin, l)) ?? + '~${m.cycles} cycles · ${_compactHm(m.estimateMin, l)} · default goal', + // When even the tight wording needs more than three + // lines: drop the duration, never the qualifiers. + l?.homeSleepEstimateDefaultMin(m.cycles!) ?? + '~${m.cycles} cycles · default goal', + ] + : [ + l?.homeSleepEstimateCompact( + m.cycles!, _compactHm(m.estimateMin, l)) ?? + '~${m.cycles} cycles · ${_compactHm(m.estimateMin, l)}', + l?.homeSleepEstimateBare(m.cycles!) ?? '~${m.cycles} cycles', + ], + frac: m.sweep, + estimated: true); + case SleepRingPhase.none: + return _gap(k, label, LucideIcons.moon, C.blue, d.sleepMin, + l?.homeSleepNoEstimate ?? 'No estimate', l, + fallbackWhy: l?.homeSleepNoEstimateWhy ?? + 'No sleep scored yet, and no alarm or typical wake time ' + 'to estimate from.'); + } } } @@ -1125,7 +1541,28 @@ _RingState _gap(HomeRingKind k, String label, IconData icon, Color color, why: whyFromNote(m.note, unit: unit) ?? (fallbackWhy.isNotEmpty ? fallbackWhy - : (l?.homeGapNoReason ?? 'Nothing recorded says why this is missing.'))); + : (l?.homeGapNoReason ?? 'No reading.'))); +} + +/// The scored night split by stage, in the hypnogram's own colours — or null +/// when the night is one solid arc (stage totals absent) or not a night. +List? _stageArcs(_RingState r, P p) { + final arcs = r.arcs; + if (arcs == null || arcs.every((a) => a.kind == SleepArcKind.solid)) { + return null; + } + final cols = Hypnogram.cols(p); + return [ + for (final a in arcs) + RingArc( + cols[switch (a.kind) { + SleepArcKind.deep => SleepStage.deep, + SleepArcKind.rem => SleepStage.rem, + SleepArcKind.light => SleepStage.light, + _ => SleepStage.awake, + }]!, + a.fraction), + ]; } /// The dial itself. An empty [frac] draws the track and nothing else — which is @@ -1150,14 +1587,24 @@ class _Dial extends StatelessWidget { // "6 of 14" draws as 14 divisions with 6 filled. ? DashedRing(r.frac ?? 0, r.arc(p), p.track, stroke: stroke, segments: r.need ?? 24) + : _stageArcs(r, p) != null + ? StageRing(_stageArcs(r, p)!, p.track, + stroke: stroke, t: animate(c, 1), background: p.card) : Ring(r.frac ?? 0, r.arc(p), p.track, - stroke: stroke, t: animate(c, 1), solid: r.measured), + stroke: stroke, + t: animate(c, 1), + solid: r.measured, + background: p.card), ), Icon(r.icon, size: icon, color: r.ink(p)), ]); } } +/// The default: three across, the number under the ring rather than inside it. +/// Inside is where a duration overflows its own circle at the first +/// accessibility step, and nothing about "7h 45m" gets shorter. + /// The default: three across, the number under the ring rather than inside it. /// Inside is where a duration overflows its own circle at the first /// accessibility step, and nothing about "7h 45m" gets shorter. @@ -1211,6 +1658,78 @@ class _RingRow extends StatelessWidget { ); } +/// The caption's render settings, resolved once from the context so the text +/// that is MEASURED and the Text that is BUILT can not disagree: the same +/// DefaultTextStyle merge, bold-text setting, locale, scaler and width basis. +class _CaptionSpec { + final TextStyle style; + final TextAlign align; + final int? maxLines; + final TextScaler scaler; + final Locale? locale; + final ui.TextDirection dir; + final TextWidthBasis basis; + final TextHeightBehavior? heightBehavior; + + const _CaptionSpec._(this.style, this.align, this.maxLines, this.scaler, + this.locale, this.dir, this.basis, this.heightBehavior); + + factory _CaptionSpec.of(BuildContext c, Color color, TextAlign align, + int? maxLines) { + final d = DefaultTextStyle.of(c); + var st = d.style.merge(F.cap.copyWith(color: color)); + if (MediaQuery.boldTextOf(c)) { + st = st.merge(const TextStyle(fontWeight: FontWeight.bold)); + } + return _CaptionSpec._( + st, + align, + maxLines, + MediaQuery.textScalerOf(c), + Localizations.maybeLocaleOf(c), + Directionality.of(c), + d.textWidthBasis, + d.textHeightBehavior); + } + + bool fits(String text, double width) { + final tp = TextPainter( + text: TextSpan(text: text, style: style), + textAlign: align, + textDirection: dir, + textScaler: scaler, + locale: locale, + maxLines: maxLines, + textWidthBasis: basis, + textHeightBehavior: heightBehavior, + )..layout(maxWidth: width); + final ok = !tp.didExceedMaxLines; + tp.dispose(); + return ok; + } + + double height(String text, double width) { + final tp = TextPainter( + text: TextSpan(text: text, style: style), + textAlign: align, + textDirection: dir, + textScaler: scaler, + locale: locale, + textWidthBasis: basis, + textHeightBehavior: heightBehavior, + )..layout(maxWidth: width); + final h = tp.height; + tp.dispose(); + return h; + } + + Text build(String text, {bool bounded = true}) => Text(text, + style: style, + textAlign: align, + maxLines: bounded ? maxLines : null, + overflow: TextOverflow.clip); +} + class _RingText extends StatelessWidget { final _RingState r; final TextAlign align; @@ -1218,7 +1737,10 @@ class _RingText extends StatelessWidget { const _RingText(this.r, {required this.align}); @override - Widget build(BuildContext c) { + Widget build(BuildContext c) => LayoutBuilder( + builder: (c, box) => _build(c, box.maxWidth)); + + Widget _build(BuildContext c, double colWidth) { final p = P.of(c); final cross = align == TextAlign.center ? CrossAxisAlignment.center @@ -1227,79 +1749,81 @@ class _RingText extends StatelessWidget { Text(r.label.toUpperCase(), style: F.over.copyWith(color: p.ink3), textAlign: align), const SizedBox(height: S.x1), - // Absent reads as words, never as a dash and never as a zero — so it + // Absent reads as words, never as a dash and never as a zero, so it // takes the sentence weight rather than the numeral one. Text( presentationText(AppLocalizations.of(c), r.value), - style: r.measured + style: r.showsNumber ? F.n24.copyWith(color: r.provisional ? p.ink3 : p.ink) : F.body.copyWith(color: p.ink2), textAlign: align, ), - if (r.sub.isNotEmpty) ...[ - const SizedBox(height: 2), - Text( - presentationText(AppLocalizations.of(c), r.sub), - style: F.cap.copyWith(color: p.ink3), - textAlign: align, - ), - ], + // No reserved caption lines: an empty caption takes no space, so the + // card has no blank band under the rings. + if (r.sub.isNotEmpty) const SizedBox(height: 2), + if (r.sub.isNotEmpty) + LayoutBuilder(builder: (c, box) { + final l = AppLocalizations.of(c); + // The two-line bound is the centred column's; the big-text + // row has the width it needs and wraps without a limit. + final spec = _CaptionSpec.of(c, p.ink3, align, + align == TextAlign.center ? 3 : null); + final forms = [ + presentationText(l, r.sub), + for (final a in r.alts) presentationText(l, a), + ]; + // Measure every wording, widest first. The columns are only + // used when the worst-case tight wording fits them + // (the tightest wording is expected to fit three lines). + for (final f in forms) { + if (spec.maxLines == null || spec.fits(f, box.maxWidth)) { + return spec.build(f); + } + } + // Past even the tightest wording (320 pt at 1.3x in es, de, + // ru): show it whole on extra lines — never cut a qualifier. + return spec.build(forms.last, bounded: false); + }), ], ); } } -/// WHY a ring is empty, on the row that also opens the screen which can say -/// more about it. The three parts of a [StatusCard] — what is missing, why, -/// what to do about it — at the size a home screen can afford to spend on an -/// absence. -class _GapRow extends StatelessWidget { - final _RingState r; - final VoidCallback? onTap; - const _GapRow(this.r, {this.onTap}); +// ═══════════════════ the screen ═══════════════════ - @override - Widget build(BuildContext c) { - final p = P.of(c); - return Pressable( - onTap: onTap, - child: Padding( - padding: const EdgeInsets.symmetric(vertical: S.x2), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon(r.icon, size: 15, color: p.ink3), - const SizedBox(width: S.x2), - Expanded( - child: Text.rich( - TextSpan( - children: [ - TextSpan( - text: '${r.label} · ', - style: F.cap.copyWith( - color: p.ink2, fontWeight: FontWeight.w600)), - TextSpan( - text: presentationText( - AppLocalizations.of(c), - r.why ?? '', - ), - style: F.cap.copyWith(color: p.ink3), - ), - ], - ), - ), - ), - const SizedBox(width: S.x2), - Icon(LucideIcons.chevronRight, size: 15, color: p.ink3), - ], - ), - ), - ); - } +/// The night's stage totals off a `getDaySleepV2` map, or null when it carries +/// none of them. Individual stages stay null when the pipeline had no figure — +/// the ring splits only when Deep, REM and Light are all there. +SleepStageMin? sleepStagesOf(Map night) { + int? m(String k) => (night[k] as num?)?.round(); + final s = SleepStageMin( + deep: m('deep_min'), + rem: m('rem_min'), + light: m('light_min'), + awake: m('awake_min')); + return s.deep == null && s.rem == null && s.light == null && s.awake == null + ? null + : s; } -// ═══════════════════ the screen ═══════════════════ +/// The learned sleep need and the coach bedtime out of `getInsights()`, shared +/// by every loader so a dated view cannot forget them (it did, and drew an +/// empty bar under a night that had a need). +({Metric need, Metric bedtime}) sleepCoachOf(Map insights) { + final coach = insights['sleep_coach']; + final needEnv = coach is Map ? coach['need'] : null; + final bedEnv = coach is Map ? coach['bedtime'] : null; + final needSec = envValue(needEnv)?['need_sec'] as num?; + return ( + // sleep_coach.need is the COMPUTED need. `sleep.need_min` is a hardcoded + // 480 and must never be shown as "your sleep need". + need: envMetric(needEnv, needSec == null ? null : needSec / 60, + unit: 'min'), + bedtime: envMetric( + bedEnv, envValue(bedEnv)?['bedtime_min_of_day'] as num?), + ); +} class HomeData { final String? name; @@ -1358,11 +1882,24 @@ class HomeData { /// `{from, to, at}` when today's final recovery changed after it was shown. final Map? readinessUpdate; - // RED-PHASE STUBS (Home + labels): inputs the sleep ring needs. Not yet read - // by any loader or widget. + /// The scored night's stage totals (`getDaySleepV2`): the sleep ring splits + /// into Deep / REM / Light / Awake arcs when all of the first three are + /// there. Null for a night that did not score, and never borrowed from + /// another night. final SleepStageMin? sleepStages; + + /// The next armed alarm. It lives on AppState, not in the repository, so the + /// screen lays it over what loaded ([withSleepInputs]). final DateTime? nextAlarm; - final int? learnedOnsetMin, learnedWakeMin, cycleLenMin; + + /// The median local clock minute of the person's recent onsets and wakes + /// (`learnedClockMinutes`), the unslept estimate's fallback sources. Null + /// until there are enough nights. + final int? learnedOnsetMin, learnedWakeMin; + + /// A per-user sleep-cycle length in minutes. Nothing sets it yet; the model + /// falls back to 90. + final int? cycleLenMin; const HomeData({ this.name, @@ -1397,7 +1934,29 @@ class HomeData { /// The three illness fields, replaced together. Test-facing sugar, and they /// travel as a set on purpose — they are read as one envelope, and setting /// one without the others describes a state the pipeline cannot produce. - HomeData copyOrIllness(String? state, String? day, double? z) => HomeData( + HomeData copyOrIllness(String? state, String? day, double? z) => _copy( + illnessState: state, + illnessDay: day, + illnessZ: z, + nextAlarm: nextAlarm, + ); + + /// The same data with the armed alarm replaced: it lives on AppState, not in + /// the repository, so the screen lays it over what loaded. + HomeData withSleepInputs({DateTime? alarm}) => _copy( + illnessState: illnessState, + illnessDay: illnessDay, + illnessZ: illnessZ, + nextAlarm: alarm, + ); + + HomeData _copy({ + required String? illnessState, + required String? illnessDay, + required double? illnessZ, + required DateTime? nextAlarm, + }) => + HomeData( name: name, dayId: dayId, readiness: readiness, @@ -1412,10 +1971,15 @@ class HomeData { sleepNeedMin: sleepNeedMin, bedtime: bedtime, strainTarget: strainTarget, + sleepStages: sleepStages, + nextAlarm: nextAlarm, + learnedOnsetMin: learnedOnsetMin, + learnedWakeMin: learnedWakeMin, + cycleLenMin: cycleLenMin, heldOverNight: heldOverNight, - illnessState: state, - illnessDay: day, - illnessZ: z, + illnessState: illnessState, + illnessDay: illnessDay, + illnessZ: illnessZ, insightsStale: insightsStale, absentDiag: absentDiag, recoveryState: recoveryState, @@ -1449,6 +2013,16 @@ class HomeData { final absentDiag = readiness.value != null ? null : await LocalDb.readinessAbsentDiag(date); + // The learned need: the day's own when the night carries one, otherwise + // the current one off the coach, like [load]. A failed read leaves it + // absent, and the ring then shows the labelled default, never an invented + // need. + var need = metricOf(sleep['need_min']); + if (need.value == null) { + try { + need = sleepCoachOf(await repo.getInsights()).need; + } catch (_) {} + } return HomeData( name: profile['name']?.toString(), dayId: date, @@ -1460,10 +2034,10 @@ class HomeData { calories: metricOf(strain['calories']), caloriesTotal: metricOf(strain['calories_total']), sleepMin: metricOf(sleep['duration_min']), - // The learned need `getDaySleepV2` already carries (absent until one is - // learned). Left unset, the ring told a user who has a need that there - // was "No target yet" on every day but today. - sleepNeedMin: metricOf(sleep['need_min']), + // Left unset, the ring told a user who has a need that there was "No + // target yet" on every day but today. + sleepNeedMin: need, + sleepStages: sleepStagesOf(sleep), stepGoal: (profile['step_goal'] as num?)?.toInt() ?? kDefaultStepGoal, ); } @@ -1490,6 +2064,31 @@ class HomeData { final heldOver = heldOverNightOf(today); + final status = today['status'] as Map?; + final sleepMin = overnightMetric(today, s('duration_min'), l); + // Stage totals of TODAY'S OWN night only: a held-over night is refused + // above, and its stages must not turn up under today's ring. + SleepStageMin? stages; + if (sleepMin.value != null) { + final night = + (status?['overnight_day'] ?? status?['today_day'])?.toString(); + if (night != null) { + try { + stages = sleepStagesOf(await repo.getDaySleepV2(night)); + } catch (_) { + stages = null; + } + } + } + + // Typical onset/wake for the unslept estimate. The armed alarm is + // AppState's and is laid over this by the screen ([withSleepInputs]); a + // failed read here is simply absent. + ({int? onsetMin, int? wakeMin}) learned = (onsetMin: null, wakeMin: null); + try { + learned = learnedClockMinutes(await repo.sleepWindows(days: 14)); + } catch (_) {} + // Same envelope Health reads. The watch runs on NOCTURNAL RESTING HEART // RATE ALONE — it has never been given a temperature series — so nothing // here may imply a second signal. @@ -1527,7 +2126,10 @@ class HomeData { if (e is Map) e.cast(), ], strain: metricOf(d('strain')), - sleepMin: overnightMetric(today, s('duration_min'), l), + sleepMin: sleepMin, + sleepStages: stages, + learnedOnsetMin: learned.onsetMin, + learnedWakeMin: learned.wakeMin, rhr: overnightMetric(today, d('resting_hr'), l), steps: metricOf(d('steps')), calories: metricOf(d('calories')), @@ -1660,14 +2262,36 @@ class _HomeScreenState extends State with RevisionReload { }); } + /// Re-renders on each whole minute, so the unslept estimate (which counts + /// down to the wake) follows the clock while the screen stays open. It + /// rides the same lifecycle as the rest of this screen: stopped while the + /// app is hidden, re-armed and refreshed on the way back. + Timer? _tick; + + void _armTick() { + _tick?.cancel(); + _tick = Timer(nextMinuteTickDelay(pc.clock.now()), () { + if (!mounted) return; + setState(() {}); + _armTick(); + }); + } + + void _stopTick() { + _tick?.cancel(); + _tick = null; + } + @override void dispose() { _syncTapTimer?.cancel(); + _stopTick(); _lifecycle?.dispose(); super.dispose(); } void _onHide() { + _stopTick(); _dayAtHide = todayLabel(widget.now ?? debugHomeWallClock()); _hiddenAt = debugHomeWallClock(); _away @@ -1692,6 +2316,8 @@ class _HomeScreenState extends State with RevisionReload { /// for good if the read failed, since the error card only shows with no day. void _onReturnFromBackground() { if (!mounted) return; + _armTick(); + setState(() {}); final hiddenAt = _hiddenAt; final wall = hiddenAt == null ? Duration.zero @@ -1720,6 +2346,7 @@ class _HomeScreenState extends State with RevisionReload { @override void initState() { super.initState(); + _armTick(); if (widget.data != null) { _d = widget.data; _loading = false; @@ -1869,8 +2496,7 @@ class _HomeScreenState extends State with RevisionReload { d.heldOverNight == null ? (l?.homeNothingDerivedBody ?? 'Wear the band tonight and open the app in the morning.') : (l?.homeNothingTodayBody(prettyDay(d.heldOverNight, l)) ?? - 'The last night this app scored was ' - '${prettyDay(d.heldOverNight, l)}. Nothing has reached it since.'), + 'Last scored night: ${prettyDay(d.heldOverNight, l)}.'), fix: sync == null ? '' : (l?.homeSyncBand ?? 'Sync the band'), icon: LucideIcons.watch, onFix: sync == null ? null : () => _tapSync(sync), @@ -1980,7 +2606,7 @@ class _HomeScreenState extends State with RevisionReload { // them apart: whether this install has ever scored a night. "No band // recordings processed yet" said to someone with three months of history is // the first-run answer to a gap, and it is wrong. - final bare = d.readiness.isEmpty && + final noData = d.readiness.isEmpty && d.sleepMin.isEmpty && d.strain.isEmpty && d.rhr.isEmpty && @@ -1992,6 +2618,16 @@ class _HomeScreenState extends State with RevisionReload { // today and reads as a stale instruction on a day already in the past. final isToday = _day == null || _day == todayLabel(); + // The armed alarm is AppState's, and only TODAY's rings estimate toward it: + // a past day with no scored sleep must not claim to be "on track" (the + // loaders never set the estimate inputs for a dated day either). + final ringData = + isToday ? d.withSleepInputs(alarm: nextAlarmOf(c) ?? d.nextAlarm) : d; + // A day with no measured data is not bare when tonight's estimate can be + // made: an armed alarm or typical sleep times are something to show. + final bare = noData && + !(isToday && RingTrio.hasSleepEstimate(ringData, now: widget.now)); + // Until recovery can score for the first time: what arrives when. // Also while the readiness note still counts nights: missed nights push // the first score past the day-15 the count alone would show. @@ -2117,9 +2753,10 @@ class _HomeScreenState extends State with RevisionReload { // diagnostic used to go nowhere but a Firebase breadcrumb. It belongs // one tap away, on the Readiness screen: a wall of per-input // diagnostics on Home makes the app read as broken. - if (RingTrio.has(d)) + if (RingTrio.has(ringData, now: widget.now)) RingTrio( - d: d, + d: ringData, + now: widget.now, onOpen: (k) => go( c, switch (k) { @@ -2161,7 +2798,7 @@ class _HomeScreenState extends State with RevisionReload { // is what actually answers it. : shortfall ?? whyFromNote(d.readiness.note) ?? - (l?.homeReadinessNoReason ?? 'Nothing recorded says why.'), + (l?.homeReadinessNoReason ?? 'No reading.'), fix: l?.homeSeeWhatWasMissing ?? 'See what was missing', icon: LucideIcons.batteryCharging, onFix: () => @@ -2254,7 +2891,6 @@ class _HomeScreenState extends State with RevisionReload { final sameNight = day == today; final z = d.illnessZ; - final zAbs = z == null ? '' : z.abs().toStringAsFixed(1); return [ Observation( @@ -2266,12 +2902,12 @@ class _HomeScreenState extends State with RevisionReload { '${prettyDay(d.illnessDay, l)} sat outside your normal range'), z == null ? (l?.homeIllnessBodyNoZ ?? - 'Your resting heart rate has run above your usual over recent nights. This tracks one signal, so it shows a pattern, not a cause.') + 'Your resting heart rate has been above your usual over recent nights. A pattern, not a cause.') : (z >= 0 - ? (l?.homeIllnessBodyAbove(zAbs) ?? - 'Recent nights put your resting heart rate above your usual. That night was $zAbs× your normal night-to-night swing above it. A pattern, not a cause.') - : (l?.homeIllnessBodyBelow(zAbs) ?? - 'Recent nights together put your resting heart rate above your usual, even though that night on its own was $zAbs× your normal night-to-night swing below it. A pattern, not a cause.')), + ? (l?.homeIllnessBodyAbove ?? + 'Recent nights put your resting heart rate above your usual. That night was above it too. A pattern, not a cause.') + : (l?.homeIllnessBodyBelow ?? + 'Recent nights together put your resting heart rate above your usual, though that night alone was below it. A pattern, not a cause.')), advice: l?.homeIllnessAdvice ?? 'Worth noting if it continues past a couple of days.', onTap: () => go(c, const MetricDetail('resting_hr')), ), @@ -2698,7 +3334,7 @@ Widget coachChip(BuildContext c) { final ready = coachReady(c); final text = ready ? (l?.homeAskCoach ?? 'Ask the coach') - : (l?.homeCoachSetUpChip ?? 'Ask questions about your data: set up the coach'); + : (l?.homeCoachSetUpChip ?? 'Set up the coach'); return Align( alignment: Alignment.centerLeft, child: Pressable( diff --git a/test/home_day_switch_decode_once_test.dart b/test/home_day_switch_decode_once_test.dart index 1b1d2a9a7..41b2e0936 100644 --- a/test/home_day_switch_decode_once_test.dart +++ b/test/home_day_switch_decode_once_test.dart @@ -10,6 +10,7 @@ import 'package:path/path.dart' as p; import 'package:sqflite_common_ffi/sqflite_ffi.dart'; import 'package:openstrap_edge/compute/derivation_engine.dart' show kAlgoVersion; import 'package:openstrap_edge/data/db.dart'; +import 'package:openstrap_edge/data/local_repository.dart'; import 'package:openstrap_edge/data/local_repository_impl.dart'; import 'package:openstrap_edge/ui2/screens/home_screen.dart' show HomeData; @@ -76,4 +77,85 @@ void main() { await repo.getDayStrain(day); expect(LocalRepositoryImpl.bundleDecodes - before, 2); }); + + group('HomeData.load keeps its extra reads minimal', () { + Map today({required bool slept}) => { + 'daily': { + 'readiness': {'value': 80, 'confidence': .8, 'tier': 'HIGH'}, + }, + 'sleep': slept + ? { + 'duration_min': { + 'value': 442, + 'confidence': .8, + 'tier': 'ESTIMATE', + 'unit': 'min', + }, + } + : const {}, + 'status': { + 'today_day': '2026-10-09', + 'overnight_state': 'ready', + 'overnight_day': '2026-10-09', + 'showing_prior_overnight': false, + }, + }; + + test('one stage read (only for a scored night) and one windows read', + () async { + final scored = _CountingRepo(today(slept: true)); + await HomeData.load(scored); + expect(scored.nightReads, 1); + expect(scored.windowReads, 1); + expect(scored.insightReads, 1); + + // No scored night: no stage read at all, and nothing borrowed. + final bare = _CountingRepo(today(slept: false)); + final d = await HomeData.load(bare); + expect(bare.nightReads, 0); + expect(bare.windowReads, 1); + expect(d.sleepStages, isNull); + }); + + test('a failed stage or windows read leaves the figure absent, not a ' + 'failed Home', () async { + final repo = _CountingRepo(today(slept: true), throwing: true); + final d = await HomeData.load(repo); + expect(d.sleepStages, isNull); + expect(d.learnedOnsetMin, isNull); + expect(d.sleepMin.value, 442); + }); + }); +} + +class _CountingRepo extends LocalRepository { + _CountingRepo(this._today, {this.throwing = false}); + final Map _today; + final bool throwing; + int nightReads = 0, windowReads = 0, insightReads = 0; + + @override + Future> getToday() async => _today; + @override + Future> getInsights() async { + insightReads++; + return const {}; + } + + @override + Future> getProfile() async => const {}; + @override + Future> getDaySleepV2(String date) async { + nightReads++; + if (throwing) throw StateError('read failed'); + return {'has_sleep': true, 'deep_min': 70, 'rem_min': 95, 'light_min': 277}; + } + + @override + Future>> sleepWindows( + {int days = 60, String? before}) async { + windowReads++; + if (throwing) throw StateError('read failed'); + return const []; + } } diff --git a/test/home_labels_test.dart b/test/home_labels_test.dart index ba97c3560..9bf475b4b 100644 --- a/test/home_labels_test.dart +++ b/test/home_labels_test.dart @@ -173,7 +173,7 @@ void main() { for (final k in pluralKeys) { final v = j[k]; if (v is! String) fail('$k missing in $lang'); - final one = RegExp(r'one\{([^}]*)\}').firstMatch(v)?.group(1) ?? ''; + final one = RegExp(r'one\{((?:[^{}]|\{[^}]*\})*)\}').firstMatch(v)?.group(1) ?? ''; expect(one, contains('{cycles}'), reason: '$k ($lang) one-branch hard-codes the count: "$one"'); } diff --git a/test/home_ring_card_test.dart b/test/home_ring_card_test.dart index 9d67d677c..8cb651b21 100644 --- a/test/home_ring_card_test.dart +++ b/test/home_ring_card_test.dart @@ -33,7 +33,11 @@ // home-update-note / home-update-note-icon // home-note:gap: an absent ring's reason row +import 'dart:io'; +import 'dart:typed_data'; + import 'package:flutter/material.dart'; +import 'package:flutter/services.dart' show FontLoader; import 'package:flutter/rendering.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; @@ -527,6 +531,24 @@ void main() { // ── 5. the sleep caption in every language ──────────────────────────────── group('sleep caption', () { + // The default test font is a block face one em wide per glyph, which no + // wording could fit into three lines of a 100 pt column. Measure with the + // fonts the app ships. + setUpAll(() async { + final fonts = Directory('assets/fonts/Manrope') + .listSync() + .whereType() + .where((f) => f.path.endsWith('.ttf')); + for (final family in ['Manrope', '.SF Pro Text']) { + final loader = FontLoader(family); + for (final file in fonts) { + loader.addFont(file.readAsBytes().then( + (bytes) => ByteData.sublistView(Uint8List.fromList(bytes)))); + } + await loader.load(); + } + }); + // A scored night against the 8 h default: 442 of 480 min is 92%. HomeData slept() => HomeData( dayId: '2026-10-04', @@ -549,6 +571,11 @@ void main() { expect(para.didExceedMaxLines, isFalse, reason: '$lang caption is cut by its line limit'); // Three lines is the reserved caption height; more would shift the card. + // Thai has no glyphs in the shipped test font, so its words measure as + // unbreakable one-em blocks, wider than any real Thai face: the line + // bound is meaningless there, and the caption must at least print its + // tightest wording whole (checked above: no line-limit cut). + if (lang == 'th') return; final fs = para.text.style?.fontSize ?? 12; expect(para.size.height / (fs * 1.3 * 1.5), lessThanOrEqualTo(3.01)); }); diff --git a/test/home_sleep_ring_widget_test.dart b/test/home_sleep_ring_widget_test.dart index be7407e6b..d10882d96 100644 --- a/test/home_sleep_ring_widget_test.dart +++ b/test/home_sleep_ring_widget_test.dart @@ -437,7 +437,8 @@ void main() { group('HomeScreen lays AppState\'s armed alarm over the loaded data', () { - Future pumpHome(WidgetTester t, AppState app) async { + Future pumpHome(WidgetTester t, AppState app, + {HomeData? data}) async { t.view.physicalSize = const Size(390 * 3, 1600 * 3); t.view.devicePixelRatio = 3; addTearDown(t.view.reset); @@ -451,7 +452,7 @@ void main() { body: HomeScreen( hour: 22, now: _evening, - data: _unslept(typical: true), + data: data ?? _unslept(typical: true), ), ), ), @@ -483,7 +484,8 @@ void main() { (t) async { final app = AppState.forTesting(); addTearDown(app.dispose); - await pumpHome(t, app); + // No learned times either: nothing at all to estimate a wake from. + await pumpHome(t, app, data: _unslept()); // An absence is a dash in the ring; the reason is a row under the rings. final gap = find.byKey(const ValueKey('home-note:gap:sleep')); expect(find.descendant(of: gap, matching: find.text('Sleep')), findsOneWidget); @@ -528,7 +530,10 @@ void main() { final app = AppState.forTesting(); addTearDown(app.dispose); await pumpBare(t, app); - expect(find.textContaining('No nights scored yet'), findsOneWidget); + // The status card (here the unpaired install's "No band is paired"; a + // paired one reads "No nights scored yet"), never three empty rings. + expect(find.byType(RingTrio), findsNothing); + expect(find.byType(StatusCard), findsWidgets); expect(find.text('No estimate'), findsNothing); }); }); diff --git a/test/ui2_home_day_switcher_test.dart b/test/ui2_home_day_switcher_test.dart index e735ff28a..a247c96b2 100644 --- a/test/ui2_home_day_switcher_test.dart +++ b/test/ui2_home_day_switcher_test.dart @@ -63,6 +63,30 @@ class _Repo extends LocalRepository { {'has_sleep': true, 'duration_min': 250}; } +/// Nothing scored on either day, but five nights of typical times. +class _NoSleepRepo extends _Repo { + @override + Future> getToday() async => { + ...await super.getToday(), + 'sleep': const {}, + }; + @override + Future> getDaySleepV2(String date) async => const {}; + @override + Future>> sleepWindows( + {int days = 60, String? before}) async => + [ + for (var day = 1; day <= 5; day++) + { + 'date': '2026-10-0$day', + 'onset_ts': DateTime(2026, 10, day - 1, 23).millisecondsSinceEpoch ~/ + 1000, + 'wake_ts': + DateTime(2026, 10, day, 7).millisecondsSinceEpoch ~/ 1000, + }, + ]; +} + Future _settle(WidgetTester t, {int n = 40}) async { for (var i = 0; i < n; i++) { await t.pump(const Duration(milliseconds: 20)); @@ -140,6 +164,38 @@ void main() { expect(find.text('70'), findsOneWidget); }); + testWidgets( + 'a past day with no scored sleep never claims tonight\'s "on track" ' + '(the estimate belongs to today)', (t) async { + final app = AppState.forTesting(); + addTearDown(app.dispose); + app.repo = _NoSleepRepo(); + + await t.pumpWidget(MaterialApp( + theme: buildTheme(Brightness.light), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: ChangeNotifierProvider.value( + value: app, + child: const Scaffold(body: HomeScreen(hour: 9)), + ), + )); + await _settle(t); + // Control: today, unslept, with typical times, shows the estimate. + expect(find.textContaining('cycles'), findsOneWidget); + + await t.tap(find.bySemanticsLabel('Previous day')); + await _settle(t); + expect(find.text('42'), findsOneWidget, reason: 'yesterday is on screen'); + expect(find.textContaining('on track for'), findsNothing); + expect(find.textContaining('cycles'), findsNothing); + expect(find.text('No estimate'), findsOneWidget); + + await t.tap(find.bySemanticsLabel('Next day')); + await _settle(t); + expect(find.textContaining('cycles'), findsOneWidget); + }); + test('a past day is kept only for a short absence on the same day', () { const day = '2026-10-31'; expect( @@ -410,6 +466,10 @@ void main() { final tile = find.text(label); await t.scrollUntilVisible(tile, 300, scrollable: find.byType(Scrollable).first); + // The card above is taller now (inset rows), so the scroll can stop with + // the tile at the very edge of the viewport. + await t.ensureVisible(tile); + await t.pump(); await t.tap(tile); await _settle(t); diff --git a/test/ui2_wiring_r2_test.dart b/test/ui2_wiring_r2_test.dart index cf3f5163e..815c500f5 100644 --- a/test/ui2_wiring_r2_test.dart +++ b/test/ui2_wiring_r2_test.dart @@ -515,7 +515,7 @@ void main() { // nights back under. Printing "1.3 deviations" without a direction read // as "above your baseline, 1.3 below it". await t.pumpWidget(frame(base.copyOrIllness('red', '2026-05-20', -1.3))); - expect(find.textContaining('1.3× your normal night-to-night swing below it'), + expect(find.textContaining('though that night alone was below it'), findsOneWidget); }); @@ -850,7 +850,7 @@ void main() { testWidgets('no model: the chip says what the coach is for', (t) async { await t.pumpWidget(frame(false)); expect(find.byIcon(LucideIcons.sparkles), findsOneWidget); - expect(find.text('Ask questions about your data: set up the coach'), + expect(find.text('Set up the coach'), findsOneWidget); // Profile is the avatar every tab carries, not a gear on Home. expect(find.byType(ProfileAvatar), findsOneWidget); From 1a35f8db03d44c6e1284646e1990403f73226d98 Mon Sep 17 00:00:00 2001 From: MikenRafter Date: Sun, 8 Nov 2026 21:04:45 -0700 Subject: [PATCH 04/17] fix(home): recovery why is the plain score; single-composite rings without a background --- lib/l10n/app_de.arb | 47 ------------ lib/l10n/app_en.arb | 51 ------------- lib/l10n/app_es.arb | 47 ------------ lib/l10n/app_fr.arb | 47 ------------ lib/l10n/app_hi.arb | 47 ------------ lib/l10n/app_ru.arb | 47 ------------ lib/l10n/app_th.arb | 51 ------------- lib/l10n/app_zh.arb | 47 ------------ lib/ui2/profile/gallery.dart | 5 -- lib/ui2/screens/home_screen.dart | 49 ++---------- lib/ui2/spiral_ring.dart | 23 ++++-- test/home_labels_test.dart | 4 - test/home_ring_card_test.dart | 104 +++++++++----------------- test/ui2_spiral_ring_round3_test.dart | 18 +++++ 14 files changed, 76 insertions(+), 511 deletions(-) diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index f2d1596ef..84f1b7dda 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -1596,34 +1596,6 @@ "homeSleepNoEstimate": "Keine Schätzung", "homeSleepNoEstimateWhy": "Noch keine Nacht gewertet, und es gibt weder Wecker noch übliche Aufwachzeit, um zu schätzen.", "homeWhyRecoveryTitle": "Erholung ist niedrig", - "homeWhyRecoveryDrivers": "Größte Bremse: {a} {za}, {b} {zb} (Streuungen von deiner Basislinie).", - "@homeWhyRecoveryDrivers": { - "placeholders": { - "a": { - "type": "String" - }, - "za": { - "type": "String" - }, - "b": { - "type": "String" - }, - "zb": { - "type": "String" - } - } - }, - "homeWhyRecoveryDriver": "Größte Bremse: {a} {za} (Streuungen von deiner Basislinie).", - "@homeWhyRecoveryDriver": { - "placeholders": { - "a": { - "type": "String" - }, - "za": { - "type": "String" - } - } - }, "homeWhyRecoveryPlain": "Erholung {score} von 100", "@homeWhyRecoveryPlain": { "placeholders": { @@ -1632,25 +1604,6 @@ } } }, - "homeWhyRecoveryPullingTwo": "Zieht ihn nach unten: {a}, {b}.", - "@homeWhyRecoveryPullingTwo": { - "placeholders": { - "a": { - "type": "String" - }, - "b": { - "type": "String" - } - } - }, - "homeWhyRecoveryPullingOne": "Zieht ihn nach unten: {a}.", - "@homeWhyRecoveryPullingOne": { - "placeholders": { - "a": { - "type": "String" - } - } - }, "homeWhySleepTitle": "Schlaf reicht nicht aus", "homeWhySleepBody": "Dein bewerteter Schlaf lag unter deinem Bedarf.", "homeWhySleepDetail": "{asleep} geschlafen, {gap} unter deinem Bedarf von {need}.", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 1e810d085..94826e1fa 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -7513,36 +7513,6 @@ "@homeWhyRecoveryTitle": { "description": "Title of the Home card row that explains a low recovery score." }, - "homeWhyRecoveryDrivers": "Biggest drag: {a} {za}, {b} {zb} (how far each sits from your usual).", - "@homeWhyRecoveryDrivers": { - "description": "Body of the low-recovery row naming the two biggest drags with their distance from the person's own usual (z-scores).", - "placeholders": { - "a": { - "type": "String" - }, - "za": { - "type": "String" - }, - "b": { - "type": "String" - }, - "zb": { - "type": "String" - } - } - }, - "homeWhyRecoveryDriver": "Biggest drag: {a} {za} (how far each sits from your usual).", - "@homeWhyRecoveryDriver": { - "description": "Body of the low-recovery row naming the one biggest drag with its distance from the person's own usual (z-score).", - "placeholders": { - "a": { - "type": "String" - }, - "za": { - "type": "String" - } - } - }, "homeWhyRecoveryPlain": "Recovery {score} of 100", "@homeWhyRecoveryPlain": { "description": "Body of the low-recovery row when no input is known to drag the score: just the score out of 100.", @@ -7552,27 +7522,6 @@ } } }, - "homeWhyRecoveryPullingTwo": "Pulling it down: {a}, {b}.", - "@homeWhyRecoveryPullingTwo": { - "description": "Body of the low-recovery row naming the two inputs pulling the score down, without numbers.", - "placeholders": { - "a": { - "type": "String" - }, - "b": { - "type": "String" - } - } - }, - "homeWhyRecoveryPullingOne": "Pulling it down: {a}.", - "@homeWhyRecoveryPullingOne": { - "description": "Body of the low-recovery row naming the one input pulling the score down, without numbers.", - "placeholders": { - "a": { - "type": "String" - } - } - }, "homeWhySleepTitle": "Sleep is short of need", "@homeWhySleepTitle": { "description": "Title of the Home card row shown when sleep is well short of the person's need." diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index db37eb83c..ac0994a21 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -1549,34 +1549,6 @@ "homeSleepNoEstimate": "Sin estimación", "homeSleepNoEstimateWhy": "Aún no hay una noche puntuada y no hay alarma ni hora habitual de despertar para estimar.", "homeWhyRecoveryTitle": "La recuperación es baja", - "homeWhyRecoveryDrivers": "Mayor lastre: {a} {za}, {b} {zb} (desviaciones respecto a tu referencia).", - "@homeWhyRecoveryDrivers": { - "placeholders": { - "a": { - "type": "String" - }, - "za": { - "type": "String" - }, - "b": { - "type": "String" - }, - "zb": { - "type": "String" - } - } - }, - "homeWhyRecoveryDriver": "Mayor lastre: {a} {za} (desviaciones respecto a tu referencia).", - "@homeWhyRecoveryDriver": { - "placeholders": { - "a": { - "type": "String" - }, - "za": { - "type": "String" - } - } - }, "homeWhyRecoveryPlain": "Recuperación {score} de 100", "@homeWhyRecoveryPlain": { "placeholders": { @@ -1585,25 +1557,6 @@ } } }, - "homeWhyRecoveryPullingTwo": "Lo reduce: {a}, {b}.", - "@homeWhyRecoveryPullingTwo": { - "placeholders": { - "a": { - "type": "String" - }, - "b": { - "type": "String" - } - } - }, - "homeWhyRecoveryPullingOne": "Lo reduce: {a}.", - "@homeWhyRecoveryPullingOne": { - "placeholders": { - "a": { - "type": "String" - } - } - }, "homeWhySleepTitle": "Falta sueño", "homeWhySleepBody": "El sueño evaluado quedó por debajo de tu necesidad.", "homeWhySleepDetail": "{asleep} dormidos, {gap} menos de las {need} que necesitas.", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 4d63c674d..14a84ef17 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -1549,34 +1549,6 @@ "homeSleepNoEstimate": "Pas d'estimation", "homeSleepNoEstimateWhy": "Aucune nuit n'est encore évaluée, et il n'y a ni alarme ni heure de réveil habituelle pour estimer.", "homeWhyRecoveryTitle": "Récupération faible", - "homeWhyRecoveryDrivers": "Principal frein : {a} {za}, {b} {zb} (écarts par rapport à votre référence).", - "@homeWhyRecoveryDrivers": { - "placeholders": { - "a": { - "type": "String" - }, - "za": { - "type": "String" - }, - "b": { - "type": "String" - }, - "zb": { - "type": "String" - } - } - }, - "homeWhyRecoveryDriver": "Principal frein : {a} {za} (écarts par rapport à votre référence).", - "@homeWhyRecoveryDriver": { - "placeholders": { - "a": { - "type": "String" - }, - "za": { - "type": "String" - } - } - }, "homeWhyRecoveryPlain": "Récupération {score} sur 100", "@homeWhyRecoveryPlain": { "placeholders": { @@ -1585,25 +1557,6 @@ } } }, - "homeWhyRecoveryPullingTwo": "Ce qui la tire vers le bas : {a}, {b}.", - "@homeWhyRecoveryPullingTwo": { - "placeholders": { - "a": { - "type": "String" - }, - "b": { - "type": "String" - } - } - }, - "homeWhyRecoveryPullingOne": "Ce qui la tire vers le bas : {a}.", - "@homeWhyRecoveryPullingOne": { - "placeholders": { - "a": { - "type": "String" - } - } - }, "homeWhySleepTitle": "Sommeil insuffisant", "homeWhySleepBody": "Votre sommeil évalué était inférieur à votre besoin.", "homeWhySleepDetail": "{asleep} de sommeil, {gap} de moins que vos {need} nécessaires.", diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index c42b2eec4..41491fa16 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -1549,34 +1549,6 @@ "homeSleepNoEstimate": "कोई अनुमान नहीं", "homeSleepNoEstimateWhy": "अभी तक कोई रात स्कोर नहीं हुई है, और अनुमान के लिए कोई अलार्म या सामान्य जागने का समय नहीं है।", "homeWhyRecoveryTitle": "रिकवरी कम है", - "homeWhyRecoveryDrivers": "सबसे बड़ा असर: {a} {za}, {b} {zb} (आपकी बेसलाइन से अंतर)।", - "@homeWhyRecoveryDrivers": { - "placeholders": { - "a": { - "type": "String" - }, - "za": { - "type": "String" - }, - "b": { - "type": "String" - }, - "zb": { - "type": "String" - } - } - }, - "homeWhyRecoveryDriver": "सबसे बड़ा असर: {a} {za} (आपकी बेसलाइन से अंतर)।", - "@homeWhyRecoveryDriver": { - "placeholders": { - "a": { - "type": "String" - }, - "za": { - "type": "String" - } - } - }, "homeWhyRecoveryPlain": "रिकवरी {score}/100", "@homeWhyRecoveryPlain": { "placeholders": { @@ -1585,25 +1557,6 @@ } } }, - "homeWhyRecoveryPullingTwo": "इसे नीचे खींच रहे हैं: {a}, {b}।", - "@homeWhyRecoveryPullingTwo": { - "placeholders": { - "a": { - "type": "String" - }, - "b": { - "type": "String" - } - } - }, - "homeWhyRecoveryPullingOne": "इसे नीचे खींच रहा है: {a}।", - "@homeWhyRecoveryPullingOne": { - "placeholders": { - "a": { - "type": "String" - } - } - }, "homeWhySleepTitle": "नींद की कमी", "homeWhySleepBody": "आपकी मापी गई नींद ज़रूरत से कम थी।", "homeWhySleepDetail": "{asleep} सोए, आपकी {need} की ज़रूरत से {gap} कम।", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index a2a991e7b..d3d1b7778 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -3503,34 +3503,6 @@ "homeSleepNoEstimate": "Нет оценки", "homeSleepNoEstimateWhy": "Сон ещё не оценён, и нет будильника или обычного времени подъёма для оценки.", "homeWhyRecoveryTitle": "Низкое восстановление", - "homeWhyRecoveryDrivers": "Сильнее всего тянут вниз: {a} {za}, {b} {zb} (отклонения от вашей нормы).", - "@homeWhyRecoveryDrivers": { - "placeholders": { - "a": { - "type": "String" - }, - "za": { - "type": "String" - }, - "b": { - "type": "String" - }, - "zb": { - "type": "String" - } - } - }, - "homeWhyRecoveryDriver": "Сильнее всего тянет вниз: {a} {za} (отклонения от вашей нормы).", - "@homeWhyRecoveryDriver": { - "placeholders": { - "a": { - "type": "String" - }, - "za": { - "type": "String" - } - } - }, "homeWhyRecoveryPlain": "Восстановление {score} из 100", "@homeWhyRecoveryPlain": { "placeholders": { @@ -3539,25 +3511,6 @@ } } }, - "homeWhyRecoveryPullingTwo": "Снижают показатель: {a}, {b}.", - "@homeWhyRecoveryPullingTwo": { - "placeholders": { - "a": { - "type": "String" - }, - "b": { - "type": "String" - } - } - }, - "homeWhyRecoveryPullingOne": "Снижает показатель: {a}.", - "@homeWhyRecoveryPullingOne": { - "placeholders": { - "a": { - "type": "String" - } - } - }, "homeWhySleepTitle": "Недостаточно сна", "homeWhySleepBody": "Оценённый сон был короче вашей потребности.", "homeWhySleepDetail": "Сон {asleep}, на {gap} меньше вашей потребности {need}.", diff --git a/lib/l10n/app_th.arb b/lib/l10n/app_th.arb index d7de901bb..3bbdeea09 100644 --- a/lib/l10n/app_th.arb +++ b/lib/l10n/app_th.arb @@ -7501,36 +7501,6 @@ "@homeWhyRecoveryTitle": { "description": "Title of the Home card row that explains a low recovery score." }, - "homeWhyRecoveryDrivers": "ตัวฉุดหลัก: {a} {za}, {b} {zb} (แต่ละค่าห่างจากค่าปกติของคุณเท่าไร)", - "@homeWhyRecoveryDrivers": { - "description": "Body of the low-recovery row naming the two biggest drags with their distance from the person's own usual (z-scores).", - "placeholders": { - "a": { - "type": "String" - }, - "za": { - "type": "String" - }, - "b": { - "type": "String" - }, - "zb": { - "type": "String" - } - } - }, - "homeWhyRecoveryDriver": "ตัวฉุดหลัก: {a} {za} (ห่างจากค่าปกติของคุณเท่าไร)", - "@homeWhyRecoveryDriver": { - "description": "Body of the low-recovery row naming the one biggest drag with its distance from the person's own usual (z-score).", - "placeholders": { - "a": { - "type": "String" - }, - "za": { - "type": "String" - } - } - }, "homeWhyRecoveryPlain": "การฟื้นตัว {score} จาก 100", "@homeWhyRecoveryPlain": { "description": "Body of the low-recovery row when no input is known to drag the score: just the score out of 100.", @@ -7540,27 +7510,6 @@ } } }, - "homeWhyRecoveryPullingTwo": "ที่ฉุดให้ต่ำลง: {a}, {b}", - "@homeWhyRecoveryPullingTwo": { - "description": "Body of the low-recovery row naming the two inputs pulling the score down, without numbers.", - "placeholders": { - "a": { - "type": "String" - }, - "b": { - "type": "String" - } - } - }, - "homeWhyRecoveryPullingOne": "ที่ฉุดให้ต่ำลง: {a}", - "@homeWhyRecoveryPullingOne": { - "description": "Body of the low-recovery row naming the one input pulling the score down, without numbers.", - "placeholders": { - "a": { - "type": "String" - } - } - }, "homeWhySleepTitle": "นอนน้อยกว่าที่ต้องการ", "@homeWhySleepTitle": { "description": "Title of the Home card row shown when sleep is well short of the person's need." diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index d29b35b52..fc6669871 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -1549,34 +1549,6 @@ "homeSleepNoEstimate": "暂无估算", "homeSleepNoEstimateWhy": "尚无已评分的夜晚,也没有闹钟或常规起床时间可供估算。", "homeWhyRecoveryTitle": "恢复状态偏低", - "homeWhyRecoveryDrivers": "拖累最大:{a} {za}、{b} {zb}(与你的基线的偏差)。", - "@homeWhyRecoveryDrivers": { - "placeholders": { - "a": { - "type": "String" - }, - "za": { - "type": "String" - }, - "b": { - "type": "String" - }, - "zb": { - "type": "String" - } - } - }, - "homeWhyRecoveryDriver": "拖累最大:{a} {za}(与你的基线的偏差)。", - "@homeWhyRecoveryDriver": { - "placeholders": { - "a": { - "type": "String" - }, - "za": { - "type": "String" - } - } - }, "homeWhyRecoveryPlain": "恢复 {score}/100", "@homeWhyRecoveryPlain": { "placeholders": { @@ -1585,25 +1557,6 @@ } } }, - "homeWhyRecoveryPullingTwo": "拉低恢复评分的因素:{a}、{b}。", - "@homeWhyRecoveryPullingTwo": { - "placeholders": { - "a": { - "type": "String" - }, - "b": { - "type": "String" - } - } - }, - "homeWhyRecoveryPullingOne": "拉低恢复评分的因素:{a}。", - "@homeWhyRecoveryPullingOne": { - "placeholders": { - "a": { - "type": "String" - } - } - }, "homeWhySleepTitle": "睡眠不足", "homeWhySleepBody": "已评分的睡眠时长低于你的需求。", "homeWhySleepDetail": "睡了 {asleep},比所需的 {need} 少 {gap}。", diff --git a/lib/ui2/profile/gallery.dart b/lib/ui2/profile/gallery.dart index 3c77a674e..fc502b728 100644 --- a/lib/ui2/profile/gallery.dart +++ b/lib/ui2/profile/gallery.dart @@ -90,11 +90,6 @@ const _tooShort = [50, 51, 50]; const _ringsMeasured = HomeData( dayId: '2026-08-16', readiness: Metric(value: 72, confidence: .8, tier: MetricTier.high), - drivers: [ - {'label': 'hrv'}, - {'label': 'rhr'}, - {'label': 'resp'}, - ], strain: Metric(value: 14.2, confidence: .6, tier: MetricTier.estimate), sleepMin: Metric( value: 465, unit: 'min', confidence: .8, tier: MetricTier.estimate), diff --git a/lib/ui2/screens/home_screen.dart b/lib/ui2/screens/home_screen.dart index 65c08987c..8c698a5da 100644 --- a/lib/ui2/screens/home_screen.dart +++ b/lib/ui2/screens/home_screen.dart @@ -1049,39 +1049,12 @@ class _HomeWhyRow extends StatelessWidget { } } -/// Evidence or nothing. Built from what BOTH driver shapes carry (label and the -/// sign/size of the weighted `contribution`; the production `detail` is prose): -/// the biggest drags first. A number is added only when a numeric z is really in -/// `detail` (`...=`) for every driver named; otherwise none is invented. +/// The recovery ring is the composite score. The only driver list on hand, +/// `readiness_glassbox.drivers`, belongs to a different (percentile) score and +/// can come from a rollup up to seven days old, so blaming it for THIS ring on +/// THIS night would be a guess. Nothing in the composite names its own drivers, +/// so the reason is the plain score. String _recoveryWhy(HomeData d, AppLocalizations? l) { - final drags = <(String, double, double?)>[]; - for (final e in d.drivers) { - final c = e['contribution']; - if (c is! num || c >= 0) continue; - final detail = '${e['detail']}'; - final z = - detail.contains('=') ? double.tryParse(detail.split('=').last) : null; - drags.add((driverLabel(e['label'], l), c.toDouble(), z)); - } - drags.sort((a, b) => a.$2.compareTo(b.$2)); // largest drag first - final top = drags.take(2).toList(); - String f(double? z) => z!.toStringAsFixed(1); - final numeric = top.isNotEmpty && top.every((x) => x.$3 != null); - if (top.length == 2) { - final (a, b) = (top[0], top[1]); - return numeric - ? (l?.homeWhyRecoveryDrivers(a.$1, f(a.$3), b.$1, f(b.$3)) ?? - 'Biggest drag: ${a.$1} ${f(a.$3)}, ${b.$1} ${f(b.$3)} (how far each sits from your usual).') - : (l?.homeWhyRecoveryPullingTwo(a.$1, b.$1) ?? - 'Pulling it down: ${a.$1}, ${b.$1}.'); - } - if (top.length == 1) { - final a = top[0]; - return numeric - ? (l?.homeWhyRecoveryDriver(a.$1, f(a.$3)) ?? - 'Biggest drag: ${a.$1} ${f(a.$3)} (how far each sits from your usual).') - : (l?.homeWhyRecoveryPullingOne(a.$1) ?? 'Pulling it down: ${a.$1}.'); - } final v = (d.readiness.value ?? 0).round(); return l?.homeWhyRecoveryPlain(v) ?? 'Recovery $v of 100'; } @@ -1829,7 +1802,6 @@ class HomeData { final String? name; final String? dayId; final Metric readiness; - final List> drivers; final Metric sleepMin, rhr, steps, calories, caloriesTotal; /// The day's 0–21 strain, read from the same `getToday` bundle the Workout @@ -1905,7 +1877,6 @@ class HomeData { this.name, this.dayId, this.readiness = Metric.empty, - this.drivers = const [], this.sleepMin = Metric.empty, this.rhr = Metric.empty, this.steps = Metric.empty, @@ -1960,7 +1931,6 @@ class HomeData { name: name, dayId: dayId, readiness: readiness, - drivers: drivers, sleepMin: sleepMin, rhr: rhr, steps: steps, @@ -1990,7 +1960,7 @@ class HomeData { /// /// Deliberately thin next to [load]: everything [load] does beyond the six /// headline numbers below (frozen-morning-headline pin, the illness watch, - /// sleep coach need/bedtime, readiness drivers, the stale-rollup notice) is + /// sleep coach need/bedtime, the stale-rollup notice) is /// about the ambiguity of an in-progress "today" — a past day already /// settled, so there is nothing there to resolve. Reuses the same /// date-parameterized getters the strain/sleep detail screens already read @@ -2052,9 +2022,6 @@ class HomeData { Object? d(String k) => daily is Map ? daily[k] : null; Object? s(String k) => sleep is Map ? sleep[k] : null; - final gb = cd['readiness_glassbox']; - final gbDrivers = gb is Map ? gb['drivers'] : null; - final coach = cd['sleep_coach']; final needEnv = coach is Map ? coach['need'] : null; final bedEnv = coach is Map ? coach['bedtime'] : null; @@ -2121,10 +2088,6 @@ class HomeData { absentDiag: absentDiag, recoveryState: recoveryState, readinessUpdate: (head['recovery_update'] as Map?)?.cast(), - drivers: [ - for (final e in (gbDrivers is List ? gbDrivers : const [])) - if (e is Map) e.cast(), - ], strain: metricOf(d('strain')), sleepMin: sleepMin, sleepStages: stages, diff --git a/lib/ui2/spiral_ring.dart b/lib/ui2/spiral_ring.dart index 95f886aee..7d6c263f2 100644 --- a/lib/ui2/spiral_ring.dart +++ b/lib/ui2/spiral_ring.dart @@ -170,8 +170,10 @@ void paintSpiral(Canvas canvas, Size size, // PRE-BLENDED against [background] and everything is drawn fully opaque, so // bands and cap discs overlap without compositing twice (a double-alpha // patch) and without any subtraction (which cracked the join and removed the - // 100% end cap). With no [background] the translucent colour is used as is, - // and a cap overlap shows a slightly denser patch: callers should pass it. + // 100% end cap). With no [background] the translucent colour is used as is; + // everything is drawn into one layer with BlendMode.src, so an overlap + // replaces what is under it instead of compositing a second time (callers + // that pass no background keep their look, with no denser patch at the caps). Color colourAt(SpiralPiece p, double s) { final c = colors[p.seg]; if (solid) return c; @@ -181,10 +183,12 @@ void paintSpiral(Canvas canvas, Size size, } final sepColour = background ?? track; + final once = background == null; + final outer = Rect.fromCircle(center: center, radius: r0 + half); canvas.save(); // The outer edge never leaves the lap-0 circle. - canvas.clipPath( - Path()..addOval(Rect.fromCircle(center: center, radius: r0 + half))); + canvas.clipPath(Path()..addOval(outer)); + if (once) canvas.saveLayer(outer.inflate(1), Paint()); final laps = >{}; for (final p in pieces) { @@ -199,7 +203,11 @@ void paintSpiral(Canvas canvas, Size size, } } void disc(SpiralPiece p, double s) => canvas.drawCircle( - pt(s, geometry.radiusAt(s, r0)), half, Paint()..color = colourAt(p, s)); + pt(s, geometry.radiusAt(s, r0)), + half, + Paint() + ..color = colourAt(p, s) + ..blendMode = once ? BlendMode.src : BlendMode.srcOver); for (final lap in laps.keys.toList()..sort()) { final group = laps[lap]!; // already last segment first @@ -208,7 +216,9 @@ void paintSpiral(Canvas canvas, Size size, for (final p in group) { // A lap cut overlaps the next lap's start slightly: no hairline seam. final to = p.capEnd ? p.to : p.to + .003; - final paint = Paint()..style = PaintingStyle.fill; + final paint = Paint() + ..style = PaintingStyle.fill + ..blendMode = once ? BlendMode.src : BlendMode.srcOver; final ua = p.from - p.lap, ub = p.to - p.lap; if (solid || ub - ua < 1e-6) { paint.color = colourAt(p, (p.from + p.to) / 2); @@ -255,6 +265,7 @@ void paintSpiral(Canvas canvas, Size size, if (p.capStart && !late) disc(p, p.from); } } + if (once) canvas.restore(); canvas.restore(); } diff --git a/test/home_labels_test.dart b/test/home_labels_test.dart index 9bf475b4b..7267fe903 100644 --- a/test/home_labels_test.dart +++ b/test/home_labels_test.dart @@ -47,11 +47,7 @@ const _newKeys = [ 'homeSleepNoEstimate', 'homeSleepNoEstimateWhy', 'homeWhyRecoveryTitle', - 'homeWhyRecoveryDrivers', - 'homeWhyRecoveryDriver', 'homeWhyRecoveryPlain', - 'homeWhyRecoveryPullingTwo', - 'homeWhyRecoveryPullingOne', 'homeWhySleepTitle', 'homeWhySleepBody', 'homeWhySleepDetail', diff --git a/test/home_ring_card_test.dart b/test/home_ring_card_test.dart index 8cb651b21..d6ccb320a 100644 --- a/test/home_ring_card_test.dart +++ b/test/home_ring_card_test.dart @@ -13,8 +13,8 @@ // Pressing means: recovery under 37 (severity 3 under 26, 2 for 26-36), sleep // under 85% of need (3 under 70%), strain above the top of today's target. A // ring with no value, a provisional or in-progress recovery is NEVER pressing -// (never fabricate). The why copy states evidence or nothing: the biggest -// drags by weighted contribution, a number only when a real z is in the driver. +// (never fabricate). The recovery why copy is the plain score: no driver list +// belongs to the composite ring, so none is blamed. // // Ported/adapted from the fork's ui2_home_card_test, ui2_home_card_fix_test, // ui2_home_card_r3_test, ui2_home_update_note_test and home_help_strip_why_test. @@ -61,7 +61,6 @@ HomeData _data({ num need = 480, num? strain = 5.0, Map? target, - List> drivers = const [], String? recoveryState, Map? update, }) => @@ -73,7 +72,6 @@ HomeData _data({ sleepNeedMin: _m(need), strain: strain == null ? Metric.empty : _m(strain), strainTarget: target, - drivers: drivers, readinessUpdate: update, ); @@ -151,8 +149,10 @@ const _drag = 'dragging your score down'; const _lift = 'lifting your score'; class _Repo extends LocalRepository { - _Repo(this.drivers); + _Repo(this.drivers, {this.score = 22.0, this.rollupDay}); final List> drivers; + final double score; + final String? rollupDay; @override Future> getToday() async => { 'daily': { @@ -162,8 +162,9 @@ class _Repo extends LocalRepository { }; @override Future> getInsights() async => { + 'rollup_day': rollupDay, 'readiness_glassbox': { - 'score': 22.0, + 'score': score, 'breakdown': const [], 'drivers': drivers, 'narrative': '', @@ -236,15 +237,9 @@ void main() { // ── 1. the why panels ───────────────────────────────────────────────────── group('why panels', () { - testWidgets('WHY: the loose "Why?" footer is gone, drivers or not', + testWidgets('WHY: the loose "Why?" footer is gone, on any day', (t) async { - await _rings( - t, - _data(drivers: const [ - {'label': 'hrv'}, - {'label': 'rhr'}, - {'label': 'resp'}, - ])); + await _rings(t, _data()); expect(find.textContaining('Why?'), findsNothing); expect(find.byIcon(LucideIcons.info), findsNothing); }); @@ -318,15 +313,13 @@ void main() { 'WHY (a11y): a panel is ONE tappable node that says its ring, title ' 'and reason, and is at least 48 dp tall', (t) async { final h = t.ensureSemantics(); - await _rings(t, _data(recovery: 10, drivers: [ - _z('hrv', -6.0, -2.0), - ]), onOpen: (_) {}); + await _rings(t, _data(recovery: 10), onOpen: (_) {}); expect(_why('recovery'), findsOneWidget); final node = t.getSemantics(_why('recovery')); final data = node.getSemanticsData(); expect(data.hasAction(SemanticsAction.tap), isTrue); expect(data.label, contains('Recovery is low')); - expect(data.label, contains('HRV')); + expect(data.label, contains('Recovery 10 of 100')); // Context, not just the reason: which ring this is about. expect(data.label.toLowerCase(), contains('recovery')); expect(t.getSize(_why('recovery')).height, greaterThanOrEqualTo(48)); @@ -336,68 +329,41 @@ void main() { }); }); - // ── 2. recovery copy: evidence or nothing ───────────────────────────────── + // ── 2. recovery copy: evidence of THIS ring, or the plain score ─────────── group('recovery why copy', () { - testWidgets('WHY: ranked by weighted contribution, with the real z', - (t) async { - await _rings( - t, - _data(recovery: 22, drivers: [ - _z('temp', -1.0, -3.0), - _z('hrv', -6.0, -2.0), - _z('rhr', -3.0, -1.0), - ])); - // HRV z=-2 (the biggest drag) outranks temp z=-3 (a smaller drag). - expect(_txt(t, _body('recovery')), - 'Biggest drag: HRV -2.0, Resting heart rate -1.0 (how far each sits from your usual).'); - }); - - testWidgets('WHY: without a numeric z the same order holds, no number', - (t) async { - await _rings( - t, - _data(recovery: 22, drivers: [ - _drv('temp', -1.0, _drag), - _drv('hrv', -6.0, _drag), - _drv('rhr', -3.0, _drag), - ])); - expect(_txt(t, _body('recovery')), - 'Pulling it down: HRV, Resting heart rate.'); - }); - - testWidgets('WHY: one drag names just that one', (t) async { - await _rings( - t, - _data(recovery: 22, drivers: [ - _drv('rhr', -3.1, _drag), - _drv('hrv', 2.0, _lift), - ])); - expect(_txt(t, _body('recovery')), - 'Pulling it down: Resting heart rate.'); - }); - - testWidgets( - 'WHY: only lifts, or no drivers, say the plain score and claim ' - 'nothing about "usual"', (t) async { - await _rings(t, _data(recovery: 22, drivers: [_drv('hrv', 2.0, _lift)])); - expect(_txt(t, _body('recovery')), 'Recovery 22 of 100'); + testWidgets('WHY: the plain score, with no driver claims', (t) async { await _rings(t, _data(recovery: 22)); expect(_txt(t, _body('recovery')), 'Recovery 22 of 100'); expect(find.textContaining('usual'), findsNothing); }); testWidgets( - 'WHY: on the shape HomeData.load really reads (readiness_glassbox ' - 'drivers) the copy names the drags by label', (t) async { + 'WHY: readiness_glassbox drivers belong to the percentile score, not ' + 'to the composite ring: a divergent percentile is never blamed', + (t) async { + // The ring shows the composite (22). The rollup's percentile score says + // 80 and its drivers drag: a different calculation, so no attribution. final d = await t.runAsync(() => HomeData.load(_Repo([ _drv('hrv', -6.2, _drag), _drv('rhr', 3.1, _lift), _drv('temp', -1.4, _drag), - ]))); - expect(d!.drivers, hasLength(3), reason: 'the loader passed them through'); - await _rings(t, d); - expect(_txt(t, _body('recovery')), - 'Pulling it down: HRV, Skin temperature.'); + ], score: 80.0))); + await _rings(t, d!); + expect(_txt(t, _body('recovery')), 'Recovery 22 of 100'); + expect(find.textContaining('Pulling it down'), findsNothing); + expect(find.textContaining('Biggest drag'), findsNothing); + }); + + testWidgets( + 'WHY: a usable rollup up to 7 days old is not evidence for today\'s ' + 'ring', (t) async { + final d = await t.runAsync(() => HomeData.load(_Repo([ + _z('hrv', -6.0, -2.0), + _z('rhr', -3.0, -1.0), + ], rollupDay: '2026-09-29'))); + await _rings(t, d!); + expect(_txt(t, _body('recovery')), 'Recovery 22 of 100'); + expect(find.textContaining('Biggest drag'), findsNothing); }); }); diff --git a/test/ui2_spiral_ring_round3_test.dart b/test/ui2_spiral_ring_round3_test.dart index f81f26618..1feda07bd 100644 --- a/test/ui2_spiral_ring_round3_test.dart +++ b/test/ui2_spiral_ring_round3_test.dart @@ -170,4 +170,22 @@ void main() { expect(_at(d, 200, 14 / (2 * pi * 90), 90)[0], greaterThan(60)); }); }); + + // Callers that pass no `background` (Readiness, Coach figures, Live, the + // gallery) get a translucent fade. Bands and caps must still composite ONCE: + // an overlap may not be denser than the band beside it. + testWidgets('gradient ring with NO background at 66%: cap overlaps are as ' + 'translucent as the band beside them', (tester) async { + await tester.runAsync(() async { + final d = await _raster(Ring(.66, _green, _none, stroke: 20), 200); + int alpha(double u, double r) => _at(d, 200, u, r)[3]; + final band = alpha(.05, 90); + expect(band, inInclusiveRange(120, 200), + reason: 'the 55%+ fade is translucent, not opaque'); + expect((alpha(.008, 90) - band).abs(), lessThan(24), + reason: 'start cap over the band: ${alpha(.008, 90)} vs $band'); + expect((alpha(.655, 90) - alpha(.6, 90)).abs(), lessThan(24), + reason: 'end cap over the band: ${alpha(.655, 90)} vs ${alpha(.6, 90)}'); + }); + }); } From a10674d00913e1beccf87d655b03d02a56784489 Mon Sep 17 00:00:00 2001 From: MikenRafter Date: Sun, 8 Nov 2026 21:05:33 -0700 Subject: [PATCH 05/17] fix(home): read today and its stages in one shared day-read scope --- lib/ui2/screens/home_screen.dart | 41 +++++++++++++--------- test/home_day_switch_decode_once_test.dart | 36 +++++++++++++++++++ 2 files changed, 60 insertions(+), 17 deletions(-) diff --git a/lib/ui2/screens/home_screen.dart b/lib/ui2/screens/home_screen.dart index 8c698a5da..6051bff03 100644 --- a/lib/ui2/screens/home_screen.dart +++ b/lib/ui2/screens/home_screen.dart @@ -2013,7 +2013,30 @@ class HomeData { } static Future load(LocalRepository repo, [AppLocalizations? l]) async { - final today = await repo.getToday(); + // getToday and the night's stage read decode the same stored day bundle: + // read both in one scope, so the UI isolate decodes it once per reload. + final (today, stages) = await repo.sharingDayReads(() async { + final t = await repo.getToday(); + final sl = t['sleep']; + final night = (overnightMetric( + t, sl is Map ? sl['duration_min'] : null, l) + .value == + null) + ? null + // Stage totals of TODAY'S OWN night only: a held-over night is + // refused by overnightMetric, and its stages must not turn up under + // today's ring. + : ((t['status'] as Map?)?['overnight_day'] ?? + (t['status'] as Map?)?['today_day']) + ?.toString(); + SleepStageMin? st; + if (night != null) { + try { + st = sleepStagesOf(await repo.getDaySleepV2(night)); + } catch (_) {} + } + return (t, st); + }); final cd = await repo.getInsights(); final profile = await repo.getProfile(); @@ -2031,23 +2054,7 @@ class HomeData { final heldOver = heldOverNightOf(today); - final status = today['status'] as Map?; final sleepMin = overnightMetric(today, s('duration_min'), l); - // Stage totals of TODAY'S OWN night only: a held-over night is refused - // above, and its stages must not turn up under today's ring. - SleepStageMin? stages; - if (sleepMin.value != null) { - final night = - (status?['overnight_day'] ?? status?['today_day'])?.toString(); - if (night != null) { - try { - stages = sleepStagesOf(await repo.getDaySleepV2(night)); - } catch (_) { - stages = null; - } - } - } - // Typical onset/wake for the unslept estimate. The armed alarm is // AppState's and is laid over this by the screen ([withSleepInputs]); a // failed read here is simply absent. diff --git a/test/home_day_switch_decode_once_test.dart b/test/home_day_switch_decode_once_test.dart index 41b2e0936..9bde02b48 100644 --- a/test/home_day_switch_decode_once_test.dart +++ b/test/home_day_switch_decode_once_test.dart @@ -78,6 +78,42 @@ void main() { expect(LocalRepositoryImpl.bundleDecodes - before, 2); }); + test('loading TODAY with a scored night decodes the day bundle once', + () async { + final now = DateTime.now(); + final todayDay = '${now.year.toString().padLeft(4, '0')}-' + '${now.month.toString().padLeft(2, '0')}-' + '${now.day.toString().padLeft(2, '0')}'; + await LocalDb.putDayResult( + dayId: todayDay, + algoVersion: kAlgoVersion, + payloadJson: jsonEncode({ + 'date': todayDay, + 'scalars': {'rhr': 51.0, 'strain': 9.4, 'steps': 7200}, + 'sleep': { + 'accounting': { + 'value': {'tst_sec': 420 * 60}, + }, + }, + 'series': { + 'strain_curve': [ + for (var i = 0; i < 50; i++) {'t': 1000 + i * 60, 'v': i / 10}, + ], + }, + }), + windowJson: '{}', + ); + // Warm anything that is not the bundle (freshness rows, the profile). + await repo.getToday(); + final before = LocalRepositoryImpl.bundleDecodes; + final h = await HomeData.load(repo); + expect(h.sleepMin.value, isNotNull, + reason: 'the fixture must be a scored night of today, or the stage ' + 'read this test is about never happens'); + expect(LocalRepositoryImpl.bundleDecodes - before, 1, + reason: 'getToday and the stage read share ONE decode'); + }); + group('HomeData.load keeps its extra reads minimal', () { Map today({required bool slept}) => { 'daily': { From 218741fb9dd5389206f7a7138fc6484661d43237 Mon Sep 17 00:00:00 2001 From: MikenRafter Date: Sun, 8 Nov 2026 21:06:06 -0700 Subject: [PATCH 06/17] fix(home): choose tonight's alarm by local calendar, not a fixed 24 h --- lib/ui2/sleep_ring_model.dart | 12 +++++++----- test/sleep_ring_model_test.dart | 28 ++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/lib/ui2/sleep_ring_model.dart b/lib/ui2/sleep_ring_model.dart index 351dab219..bc7b84251 100644 --- a/lib/ui2/sleep_ring_model.dart +++ b/lib/ui2/sleep_ring_model.dart @@ -83,10 +83,6 @@ class SleepRingModel { /// stands instead. const int _minCycleLenMin = 60, _maxCycleLenMin = 150; -/// An alarm further away than this is not tonight's wake (a weekday alarm seen -/// from Friday evening), so the learned wake answers instead. -const int _maxAlarmHorizonMicros = 24 * 3600 * 1000000; - SleepRingModel sleepRingModel({ required DateTime now, num? durationMin, @@ -115,9 +111,15 @@ SleepRingModel sleepRingModel({ final n = now.toLocal(); DateTime? wake; WakeSource? wakeSource; + // An alarm past the same wall-clock time tomorrow is not tonight's wake (a + // weekday alarm seen from Friday evening), so the learned wake answers + // instead. The cutoff is a calendar step, not `+ 24 h`: across a fall-back + // tomorrow's 07:00 alarm is 24 h 30 min away and is still tonight's. + final horizon = + DateTime(n.year, n.month, n.day + 1, n.hour, n.minute, n.second); if (nextAlarm != null && nextAlarm.isAfter(now) && - nextAlarm.difference(now).inMicroseconds <= _maxAlarmHorizonMicros) { + !nextAlarm.isAfter(horizon)) { wake = nextAlarm.toLocal(); wakeSource = WakeSource.alarm; } else if (learnedWakeMin != null) { diff --git a/test/sleep_ring_model_test.dart b/test/sleep_ring_model_test.dart index 97fb464ec..5f38fb122 100644 --- a/test/sleep_ring_model_test.dart +++ b/test/sleep_ring_model_test.dart @@ -549,6 +549,34 @@ void main() { expect(m.cycles, 6); }); + test('fall back: tomorrow\'s 07:00 alarm is 24 h 30 min away by the clock ' + 'but is still tonight\'s wake', () { + final now = DateTime(2026, 10, 31, 7, 30); + final m = _unslept( + now: now, + learnedOnset: _h23, + learnedWake: 8 * 60, + alarm: DateTime.utc(2026, 11, 1, 14, 0), // 07:00 MST + ); + expect(m.wakeSource, WakeSource.alarm); + expect(m.wakeAt, DateTime(2026, 11, 1, 7, 0)); + expect(m.windowStart, DateTime(2026, 10, 31, 23, 0)); + expect(m.estimateMin, 540); + }); + + test('spring forward: an alarm two calendar days out is still refused', () { + // Friday-evening style guard survives the change: 07:00 on the 9th is not + // tonight from 07:30 on the 7th, so the learned wake answers. + final now = DateTime(2026, 3, 7, 7, 30); + final m = _unslept( + now: now, + learnedOnset: _h23, + learnedWake: 8 * 60, + alarm: DateTime.utc(2026, 3, 9, 13, 0), // 07:00 MDT on the 9th + ); + expect(m.wakeSource, WakeSource.learned); + }); + test('now inside the transition night still measures real elapsed time', () { // 00:30 MDT on spring-forward day, before the 02:00 jump: 7:00 MDT is From 1868c04d7d5910eba545285317143244d3e84c23 Mon Sep 17 00:00:00 2001 From: MikenRafter Date: Sun, 8 Nov 2026 21:07:06 -0700 Subject: [PATCH 07/17] fix(home): translate the coach chip in es/fr/hi/zh; test cycle strings by output --- lib/l10n/app_es.arb | 1 + lib/l10n/app_fr.arb | 1 + lib/l10n/app_hi.arb | 1 + lib/l10n/app_zh.arb | 15 ++++++------ test/home_labels_test.dart | 48 +++++++++++++++++++++++--------------- 5 files changed, 40 insertions(+), 26 deletions(-) diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index ac0994a21..e9a99fff5 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -1428,6 +1428,7 @@ "homeDriverRhr": "Frecuencia cardíaca en reposo", "homeDriverResp": "Frecuencia respiratoria", "homeDriverTemp": "Temperatura de la piel", + "homeCoachSetUpChip": "Configurar el entrenador", "homeDbRebuiltTitle": "Tu base de datos se reconstruyó", "homeDbRebuiltNothingRecovered": "No se pudo recuperar nada.", "homeDbRebuiltRecovered": "Recuperado: {list}.", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 14a84ef17..0e54ec6ca 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -1428,6 +1428,7 @@ "homeDriverRhr": "Fréquence cardiaque au repos", "homeDriverResp": "Fréquence respiratoire", "homeDriverTemp": "Température cutanée", + "homeCoachSetUpChip": "Configurer le coach", "homeDbRebuiltTitle": "Ta base de données a été reconstruite", "homeDbRebuiltNothingRecovered": "Rien n'a pu être récupéré.", "homeDbRebuiltRecovered": "Récupéré : {list}.", diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index 41491fa16..4bc5db653 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -1428,6 +1428,7 @@ "homeDriverRhr": "आराम की हृदय गति", "homeDriverResp": "श्वसन दर", "homeDriverTemp": "त्वचा का तापमान", + "homeCoachSetUpChip": "कोच सेट अप करें", "homeDbRebuiltTitle": "आपका डेटाबेस दोबारा बनाया गया", "homeDbRebuiltNothingRecovered": "कुछ भी वापस नहीं पढ़ा जा सका।", "homeDbRebuiltRecovered": "पुनर्प्राप्त: {list}।", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index fc6669871..c61a936d4 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -1428,6 +1428,7 @@ "homeDriverRhr": "静息心率", "homeDriverResp": "呼吸频率", "homeDriverTemp": "皮肤温度", + "homeCoachSetUpChip": "设置教练", "homeDbRebuiltTitle": "你的数据库已重建", "homeDbRebuiltNothingRecovered": "没有任何数据可以恢复。", "homeDbRebuiltRecovered": "已恢复:{list}。", @@ -1490,9 +1491,9 @@ } } }, - "homeSleepOnTrack": "有望达到 {cycles, plural, other{{cycles} 个周期}},{duration}", - "homeSleepOnTrackDefault": "有望达到 {cycles, plural, other{{cycles} 个周期}},{duration} · 目标 {target}(默认)", - "homeSleepEstimateCompact": "~{cycles, plural, other{{cycles} 个周期}} · {duration}", + "homeSleepOnTrack": "有望达到 {cycles} 个周期,{duration}", + "homeSleepOnTrackDefault": "有望达到 {cycles} 个周期,{duration} · 目标 {target}(默认)", + "homeSleepEstimateCompact": "~{cycles} 个周期 · {duration}", "@homeSleepEstimateCompact": { "description": "Short sleep estimate: \"~\" marks it as an estimate; keeps cycle count and duration.", "placeholders": { @@ -1504,7 +1505,7 @@ } } }, - "homeSleepEstimateDefaultCompact": "~{cycles, plural, other{{cycles} 个周期}} · {duration} · 默认目标", + "homeSleepEstimateDefaultCompact": "~{cycles} 个周期 · {duration} · 默认目标", "@homeSleepEstimateDefaultCompact": { "description": "Short sleep estimate keeping the \"~\" estimate marker and an explicit default-target qualifier.", "placeholders": { @@ -1516,7 +1517,7 @@ } } }, - "homeSleepEstimateDefaultTight": "~{cycles, plural, other{{cycles} 个周期}} · {duration} · 默认目标", + "homeSleepEstimateDefaultTight": "~{cycles} 个周期 · {duration} · 默认目标", "@homeSleepEstimateDefaultTight": { "description": "Tightest sleep estimate caption: keeps the estimate marker, cycles, duration and the default-target qualifier.", "placeholders": { @@ -1528,7 +1529,7 @@ } } }, - "homeSleepEstimateDefaultMin": "~{cycles, plural, other{{cycles} 个周期}} · 默认目标", + "homeSleepEstimateDefaultMin": "~{cycles} 个周期 · 默认目标", "@homeSleepEstimateDefaultMin": { "description": "Smallest default-target estimate caption: drops the duration, keeps the estimate marker, cycles and the default goal.", "placeholders": { @@ -1537,7 +1538,7 @@ } } }, - "homeSleepEstimateBare": "~{cycles, plural, other{{cycles} 个周期}}", + "homeSleepEstimateBare": "~{cycles} 个周期", "@homeSleepEstimateBare": { "description": "Smallest estimate caption: the estimate marker and the cycle count.", "placeholders": { diff --git a/test/home_labels_test.dart b/test/home_labels_test.dart index 7267fe903..cf7c95695 100644 --- a/test/home_labels_test.dart +++ b/test/home_labels_test.dart @@ -144,10 +144,6 @@ void main() { if (ref is! String) continue; // reported by the English test expect(_placeholders(v), _placeholders(ref), reason: '$k placeholders in $name'); - if (ref.contains('plural,')) { - expect(v, contains('plural,'), - reason: '$k keeps its ICU plural in $name'); - } if (name != 'app_en.arb') { expect(v, isNot(ref), reason: '$k is still English in $name'); } @@ -157,21 +153,34 @@ void main() { } test( - 'WHY: fr and hi classify 0 as "one", so a plural singular must show ' - 'the count, not a literal 1', () { - final pluralKeys = _newKeys - .where((k) => (en[k] as String?)?.contains('{cycles, plural') ?? false) + 'WHY: in every locale the cycle strings print the real count for each ' + 'of its plural forms (0, 1, 2, 5, 11, 21, 100)', () { + String render(AppLocalizations l, String key, int n) => switch (key) { + 'homeSleepOnTrack' => l.homeSleepOnTrack(n, '8h'), + 'homeSleepOnTrackDefault' => + l.homeSleepOnTrackDefault(n, '8h', '8h'), + 'homeSleepEstimateCompact' => l.homeSleepEstimateCompact(n, '8h'), + 'homeSleepEstimateDefaultCompact' => + l.homeSleepEstimateDefaultCompact(n, '8h'), + 'homeSleepEstimateDefaultTight' => + l.homeSleepEstimateDefaultTight(n, '8h'), + 'homeSleepEstimateDefaultMin' => l.homeSleepEstimateDefaultMin(n), + 'homeSleepEstimateBare' => l.homeSleepEstimateBare(n), + _ => fail('no renderer for $key'), + }; + final cycleKeys = _newKeys + .where((k) => (en[k] as String?)?.contains('{cycles') ?? false) .toList(); - expect(pluralKeys, hasLength(7), - reason: 'English must define the seven cycle-plural keys first'); - for (final lang in ['fr', 'hi']) { - final j = _arb(File('lib/l10n/app_$lang.arb')); - for (final k in pluralKeys) { - final v = j[k]; - if (v is! String) fail('$k missing in $lang'); - final one = RegExp(r'one\{((?:[^{}]|\{[^}]*\})*)\}').firstMatch(v)?.group(1) ?? ''; - expect(one, contains('{cycles}'), - reason: '$k ($lang) one-branch hard-codes the count: "$one"'); + expect(cycleKeys, hasLength(7), + reason: 'English must define the seven cycle keys first'); + for (final loc in AppLocalizations.supportedLocales) { + final l = lookupAppLocalizations(loc); + for (final k in cycleKeys) { + for (final n in [0, 1, 2, 5, 11, 21, 100]) { + final out = render(l, k, n); + expect(RegExp('(?(), + reason: '$k is missing in ${f.uri.pathSegments.last}'); expect(_placeholders(j[k] as String), _placeholders(en[k] as String), reason: '$k in ${f.uri.pathSegments.last}'); } From 58e302f7d4ebeabd3965f2a96eba4145ec1cb4e9 Mon Sep 17 00:00:00 2001 From: MikenRafter Date: Sun, 8 Nov 2026 21:07:35 -0700 Subject: [PATCH 08/17] fix(home): spiral no-crack test picks join pixels from geometry and asserts alpha --- test/ui2_spiral_ring_round3_test.dart | 60 ++++++++++++++++----------- 1 file changed, 35 insertions(+), 25 deletions(-) diff --git a/test/ui2_spiral_ring_round3_test.dart b/test/ui2_spiral_ring_round3_test.dart index 1feda07bd..79986367e 100644 --- a/test/ui2_spiral_ring_round3_test.dart +++ b/test/ui2_spiral_ring_round3_test.dart @@ -116,36 +116,46 @@ void main() { const g = SpiralGeometry(20); final d = await _raster( Ring(v, _green, _none, stroke: 20, background: _bg), 200); - // Points that are inside the band or its end caps, 3 px clear of the - // edges: every one must be fully opaque and match its horizontal - // neighbours (a crack is a lighter or more transparent hairline). + // Probes chosen from the geometry alone (never from what was drawn): + // along the tangent at offset `a` px and radially at `dr` px from the + // cap centre. Behind the join (a <= 0 at the start, a <= 0 at the end) + // is band; ahead of it only the disc covers, so it counts as interior + // within 6.5 px of the centre. Every such pixel must be fully opaque and + // match its horizontal neighbours (a crack is a lighter or more + // transparent hairline). final end = v % 1; - final probes = <(double, double)>[ - for (var k = -6; k <= 6; k++) (k / 200, 90.0), // start cap region - for (var k = -6; k <= 6; k++) - (end + k / 200, g.radiusAt(v, 90).toDouble()), // end cap region - ]; - for (final (u, r) in probes) { - for (var dr = -6.0; dr <= 6; dr += 1) { - final rr = r + dr; - if (v > 1 && u > .5 && u < end - .5) continue; - final c = _at(d, 200, u, rr); - if (c[3] < 255) { - // Allowed only outside the painted shape (track is transparent). - continue; - } - final x = (100 + rr * sin(2 * pi * u)).floor(); - final y = (100 - rr * cos(2 * pi * u)).floor(); - for (final nx in [x - 1, x + 1]) { - final i = (y * 200 + nx) * 4; - if (d.getUint8(i + 3) < 255) continue; // shape boundary - for (var ch = 0; ch < 3; ch++) { - expect((d.getUint8(i + ch) - c[ch]).abs(), lessThan(40), - reason: 'crack at u=$u r=$rr ch=$ch'); + const half = 10.0, reach = 6.5; + final probes = <(double, double)>[]; + void region(double centreU, double radius, {required bool bandBehind}) { + for (var a = -7.0; a <= 7; a += 1) { + for (var dr = -6.0; dr <= 6; dr += 1) { + final behind = bandBehind ? a <= 0 : a >= 0; + final inDisc = sqrt(a * a + dr * dr) <= reach; + if ((behind && dr.abs() <= half - 3) || inDisc) { + probes.add((centreU + a / (2 * pi * radius), radius + dr)); } } } } + + region(0, 90, bandBehind: false); // start cap; the band runs ahead + region(end, g.radiusAt(v, 90).toDouble(), bandBehind: true); + expect(probes.length, greaterThan(100)); + for (final (u, rr) in probes) { + final c = _at(d, 200, u, rr); + expect(c[3], 255, reason: 'transparent join pixel at u=$u r=$rr'); + final x = (100 + rr * sin(2 * pi * u)).floor(); + final y = (100 - rr * cos(2 * pi * u)).floor(); + for (final nx in [x - 1, x + 1]) { + final i = (y * 200 + nx) * 4; + expect(d.getUint8(i + 3), 255, + reason: 'transparent neighbour of u=$u r=$rr'); + for (var ch = 0; ch < 3; ch++) { + expect((d.getUint8(i + ch) - c[ch]).abs(), lessThan(40), + reason: 'crack at u=$u r=$rr ch=$ch'); + } + } + } // Inside both band and cap the pixel is opaque and not background. for (final (u, r) in [(.02, 90.0), (end - .02, g.radiusAt(v, 90))]) { final c = _at(d, 200, u, r); From ed50e0d5b14b98e0d77da0899a66ad69edac563c Mon Sep 17 00:00:00 2001 From: MikenRafter Date: Sun, 8 Nov 2026 21:10:03 -0700 Subject: [PATCH 09/17] fix(home): measure the Thai caption with a Thai font; shorten the Thai tight wording to three lines --- lib/l10n/app_th.arb | 2 +- test/home_ring_card_test.dart | 57 ++++++++++++++++++++++++++++++++--- 2 files changed, 53 insertions(+), 6 deletions(-) diff --git a/lib/l10n/app_th.arb b/lib/l10n/app_th.arb index 3bbdeea09..8e8b323d2 100644 --- a/lib/l10n/app_th.arb +++ b/lib/l10n/app_th.arb @@ -7393,7 +7393,7 @@ } } }, - "homeSleepPctDefaultTight": "{pct}% ของเป้าหมายเริ่มต้น {target}", + "homeSleepPctDefaultTight": "{pct}% ของ {target} เริ่มต้น", "@homeSleepPctDefaultTight": { "description": "Tightest sleep caption when the target is the default: still names the target as the default.", "placeholders": { diff --git a/test/home_ring_card_test.dart b/test/home_ring_card_test.dart index d6ccb320a..e1c969516 100644 --- a/test/home_ring_card_test.dart +++ b/test/home_ring_card_test.dart @@ -175,6 +175,43 @@ class _Repo extends LocalRepository { Future> getProfile() async => const {}; } +const _thaiFontEnv = 'EDGE_TEST_THAI_FONT'; + +/// A Thai-capable TrueType face for measuring Thai text, or null. The repo +/// ships none (Manrope has no Thai), so look at an explicit override, then at +/// what fontconfig lists for Thai, then at the usual FreeFont install paths. +File? _thaiFontFile() { + final candidates = [ + ?Platform.environment[_thaiFontEnv], + ]; + try { + final r = Process.runSync('fc-list', [':lang=th', 'file']); + if (r.exitCode == 0) { + for (final line in (r.stdout as String).split('\n')) { + final f = line.split(':').first.trim(); + if (RegExp(r'\.(ttf|otf)$', caseSensitive: false).hasMatch(f)) { + candidates.add(f); + } + } + } + } on ProcessException { + // no fontconfig + } + candidates.addAll([ + '/usr/share/fonts/truetype/freefont/FreeSans.ttf', + '/run/current-system/sw/share/X11/fonts/FreeSans.ttf', // NixOS + '/usr/share/fonts/truetype/noto/NotoSansThai-Regular.ttf', + ]); + // Prefer a sans face: the app's text is sans. + candidates.sort((a, b) => + (b.contains('Sans') ? 1 : 0) - (a.contains('Sans') ? 1 : 0)); + for (final c in candidates) { + final f = File(c); + if (f.existsSync()) return f; + } + return null; +} + void main() { // ── 0. what "pressing" means (pure) ─────────────────────────────────────── group('pressingRings', () { @@ -513,6 +550,15 @@ void main() { } await loader.load(); } + // Manrope has no Thai glyphs. Without a Thai face every Thai word is an + // unbreakable one-em block and the line bound below means nothing. + final thai = _thaiFontFile(); + if (thai != null) { + final loader = FontLoader('ThaiTestFace') + ..addFont(thai.readAsBytes().then( + (bytes) => ByteData.sublistView(Uint8List.fromList(bytes)))); + await loader.load(); + } }); // A scored night against the 8 h default: 442 of 480 min is 92%. @@ -537,11 +583,12 @@ void main() { expect(para.didExceedMaxLines, isFalse, reason: '$lang caption is cut by its line limit'); // Three lines is the reserved caption height; more would shift the card. - // Thai has no glyphs in the shipped test font, so its words measure as - // unbreakable one-em blocks, wider than any real Thai face: the line - // bound is meaningless there, and the caption must at least print its - // tightest wording whole (checked above: no line-limit cut). - if (lang == 'th') return; + if (lang == 'th' && _thaiFontFile() == null) { + markTestSkipped('no Thai-capable font found (set $_thaiFontEnv, or ' + 'install FreeFont/Noto Sans Thai): the Thai three-line bound is ' + 'NOT measured on this machine'); + return; + } final fs = para.text.style?.fontSize ?? 12; expect(para.size.height / (fs * 1.3 * 1.5), lessThanOrEqualTo(3.01)); }); From c50b5e4b909b77301e189b86e166fe63676b6c26 Mon Sep 17 00:00:00 2001 From: MikenRafter Date: Sun, 8 Nov 2026 21:20:08 -0700 Subject: [PATCH 10/17] fix(home): pick tonight's alarm by local night boundary; verify Thai coverage before measuring Thai --- lib/ui2/sleep_ring_model.dart | 13 +++--- test/home_ring_card_test.dart | 70 ++++++++++++++++++++++++++++----- test/sleep_ring_model_test.dart | 29 +++++++++++--- 3 files changed, 90 insertions(+), 22 deletions(-) diff --git a/lib/ui2/sleep_ring_model.dart b/lib/ui2/sleep_ring_model.dart index bc7b84251..c8c2e7e23 100644 --- a/lib/ui2/sleep_ring_model.dart +++ b/lib/ui2/sleep_ring_model.dart @@ -111,12 +111,13 @@ SleepRingModel sleepRingModel({ final n = now.toLocal(); DateTime? wake; WakeSource? wakeSource; - // An alarm past the same wall-clock time tomorrow is not tonight's wake (a - // weekday alarm seen from Friday evening), so the learned wake answers - // instead. The cutoff is a calendar step, not `+ 24 h`: across a fall-back - // tomorrow's 07:00 alarm is 24 h 30 min away and is still tonight's. - final horizon = - DateTime(n.year, n.month, n.day + 1, n.hour, n.minute, n.second); + // Tonight is the night that starts this evening and ends tomorrow morning, + // so an alarm after tomorrow's local noon is not tonight's wake (a weekday + // alarm seen from Friday evening) and the learned wake answers instead. The + // boundary is a local calendar point, not a duration: tomorrow's 07:00 + // alarm is 24 h 30 min away across a fall-back and 23 h 30 min across a + // spring-forward, and is tonight's in both. + final horizon = DateTime(n.year, n.month, n.day + 1, 12); if (nextAlarm != null && nextAlarm.isAfter(now) && !nextAlarm.isAfter(horizon)) { diff --git a/test/home_ring_card_test.dart b/test/home_ring_card_test.dart index e1c969516..ccf3d536c 100644 --- a/test/home_ring_card_test.dart +++ b/test/home_ring_card_test.dart @@ -96,6 +96,17 @@ void _surface(WidgetTester t, double w, {double scale = 1.0}) { addTearDown(t.platformDispatcher.clearTextScaleFactorTestValue); } +/// Family of the verified Thai face once the caption group has loaded it. +String? _thaiFamily; + +ThemeData _themeWithThai() { + final th = buildTheme(Brightness.light); + final fam = _thaiFamily; + if (fam == null) return th; + return th.copyWith( + textTheme: th.textTheme.apply(fontFamilyFallback: [fam])); +} + Future _rings(WidgetTester t, HomeData d, {void Function(HomeRingKind)? onOpen, double w = 390, @@ -104,7 +115,7 @@ Future _rings(WidgetTester t, HomeData d, _surface(t, w, scale: scale); await t.pumpWidget(MaterialApp( locale: locale, - theme: buildTheme(Brightness.light), + theme: _themeWithThai(), localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, home: Scaffold( @@ -177,15 +188,45 @@ class _Repo extends LocalRepository { const _thaiFontEnv = 'EDGE_TEST_THAI_FONT'; +/// Runs a fontconfig tool against the system configuration: `flutter test` +/// points FONTCONFIG_FILE at its own font-less one, which lists nothing. +ProcessResult _fc(String tool, List args) => Process.runSync( + tool, args, + environment: File('/etc/fonts/fonts.conf').existsSync() + ? {'FONTCONFIG_FILE': '/etc/fonts/fonts.conf'} + : null); + +/// True when [path] has every Thai letter, vowel and digit (U+0E01-0E3A, +/// U+0E3F-0E5B) in its character map, as fontconfig reports it. A face that +/// is merely installed or listed is not enough: FreeSans has no Thai. +bool _coversThai(String path) { + try { + final r = _fc('fc-query', ['-f', '%{charset}', path]); + if (r.exitCode != 0) return false; + final ranges = <(int, int)>[]; + for (final tok in (r.stdout as String).trim().split(RegExp(r'\s+'))) { + final p = tok.split('-'); + final lo = int.tryParse(p.first, radix: 16); + final hi = int.tryParse(p.last, radix: 16); + if (lo != null && hi != null) ranges.add((lo, hi)); + } + bool has(int lo, int hi) => ranges.any((e) => e.$1 <= lo && e.$2 >= hi); + return has(0x0E01, 0x0E3A) && has(0x0E3F, 0x0E5B); + } on ProcessException { + return false; // no fontconfig: cannot verify, so do not trust it + } +} + /// A Thai-capable TrueType face for measuring Thai text, or null. The repo -/// ships none (Manrope has no Thai), so look at an explicit override, then at -/// what fontconfig lists for Thai, then at the usual FreeFont install paths. +/// ships none (Manrope has no Thai) and no binary is added. Candidates are the +/// explicit override and what fontconfig lists for Thai; each must pass +/// [_coversThai] before it is used. File? _thaiFontFile() { final candidates = [ ?Platform.environment[_thaiFontEnv], ]; try { - final r = Process.runSync('fc-list', [':lang=th', 'file']); + final r = _fc('fc-list', [':lang=th', 'file']); if (r.exitCode == 0) { for (final line in (r.stdout as String).split('\n')) { final f = line.split(':').first.trim(); @@ -197,17 +238,12 @@ File? _thaiFontFile() { } on ProcessException { // no fontconfig } - candidates.addAll([ - '/usr/share/fonts/truetype/freefont/FreeSans.ttf', - '/run/current-system/sw/share/X11/fonts/FreeSans.ttf', // NixOS - '/usr/share/fonts/truetype/noto/NotoSansThai-Regular.ttf', - ]); // Prefer a sans face: the app's text is sans. candidates.sort((a, b) => (b.contains('Sans') ? 1 : 0) - (a.contains('Sans') ? 1 : 0)); for (final c in candidates) { final f = File(c); - if (f.existsSync()) return f; + if (f.existsSync() && _coversThai(c)) return f; } return null; } @@ -558,8 +594,10 @@ void main() { ..addFont(thai.readAsBytes().then( (bytes) => ByteData.sublistView(Uint8List.fromList(bytes)))); await loader.load(); + _thaiFamily = 'ThaiTestFace'; } }); + tearDownAll(() => _thaiFamily = null); // A scored night against the 8 h default: 442 of 480 min is 92%. HomeData slept() => HomeData( @@ -590,6 +628,18 @@ void main() { return; } final fs = para.text.style?.fontSize ?? 12; + if (lang == 'th') { + // Proof the Thai face is in use: a real face gives the combining + // marks no advance, so the run is narrower than one em per code + // unit (the block face's width). + final text = para.text.toPlainText(); + final tp = TextPainter( + text: TextSpan(text: text, style: para.text.style), + textDirection: TextDirection.ltr) + ..layout(); + expect(tp.width, lessThan(text.length * fs * 0.9), + reason: 'Thai measured with a face that has no Thai glyphs'); + } expect(para.size.height / (fs * 1.3 * 1.5), lessThanOrEqualTo(3.01)); }); } diff --git a/test/sleep_ring_model_test.dart b/test/sleep_ring_model_test.dart index 5f38fb122..b9e0dc2b4 100644 --- a/test/sleep_ring_model_test.dart +++ b/test/sleep_ring_model_test.dart @@ -418,32 +418,34 @@ void main() { expect(m.wakeAt, DateTime(2026, 10, 9, 7, 0)); }); - test('the 24 h horizon is compared in full: exactly 24 h is accepted', () { + test('the night ends at tomorrow\'s local noon: 11:59:59 is tonight\'s', + () { final m = _unslept( now: _evening, learnedOnset: _h23, learnedWake: _h07, - alarm: _evening.add(const Duration(hours: 24)), + alarm: DateTime(2026, 10, 9, 11, 59, 59), ); expect(m.wakeSource, WakeSource.alarm); }); - test('24 h and one second away is rejected', () { + test('an alarm just past tomorrow\'s noon is the next night\'s, not ' + 'tonight\'s', () { final m = _unslept( now: _evening, learnedOnset: _h23, learnedWake: _h07, - alarm: _evening.add(const Duration(hours: 24, seconds: 1)), + alarm: DateTime(2026, 10, 9, 12, 0, 1), ); expect(m.wakeSource, WakeSource.learned); }); - test('24 h 0 m 59 s away is rejected (not truncated to 24 h 0 m)', () { + test('a 22:00 alarm tomorrow evening is not tonight\'s wake', () { final m = _unslept( now: _evening, learnedOnset: _h23, learnedWake: _h07, - alarm: _evening.add(const Duration(hours: 24, seconds: 59)), + alarm: _evening.add(const Duration(hours: 24)), ); expect(m.wakeSource, WakeSource.learned); }); @@ -564,6 +566,21 @@ void main() { expect(m.estimateMin, 540); }); + test('spring forward: tomorrow\'s 07:00 alarm is 23 h 30 min away but is ' + 'still tonight\'s wake', () { + final now = DateTime(2026, 3, 7, 6, 30); + final m = _unslept( + now: now, + learnedOnset: _h23, + learnedWake: 8 * 60, + alarm: DateTime.utc(2026, 3, 8, 13, 0), // 07:00 MDT + ); + expect(m.wakeSource, WakeSource.alarm); + expect(m.wakeAt, DateTime(2026, 3, 8, 7, 0)); + expect(m.windowStart, DateTime(2026, 3, 7, 23, 0)); + expect(m.estimateMin, 420); + }); + test('spring forward: an alarm two calendar days out is still refused', () { // Friday-evening style guard survives the change: 07:00 on the 9th is not // tonight from 07:30 on the 7th, so the learned wake answers. From 4cab5efefc23d174099942028e7a66b048853c33 Mon Sep 17 00:00:00 2001 From: MikenRafter Date: Sun, 8 Nov 2026 21:21:19 -0700 Subject: [PATCH 11/17] fix(home): before 04:00 the night ends at today's noon, not tomorrow's --- lib/ui2/sleep_ring_model.dart | 4 +++- test/sleep_ring_model_test.dart | 34 +++++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/lib/ui2/sleep_ring_model.dart b/lib/ui2/sleep_ring_model.dart index c8c2e7e23..fbb938dca 100644 --- a/lib/ui2/sleep_ring_model.dart +++ b/lib/ui2/sleep_ring_model.dart @@ -117,7 +117,9 @@ SleepRingModel sleepRingModel({ // boundary is a local calendar point, not a duration: tomorrow's 07:00 // alarm is 24 h 30 min away across a fall-back and 23 h 30 min across a // spring-forward, and is tonight's in both. - final horizon = DateTime(n.year, n.month, n.day + 1, 12); + // Before 04:00 the user is already inside the night that started yesterday + // evening, so it ends at today's noon instead. + final horizon = DateTime(n.year, n.month, n.day + (n.hour < 4 ? 0 : 1), 12); if (nextAlarm != null && nextAlarm.isAfter(now) && !nextAlarm.isAfter(horizon)) { diff --git a/test/sleep_ring_model_test.dart b/test/sleep_ring_model_test.dart index b9e0dc2b4..cd74c27b3 100644 --- a/test/sleep_ring_model_test.dart +++ b/test/sleep_ring_model_test.dart @@ -440,6 +440,23 @@ void main() { expect(m.wakeSource, WakeSource.learned); }); + test('1 am: already in the night, so it ends at TODAY\'s noon', () { + final now = DateTime(2026, 10, 9, 1, 0); + final today = _unslept( + now: now, + learnedOnset: _h23, + learnedWake: _h07, + alarm: DateTime(2026, 10, 9, 7, 0)); + expect(today.wakeSource, WakeSource.alarm); + final next = _unslept( + now: now, + learnedOnset: _h23, + learnedWake: _h07, + alarm: DateTime(2026, 10, 10, 7, 0)); + expect(next.wakeSource, WakeSource.learned); + expect(next.wakeAt, DateTime(2026, 10, 9, 7, 0)); + }); + test('a 22:00 alarm tomorrow evening is not tonight\'s wake', () { final m = _unslept( now: _evening, @@ -581,6 +598,23 @@ void main() { expect(m.estimateMin, 420); }); + test('1 am inside a DST night: today\'s alarm accepted, tomorrow\'s ' + 'refused (spring forward and fall back)', () { + for (final c in [ + (DateTime(2026, 3, 8, 1, 0), DateTime.utc(2026, 3, 8, 13, 0), + DateTime.utc(2026, 3, 9, 13, 0)), + (DateTime(2026, 11, 1, 0, 30), DateTime.utc(2026, 11, 1, 14, 0), + DateTime.utc(2026, 11, 2, 14, 0)), + ]) { + final ok = _unslept( + now: c.$1, learnedOnset: _h23, learnedWake: 8 * 60, alarm: c.$2); + expect(ok.wakeSource, WakeSource.alarm, reason: '${c.$1} today'); + final no = _unslept( + now: c.$1, learnedOnset: _h23, learnedWake: 8 * 60, alarm: c.$3); + expect(no.wakeSource, WakeSource.learned, reason: '${c.$1} tomorrow'); + } + }); + test('spring forward: an alarm two calendar days out is still refused', () { // Friday-evening style guard survives the change: 07:00 on the 9th is not // tonight from 07:30 on the 7th, so the learned wake answers. From 55c8394e203df73a34a9f6edcd43bc65e27da884 Mon Sep 17 00:00:00 2001 From: MikenRafter Date: Sun, 8 Nov 2026 21:32:11 -0700 Subject: [PATCH 12/17] refactor(home): leave the pre-sleep estimate for the wake-to-wake work The 'on track for N cycles' estimate and everything that only served it move out of this PR: the alarm/learned-wake/learned-onset/coach-bedtime selection and night boundary in sleep_ring_model, the minute tick, the HomeData estimate inputs and sleepWindows read, the estimate strings in all eight locales, and their tests. The no-sleep ring uses upstream's homeSleepGapFallback again. --- lib/l10n/app_de.arb | 58 --- lib/l10n/app_en.arb | 92 ---- lib/l10n/app_es.arb | 58 --- lib/l10n/app_fr.arb | 58 --- lib/l10n/app_hi.arb | 58 --- lib/l10n/app_ru.arb | 84 ---- lib/l10n/app_th.arb | 92 ---- lib/l10n/app_zh.arb | 58 --- lib/ui2/screens/home_screen.dart | 300 ++--------- lib/ui2/sleep_ring_model.dart | 213 +------- test/home_day_switch_decode_once_test.dart | 18 +- test/home_labels_test.dart | 78 +-- test/home_ring_card_test.dart | 38 +- test/home_sleep_ring_widget_test.dart | 410 +-------------- test/home_tick_dst_test.dart | 72 --- test/sleep_ring_model_test.dart | 556 +-------------------- test/ui2_home_day_switcher_test.dart | 56 --- 17 files changed, 122 insertions(+), 2177 deletions(-) delete mode 100644 test/home_tick_dst_test.dart diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 84f1b7dda..96bd607c3 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -1537,64 +1537,6 @@ } } }, - "homeSleepOnTrack": "auf Kurs für {cycles, plural, =1{1 Zyklus} other{{cycles} Zyklen}}, {duration}", - "homeSleepOnTrackDefault": "auf Kurs für {cycles, plural, =1{1 Zyklus} other{{cycles} Zyklen}}, {duration} · Ziel {target} (Standard)", - "homeSleepEstimateCompact": "~{cycles, plural, =1{1 Zyklus} other{{cycles} Zyklen}} · {duration}", - "@homeSleepEstimateCompact": { - "description": "Short sleep estimate: \"~\" marks it as an estimate; keeps cycle count and duration.", - "placeholders": { - "cycles": { - "type": "int" - }, - "duration": { - "type": "String" - } - } - }, - "homeSleepEstimateDefaultCompact": "~{cycles, plural, =1{1 Zyklus} other{{cycles} Zyklen}} · {duration} · Standardziel", - "@homeSleepEstimateDefaultCompact": { - "description": "Short sleep estimate keeping the \"~\" estimate marker and an explicit default-target qualifier.", - "placeholders": { - "cycles": { - "type": "int" - }, - "duration": { - "type": "String" - } - } - }, - "homeSleepEstimateDefaultTight": "~{cycles, plural, =1{1 Zyklus} other{{cycles} Zyklen}} · {duration} · Standardziel", - "@homeSleepEstimateDefaultTight": { - "description": "Tightest sleep estimate caption: keeps the estimate marker, cycles, duration and the default-target qualifier.", - "placeholders": { - "cycles": { - "type": "int" - }, - "duration": { - "type": "String" - } - } - }, - "homeSleepEstimateDefaultMin": "~{cycles, plural, =1{1 Zyklus} other{{cycles} Zyklen}} · Standardziel", - "@homeSleepEstimateDefaultMin": { - "description": "Smallest default-target estimate caption: drops the duration, keeps the estimate marker, cycles and the default goal.", - "placeholders": { - "cycles": { - "type": "int" - } - } - }, - "homeSleepEstimateBare": "~{cycles, plural, =1{1 Zyklus} other{{cycles} Zyklen}}", - "@homeSleepEstimateBare": { - "description": "Smallest estimate caption: the estimate marker and the cycle count.", - "placeholders": { - "cycles": { - "type": "int" - } - } - }, - "homeSleepNoEstimate": "Keine Schätzung", - "homeSleepNoEstimateWhy": "Noch keine Nacht gewertet, und es gibt weder Wecker noch übliche Aufwachzeit, um zu schätzen.", "homeWhyRecoveryTitle": "Erholung ist niedrig", "homeWhyRecoveryPlain": "Erholung {score} von 100", "@homeWhyRecoveryPlain": { diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 94826e1fa..eb23aa71d 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -7417,98 +7417,6 @@ } } }, - "homeSleepOnTrack": "on track for {cycles, plural, =1{1 cycle} other{{cycles} cycles}}, {duration}", - "@homeSleepOnTrack": { - "description": "Sleep ring subtitle before a night is scored: whole sleep cycles the estimate covers, then the estimated time asleep.", - "placeholders": { - "cycles": { - "type": "int" - }, - "duration": { - "type": "String", - "example": "7h 30m" - } - } - }, - "homeSleepOnTrackDefault": "on track for {cycles, plural, =1{1 cycle} other{{cycles} cycles}}, {duration} · target {target} (default)", - "@homeSleepOnTrackDefault": { - "description": "Sleep ring subtitle before a night is scored when the target is the 8 h default (no need learned yet): the estimate, then the default target, labelled as a default.", - "placeholders": { - "cycles": { - "type": "int" - }, - "duration": { - "type": "String", - "example": "7h 30m" - }, - "target": { - "type": "String", - "example": "8h 00m" - } - } - }, - "homeSleepEstimateCompact": "~{cycles, plural, one{{cycles} cycle} other{{cycles} cycles}} · {duration}", - "@homeSleepEstimateCompact": { - "description": "Short sleep estimate: \"~\" marks it as an estimate; keeps cycle count and duration.", - "placeholders": { - "cycles": { - "type": "int" - }, - "duration": { - "type": "String" - } - } - }, - "homeSleepEstimateDefaultCompact": "~{cycles, plural, one{{cycles} cycle} other{{cycles} cycles}} · {duration} · default target", - "@homeSleepEstimateDefaultCompact": { - "description": "Short sleep estimate keeping the \"~\" estimate marker and an explicit default-target qualifier.", - "placeholders": { - "cycles": { - "type": "int" - }, - "duration": { - "type": "String" - } - } - }, - "homeSleepEstimateDefaultTight": "~{cycles, plural, one{{cycles} cycle} other{{cycles} cycles}} · {duration} · default goal", - "@homeSleepEstimateDefaultTight": { - "description": "Tightest sleep estimate caption: keeps the estimate marker, cycles, duration and the default-target qualifier.", - "placeholders": { - "cycles": { - "type": "int" - }, - "duration": { - "type": "String" - } - } - }, - "homeSleepEstimateDefaultMin": "~{cycles, plural, one{{cycles} cycle} other{{cycles} cycles}} · default goal", - "@homeSleepEstimateDefaultMin": { - "description": "Smallest default-target estimate caption: drops the duration, keeps the estimate marker, cycles and the default goal.", - "placeholders": { - "cycles": { - "type": "int" - } - } - }, - "homeSleepEstimateBare": "~{cycles, plural, one{{cycles} cycle} other{{cycles} cycles}}", - "@homeSleepEstimateBare": { - "description": "Smallest estimate caption: the estimate marker and the cycle count.", - "placeholders": { - "cycles": { - "type": "int" - } - } - }, - "homeSleepNoEstimate": "No estimate", - "@homeSleepNoEstimate": { - "description": "Sleep ring value when no night is scored and there is no wake time to estimate to." - }, - "homeSleepNoEstimateWhy": "No sleep scored yet, and no alarm or typical wake time to estimate from.", - "@homeSleepNoEstimateWhy": { - "description": "Why the sleep ring has no estimate." - }, "homeWhyRecoveryTitle": "Recovery is low", "@homeWhyRecoveryTitle": { "description": "Title of the Home card row that explains a low recovery score." diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index e9a99fff5..3a02ea0bc 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -1491,64 +1491,6 @@ } } }, - "homeSleepOnTrack": "en camino a {cycles, plural, =1{1 ciclo} other{{cycles} ciclos}}, {duration}", - "homeSleepOnTrackDefault": "en camino a {cycles, plural, =1{1 ciclo} other{{cycles} ciclos}}, {duration} · objetivo {target} (predeterminado)", - "homeSleepEstimateCompact": "~{cycles, plural, =1{1 ciclo} other{{cycles} ciclos}} · {duration}", - "@homeSleepEstimateCompact": { - "description": "Short sleep estimate: \"~\" marks it as an estimate; keeps cycle count and duration.", - "placeholders": { - "cycles": { - "type": "int" - }, - "duration": { - "type": "String" - } - } - }, - "homeSleepEstimateDefaultCompact": "~{cycles, plural, =1{1 ciclo} other{{cycles} ciclos}} · {duration} · objetivo predeterminado", - "@homeSleepEstimateDefaultCompact": { - "description": "Short sleep estimate keeping the \"~\" estimate marker and an explicit default-target qualifier.", - "placeholders": { - "cycles": { - "type": "int" - }, - "duration": { - "type": "String" - } - } - }, - "homeSleepEstimateDefaultTight": "~{cycles, plural, =1{1 ciclo} other{{cycles} ciclos}} · {duration} · meta por defecto", - "@homeSleepEstimateDefaultTight": { - "description": "Tightest sleep estimate caption: keeps the estimate marker, cycles, duration and the default-target qualifier.", - "placeholders": { - "cycles": { - "type": "int" - }, - "duration": { - "type": "String" - } - } - }, - "homeSleepEstimateDefaultMin": "~{cycles, plural, =1{1 ciclo} other{{cycles} ciclos}} · meta por defecto", - "@homeSleepEstimateDefaultMin": { - "description": "Smallest default-target estimate caption: drops the duration, keeps the estimate marker, cycles and the default goal.", - "placeholders": { - "cycles": { - "type": "int" - } - } - }, - "homeSleepEstimateBare": "~{cycles, plural, =1{1 ciclo} other{{cycles} ciclos}}", - "@homeSleepEstimateBare": { - "description": "Smallest estimate caption: the estimate marker and the cycle count.", - "placeholders": { - "cycles": { - "type": "int" - } - } - }, - "homeSleepNoEstimate": "Sin estimación", - "homeSleepNoEstimateWhy": "Aún no hay una noche puntuada y no hay alarma ni hora habitual de despertar para estimar.", "homeWhyRecoveryTitle": "La recuperación es baja", "homeWhyRecoveryPlain": "Recuperación {score} de 100", "@homeWhyRecoveryPlain": { diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 0e54ec6ca..8542cd7d9 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -1491,64 +1491,6 @@ } } }, - "homeSleepOnTrack": "sur la bonne voie pour {cycles, plural, =0{{cycles} cycles} one{{cycles} cycle} other{{cycles} cycles}}, {duration}", - "homeSleepOnTrackDefault": "sur la bonne voie pour {cycles, plural, =0{{cycles} cycles} one{{cycles} cycle} other{{cycles} cycles}}, {duration} · objectif {target} (par défaut)", - "homeSleepEstimateCompact": "~{cycles, plural, =0{{cycles} cycles} one{{cycles} cycle} other{{cycles} cycles}} · {duration}", - "@homeSleepEstimateCompact": { - "description": "Short sleep estimate: \"~\" marks it as an estimate; keeps cycle count and duration.", - "placeholders": { - "cycles": { - "type": "int" - }, - "duration": { - "type": "String" - } - } - }, - "homeSleepEstimateDefaultCompact": "~{cycles, plural, =0{{cycles} cycles} one{{cycles} cycle} other{{cycles} cycles}} · {duration} · objectif par défaut", - "@homeSleepEstimateDefaultCompact": { - "description": "Short sleep estimate keeping the \"~\" estimate marker and an explicit default-target qualifier.", - "placeholders": { - "cycles": { - "type": "int" - }, - "duration": { - "type": "String" - } - } - }, - "homeSleepEstimateDefaultTight": "~{cycles, plural, =0{{cycles} cycles} one{{cycles} cycle} other{{cycles} cycles}} · {duration} · cible par défaut", - "@homeSleepEstimateDefaultTight": { - "description": "Tightest sleep estimate caption: keeps the estimate marker, cycles, duration and the default-target qualifier.", - "placeholders": { - "cycles": { - "type": "int" - }, - "duration": { - "type": "String" - } - } - }, - "homeSleepEstimateDefaultMin": "~{cycles, plural, =0{{cycles} cycles} one{{cycles} cycle} other{{cycles} cycles}} · cible par défaut", - "@homeSleepEstimateDefaultMin": { - "description": "Smallest default-target estimate caption: drops the duration, keeps the estimate marker, cycles and the default goal.", - "placeholders": { - "cycles": { - "type": "int" - } - } - }, - "homeSleepEstimateBare": "~{cycles, plural, =0{{cycles} cycles} one{{cycles} cycle} other{{cycles} cycles}}", - "@homeSleepEstimateBare": { - "description": "Smallest estimate caption: the estimate marker and the cycle count.", - "placeholders": { - "cycles": { - "type": "int" - } - } - }, - "homeSleepNoEstimate": "Pas d'estimation", - "homeSleepNoEstimateWhy": "Aucune nuit n'est encore évaluée, et il n'y a ni alarme ni heure de réveil habituelle pour estimer.", "homeWhyRecoveryTitle": "Récupération faible", "homeWhyRecoveryPlain": "Récupération {score} sur 100", "@homeWhyRecoveryPlain": { diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index 4bc5db653..bfe03d300 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -1491,64 +1491,6 @@ } } }, - "homeSleepOnTrack": "{cycles, plural, =0{{cycles} चक्र} one{{cycles} चक्र} other{{cycles} चक्र}} की राह पर, {duration}", - "homeSleepOnTrackDefault": "{cycles, plural, =0{{cycles} चक्र} one{{cycles} चक्र} other{{cycles} चक्र}} की राह पर, {duration} · लक्ष्य {target} (डिफ़ॉल्ट)", - "homeSleepEstimateCompact": "~{cycles, plural, =0{{cycles} चक्र} one{{cycles} चक्र} other{{cycles} चक्र}} · {duration}", - "@homeSleepEstimateCompact": { - "description": "Short sleep estimate: \"~\" marks it as an estimate; keeps cycle count and duration.", - "placeholders": { - "cycles": { - "type": "int" - }, - "duration": { - "type": "String" - } - } - }, - "homeSleepEstimateDefaultCompact": "~{cycles, plural, =0{{cycles} चक्र} one{{cycles} चक्र} other{{cycles} चक्र}} · {duration} · डिफ़ॉल्ट लक्ष्य", - "@homeSleepEstimateDefaultCompact": { - "description": "Short sleep estimate keeping the \"~\" estimate marker and an explicit default-target qualifier.", - "placeholders": { - "cycles": { - "type": "int" - }, - "duration": { - "type": "String" - } - } - }, - "homeSleepEstimateDefaultTight": "~{cycles, plural, =0{{cycles} चक्र} one{{cycles} चक्र} other{{cycles} चक्र}} · {duration} · डिफ़ॉल्ट लक्ष्य", - "@homeSleepEstimateDefaultTight": { - "description": "Tightest sleep estimate caption: keeps the estimate marker, cycles, duration and the default-target qualifier.", - "placeholders": { - "cycles": { - "type": "int" - }, - "duration": { - "type": "String" - } - } - }, - "homeSleepEstimateDefaultMin": "~{cycles, plural, =0{{cycles} चक्र} one{{cycles} चक्र} other{{cycles} चक्र}} · डिफ़ॉल्ट लक्ष्य", - "@homeSleepEstimateDefaultMin": { - "description": "Smallest default-target estimate caption: drops the duration, keeps the estimate marker, cycles and the default goal.", - "placeholders": { - "cycles": { - "type": "int" - } - } - }, - "homeSleepEstimateBare": "~{cycles, plural, =0{{cycles} चक्र} one{{cycles} चक्र} other{{cycles} चक्र}}", - "@homeSleepEstimateBare": { - "description": "Smallest estimate caption: the estimate marker and the cycle count.", - "placeholders": { - "cycles": { - "type": "int" - } - } - }, - "homeSleepNoEstimate": "कोई अनुमान नहीं", - "homeSleepNoEstimateWhy": "अभी तक कोई रात स्कोर नहीं हुई है, और अनुमान के लिए कोई अलार्म या सामान्य जागने का समय नहीं है।", "homeWhyRecoveryTitle": "रिकवरी कम है", "homeWhyRecoveryPlain": "रिकवरी {score}/100", "@homeWhyRecoveryPlain": { diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index d3d1b7778..15c49634c 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -3418,90 +3418,6 @@ } } }, - "homeSleepOnTrack": "успеваете на {cycles, plural, one{{cycles} цикл} few{{cycles} цикла} many{{cycles} циклов} other{{cycles} цикла}}, {duration}", - "@homeSleepOnTrack": { - "placeholders": { - "cycles": { - "type": "int" - }, - "duration": { - "type": "String", - "example": "7h 30m" - } - } - }, - "homeSleepOnTrackDefault": "успеваете на {cycles, plural, one{{cycles} цикл} few{{cycles} цикла} many{{cycles} циклов} other{{cycles} цикла}}, {duration} · цель {target} (по умолчанию)", - "@homeSleepOnTrackDefault": { - "placeholders": { - "cycles": { - "type": "int" - }, - "duration": { - "type": "String", - "example": "7h 30m" - }, - "target": { - "type": "String", - "example": "8h 00m" - } - } - }, - "homeSleepEstimateCompact": "~{cycles, plural, one{{cycles} цикл} few{{cycles} цикла} many{{cycles} циклов} other{{cycles} цикла}} · {duration}", - "@homeSleepEstimateCompact": { - "description": "Short sleep estimate: \"~\" marks it as an estimate; keeps cycle count and duration.", - "placeholders": { - "cycles": { - "type": "int" - }, - "duration": { - "type": "String" - } - } - }, - "homeSleepEstimateDefaultCompact": "~{cycles, plural, one{{cycles} цикл} few{{cycles} цикла} many{{cycles} циклов} other{{cycles} цикла}} · {duration} · цель по умолчанию", - "@homeSleepEstimateDefaultCompact": { - "description": "Short sleep estimate keeping the \"~\" estimate marker and an explicit default-target qualifier.", - "placeholders": { - "cycles": { - "type": "int" - }, - "duration": { - "type": "String" - } - } - }, - "homeSleepEstimateDefaultTight": "~{cycles, plural, one{{cycles} цикл} few{{cycles} цикла} many{{cycles} циклов} other{{cycles} цикла}} · {duration} · цель по умолч.", - "@homeSleepEstimateDefaultTight": { - "description": "Tightest sleep estimate caption: keeps the estimate marker, cycles, duration and the default-target qualifier.", - "placeholders": { - "cycles": { - "type": "int" - }, - "duration": { - "type": "String" - } - } - }, - "homeSleepEstimateDefaultMin": "~{cycles, plural, one{{cycles} цикл} few{{cycles} цикла} many{{cycles} циклов} other{{cycles} цикла}} · цель по умолч.", - "@homeSleepEstimateDefaultMin": { - "description": "Smallest default-target estimate caption: drops the duration, keeps the estimate marker, cycles and the default goal.", - "placeholders": { - "cycles": { - "type": "int" - } - } - }, - "homeSleepEstimateBare": "~{cycles, plural, one{{cycles} цикл} few{{cycles} цикла} many{{cycles} циклов} other{{cycles} цикла}}", - "@homeSleepEstimateBare": { - "description": "Smallest estimate caption: the estimate marker and the cycle count.", - "placeholders": { - "cycles": { - "type": "int" - } - } - }, - "homeSleepNoEstimate": "Нет оценки", - "homeSleepNoEstimateWhy": "Сон ещё не оценён, и нет будильника или обычного времени подъёма для оценки.", "homeWhyRecoveryTitle": "Низкое восстановление", "homeWhyRecoveryPlain": "Восстановление {score} из 100", "@homeWhyRecoveryPlain": { diff --git a/lib/l10n/app_th.arb b/lib/l10n/app_th.arb index 8e8b323d2..2663e39d5 100644 --- a/lib/l10n/app_th.arb +++ b/lib/l10n/app_th.arb @@ -7405,98 +7405,6 @@ } } }, - "homeSleepOnTrack": "คาดว่าจะนอนได้ {cycles, plural, other{{cycles} รอบการนอน}} รวม {duration}", - "@homeSleepOnTrack": { - "description": "Sleep ring subtitle before a night is scored: whole sleep cycles the estimate covers, then the estimated time asleep.", - "placeholders": { - "cycles": { - "type": "int" - }, - "duration": { - "type": "String", - "example": "7h 30m" - } - } - }, - "homeSleepOnTrackDefault": "คาดว่าจะนอนได้ {cycles, plural, other{{cycles} รอบการนอน}} รวม {duration} · เป้าหมาย {target} (เริ่มต้น)", - "@homeSleepOnTrackDefault": { - "description": "Sleep ring subtitle before a night is scored when the target is the 8 h default (no need learned yet): the estimate, then the default target, labelled as a default.", - "placeholders": { - "cycles": { - "type": "int" - }, - "duration": { - "type": "String", - "example": "7h 30m" - }, - "target": { - "type": "String", - "example": "8h 00m" - } - } - }, - "homeSleepEstimateCompact": "~{cycles, plural, other{{cycles} รอบการนอน}} · {duration}", - "@homeSleepEstimateCompact": { - "description": "Short sleep estimate: \"~\" marks it as an estimate; keeps cycle count and duration.", - "placeholders": { - "cycles": { - "type": "int" - }, - "duration": { - "type": "String" - } - } - }, - "homeSleepEstimateDefaultCompact": "~{cycles, plural, other{{cycles} รอบการนอน}} · {duration} · เป้าหมายเริ่มต้น", - "@homeSleepEstimateDefaultCompact": { - "description": "Short sleep estimate keeping the \"~\" estimate marker and an explicit default-target qualifier.", - "placeholders": { - "cycles": { - "type": "int" - }, - "duration": { - "type": "String" - } - } - }, - "homeSleepEstimateDefaultTight": "~{cycles, plural, other{{cycles} รอบการนอน}} · {duration} · เป้าเริ่มต้น", - "@homeSleepEstimateDefaultTight": { - "description": "Tightest sleep estimate caption: keeps the estimate marker, cycles, duration and the default-target qualifier.", - "placeholders": { - "cycles": { - "type": "int" - }, - "duration": { - "type": "String" - } - } - }, - "homeSleepEstimateDefaultMin": "~{cycles, plural, other{{cycles} รอบการนอน}} · เป้าเริ่มต้น", - "@homeSleepEstimateDefaultMin": { - "description": "Smallest default-target estimate caption: drops the duration, keeps the estimate marker, cycles and the default goal.", - "placeholders": { - "cycles": { - "type": "int" - } - } - }, - "homeSleepEstimateBare": "~{cycles, plural, other{{cycles} รอบการนอน}}", - "@homeSleepEstimateBare": { - "description": "Smallest estimate caption: the estimate marker and the cycle count.", - "placeholders": { - "cycles": { - "type": "int" - } - } - }, - "homeSleepNoEstimate": "ยังประมาณไม่ได้", - "@homeSleepNoEstimate": { - "description": "Sleep ring value when no night is scored and there is no wake time to estimate to." - }, - "homeSleepNoEstimateWhy": "ยังไม่มีการนอนที่คำนวณคะแนน และไม่มีเวลาปลุกหรือเวลาตื่นปกติให้ใช้ประมาณ", - "@homeSleepNoEstimateWhy": { - "description": "Why the sleep ring has no estimate." - }, "homeWhyRecoveryTitle": "การฟื้นตัวต่ำ", "@homeWhyRecoveryTitle": { "description": "Title of the Home card row that explains a low recovery score." diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index c61a936d4..31689538e 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -1491,64 +1491,6 @@ } } }, - "homeSleepOnTrack": "有望达到 {cycles} 个周期,{duration}", - "homeSleepOnTrackDefault": "有望达到 {cycles} 个周期,{duration} · 目标 {target}(默认)", - "homeSleepEstimateCompact": "~{cycles} 个周期 · {duration}", - "@homeSleepEstimateCompact": { - "description": "Short sleep estimate: \"~\" marks it as an estimate; keeps cycle count and duration.", - "placeholders": { - "cycles": { - "type": "int" - }, - "duration": { - "type": "String" - } - } - }, - "homeSleepEstimateDefaultCompact": "~{cycles} 个周期 · {duration} · 默认目标", - "@homeSleepEstimateDefaultCompact": { - "description": "Short sleep estimate keeping the \"~\" estimate marker and an explicit default-target qualifier.", - "placeholders": { - "cycles": { - "type": "int" - }, - "duration": { - "type": "String" - } - } - }, - "homeSleepEstimateDefaultTight": "~{cycles} 个周期 · {duration} · 默认目标", - "@homeSleepEstimateDefaultTight": { - "description": "Tightest sleep estimate caption: keeps the estimate marker, cycles, duration and the default-target qualifier.", - "placeholders": { - "cycles": { - "type": "int" - }, - "duration": { - "type": "String" - } - } - }, - "homeSleepEstimateDefaultMin": "~{cycles} 个周期 · 默认目标", - "@homeSleepEstimateDefaultMin": { - "description": "Smallest default-target estimate caption: drops the duration, keeps the estimate marker, cycles and the default goal.", - "placeholders": { - "cycles": { - "type": "int" - } - } - }, - "homeSleepEstimateBare": "~{cycles} 个周期", - "@homeSleepEstimateBare": { - "description": "Smallest estimate caption: the estimate marker and the cycle count.", - "placeholders": { - "cycles": { - "type": "int" - } - } - }, - "homeSleepNoEstimate": "暂无估算", - "homeSleepNoEstimateWhy": "尚无已评分的夜晚,也没有闹钟或常规起床时间可供估算。", "homeWhyRecoveryTitle": "恢复状态偏低", "homeWhyRecoveryPlain": "恢复 {score}/100", "@homeWhyRecoveryPlain": { diff --git a/lib/ui2/screens/home_screen.dart b/lib/ui2/screens/home_screen.dart index 6051bff03..6483ac1a9 100644 --- a/lib/ui2/screens/home_screen.dart +++ b/lib/ui2/screens/home_screen.dart @@ -317,17 +317,6 @@ Widget syncedThroughLine(BuildContext c, String? todayId, ); } -/// The next armed alarm, or null with none held (or in a golden). May be an -/// instant already past: the sleep ring ignores those. -DateTime? nextAlarmOf(BuildContext c) { - try { - final e = c.select((a) => a.alarmEpoch); - return e == null ? null : DateTime.fromMillisecondsSinceEpoch(e * 1000); - } catch (_) { - return null; - } -} - bool derivingOf(BuildContext c) { try { return c.select((a) => a.deriving || a.derivePending); @@ -870,29 +859,17 @@ class RingTrio extends StatelessWidget { /// worth pushing onto. final void Function(HomeRingKind)? onOpen; - /// The instant the sleep ring measures an unslept night's estimate from. - /// Injected by tests; production reads `clock.now()`. - final DateTime? now; - - const RingTrio({super.key, required this.d, this.onOpen, this.now}); + const RingTrio({super.key, required this.d, this.onOpen}); /// Whether ANY of the three has something to draw. When none do, the screen /// owes the user one written absence, not three empty circles. - static bool has(HomeData d, {DateTime? now}) { - final at = now ?? pc.clock.now(); - return HomeRingKind.values.any((k) => _ringOf(k, d, null, at).why == null); - } - - /// Whether the sleep ring can estimate tonight's sleep, and so has something - /// to draw even when nothing else on the day does. - static bool hasSleepEstimate(HomeData d, {DateTime? now}) => - _ringOf(HomeRingKind.sleep, d, null, now ?? pc.clock.now()).estimated; + static bool has(HomeData d) => + HomeRingKind.values.any((k) => _ringOf(k, d, null).why == null); @override Widget build(BuildContext c) { final l = AppLocalizations.of(c); - final at = now ?? pc.clock.now(); - final rings = [for (final k in HomeRingKind.values) _ringOf(k, d, l, at)]; + final rings = [for (final k in HomeRingKind.values) _ringOf(k, d, l)]; final gaps = rings.where((r) => r.why != null).toList(); // THERE IS NO "THESE TWO ARE FROM SATURDAY" LINE ANY MORE, and there is // nothing left for one to explain. Recovery and sleep used to be served @@ -909,8 +886,8 @@ class RingTrio extends StatelessWidget { // win, in this order: why a ring is low, what changed the recovery number, // then an absent ring's reason. final rows = [ - for (final k in pressingRings(d, now: at)) - _HomeWhyPanel(kind: k, data: d, now: at, onTap: _open(k)), + for (final k in pressingRings(d)) + _HomeWhyPanel(kind: k, data: d, onTap: _open(k)), if (recoveryUpdatedLine(d.readinessUpdate, l) case final note?) _HomeUpdateNote(line: note, onTap: _open(HomeRingKind.recovery)), for (final r in gaps) @@ -965,15 +942,13 @@ class RingTrio extends StatelessWidget { class _HomeWhyPanel extends StatelessWidget { final HomeRingKind kind; final HomeData data; - final DateTime now; final VoidCallback? onTap; - const _HomeWhyPanel( - {required this.kind, required this.data, required this.now, this.onTap}); + const _HomeWhyPanel({required this.kind, required this.data, this.onTap}); @override Widget build(BuildContext c) { final l = AppLocalizations.of(c); - final state = _ringOf(kind, data, l, now); + final state = _ringOf(kind, data, l); final title = switch (kind) { HomeRingKind.recovery => l?.homeWhyRecoveryTitle ?? 'Recovery is low', HomeRingKind.sleep => l?.homeWhySleepTitle ?? 'Sleep is short of need', @@ -1211,7 +1186,7 @@ enum HomeRingKind { recovery, strain, sleep } /// recovery under 37, sleep under 85% of need, strain above the top of today's /// target range. A value that is absent, provisional or still being scored is /// never pressing (never fabricate). -List pressingRings(HomeData d, {DateTime? now}) { +List pressingRings(HomeData d) { final ranked = <(HomeRingKind, int)>[]; final recovery = d.readiness.value; if (recovery != null && @@ -1243,30 +1218,13 @@ List pressingRings(HomeData d, {DateTime? now}) { return [for (final item in ranked.take(2)) item.$1]; } -/// How long until the next whole minute after [now], in (0, 60 s]. -/// -/// Taken from the UTC instant, never from local wall-clock fields: a minute -/// boundary is the same instant in every whole-, half- and quarter-hour zone, -/// but `DateTime(y, m, d, h, min + 1)` in the repeated fall-back hour resolves -/// to the FIRST occurrence of that wall time, an hour in the past: a negative -/// delay, a zero-delay Timer, and a setState loop. -@visibleForTesting -Duration nextMinuteTickDelay(DateTime now) { - const minuteUs = 60 * 1000000; - final us = now.toUtc().microsecondsSinceEpoch; - final next = DateTime.fromMicrosecondsSinceEpoch( - us - us % minuteUs + minuteUs, - isUtc: true); - return next.difference(now.toUtc()); -} - /// One ring's resolved state — the only place a metric becomes a shape. class _RingState { final HomeRingKind kind; final String label, value, sub; /// Shorter wordings of [sub], widest first, that keep its meaning (the - /// estimate marker, the default-target qualifier). The caption tries + /// default-target qualifier). The caption tries /// [sub], then each of these, and takes the first that fits. final List alts; final IconData icon; @@ -1291,10 +1249,6 @@ class _RingState { /// arc. Null for every other ring and state. final List? arcs; - /// An ESTIMATE of tonight's sleep, not a reading: drawn in the muted ink, - /// never a stage colour, and never solid. - final bool estimated; - const _RingState( this.kind, this.label, @@ -1310,7 +1264,6 @@ class _RingState { this.why, this.provisional = false, this.arcs, - this.estimated = false, }); /// A number that may still move (the night is still draining), drawn grey. @@ -1318,26 +1271,20 @@ class _RingState { /// A number the ring is actually reporting. Calibration is progress, not a /// reading, so it is not one. - bool get measured => why == null && !calibrating && !estimated; + bool get measured => why == null && !calibrating; - /// Whether the value is a number to set in numerals (a reading or an - /// estimate) rather than an absence in words. - bool get showsNumber => why == null && !calibrating; - - Color arc(P p) => - calibrating || estimated || provisional ? p.ink3 : p.on(color); + Color arc(P p) => calibrating || provisional ? p.ink3 : p.on(color); Color ink(P p) => measured && !provisional ? p.on(color) : p.ink3; String get spoken => [ label, - showsNumber ? value : value.toLowerCase(), + measured ? value : value.toLowerCase(), if (sub.isNotEmpty) sub, ?why, ].join('. '); } -_RingState _ringOf( - HomeRingKind k, HomeData d, AppLocalizations? l, DateTime now) { +_RingState _ringOf(HomeRingKind k, HomeData d, AppLocalizations? l) { switch (k) { case HomeRingKind.recovery: final v = d.readiness.value; @@ -1394,96 +1341,42 @@ _RingState _ringOf( ); case HomeRingKind.sleep: final label = l?.homeRingSleep ?? 'Sleep'; - // A baseline still filling is progress, not an absence, and is drawn - // exactly as before: dashed, ahead of any estimate. - if (d.sleepMin.value == null && - baselineCountsFromNote(d.sleepMin.note) != null) { + final v = d.sleepMin.value; + if (v == null) { return _gap(k, label, LucideIcons.moon, C.blue, d.sleepMin, - l?.homeRingNoSleep ?? 'No sleep', l); + l?.homeRingNoSleep ?? 'No sleep', l, + fallbackWhy: l?.homeSleepGapFallback ?? + 'No night long enough to score was recorded.'); } final m = sleepRingModel( - now: now, - durationMin: d.sleepMin.value, + durationMin: v, stages: d.sleepStages, needMin: d.sleepNeedMin.value, - coachBedtimeMinOfDay: d.bedtime.value, - nextAlarm: d.nextAlarm, - learnedOnsetMin: d.learnedOnsetMin, - learnedWakeMin: d.learnedWakeMin, - cycleLenMin: d.cycleLenMin, ); - switch (m.phase) { - case SleepRingPhase.slept: - final target = hm(m.targetMin); - return _RingState(k, label, LucideIcons.moon, C.blue, - value: hm(m.asleepMin), - // The target is the learned need, or the 8 h default — and the - // default says so. It is never passed off as this user's need. - sub: m.targetIsDefault - ? (l?.homeSleepPctOfDefault(m.pct!, target) ?? - '${m.pct}% of $target (default)') - : (l?.homeSleepPctOf(m.pct!, target) ?? - '${m.pct}% of $target'), - alts: m.targetIsDefault - ? [ - l?.homeSleepPctDefaultCompact( - m.pct!, _compactHm(m.targetMin, l)) ?? - '${m.pct}% of ${_compactHm(m.targetMin, l)} · default target', - l?.homeSleepPctDefaultTight( - m.pct!, _compactHm(m.targetMin, l)) ?? - '${m.pct}% of default ${_compactHm(m.targetMin, l)}', - ] - : [ - l?.homeSleepPctCompact( - m.pct!, _compactHm(m.targetMin, l)) ?? - '${m.pct}% of ${_compactHm(m.targetMin, l)}', - ], - frac: m.sweep, - arcs: m.arcs); - case SleepRingPhase.estimate: - final est = hm(m.estimateMin); - return _RingState(k, label, LucideIcons.moon, C.blue, - value: est, - // The ring is filled against the target, so the default says - // so here too — it is never passed off as this user's need. - sub: m.targetIsDefault - ? (l?.homeSleepOnTrackDefault( - m.cycles!, est, hm(m.targetMin)) ?? - 'on track for ${m.cycles} ' - '${m.cycles == 1 ? 'cycle' : 'cycles'}, $est' - ' · target ${hm(m.targetMin)} (default)') - : (l?.homeSleepOnTrack(m.cycles!, est) ?? - 'on track for ${m.cycles} ' - '${m.cycles == 1 ? 'cycle' : 'cycles'}, $est'), - // "~" marks an estimate in every compact form. - alts: m.targetIsDefault - ? [ - l?.homeSleepEstimateDefaultCompact( - m.cycles!, _compactHm(m.estimateMin, l)) ?? - '~${m.cycles} cycles · ${_compactHm(m.estimateMin, l)} · default target', - l?.homeSleepEstimateDefaultTight( - m.cycles!, _compactHm(m.estimateMin, l)) ?? - '~${m.cycles} cycles · ${_compactHm(m.estimateMin, l)} · default goal', - // When even the tight wording needs more than three - // lines: drop the duration, never the qualifiers. - l?.homeSleepEstimateDefaultMin(m.cycles!) ?? - '~${m.cycles} cycles · default goal', - ] - : [ - l?.homeSleepEstimateCompact( - m.cycles!, _compactHm(m.estimateMin, l)) ?? - '~${m.cycles} cycles · ${_compactHm(m.estimateMin, l)}', - l?.homeSleepEstimateBare(m.cycles!) ?? '~${m.cycles} cycles', - ], - frac: m.sweep, - estimated: true); - case SleepRingPhase.none: - return _gap(k, label, LucideIcons.moon, C.blue, d.sleepMin, - l?.homeSleepNoEstimate ?? 'No estimate', l, - fallbackWhy: l?.homeSleepNoEstimateWhy ?? - 'No sleep scored yet, and no alarm or typical wake time ' - 'to estimate from.'); - } + final target = hm(m.targetMin); + return _RingState(k, label, LucideIcons.moon, C.blue, + value: hm(m.asleepMin), + // The target is the learned need, or the 8 h default — and the + // default says so. It is never passed off as this user's need. + sub: m.targetIsDefault + ? (l?.homeSleepPctOfDefault(m.pct, target) ?? + '${m.pct}% of $target (default)') + : (l?.homeSleepPctOf(m.pct, target) ?? '${m.pct}% of $target'), + alts: m.targetIsDefault + ? [ + l?.homeSleepPctDefaultCompact( + m.pct, _compactHm(m.targetMin, l)) ?? + '${m.pct}% of ${_compactHm(m.targetMin, l)} · default target', + l?.homeSleepPctDefaultTight( + m.pct, _compactHm(m.targetMin, l)) ?? + '${m.pct}% of default ${_compactHm(m.targetMin, l)}', + ] + : [ + l?.homeSleepPctCompact(m.pct, _compactHm(m.targetMin, l)) ?? + '${m.pct}% of ${_compactHm(m.targetMin, l)}', + ], + frac: m.sweep, + arcs: m.arcs); } } @@ -1726,7 +1619,7 @@ class _RingText extends StatelessWidget { // takes the sentence weight rather than the numeral one. Text( presentationText(AppLocalizations.of(c), r.value), - style: r.showsNumber + style: r.measured ? F.n24.copyWith(color: r.provisional ? p.ink3 : p.ink) : F.body.copyWith(color: p.ink2), textAlign: align, @@ -1860,19 +1753,6 @@ class HomeData { /// another night. final SleepStageMin? sleepStages; - /// The next armed alarm. It lives on AppState, not in the repository, so the - /// screen lays it over what loaded ([withSleepInputs]). - final DateTime? nextAlarm; - - /// The median local clock minute of the person's recent onsets and wakes - /// (`learnedClockMinutes`), the unslept estimate's fallback sources. Null - /// until there are enough nights. - final int? learnedOnsetMin, learnedWakeMin; - - /// A per-user sleep-cycle length in minutes. Nothing sets it yet; the model - /// falls back to 90. - final int? cycleLenMin; - const HomeData({ this.name, this.dayId, @@ -1896,38 +1776,12 @@ class HomeData { this.recoveryState, this.readinessUpdate, this.sleepStages, - this.nextAlarm, - this.learnedOnsetMin, - this.learnedWakeMin, - this.cycleLenMin, }); /// The three illness fields, replaced together. Test-facing sugar, and they /// travel as a set on purpose — they are read as one envelope, and setting /// one without the others describes a state the pipeline cannot produce. - HomeData copyOrIllness(String? state, String? day, double? z) => _copy( - illnessState: state, - illnessDay: day, - illnessZ: z, - nextAlarm: nextAlarm, - ); - - /// The same data with the armed alarm replaced: it lives on AppState, not in - /// the repository, so the screen lays it over what loaded. - HomeData withSleepInputs({DateTime? alarm}) => _copy( - illnessState: illnessState, - illnessDay: illnessDay, - illnessZ: illnessZ, - nextAlarm: alarm, - ); - - HomeData _copy({ - required String? illnessState, - required String? illnessDay, - required double? illnessZ, - required DateTime? nextAlarm, - }) => - HomeData( + HomeData copyOrIllness(String? state, String? day, double? z) => HomeData( name: name, dayId: dayId, readiness: readiness, @@ -1942,14 +1796,10 @@ class HomeData { bedtime: bedtime, strainTarget: strainTarget, sleepStages: sleepStages, - nextAlarm: nextAlarm, - learnedOnsetMin: learnedOnsetMin, - learnedWakeMin: learnedWakeMin, - cycleLenMin: cycleLenMin, heldOverNight: heldOverNight, - illnessState: illnessState, - illnessDay: illnessDay, - illnessZ: illnessZ, + illnessState: state, + illnessDay: day, + illnessZ: z, insightsStale: insightsStale, absentDiag: absentDiag, recoveryState: recoveryState, @@ -2055,14 +1905,6 @@ class HomeData { final heldOver = heldOverNightOf(today); final sleepMin = overnightMetric(today, s('duration_min'), l); - // Typical onset/wake for the unslept estimate. The armed alarm is - // AppState's and is laid over this by the screen ([withSleepInputs]); a - // failed read here is simply absent. - ({int? onsetMin, int? wakeMin}) learned = (onsetMin: null, wakeMin: null); - try { - learned = learnedClockMinutes(await repo.sleepWindows(days: 14)); - } catch (_) {} - // Same envelope Health reads. The watch runs on NOCTURNAL RESTING HEART // RATE ALONE — it has never been given a temperature series — so nothing // here may imply a second signal. @@ -2098,8 +1940,6 @@ class HomeData { strain: metricOf(d('strain')), sleepMin: sleepMin, sleepStages: stages, - learnedOnsetMin: learned.onsetMin, - learnedWakeMin: learned.wakeMin, rhr: overnightMetric(today, d('resting_hr'), l), steps: metricOf(d('steps')), calories: metricOf(d('calories')), @@ -2232,36 +2072,14 @@ class _HomeScreenState extends State with RevisionReload { }); } - /// Re-renders on each whole minute, so the unslept estimate (which counts - /// down to the wake) follows the clock while the screen stays open. It - /// rides the same lifecycle as the rest of this screen: stopped while the - /// app is hidden, re-armed and refreshed on the way back. - Timer? _tick; - - void _armTick() { - _tick?.cancel(); - _tick = Timer(nextMinuteTickDelay(pc.clock.now()), () { - if (!mounted) return; - setState(() {}); - _armTick(); - }); - } - - void _stopTick() { - _tick?.cancel(); - _tick = null; - } - @override void dispose() { _syncTapTimer?.cancel(); - _stopTick(); _lifecycle?.dispose(); super.dispose(); } void _onHide() { - _stopTick(); _dayAtHide = todayLabel(widget.now ?? debugHomeWallClock()); _hiddenAt = debugHomeWallClock(); _away @@ -2286,8 +2104,6 @@ class _HomeScreenState extends State with RevisionReload { /// for good if the read failed, since the error card only shows with no day. void _onReturnFromBackground() { if (!mounted) return; - _armTick(); - setState(() {}); final hiddenAt = _hiddenAt; final wall = hiddenAt == null ? Duration.zero @@ -2316,7 +2132,6 @@ class _HomeScreenState extends State with RevisionReload { @override void initState() { super.initState(); - _armTick(); if (widget.data != null) { _d = widget.data; _loading = false; @@ -2576,7 +2391,7 @@ class _HomeScreenState extends State with RevisionReload { // them apart: whether this install has ever scored a night. "No band // recordings processed yet" said to someone with three months of history is // the first-run answer to a gap, and it is wrong. - final noData = d.readiness.isEmpty && + final bare = d.readiness.isEmpty && d.sleepMin.isEmpty && d.strain.isEmpty && d.rhr.isEmpty && @@ -2588,16 +2403,6 @@ class _HomeScreenState extends State with RevisionReload { // today and reads as a stale instruction on a day already in the past. final isToday = _day == null || _day == todayLabel(); - // The armed alarm is AppState's, and only TODAY's rings estimate toward it: - // a past day with no scored sleep must not claim to be "on track" (the - // loaders never set the estimate inputs for a dated day either). - final ringData = - isToday ? d.withSleepInputs(alarm: nextAlarmOf(c) ?? d.nextAlarm) : d; - // A day with no measured data is not bare when tonight's estimate can be - // made: an armed alarm or typical sleep times are something to show. - final bare = noData && - !(isToday && RingTrio.hasSleepEstimate(ringData, now: widget.now)); - // Until recovery can score for the first time: what arrives when. // Also while the readiness note still counts nights: missed nights push // the first score past the day-15 the count alone would show. @@ -2723,10 +2528,9 @@ class _HomeScreenState extends State with RevisionReload { // diagnostic used to go nowhere but a Firebase breadcrumb. It belongs // one tap away, on the Readiness screen: a wall of per-input // diagnostics on Home makes the app read as broken. - if (RingTrio.has(ringData, now: widget.now)) + if (RingTrio.has(d)) RingTrio( - d: ringData, - now: widget.now, + d: d, onOpen: (k) => go( c, switch (k) { diff --git a/lib/ui2/sleep_ring_model.dart b/lib/ui2/sleep_ring_model.dart index fbb938dca..6c6290ad4 100644 --- a/lib/ui2/sleep_ring_model.dart +++ b/lib/ui2/sleep_ring_model.dart @@ -1,8 +1,7 @@ // sleep_ring_model.dart — what the Home sleep ring says and draws, as data. // -// PURE. No clock (the caller passes `now`), no I/O, no widgets, no l10n: the -// ring turns this into shapes and strings. Kept apart so every rule below is a -// fixed-date unit test, including the DST night. +// PURE. No clock, no I/O, no widgets, no l10n: the ring turns this into shapes +// and strings. Kept apart so every rule below is a fixed-input unit test. // import 'dart:math' as math; @@ -10,16 +9,7 @@ import 'dart:math' as math; /// learned need exists, and always labelled as a default where it is shown. const int kDefaultSleepTargetMin = 480; -/// Assumed length of one sleep cycle, until a per-user value exists. -const int kDefaultCycleLenMin = 90; - -enum SleepRingPhase { slept, estimate, none } - -enum SleepArcKind { deep, rem, light, awake, solid, estimate } - -enum WindowStartSource { coach, learned } - -enum WakeSource { alarm, learned } +enum SleepArcKind { deep, rem, light, awake, solid } /// One night's stage totals, minutes. Null means the pipeline had no figure. class SleepStageMin { @@ -35,152 +25,42 @@ class SleepArc { } class SleepRingModel { - final SleepRingPhase phase; final List arcs; final int targetMin; final bool targetIsDefault; - /// Slept: minutes asleep. Null otherwise. - final int? asleepMin; - - /// Slept: whole percent of [targetMin]. Null otherwise. - final int? pct; + /// Minutes asleep. + final int asleepMin; - /// Estimate: minutes between the window start and the wake. Null otherwise. - final int? estimateMin; - - /// Estimate: whole cycles in [estimateMin]. Null otherwise. - final int? cycles; - final int cycleLenMin; - - /// Estimate: the instants the estimate was measured between, and where each - /// came from. [startSource] is null when the estimate runs from `now`. - final DateTime? windowStart, wakeAt; - final WindowStartSource? startSource; - final WakeSource? wakeSource; + /// Whole percent of [targetMin]. + final int pct; const SleepRingModel({ - required this.phase, - this.arcs = const [], - this.targetMin = kDefaultSleepTargetMin, - this.targetIsDefault = true, - this.asleepMin, - this.pct, - this.estimateMin, - this.cycles, - this.cycleLenMin = kDefaultCycleLenMin, - this.windowStart, - this.wakeAt, - this.startSource, - this.wakeSource, + required this.arcs, + required this.targetMin, + required this.targetIsDefault, + required this.asleepMin, + required this.pct, }); /// Total sweep of the ring in laps: 1.0 is a full circle, above 1 a spiral. double get sweep => arcs.fold(0.0, (a, e) => a + e.fraction); } -/// Cycle lengths outside this are not a sleep cycle; the 90 min assumption -/// stands instead. -const int _minCycleLenMin = 60, _maxCycleLenMin = 150; - +/// The scored night as a ring: the target (learned need, else the flagged 8 h +/// default), the percent of it, and the stage arcs. SleepRingModel sleepRingModel({ - required DateTime now, - num? durationMin, + required num durationMin, SleepStageMin? stages, num? needMin, - num? coachBedtimeMinOfDay, - DateTime? nextAlarm, - int? learnedOnsetMin, - int? learnedWakeMin, - int? cycleLenMin, }) { final learnedNeed = needMin != null && needMin > 0; final target = learnedNeed ? needMin.round() : kDefaultSleepTargetMin; - final isDefault = !learnedNeed; - final cycleLen = cycleLenMin != null && - cycleLenMin >= _minCycleLenMin && - cycleLenMin <= _maxCycleLenMin - ? cycleLenMin - : kDefaultCycleLenMin; - - if (durationMin != null) { - return _slept(durationMin, stages, target, isDefault, cycleLen); - } - - // ── unslept: wake ──────────────────────────────────────────────────────── - final n = now.toLocal(); - DateTime? wake; - WakeSource? wakeSource; - // Tonight is the night that starts this evening and ends tomorrow morning, - // so an alarm after tomorrow's local noon is not tonight's wake (a weekday - // alarm seen from Friday evening) and the learned wake answers instead. The - // boundary is a local calendar point, not a duration: tomorrow's 07:00 - // alarm is 24 h 30 min away across a fall-back and 23 h 30 min across a - // spring-forward, and is tonight's in both. - // Before 04:00 the user is already inside the night that started yesterday - // evening, so it ends at today's noon instead. - final horizon = DateTime(n.year, n.month, n.day + (n.hour < 4 ? 0 : 1), 12); - if (nextAlarm != null && - nextAlarm.isAfter(now) && - !nextAlarm.isAfter(horizon)) { - wake = nextAlarm.toLocal(); - wakeSource = WakeSource.alarm; - } else if (learnedWakeMin != null) { - wake = _nextClock(n, learnedWakeMin); - wakeSource = WakeSource.learned; - } - if (wake == null) { - return SleepRingModel( - phase: SleepRingPhase.none, - targetMin: target, - targetIsDefault: isDefault, - cycleLenMin: cycleLen, - ); - } - - // ── unslept: window start ──────────────────────────────────────────────── - int? onsetMinute; - WindowStartSource? startSource; - if (coachBedtimeMinOfDay != null) { - onsetMinute = coachBedtimeMinOfDay.round() % 1440; - startSource = WindowStartSource.coach; - } else if (learnedOnsetMin != null) { - onsetMinute = learnedOnsetMin; - startSource = WindowStartSource.learned; - } - DateTime start = n; - if (onsetMinute != null) { - final onset = _clockBefore(wake, onsetMinute); - if (onset.isAfter(n)) start = onset; - } - - // Real elapsed time between two instants, so a DST night is 7 h or 9 h. - final est = wake.difference(start).inMinutes; - if (est <= 0) { - return SleepRingModel( - phase: SleepRingPhase.none, - targetMin: target, - targetIsDefault: isDefault, - cycleLenMin: cycleLen, - ); - } - return SleepRingModel( - phase: SleepRingPhase.estimate, - arcs: [SleepArc(SleepArcKind.estimate, math.min(1.0, est / target))], - targetMin: target, - targetIsDefault: isDefault, - estimateMin: est, - cycles: est ~/ cycleLen, - cycleLenMin: cycleLen, - windowStart: start, - wakeAt: wake, - startSource: startSource, - wakeSource: wakeSource, - ); + return _slept(durationMin, stages, target, !learnedNeed); } -SleepRingModel _slept(num durationMin, SleepStageMin? stages, int target, - bool isDefault, int cycleLen) { +SleepRingModel _slept( + num durationMin, SleepStageMin? stages, int target, bool isDefault) { final asleep = durationMin.round(); final asleepFrac = asleep / target; final pct = (asleepFrac * 100).round(); @@ -212,67 +92,10 @@ SleepRingModel _slept(num durationMin, SleepStageMin? stages, int target, arcs.add(SleepArc(SleepArcKind.solid, asleepFrac)); } return SleepRingModel( - phase: SleepRingPhase.slept, arcs: arcs, targetMin: target, targetIsDefault: isDefault, asleepMin: asleep, pct: pct, - cycleLenMin: cycleLen, ); } - -/// The first local [minuteOfDay] strictly after [from]. Calendar arithmetic, -/// not `+ 24 h`, so the wall-clock time survives a DST change. -DateTime _nextClock(DateTime from, int minuteOfDay) { - final today = DateTime( - from.year, from.month, from.day, minuteOfDay ~/ 60, minuteOfDay % 60); - return today.isAfter(from) - ? today - : DateTime(from.year, from.month, from.day + 1, minuteOfDay ~/ 60, - minuteOfDay % 60); -} - -/// The latest local [minuteOfDay] strictly before [wake]. -DateTime _clockBefore(DateTime wake, int minuteOfDay) { - final same = DateTime( - wake.year, wake.month, wake.day, minuteOfDay ~/ 60, minuteOfDay % 60); - return same.isBefore(wake) - ? same - : DateTime(wake.year, wake.month, wake.day - 1, minuteOfDay ~/ 60, - minuteOfDay % 60); -} - -/// Median local clock minute-of-day of the onsets and of the wakes in -/// [windows] (`LocalRepository.sleepWindows` rows: epoch-second `onset_ts` / -/// `wake_ts`, either may be null). A side is null with fewer than -/// [minSamples] usable nights. Onsets are medianed on a noon-to-noon axis so -/// 23:30 and 00:10 average to ~23:50, not 11:50. -({int? onsetMin, int? wakeMin}) learnedClockMinutes( - List> windows, { - int minSamples = 3, -}) { - int minuteOf(num sec) { - final t = DateTime.fromMillisecondsSinceEpoch(sec.toInt() * 1000); - return t.hour * 60 + t.minute; - } - - int? median(List v) { - if (v.length < minSamples || v.isEmpty) return null; - v.sort(); - final mid = v.length ~/ 2; - return v.length.isOdd ? v[mid] : ((v[mid - 1] + v[mid]) / 2).round(); - } - - final onsets = [], wakes = []; - for (final w in windows) { - final o = w['onset_ts'], k = w['wake_ts']; - if (o is num) { - final m = minuteOf(o); - onsets.add(m < 720 ? m + 1440 : m); - } - if (k is num) wakes.add(minuteOf(k)); - } - final on = median(onsets); - return (onsetMin: on == null ? null : on % 1440, wakeMin: median(wakes)); -} diff --git a/test/home_day_switch_decode_once_test.dart b/test/home_day_switch_decode_once_test.dart index 9bde02b48..d86547096 100644 --- a/test/home_day_switch_decode_once_test.dart +++ b/test/home_day_switch_decode_once_test.dart @@ -137,28 +137,24 @@ void main() { }, }; - test('one stage read (only for a scored night) and one windows read', + test('one stage read, and only for a scored night', () async { final scored = _CountingRepo(today(slept: true)); await HomeData.load(scored); expect(scored.nightReads, 1); - expect(scored.windowReads, 1); expect(scored.insightReads, 1); // No scored night: no stage read at all, and nothing borrowed. final bare = _CountingRepo(today(slept: false)); final d = await HomeData.load(bare); expect(bare.nightReads, 0); - expect(bare.windowReads, 1); expect(d.sleepStages, isNull); }); - test('a failed stage or windows read leaves the figure absent, not a ' - 'failed Home', () async { + test('a failed stage read leaves the figure absent, not a failed Home', () async { final repo = _CountingRepo(today(slept: true), throwing: true); final d = await HomeData.load(repo); expect(d.sleepStages, isNull); - expect(d.learnedOnsetMin, isNull); expect(d.sleepMin.value, 442); }); }); @@ -168,7 +164,7 @@ class _CountingRepo extends LocalRepository { _CountingRepo(this._today, {this.throwing = false}); final Map _today; final bool throwing; - int nightReads = 0, windowReads = 0, insightReads = 0; + int nightReads = 0, insightReads = 0; @override Future> getToday() async => _today; @@ -186,12 +182,4 @@ class _CountingRepo extends LocalRepository { if (throwing) throw StateError('read failed'); return {'has_sleep': true, 'deep_min': 70, 'rem_min': 95, 'light_min': 277}; } - - @override - Future>> sleepWindows( - {int days = 60, String? before}) async { - windowReads++; - if (throwing) throw StateError('read failed'); - return const []; - } } diff --git a/test/home_labels_test.dart b/test/home_labels_test.dart index cf7c95695..911638b34 100644 --- a/test/home_labels_test.dart +++ b/test/home_labels_test.dart @@ -2,8 +2,7 @@ // upgrade, each pinned where it can be checked without a screen. // // 1. NEW COPY, ALL LOCALES. The sleep ring's wording ("92% of 8h 00m -// (default)", "on track for 5 cycles ...", compact forms) and the why panels -// add 25 message keys. Every locale file (glob, so a newly added locale is +// (default)", compact forms) and the why panels add message keys. Every locale file (glob, so a newly added locale is // covered; today de en es fr hi ru th zh) must carry each one with the // SAME placeholders as English, a real ICU plural where English has one, // and a translation (not English pasted in). Checklist "Copy and UI": @@ -30,22 +29,13 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:openstrap_edge/l10n/app_localizations.dart'; import 'package:openstrap_edge/ui2/screens/screens.dart'; -/// The 25 keys this PR adds (sleep ring wording + why panels). +/// The keys this PR adds (sleep ring wording + why panels). const _newKeys = [ 'homeSleepPctOf', 'homeSleepPctOfDefault', 'homeSleepPctCompact', 'homeSleepPctDefaultCompact', 'homeSleepPctDefaultTight', - 'homeSleepOnTrack', - 'homeSleepOnTrackDefault', - 'homeSleepEstimateCompact', - 'homeSleepEstimateDefaultCompact', - 'homeSleepEstimateDefaultTight', - 'homeSleepEstimateDefaultMin', - 'homeSleepEstimateBare', - 'homeSleepNoEstimate', - 'homeSleepNoEstimateWhy', 'homeWhyRecoveryTitle', 'homeWhyRecoveryPlain', 'homeWhySleepTitle', @@ -151,39 +141,6 @@ void main() { expect(missing, isEmpty, reason: 'missing or empty in $name'); }); } - - test( - 'WHY: in every locale the cycle strings print the real count for each ' - 'of its plural forms (0, 1, 2, 5, 11, 21, 100)', () { - String render(AppLocalizations l, String key, int n) => switch (key) { - 'homeSleepOnTrack' => l.homeSleepOnTrack(n, '8h'), - 'homeSleepOnTrackDefault' => - l.homeSleepOnTrackDefault(n, '8h', '8h'), - 'homeSleepEstimateCompact' => l.homeSleepEstimateCompact(n, '8h'), - 'homeSleepEstimateDefaultCompact' => - l.homeSleepEstimateDefaultCompact(n, '8h'), - 'homeSleepEstimateDefaultTight' => - l.homeSleepEstimateDefaultTight(n, '8h'), - 'homeSleepEstimateDefaultMin' => l.homeSleepEstimateDefaultMin(n), - 'homeSleepEstimateBare' => l.homeSleepEstimateBare(n), - _ => fail('no renderer for $key'), - }; - final cycleKeys = _newKeys - .where((k) => (en[k] as String?)?.contains('{cycles') ?? false) - .toList(); - expect(cycleKeys, hasLength(7), - reason: 'English must define the seven cycle keys first'); - for (final loc in AppLocalizations.supportedLocales) { - final l = lookupAppLocalizations(loc); - for (final k in cycleKeys) { - for (final n in [0, 1, 2, 5, 11, 21, 100]) { - final out = render(l, k, n); - expect(RegExp('(? (l as dynamic).homeSleepPctOf(96, '7h 42m')); expect(n, '96% of 7h 42m'); }); - - test('WHY: the estimate sentence has a real singular and plural', () { - String f(int c) => _get('homeSleepOnTrackDefault', - () => (l as dynamic).homeSleepOnTrackDefault(c, '8h 00m', '8h 00m')); - expect(f(1), 'on track for 1 cycle, 8h 00m · target 8h 00m (default)'); - expect(f(5), 'on track for 5 cycles, 8h 00m · target 8h 00m (default)'); - }); - - test('WHY: compact forms keep the estimate marker and the default label', - () { - expect( - _get('homeSleepEstimateDefaultCompact', - () => (l as dynamic).homeSleepEstimateDefaultCompact(4, '6h')), - '~4 cycles · 6h · default target'); - expect( - _get('homeSleepEstimateBare', - () => (l as dynamic).homeSleepEstimateBare(1)), - '~1 cycle'); - }); - - test('WHY: cycle plurals show the real count for 0, 1, 2 and 5', () { - for (final lang in ['en', 'fr', 'hi']) { - final loc = lookupAppLocalizations(Locale(lang)); - for (final n in [0, 1, 2, 5]) { - final s = _get('homeSleepEstimateBare', - () => (loc as dynamic).homeSleepEstimateBare(n) as String); - expect(RegExp('(? Metric(value: v, confidence: .8, tier: MetricTier.high); const _target = {'value': 10, 'low': 8, 'high': 12}; @@ -120,7 +118,7 @@ Future _rings(WidgetTester t, HomeData d, supportedLocales: AppLocalizations.supportedLocales, home: Scaffold( body: SingleChildScrollView( - child: RingTrio(d: d, now: _now, onOpen: onOpen))), + child: RingTrio(d: d, onOpen: onOpen))), )); await t.pump(); } @@ -252,58 +250,56 @@ void main() { // ── 0. what "pressing" means (pure) ─────────────────────────────────────── group('pressingRings', () { test('WHY: a healthy day explains nothing', () { - expect(pressingRings(_healthy, now: _now), isEmpty); + expect(pressingRings(_healthy), isEmpty); }); test('WHY: recovery is pressing below 37; 36 yes, 37 no', () { - expect(pressingRings(_data(recovery: 10), now: _now), + expect(pressingRings(_data(recovery: 10)), [HomeRingKind.recovery]); - expect(pressingRings(_data(recovery: 36), now: _now), + expect(pressingRings(_data(recovery: 36)), [HomeRingKind.recovery]); - expect(pressingRings(_data(recovery: 37), now: _now), isEmpty); + expect(pressingRings(_data(recovery: 37)), isEmpty); }); test('WHY: sleep is pressing under 85% of need; 84% yes, 86% no', () { - expect(pressingRings(_data(sleep: 403, need: 480), now: _now), + expect(pressingRings(_data(sleep: 403, need: 480)), [HomeRingKind.sleep]); - expect(pressingRings(_data(sleep: 413, need: 480), now: _now), isEmpty); + expect(pressingRings(_data(sleep: 413, need: 480)), isEmpty); }); test('WHY: strain is pressing only above the top of the target range', () { - expect(pressingRings(_data(strain: 15, target: _target), now: _now), + expect(pressingRings(_data(strain: 15, target: _target)), [HomeRingKind.strain]); - expect(pressingRings(_data(strain: 11, target: _target), now: _now), + expect(pressingRings(_data(strain: 11, target: _target)), isEmpty); // Low strain is not pressing, and with no target there is no range. - expect(pressingRings(_data(strain: 0.0, target: _target), now: _now), + expect(pressingRings(_data(strain: 0.0, target: _target)), isEmpty); - expect(pressingRings(_data(strain: 20), now: _now), isEmpty); + expect(pressingRings(_data(strain: 20)), isEmpty); }); test('WHY: severity decides the order, ties go recovery then sleep', () { - expect(pressingRings(_rec2Sleep3, now: _now), + expect(pressingRings(_rec2Sleep3), [HomeRingKind.sleep, HomeRingKind.recovery]); - expect(pressingRings(_recAndSleep, now: _now), + expect(pressingRings(_recAndSleep), [HomeRingKind.recovery, HomeRingKind.sleep]); }); test('WHY: at most two; the least pressing is dropped', () { - expect(pressingRings(_allThree, now: _now), + expect(pressingRings(_allThree), [HomeRingKind.recovery, HomeRingKind.sleep]); }); test('WHY: an absent, provisional or in-progress value is never pressing', () { - expect(pressingRings(_data(recovery: null, sleep: null), now: _now), + expect(pressingRings(_data(recovery: null, sleep: null)), isEmpty); expect( - pressingRings(_data(recovery: 10, recoveryState: 'provisional'), - now: _now), + pressingRings(_data(recovery: 10, recoveryState: 'provisional')), isEmpty); expect( pressingRings( - _data(recovery: null, recoveryState: 'night_in_progress'), - now: _now), + _data(recovery: null, recoveryState: 'night_in_progress')), isEmpty); }); }); diff --git a/test/home_sleep_ring_widget_test.dart b/test/home_sleep_ring_widget_test.dart index d10882d96..30e529940 100644 --- a/test/home_sleep_ring_widget_test.dart +++ b/test/home_sleep_ring_widget_test.dart @@ -1,17 +1,6 @@ -// RED (Home + labels, phase 0). WHY: the Home sleep ring must show a scored -// night as stage arcs (Deep, REM, Light, Awake) against a target that says when -// it is the 8 h default, show an unslept evening as ONE grey estimate arc with -// its sources honest (alarm > learned wake, coach bedtime > learned onset), keep -// a slept night from ever being an empty bar, and follow the clock while open. -// Ported from the fork's test/home_sleep_ring_widget_test.dart with the saved -// "expected sleep schedule" (fork-only) replaced by the learned typical times, -// and the AppState overlay reduced to the armed alarm (upstream has alarmEpoch). -// -// The Home sleep ring as rendered: stage arcs, the grey estimate arc, the -// words under them, and the data HomeData hands it. -// -// Every instant is injected (`RingTrio.now`, fixed dates). Nothing here reads -// the system clock. +// The Home sleep ring as rendered: a scored night as stage arcs (Deep, REM, +// Light, Awake) against a target that says when it is the 8 h default, a slept +// night never an empty bar, and the data HomeData hands it. // // ROOT-CAUSE REGRESSIONS (owner saw "of 8h 10m" only BEFORE sleeping and an // EMPTY bar AFTER sleeping): @@ -23,31 +12,22 @@ // Both are pinned below: a slept night is never an empty bar, and the need // reaches the ring from both loaders. -import 'package:clock/clock.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:provider/provider.dart'; import 'package:openstrap_edge/data/local_repository.dart'; import 'package:openstrap_edge/l10n/app_localizations.dart'; import 'package:openstrap_edge/models/metric.dart'; -import 'package:openstrap_edge/state/app_state.dart'; import 'package:openstrap_edge/ui2/screens/screens.dart'; import 'package:openstrap_edge/ui2/ui2.dart'; // ── fixtures ─────────────────────────────────────────────────────────────── -final _evening = DateTime(2026, 10, 8, 22, 0); - -/// The unslept sub-line's tail when the target is the 8 h default. -const _dflt = ' · target 8h 00m (default)'; - Finder _spokenCaption(String text) => find.byWidgetPredicate((widget) => widget is Pressable && (widget.semanticLabel?.contains(text) ?? false)); /// The caption is BOTH spoken in full and VISIBLE: as the full text, or as one -/// of its measured compact wordings (which keep the default / estimate -/// qualifiers). Exactly one Text shows it. +/// of its measured compact wordings (which keep the default qualifier). Exactly one Text shows it. void _caption(String full, List compact) { expect(_spokenCaption(full), findsOneWidget); expect( @@ -83,31 +63,16 @@ HomeData _slept({ sleepStages: stages, ); -HomeData _unslept({ - bool typical = false, // learned onset 23:00, learned wake 07:00 - DateTime? alarm, - Metric need = Metric.empty, - Metric bedtime = Metric.empty, - int? learnedOnset, - int? learnedWake, - Metric sleepMin = Metric.empty, -}) => - HomeData( +HomeData _unslept({Metric sleepMin = Metric.empty}) => HomeData( dayId: '2026-10-08', readiness: const Metric(value: 82, confidence: .8, tier: MetricTier.high), strain: const Metric(value: 9.0, confidence: .6, tier: MetricTier.estimate), sleepMin: sleepMin, - sleepNeedMin: need, - bedtime: bedtime, - nextAlarm: alarm, - learnedOnsetMin: learnedOnset ?? (typical ? 1380 : null), - learnedWakeMin: learnedWake ?? (typical ? 420 : null), ); Future _pump( WidgetTester t, HomeData d, { - DateTime? now, double textScale = 1, }) async { t.view.physicalSize = const Size(390 * 3, 800 * 3); @@ -124,7 +89,7 @@ Future _pump( ), child: Scaffold( body: SingleChildScrollView( - child: RingTrio(d: d, now: now ?? _evening), + child: RingTrio(d: d), ), ), ), @@ -151,11 +116,9 @@ class _Repo extends LocalRepository { this.today = const {}, this.insights = const {}, this.night = const {}, - this.windows = const [], }); final Map today, insights, night; - final List> windows; @override Future> getToday() async => today; @@ -169,10 +132,6 @@ class _Repo extends LocalRepository { Future> getDayOverview(String date) async => const {}; @override Future> getDayStrain(String date) async => const {}; - @override - Future>> sleepWindows( - {int days = 60, String? before}) async => - windows; } Map _env(num v) => { @@ -212,19 +171,6 @@ const _nightMap = { 'awake_min': 30, }; -/// Five nights, onset 23:00 the evening before and wake 07:00, as -/// `sleepWindows` returns them (epoch seconds). -List> _windows() => [ - for (var day = 1; day <= 5; day++) - { - 'date': '2026-10-0$day', - 'onset_ts': - DateTime(2026, 10, day - 1, 23, 0).millisecondsSinceEpoch ~/ 1000, - 'wake_ts': - DateTime(2026, 10, day, 7, 0).millisecondsSinceEpoch ~/ 1000, - }, - ]; - void main() { group('bounded scores are clamped at the call site', () { testWidgets('recovery 130 and strain 30 draw one lap, not a spiral', @@ -399,19 +345,7 @@ void main() { }); }); - group('loaders: learned times, no stale stages', () { - test('HomeData.load learns the typical onset and wake from recent nights', - () async { - final d = await HomeData.load(_Repo( - today: _sleptToday, - insights: _needInsights, - night: _nightMap, - windows: _windows(), - )); - expect(d.learnedOnsetMin, 23 * 60); - expect(d.learnedWakeMin, 7 * 60); - }); - + group('loaders: no stale stages', () { test('a night that is NOT today\'s never lends its stages to today', () async { final repo = _Repo( @@ -435,313 +369,22 @@ void main() { }); }); - group('HomeScreen lays AppState\'s armed alarm over the loaded data', - () { - Future pumpHome(WidgetTester t, AppState app, - {HomeData? data}) async { - t.view.physicalSize = const Size(390 * 3, 1600 * 3); - t.view.devicePixelRatio = 3; - addTearDown(t.view.reset); - await t.pumpWidget(MaterialApp( - theme: buildTheme(Brightness.light), - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - home: ChangeNotifierProvider.value( - value: app, - child: Scaffold( - body: HomeScreen( - hour: 22, - now: _evening, - data: data ?? _unslept(typical: true), - ), - ), - ), - )); - for (var i = 0; i < 10; i++) { - await t.pump(const Duration(milliseconds: 20)); - } - } - - testWidgets('an armed alarm beats the learned wake', (t) async { - final app = AppState.forTesting(); - addTearDown(app.dispose); - app.device.alarmEpoch = - DateTime(2026, 10, 9, 6, 30).millisecondsSinceEpoch ~/ 1000; - await pumpHome(t, app); - _caption('on track for 5 cycles, 7h 30m$_dflt', ['~5 cycles · 7h 30m · default target', '~5 cycles · 7h 30m · default goal', '~5 cycles · default goal']); - }); - - testWidgets('an alarm already past is ignored', (t) async { - final app = AppState.forTesting(); - addTearDown(app.dispose); - app.device.alarmEpoch = - DateTime(2026, 10, 8, 21, 0).millisecondsSinceEpoch ~/ 1000; - await pumpHome(t, app); - _caption('on track for 5 cycles, 8h 00m$_dflt', ['~5 cycles · 8h · default target', '~5 cycles · 8h · default goal', '~5 cycles · default goal']); - }); - - testWidgets('with nothing saved and nothing armed there is no estimate', + group('no scored night', () { + testWidgets('an honest absence and an empty ring — never a guessed time', (t) async { - final app = AppState.forTesting(); - addTearDown(app.dispose); - // No learned times either: nothing at all to estimate a wake from. - await pumpHome(t, app, data: _unslept()); + await _pump(t, _unslept()); // An absence is a dash in the ring; the reason is a row under the rings. final gap = find.byKey(const ValueKey('home-note:gap:sleep')); - expect(find.descendant(of: gap, matching: find.text('Sleep')), findsOneWidget); - expect(find.descendant(of: gap, matching: find.textContaining('No sleep scored yet')), + expect(find.descendant(of: gap, matching: find.text('Sleep')), findsOneWidget); - }); - }); - - group('a day with no measured data still shows the estimate', () { - Future pumpBare(WidgetTester t, AppState app, - {HomeData data = const HomeData()}) async { - t.view.physicalSize = const Size(390 * 3, 1600 * 3); - t.view.devicePixelRatio = 3; - addTearDown(t.view.reset); - await t.pumpWidget(MaterialApp( - theme: buildTheme(Brightness.light), - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - home: ChangeNotifierProvider.value( - value: app, - child: Scaffold( - body: HomeScreen(hour: 22, now: _evening, data: data), - ), - ), - )); - for (var i = 0; i < 10; i++) { - await t.pump(const Duration(milliseconds: 20)); - } - } - - testWidgets('learned times and no readiness/strain/sleep: rings, not ' - 'the "Nothing derived yet" card', (t) async { - final app = AppState.forTesting(); - addTearDown(app.dispose); - await pumpBare(t, app, data: const HomeData(learnedOnsetMin: 1380, learnedWakeMin: 420)); - expect(find.textContaining('No nights scored yet'), findsNothing); - _caption('on track for 5 cycles, 8h 00m$_dflt', ['~5 cycles · 8h · default target', '~5 cycles · 8h · default goal', '~5 cycles · default goal']); - }); - - testWidgets('nothing to estimate from: the status card, as before', - (t) async { - final app = AppState.forTesting(); - addTearDown(app.dispose); - await pumpBare(t, app); - // The status card (here the unpaired install's "No band is paired"; a - // paired one reads "No nights scored yet"), never three empty rings. - expect(find.byType(RingTrio), findsNothing); - expect(find.byType(StatusCard), findsWidgets); - expect(find.text('No estimate'), findsNothing); - }); - }); - - group('the estimate follows the clock while the screen stays open', () { - final base = DateTime(2026, 10, 9, 2, 0, 30); - - testWidgets('it re-reads the clock on each minute boundary — no injected ' - 'now', (t) async { - var fake = base; - await withClock(Clock(() => fake), () async { - final app = AppState.forTesting(); - addTearDown(app.dispose); - t.view.physicalSize = const Size(390 * 3, 1600 * 3); - t.view.devicePixelRatio = 3; - addTearDown(t.view.reset); - await t.pumpWidget(MaterialApp( - theme: buildTheme(Brightness.light), - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - home: ChangeNotifierProvider.value( - value: app, - child: Scaffold( - body: HomeScreen(hour: 2, data: _unslept(typical: true)), - ), - ), - )); - await t.pump(const Duration(milliseconds: 20)); - // 02:00:30 -> wake 07:00 is 4 h 59 m. - expect(find.text('4h 59m'), findsWidgets); - - // Half a minute later the next boundary has passed. - fake = base.add(const Duration(seconds: 30)); - await t.pump(const Duration(seconds: 30)); - // 02:01:00 -> 4 h 59 m still; one more minute is 4 h 58 m. - fake = base.add(const Duration(seconds: 90)); - await t.pump(const Duration(seconds: 60)); - expect(find.text('4h 58m'), findsWidgets); - expect(find.text('4h 59m'), findsNothing); - - // Half an hour on, with nothing but the passing minutes. - for (var i = 0; i < 30; i++) { - fake = fake.add(const Duration(minutes: 1)); - await t.pump(const Duration(minutes: 1)); - } - expect(find.text('4h 28m'), findsWidgets); - }); - }); - - testWidgets('it stops ticking once the screen is gone', (t) async { - var fake = base; - await withClock(Clock(() => fake), () async { - final app = AppState.forTesting(); - addTearDown(app.dispose); - await t.pumpWidget(MaterialApp( - theme: buildTheme(Brightness.light), - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - home: ChangeNotifierProvider.value( - value: app, - child: Scaffold(body: HomeScreen(hour: 2, data: _unslept(typical: true))), - ), - )); - await t.pump(const Duration(milliseconds: 20)); - await t.pumpWidget(const SizedBox()); - // A timer left behind would be reported as pending by the framework. - fake = fake.add(const Duration(minutes: 5)); - await t.pump(const Duration(minutes: 5)); - }); - }); - }); - - group('unslept: the grey estimate', () { - testWidgets('ring is ONE grey arc (track ink, no stage colours) filled to ' - 'estimate / target', (t) async { - await _pump( - t, - _unslept(typical: true, need: _min(540)), - ); - final painter = _sleepPainter(t); - expect(painter, isNot(isA())); - final ring = painter as Ring; - expect(ring.color, _p(t).ink3); - expect(ring.v, closeTo(480 / 540, 1e-9)); - final stageColours = Hypnogram.cols(_p(t)).values; - expect(stageColours.contains(ring.color), isFalse); - }); - - testWidgets('value is the estimate; sub is "on track for n cycles, xxh ' - 'yym"', (t) async { - await _pump(t, _unslept(typical: true)); - expect(find.text('8h 00m'), findsNWidgets(1)); - _caption('on track for 5 cycles, 8h 00m$_dflt', ['~5 cycles · 8h · default target', '~5 cycles · 8h · default goal', '~5 cycles · default goal']); - }); - - testWidgets('one cycle is singular', (t) async { - await _pump( - t, - _unslept(typical: true), - now: DateTime(2026, 10, 9, 5, 20), - ); - _caption('on track for 1 cycle, 1h 40m$_dflt', ['~1 cycle · 1h 40m · default target', '~1 cycle · 1h 40m · default goal', '~1 cycle · default goal']); - }); - - testWidgets('a LEARNED need is the denominator: no "(default)" tail', - (t) async { - await _pump(t, _unslept(typical: true, need: _min(540))); - _caption('on track for 5 cycles, 8h 00m', ['~5 cycles · 8h', '~5 cycles']); - expect(find.textContaining('default'), findsNothing); - }); - - testWidgets('the DEFAULT denominator is visibly labelled on the unslept ' - 'sub-line too', (t) async { - await _pump(t, _unslept(typical: true)); - expect(find.textContaining('default'), findsOneWidget); - _caption('on track for 5 cycles, 8h 00m$_dflt', ['~5 cycles · 8h · default target', '~5 cycles · 8h · default goal', '~5 cycles · default goal']); - }); - - testWidgets('an estimate has no percentage and no "(default)" label ' - 'on screen', (t) async { - await _pump(t, _unslept(typical: true)); - expect(find.textContaining('%'), findsNothing); - }); - - testWidgets('next armed alarm sets the wake', (t) async { - await _pump( - t, - _unslept( - typical: true, - alarm: DateTime(2026, 10, 9, 6, 30), - ), - ); - _caption('on track for 5 cycles, 7h 30m$_dflt', ['~5 cycles · 7h 30m · default target', '~5 cycles · 7h 30m · default goal', '~5 cycles · default goal']); - }); - - testWidgets('coach bedtime beats the learned onset', (t) async { - await _pump( - t, - _unslept( - typical: true, - bedtime: const Metric( - value: 22 * 60 + 30, confidence: .7, tier: MetricTier.estimate), - ), - ); - _caption('on track for 5 cycles, 8h 30m$_dflt', ['~5 cycles · 8h 30m · default target', '~5 cycles · 8h 30m · default goal', '~5 cycles · default goal']); - }); - - testWidgets('learned onset and wake alone give the estimate', (t) async { - await _pump( - t, - _unslept(learnedOnset: 23 * 60 + 30, learnedWake: 7 * 60 + 30), - ); - _caption('on track for 5 cycles, 8h 00m$_dflt', ['~5 cycles · 8h · default target', '~5 cycles · 8h · default goal', '~5 cycles · default goal']); - }); - - testWidgets('a user cycle length (hook) changes the cycle count', - (t) async { - final d = HomeData( - learnedOnsetMin: 1380, learnedWakeMin: 420, - cycleLenMin: 100, - ); - await _pump(t, d); - _caption('on track for 4 cycles, 8h 00m$_dflt', ['~4 cycles · 8h · default target', '~4 cycles · 8h · default goal', '~4 cycles · default goal']); - }); - - testWidgets('the estimate depends only on the injected now — time passing ' - 'in the test changes nothing', (t) async { - await _pump(t, _unslept(typical: true)); - await t.pump(const Duration(hours: 3)); - _caption('on track for 5 cycles, 8h 00m$_dflt', ['~5 cycles · 8h · default target', '~5 cycles · 8h · default goal', '~5 cycles · default goal']); - }); - - testWidgets('spoken text carries the estimate sentence', (t) async { - final h = t.ensureSemantics(); - await _pump(t, _unslept(typical: true)); expect( - find.bySemanticsLabel( - RegExp(r'Sleep\. 8h 00m\. on track for 5 cycles, 8h 00m · target 8h 00m \(default\)')), - findsOneWidget, - ); - h.dispose(); - }); - }); - - group('unslept: no wake, no estimate', () { - testWidgets('an honest absence and an empty ring — never a guessed time', - (t) async { - await _pump( - t, - _unslept( - bedtime: const Metric( - value: 1350, confidence: .7, tier: MetricTier.estimate), - learnedOnset: 1380, - ), - ); - // An absence is a dash in the ring; the reason is a row under the rings. - final gap = find.byKey(const ValueKey('home-note:gap:sleep')); - expect(find.descendant(of: gap, matching: find.text('Sleep')), findsOneWidget); - expect(find.descendant(of: gap, matching: find.textContaining('No sleep scored yet')), + find.descendant( + of: gap, + matching: find.textContaining( + 'No night long enough to score was recorded.')), findsOneWidget); - expect(find.textContaining('on track'), findsNothing); final ring = _sleepPainter(t) as Ring; expect(ring.v, 0); - expect( - find.textContaining('no alarm or typical wake time', - findRichText: true), - findsOneWidget, - ); }); testWidgets('the pipeline\'s own reason comes first', (t) async { @@ -758,33 +401,22 @@ void main() { ); }); - testWidgets('RingTrio.has: an estimate is something to draw; nothing is ' - 'not', (t) async { - expect( - RingTrio.has( - HomeData(learnedOnsetMin: 1380, learnedWakeMin: 420), - now: _evening, - ), - isTrue, - ); - expect(RingTrio.has(const HomeData(), now: _evening), isFalse); + testWidgets('RingTrio.has: a scored night is something to draw; nothing ' + 'is not', (t) async { + expect(RingTrio.has(_slept()), isTrue); + expect(RingTrio.has(const HomeData()), isFalse); }); }); group('calibrating is unchanged', () { - testWidgets('a baseline note still draws the dashed ring, even when an ' - 'estimate could be made', (t) async { + testWidgets('a baseline note still draws the dashed ring', (t) async { await _pump( t, - _unslept( - typical: true, - sleepMin: const Metric(note: 'need_baseline:have=3,need=14'), - ), + _unslept(sleepMin: const Metric(note: 'need_baseline:have=3,need=14')), ); final p = _sleepPainter(t); expect(p, isA()); expect((p as DashedRing).segments, 14); - expect(find.textContaining('on track'), findsNothing); }); }); } diff --git a/test/home_tick_dst_test.dart b/test/home_tick_dst_test.dart deleted file mode 100644 index 7fd3d997a..000000000 --- a/test/home_tick_dst_test.dart +++ /dev/null @@ -1,72 +0,0 @@ -// RED (Home + labels, phase 0). WHY: the sleep ring's unslept estimate is -// "wake minus now", so Home re-reads the clock on every minute boundary. The -// delay to the next minute must come from the UTC instant: in the repeated -// fall-back hour `DateTime(y, m, d, h, min + 1)` resolves to the FIRST -// occurrence of that wall time, an hour in the past, which became a zero-delay -// Timer and a setState loop. Ported from the fork's test/home_tick_dst_test.dart -// (the pure delay function only; the screen-level ticking is pinned in -// home_sleep_ring_widget_test.dart). -// -// The process timezone is moved with the shared setProcessTz helper (POSIX -// setenv/tzset) and restored afterwards, so the repeated hour is real on any -// host. Every instant is built from UTC; nothing reads the system clock. - -import 'dart:io'; - -import 'package:flutter_test/flutter_test.dart'; -import 'package:openstrap_edge/ui2/screens/screens.dart'; - -import 'support/process_tz.dart'; - -void main() { - final originalTz = Platform.environment['TZ']; - - setUp(() { - if (Platform.isWindows) markTestSkipped('POSIX-only (libc setenv)'); - setProcessTz('America/Denver'); - }); - tearDown(() => setProcessTz(originalTz)); - - group('nextMinuteTickDelay', () { - test('fall back 2026-11-01, second 01:30:30 MST (08:30:30Z): 30 s', () { - final now = DateTime.utc(2026, 11, 1, 8, 30, 30).toLocal(); - expect(nextMinuteTickDelay(now), const Duration(seconds: 30)); - }); - - test('every second of the repeated hour waits (0, 60 s]', () { - final start = DateTime.utc(2026, 11, 1, 7, 55); - for (var s = 0; s < 15 * 60; s += 7) { - final now = start.add(Duration(seconds: s)).toLocal(); - final d = nextMinuteTickDelay(now); - expect(d > Duration.zero, isTrue, reason: '$now -> $d'); - expect(d <= const Duration(seconds: 60), isTrue, reason: '$now -> $d'); - } - }); - - test('on a whole minute the next tick is a full minute away', () { - final now = DateTime.utc(2026, 11, 1, 8, 30).toLocal(); - expect(nextMinuteTickDelay(now), const Duration(seconds: 60)); - }); - - test('one millisecond before the boundary', () { - final now = DateTime.utc(2026, 11, 1, 8, 30, 59, 999).toLocal(); - expect(nextMinuteTickDelay(now), const Duration(milliseconds: 1)); - }); - - test('spring forward 2026-03-08: 01:59:30 MST (08:59:30Z) is 30 s', () { - final now = DateTime.utc(2026, 3, 8, 8, 59, 30).toLocal(); - expect(nextMinuteTickDelay(now), const Duration(seconds: 30)); - }); - - test('spring forward: the first MDT second waits a full minute', () { - final now = DateTime.utc(2026, 3, 8, 9, 0).toLocal(); - expect(nextMinuteTickDelay(now), const Duration(seconds: 60)); - }); - - test('a half-hour zone has the same minute boundaries', () { - setProcessTz('Asia/Kolkata'); - final now = DateTime.utc(2026, 11, 1, 8, 30, 45).toLocal(); - expect(nextMinuteTickDelay(now), const Duration(seconds: 15)); - }); - }); -} diff --git a/test/sleep_ring_model_test.dart b/test/sleep_ring_model_test.dart index cd74c27b3..54764c13f 100644 --- a/test/sleep_ring_model_test.dart +++ b/test/sleep_ring_model_test.dart @@ -1,66 +1,18 @@ -// RED (Home + labels, phase 0). WHY: the Home sleep ring must draw and word the -// night from one pure, fixed-date model, so the rules (default target flagged, -// stage arcs grouped Deep/REM/Light/Awake, no invented split, honest estimate -// sources, real elapsed time across DST) are pinned apart from any widget. -// Ported from the fork's test/sleep_ring_model_test.dart with the saved -// "expected sleep schedule" source removed (fork-only, state/control_operations); -// the learned typical times stand in for it. The fork's source-grep guard on the -// model file is dropped: not reading a clock is shown by the fixed-now tests. +// The Home sleep ring's rules as a pure function of fixed inputs: default +// target flagged, stage arcs grouped Deep/REM/Light/Awake, no invented split. // -// The Home sleep ring's rules, as a pure function of fixed dates. -// -// Two states, one function (`sleepRingModel`): -// -// SLEPT today has a scored night. The ring is that night's stages as -// contiguous arcs grouped Deep, REM, Light, then Awake; the total -// sweep of the asleep stages is asleep / target, NOT capped: above a full -// circle. Stage totals absent => ONE solid arc, never an invented -// split. Sub-line percentage is asleep / target. -// UNSLEPT no scored night yet. Value is an ESTIMATE = wake - max(start, now). -// start: coach bedtime > learned onset. -// wake : next armed alarm > learned wake. -// No wake => no estimate (phase `none`), ring empty. One grey arc. +// SLEPT today has a scored night. The ring is that night's stages as +// contiguous arcs grouped Deep, REM, Light, then Awake; the total +// sweep of the asleep stages is asleep / target, NOT capped: above a +// full circle it is a spiral. Stage totals absent => ONE solid arc, +// never an invented split. Sub-line percentage is asleep / target. // // TARGET: the learned need when there is one, otherwise the owner's explicit // default of 8 h (5 cycles + 30 min), flagged `targetIsDefault`. -// -// Every instant is injected. No test here reads the system clock. The DST -// cases move the PROCESS timezone with libc setenv/tzset (same idiom as -// day_window_dst_test.dart) and put it back afterwards; elapsed time across a -// transition is real elapsed time, not wall-clock subtraction. - -import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; import 'package:openstrap_edge/ui2/sleep_ring_model.dart'; -import 'support/process_tz.dart'; - -const _h23 = 23 * 60; // 23:00 -const _h07 = 7 * 60; // 07:00 - -SleepRingModel _unslept({ - required DateTime now, - num? need, - num? bedtime, - DateTime? alarm, - int? learnedOnset, - int? learnedWake, - int? cycleLen, -}) => - sleepRingModel( - now: now, - needMin: need, - coachBedtimeMinOfDay: bedtime, - nextAlarm: alarm, - learnedOnsetMin: learnedOnset, - learnedWakeMin: learnedWake, - cycleLenMin: cycleLen, - ); - -// The evening before the night under test. -final _evening = DateTime(2026, 10, 8, 22, 0); - SleepRingModel _slept({ num duration = 442, SleepStageMin? stages = const SleepStageMin( @@ -70,14 +22,8 @@ SleepRingModel _slept({ awake: 30, ), num? need, - DateTime? now, }) => - sleepRingModel( - now: now ?? DateTime(2026, 10, 9, 9, 0), - durationMin: duration, - stages: stages, - needMin: need, - ); + sleepRingModel(durationMin: duration, stages: stages, needMin: need); void main() { group('target', () { @@ -104,9 +50,8 @@ void main() { }); group('slept', () { - test('phase, asleep minutes and whole-percent of the default target', () { + test('asleep minutes and whole-percent of the default target', () { final m = _slept(); - expect(m.phase, SleepRingPhase.slept); expect(m.asleepMin, 442); // 442 / 480 = 92.08 % expect(m.pct, 92); @@ -216,7 +161,6 @@ void main() { test('NO stage totals => ONE solid arc, never an invented split', () { final m = _slept(stages: null); - expect(m.phase, SleepRingPhase.slept); expect(m.arcs, hasLength(1)); expect(m.arcs.single.kind, SleepArcKind.solid); expect(m.arcs.single.fraction, closeTo(442 / 480, 1e-9)); @@ -244,487 +188,5 @@ void main() { expect(m.arcs.single.fraction, closeTo(600 / 480, 1e-9)); expect(m.sweep, closeTo(600 / 480, 1e-9)); }); - - test('a scored night wins over every alarm/learned input', () { - final m = sleepRingModel( - now: _evening, - durationMin: 442, - nextAlarm: DateTime(2026, 10, 9, 6, 30), - coachBedtimeMinOfDay: 1350, - learnedOnsetMin: _h23, - learnedWakeMin: _h07, - ); - expect(m.phase, SleepRingPhase.slept); - expect(m.estimateMin, isNull); - expect(m.cycles, isNull); - }); - }); - - group('unslept estimate', () { - test('wake - start from the learned typical times; cycles = floor(est / 90)', () { - final m = _unslept(now: _evening, learnedOnset: _h23, learnedWake: _h07); - expect(m.phase, SleepRingPhase.estimate); - expect(m.windowStart, DateTime(2026, 10, 8, 23, 0)); - expect(m.wakeAt, DateTime(2026, 10, 9, 7, 0)); - expect(m.estimateMin, 480); - expect(m.cycleLenMin, 90); - expect(m.cycles, 5); // 480 / 90 = 5.33 - expect(m.startSource, WindowStartSource.learned); - expect(m.wakeSource, WakeSource.learned); - expect(m.asleepMin, isNull); - expect(m.pct, isNull); - }); - - test('ONE grey (estimate) arc: estimate / target of the circle', () { - final m = _unslept(now: _evening, learnedOnset: _h23, learnedWake: _h07, need: 540); - expect(m.arcs, hasLength(1)); - expect(m.arcs.single.kind, SleepArcKind.estimate); - expect(m.arcs.single.fraction, closeTo(480 / 540, 1e-9)); - expect(m.targetMin, 540); - expect(m.targetIsDefault, isFalse); - }); - - test('the estimate arc is capped at a full circle', () { - final m = _unslept( - now: _evening, - learnedOnset: 22 * 60, - learnedWake: 9 * 60, - ); - expect(m.estimateMin, 11 * 60); // 22:00 -> 09:00, now is 22:00 - expect(m.arcs.single.fraction, closeTo(1.0, 1e-9)); - }); - - test('with no learned need the estimate is measured against the flagged ' - 'default', () { - final m = _unslept(now: _evening, learnedOnset: _h23, learnedWake: _h07); - expect(m.targetMin, 480); - expect(m.targetIsDefault, isTrue); - expect(m.arcs.single.fraction, closeTo(1.0, 1e-9)); - }); - - test('NEXT ARMED ALARM beats the learned wake', () { - final m = _unslept( - now: _evening, - learnedOnset: _h23, - learnedWake: _h07, - alarm: DateTime(2026, 10, 9, 6, 30), - ); - expect(m.wakeAt, DateTime(2026, 10, 9, 6, 30)); - expect(m.wakeSource, WakeSource.alarm); - expect(m.estimateMin, 450); // 23:00 -> 06:30 - expect(m.cycles, 5); - }); - - test('learned wake is used when there is no alarm', () { - final m = _unslept( - now: _evening, - learnedOnset: 23 * 60 + 30, - learnedWake: 7 * 60 + 30, - ); - expect(m.wakeAt, DateTime(2026, 10, 9, 7, 30)); - expect(m.wakeSource, WakeSource.learned); - expect(m.windowStart, DateTime(2026, 10, 8, 23, 30)); - expect(m.startSource, WindowStartSource.learned); - expect(m.estimateMin, 480); - }); - - test('COACH BEDTIME beats the learned onset', () { - final m = _unslept( - now: _evening, - bedtime: 22 * 60 + 30, - learnedOnset: _h23, - learnedWake: _h07, - ); - expect(m.windowStart, DateTime(2026, 10, 8, 22, 30)); - expect(m.startSource, WindowStartSource.coach); - expect(m.estimateMin, 510); // 22:30 -> 07:00 - expect(m.cycles, 5); - }); - - test('the coach bedtime is a clock minute, so it can fall after midnight', - () { - final m = _unslept( - now: DateTime(2026, 10, 8, 21, 0), - bedtime: 30, // 00:30 - learnedOnset: _h23, - learnedWake: _h07, - ); - expect(m.windowStart, DateTime(2026, 10, 9, 0, 30)); - expect(m.estimateMin, 390); // 00:30 -> 07:00 - expect(m.cycles, 4); // 390 / 90 = 4.33 - }); - - test('a fractional coach bedtime minute still resolves to a clock time', - () { - final m = _unslept( - now: _evening, - bedtime: 1350.4, - learnedOnset: _h23, - learnedWake: _h07, - ); - expect(m.windowStart, DateTime(2026, 10, 8, 22, 30)); - }); - - test('estimate runs from NOW once the window has started (max(start, now))', - () { - final now = DateTime(2026, 10, 9, 2, 0); - final m = _unslept(now: now, learnedOnset: _h23, learnedWake: _h07); - expect(m.windowStart, now); - expect(m.wakeAt, DateTime(2026, 10, 9, 7, 0)); - expect(m.estimateMin, 300); - expect(m.cycles, 3); - expect(m.startSource, WindowStartSource.learned); - }); - - test('estimate runs from the window start while it is still in the future', - () { - final m = _unslept(now: DateTime(2026, 10, 8, 18, 0), learnedOnset: _h23, learnedWake: _h07); - expect(m.windowStart, DateTime(2026, 10, 8, 23, 0)); - expect(m.estimateMin, 480); - }); - - test('no onset from any source => the estimate runs from now, saying so', - () { - final m = _unslept( - now: _evening, - alarm: DateTime(2026, 10, 9, 7, 0), - ); - expect(m.phase, SleepRingPhase.estimate); - expect(m.windowStart, _evening); - expect(m.startSource, isNull); - expect(m.estimateMin, 540); - expect(m.cycles, 6); - }); - - test('an alarm that has already passed is not a wake', () { - final m = _unslept( - now: _evening, - learnedOnset: _h23, - learnedWake: _h07, - alarm: _evening.subtract(const Duration(minutes: 1)), - ); - expect(m.wakeSource, WakeSource.learned); - expect(m.wakeAt, DateTime(2026, 10, 9, 7, 0)); - }); - - test('an alarm more than 24 h away is not tonight\'s wake', () { - final m = _unslept( - now: _evening, - learnedOnset: _h23, - learnedWake: _h07, - alarm: DateTime(2026, 10, 10, 7, 0), // a weekday alarm, 33 h away - ); - expect(m.wakeSource, WakeSource.learned); - expect(m.wakeAt, DateTime(2026, 10, 9, 7, 0)); - }); - - test('the night ends at tomorrow\'s local noon: 11:59:59 is tonight\'s', - () { - final m = _unslept( - now: _evening, - learnedOnset: _h23, - learnedWake: _h07, - alarm: DateTime(2026, 10, 9, 11, 59, 59), - ); - expect(m.wakeSource, WakeSource.alarm); - }); - - test('an alarm just past tomorrow\'s noon is the next night\'s, not ' - 'tonight\'s', () { - final m = _unslept( - now: _evening, - learnedOnset: _h23, - learnedWake: _h07, - alarm: DateTime(2026, 10, 9, 12, 0, 1), - ); - expect(m.wakeSource, WakeSource.learned); - }); - - test('1 am: already in the night, so it ends at TODAY\'s noon', () { - final now = DateTime(2026, 10, 9, 1, 0); - final today = _unslept( - now: now, - learnedOnset: _h23, - learnedWake: _h07, - alarm: DateTime(2026, 10, 9, 7, 0)); - expect(today.wakeSource, WakeSource.alarm); - final next = _unslept( - now: now, - learnedOnset: _h23, - learnedWake: _h07, - alarm: DateTime(2026, 10, 10, 7, 0)); - expect(next.wakeSource, WakeSource.learned); - expect(next.wakeAt, DateTime(2026, 10, 9, 7, 0)); - }); - - test('a 22:00 alarm tomorrow evening is not tonight\'s wake', () { - final m = _unslept( - now: _evening, - learnedOnset: _h23, - learnedWake: _h07, - alarm: _evening.add(const Duration(hours: 24)), - ); - expect(m.wakeSource, WakeSource.learned); - }); - - test('a wake at exactly now rolls to the next day\'s wake', () { - final now = DateTime(2026, 10, 9, 7, 0); - final m = _unslept(now: now, learnedOnset: _h23, learnedWake: _h07); - expect(m.wakeAt, DateTime(2026, 10, 10, 7, 0)); - expect(m.windowStart, DateTime(2026, 10, 9, 23, 0)); - expect(m.estimateMin, 480); - }); - - test('NO WAKE FROM ANY SOURCE => no estimate, empty ring, never a guess', - () { - final m = _unslept( - now: _evening, - bedtime: 1350, - learnedOnset: _h23, // an onset alone is not a wake - ); - expect(m.phase, SleepRingPhase.none); - expect(m.estimateMin, isNull); - expect(m.cycles, isNull); - expect(m.wakeAt, isNull); - expect(m.arcs, isEmpty); - expect(m.sweep, 0); - // Control: the same inputs plus a wake DO give an estimate, so the - // refusal above is the model's rule, not an inert model. - final withWake = _unslept( - now: _evening, bedtime: 1350, learnedOnset: _h23, learnedWake: _h07); - expect(withWake.phase, SleepRingPhase.estimate); - }); - - test('nothing at all is also no estimate', () { - final m = _unslept(now: _evening); - expect(m.phase, SleepRingPhase.none); - expect(m.arcs, isEmpty); - // Control: the default target is still reported (a real model). - expect(m.targetMin, kDefaultSleepTargetMin); - expect(kDefaultSleepTargetMin, 480); - }); - - test('cycle length is injectable: 100 min => floor(480 / 100) = 4', () { - final m = _unslept(now: _evening, learnedOnset: _h23, learnedWake: _h07, cycleLen: 100); - expect(m.cycleLenMin, 100); - expect(m.cycles, 4); - }); - - test('an implausible cycle length falls back to 90', () { - for (final bad in [0, -5, 20, 400]) { - final m = _unslept(now: _evening, learnedOnset: _h23, learnedWake: _h07, cycleLen: bad); - expect(m.cycleLenMin, 90, reason: 'cycleLen $bad'); - expect(m.cycles, 5, reason: 'cycleLen $bad'); - } - }); - - test('under one cycle is zero cycles, not one', () { - final m = _unslept( - now: DateTime(2026, 10, 9, 6, 0), - learnedOnset: _h23, - learnedWake: _h07, - ); - expect(m.estimateMin, 60); - expect(m.cycles, 0); - }); - }); - - group('DST: elapsed time, not wall-clock subtraction', () { - final originalTz = Platform.environment['TZ']; - - setUp(() { - if (Platform.isWindows) markTestSkipped('POSIX-only (libc setenv)'); - setProcessTz('America/Denver'); - }); - tearDown(() => setProcessTz(originalTz)); - - test('spring forward 2026-03-08: 23:00 -> 07:00 is 7 h, not 8', () { - final now = DateTime(2026, 3, 7, 22, 0); - final m = _unslept(now: now, learnedOnset: _h23, learnedWake: _h07); - expect(m.windowStart, DateTime(2026, 3, 7, 23, 0)); - expect(m.wakeAt, DateTime(2026, 3, 8, 7, 0)); - expect(m.estimateMin, 420); - expect(m.cycles, 4); // 420 / 90 = 4.67 - }); - - test('spring forward, wake given as a UTC alarm instant', () { - final now = DateTime(2026, 3, 7, 22, 0); - final m = _unslept( - now: now, - learnedOnset: _h23, - learnedWake: _h07, - alarm: DateTime.utc(2026, 3, 8, 13, 0), // 07:00 MDT - ); - expect(m.wakeSource, WakeSource.alarm); - expect(m.estimateMin, 420); - }); - - test('fall back 2026-11-01: 23:00 -> 07:00 is 9 h, not 8', () { - final now = DateTime(2026, 10, 31, 22, 0); - final m = _unslept(now: now, learnedOnset: _h23, learnedWake: _h07); - expect(m.windowStart, DateTime(2026, 10, 31, 23, 0)); - expect(m.wakeAt, DateTime(2026, 11, 1, 7, 0)); - expect(m.estimateMin, 540); - expect(m.cycles, 6); - }); - - test('fall back: tomorrow\'s 07:00 alarm is 24 h 30 min away by the clock ' - 'but is still tonight\'s wake', () { - final now = DateTime(2026, 10, 31, 7, 30); - final m = _unslept( - now: now, - learnedOnset: _h23, - learnedWake: 8 * 60, - alarm: DateTime.utc(2026, 11, 1, 14, 0), // 07:00 MST - ); - expect(m.wakeSource, WakeSource.alarm); - expect(m.wakeAt, DateTime(2026, 11, 1, 7, 0)); - expect(m.windowStart, DateTime(2026, 10, 31, 23, 0)); - expect(m.estimateMin, 540); - }); - - test('spring forward: tomorrow\'s 07:00 alarm is 23 h 30 min away but is ' - 'still tonight\'s wake', () { - final now = DateTime(2026, 3, 7, 6, 30); - final m = _unslept( - now: now, - learnedOnset: _h23, - learnedWake: 8 * 60, - alarm: DateTime.utc(2026, 3, 8, 13, 0), // 07:00 MDT - ); - expect(m.wakeSource, WakeSource.alarm); - expect(m.wakeAt, DateTime(2026, 3, 8, 7, 0)); - expect(m.windowStart, DateTime(2026, 3, 7, 23, 0)); - expect(m.estimateMin, 420); - }); - - test('1 am inside a DST night: today\'s alarm accepted, tomorrow\'s ' - 'refused (spring forward and fall back)', () { - for (final c in [ - (DateTime(2026, 3, 8, 1, 0), DateTime.utc(2026, 3, 8, 13, 0), - DateTime.utc(2026, 3, 9, 13, 0)), - (DateTime(2026, 11, 1, 0, 30), DateTime.utc(2026, 11, 1, 14, 0), - DateTime.utc(2026, 11, 2, 14, 0)), - ]) { - final ok = _unslept( - now: c.$1, learnedOnset: _h23, learnedWake: 8 * 60, alarm: c.$2); - expect(ok.wakeSource, WakeSource.alarm, reason: '${c.$1} today'); - final no = _unslept( - now: c.$1, learnedOnset: _h23, learnedWake: 8 * 60, alarm: c.$3); - expect(no.wakeSource, WakeSource.learned, reason: '${c.$1} tomorrow'); - } - }); - - test('spring forward: an alarm two calendar days out is still refused', () { - // Friday-evening style guard survives the change: 07:00 on the 9th is not - // tonight from 07:30 on the 7th, so the learned wake answers. - final now = DateTime(2026, 3, 7, 7, 30); - final m = _unslept( - now: now, - learnedOnset: _h23, - learnedWake: 8 * 60, - alarm: DateTime.utc(2026, 3, 9, 13, 0), // 07:00 MDT on the 9th - ); - expect(m.wakeSource, WakeSource.learned); - }); - - test('now inside the transition night still measures real elapsed time', - () { - // 00:30 MDT on spring-forward day, before the 02:00 jump: 7:00 MDT is - // 5.5 real hours away, not 6.5. - final now = DateTime(2026, 3, 8, 0, 30); - final m = _unslept(now: now, learnedOnset: _h23, learnedWake: _h07); - expect(m.windowStart, now); - expect(m.estimateMin, 330); - }); - }); - - group('learnedClockMinutes', () { - int sec(int y, int mo, int d, int h, int mi) => - DateTime(y, mo, d, h, mi).millisecondsSinceEpoch ~/ 1000; - - Map night(int day, (int, int)? onset, (int, int)? wake) => - { - 'date': '2026-09-${day.toString().padLeft(2, '0')}', - 'onset_ts': onset == null - ? null - : (onset.$1 >= 12 - ? sec(2026, 9, day - 1, onset.$1, onset.$2) - : sec(2026, 9, day, onset.$1, onset.$2)), - 'wake_ts': wake == null ? null : sec(2026, 9, day, wake.$1, wake.$2), - }; - - test('median local clock minute of onsets and of wakes', () { - final r = learnedClockMinutes([ - night(10, (23, 0), (7, 0)), - night(11, (23, 0), (7, 0)), - night(12, (23, 0), (7, 30)), - night(13, (23, 30), (6, 45)), - night(14, (0, 10), (7, 15)), - ]); - expect(r.onsetMin, 23 * 60); - expect(r.wakeMin, 7 * 60); - }); - - test('onsets straddling midnight do not average to midday', () { - final r = learnedClockMinutes([ - night(10, (23, 10), (7, 0)), - night(11, (23, 30), (7, 0)), - night(12, (23, 50), (7, 0)), - night(13, (0, 10), (7, 0)), - night(14, (0, 30), (7, 0)), - ]); - expect(r.onsetMin, 23 * 60 + 50); - }); - - test('all-after-midnight onsets stay after midnight', () { - final r = learnedClockMinutes([ - night(10, (0, 10), (8, 0)), - night(11, (0, 20), (8, 0)), - night(12, (0, 30), (8, 0)), - ]); - expect(r.onsetMin, 20); - }); - - test('fewer than the minimum usable nights => null, not a guess', () { - final r = learnedClockMinutes([ - night(10, (23, 0), (7, 0)), - night(11, (23, 0), (7, 0)), - ]); - expect(r.onsetMin, isNull); - expect(r.wakeMin, isNull); - // Control: one more night and both sides answer. - final three = learnedClockMinutes([ - night(10, (23, 0), (7, 0)), - night(11, (23, 0), (7, 0)), - night(12, (23, 0), (7, 0)), - ]); - expect(three.onsetMin, 23 * 60); - expect(three.wakeMin, 7 * 60); - }); - - test('nights with no window are skipped, and each side counts its own', - () { - final r = learnedClockMinutes([ - night(10, null, null), - night(11, (23, 0), (7, 0)), - night(12, (23, 0), null), - night(13, (23, 0), (7, 0)), - night(14, null, (7, 0)), - ]); - expect(r.onsetMin, 23 * 60); // 3 onsets - expect(r.wakeMin, 7 * 60); // 3 wakes - }); - - test('empty input is null on both sides', () { - final r = learnedClockMinutes(const []); - expect(r.onsetMin, isNull); - expect(r.wakeMin, isNull); - // Control: minSamples is honoured (a single night answers at 1). - expect( - learnedClockMinutes([night(10, (23, 0), (7, 0))], minSamples: 1) - .wakeMin, - 7 * 60); - }); }); } diff --git a/test/ui2_home_day_switcher_test.dart b/test/ui2_home_day_switcher_test.dart index a247c96b2..f1dd368dc 100644 --- a/test/ui2_home_day_switcher_test.dart +++ b/test/ui2_home_day_switcher_test.dart @@ -63,30 +63,6 @@ class _Repo extends LocalRepository { {'has_sleep': true, 'duration_min': 250}; } -/// Nothing scored on either day, but five nights of typical times. -class _NoSleepRepo extends _Repo { - @override - Future> getToday() async => { - ...await super.getToday(), - 'sleep': const {}, - }; - @override - Future> getDaySleepV2(String date) async => const {}; - @override - Future>> sleepWindows( - {int days = 60, String? before}) async => - [ - for (var day = 1; day <= 5; day++) - { - 'date': '2026-10-0$day', - 'onset_ts': DateTime(2026, 10, day - 1, 23).millisecondsSinceEpoch ~/ - 1000, - 'wake_ts': - DateTime(2026, 10, day, 7).millisecondsSinceEpoch ~/ 1000, - }, - ]; -} - Future _settle(WidgetTester t, {int n = 40}) async { for (var i = 0; i < n; i++) { await t.pump(const Duration(milliseconds: 20)); @@ -164,38 +140,6 @@ void main() { expect(find.text('70'), findsOneWidget); }); - testWidgets( - 'a past day with no scored sleep never claims tonight\'s "on track" ' - '(the estimate belongs to today)', (t) async { - final app = AppState.forTesting(); - addTearDown(app.dispose); - app.repo = _NoSleepRepo(); - - await t.pumpWidget(MaterialApp( - theme: buildTheme(Brightness.light), - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - home: ChangeNotifierProvider.value( - value: app, - child: const Scaffold(body: HomeScreen(hour: 9)), - ), - )); - await _settle(t); - // Control: today, unslept, with typical times, shows the estimate. - expect(find.textContaining('cycles'), findsOneWidget); - - await t.tap(find.bySemanticsLabel('Previous day')); - await _settle(t); - expect(find.text('42'), findsOneWidget, reason: 'yesterday is on screen'); - expect(find.textContaining('on track for'), findsNothing); - expect(find.textContaining('cycles'), findsNothing); - expect(find.text('No estimate'), findsOneWidget); - - await t.tap(find.bySemanticsLabel('Next day')); - await _settle(t); - expect(find.textContaining('cycles'), findsOneWidget); - }); - test('a past day is kept only for a short absence on the same day', () { const day = '2026-10-31'; expect( From eb3190a24598e190403085b2190653b795f91093 Mon Sep 17 00:00:00 2001 From: MikenRafter Date: Sun, 8 Nov 2026 22:15:53 -0700 Subject: [PATCH 13/17] fix(home): drop the redundant past-day need read, format the strain target per locale, trim dead code and keys Past-day need comes from the night's own need_min (same source and staleness gate as getInsights); drop the second read and sleepCoachOf. The strain target range uses displayFixed. Remove unused homeWhyLabel/homeSleepNoTarget/homeOfSpan, dead caption/LayoutBuilder code and stale comments, the test-only Ring/StageRing.pieces getters and the duplicate gradientAt. Tests call SpiralGeometry directly; the round2/round3 spiral tests merge into ui2_spiral_ring_test.dart; the glassbox absence tests become one load assertion. --- lib/l10n/app_de.arb | 7 +- lib/l10n/app_en.arb | 26 +- lib/l10n/app_es.arb | 7 +- lib/l10n/app_fr.arb | 7 +- lib/l10n/app_hi.arb | 7 +- lib/l10n/app_ru.arb | 17 +- lib/l10n/app_th.arb | 22 +- lib/l10n/app_zh.arb | 7 +- lib/ui2/charts.dart | 14 +- lib/ui2/screens/home_screen.dart | 64 +--- lib/ui2/spiral_ring.dart | 4 +- test/home_labels_test.dart | 4 +- test/home_ring_card_test.dart | 51 ++-- test/home_sleep_ring_widget_test.dart | 5 +- test/ui2_spiral_ring_round2_test.dart | 150 ---------- test/ui2_spiral_ring_round3_test.dart | 201 ------------- test/ui2_spiral_ring_test.dart | 414 ++++++++++++++++++++++---- test/ui2_wiring_r2_test.dart | 2 +- 18 files changed, 416 insertions(+), 593 deletions(-) delete mode 100644 test/ui2_spiral_ring_round2_test.dart delete mode 100644 test/ui2_spiral_ring_round3_test.dart diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 96bd607c3..6edd17aeb 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -1488,11 +1488,10 @@ "homeInsightsStaleOnDay": "Die letzte Auswertung wurde am {day} erstellt – zu alt, um sich darauf zu verlassen.", "homeInsightsNoVersionStamp": "Die gespeicherte Auswertung hat keine Versionsangabe.", "homeSyncBand": "Band synchronisieren", - "homeWhyLabel": "Warum?", "homeCalibrating": "Wird kalibriert", "homeCalibratingNights": "{have} von {need} Nächten", "homeCalibratingDays": "{have} von {need} Tagen", - "homeGapNoReason": "Kein Messwert.", + "homeGapNoReason": "Kein Grund erfasst.", "homeRingRecovery": "Erholung", "homeRingStrain": "Strain", "homeRingSleep": "Schlaf", @@ -1575,8 +1574,6 @@ } }, "homeStrainOf21": "von 21", - "homeSleepNoTarget": "Noch kein Ziel", - "homeOfSpan": "von {duration}", "homeLoadFailedTitle": "Der heutige Tag konnte nicht geladen werden", "homeLoadFailedBody": "Der gespeicherte Tag konnte nicht geladen werden. Es wurde nichts gelöscht – das ist ein Ladefehler, es fehlen keine Daten.", "homeDayLoadFailedTitle": "{day} konnte nicht geladen werden", @@ -1602,7 +1599,7 @@ "homeSomeDayDataMissingTitle": "Einige Daten dieses Tages fehlen", "homeReadinessNotScoredTitle": "Heute keine Erholung berechnet", "homeReadinessNeedBody": "{need}, um zu wissen, was für dich normal ist.", - "homeReadinessNoReason": "Kein Messwert.", + "homeReadinessNoReason": "Kein Grund erfasst.", "homeSeeWhatWasMissing": "Anzeigen, was gefehlt hat", "homeAtAGlance": "Auf einen Blick", "homeTodaysPlan": "Plan für heute", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index eb23aa71d..057e14bba 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -7295,10 +7295,6 @@ "@homeSyncBand": { "description": "Call-to-action button label: sync the band." }, - "homeWhyLabel": "Why?", - "@homeWhyLabel": { - "description": "Short label introducing the readiness driver list on Home." - }, "homeCalibrating": "Learning your usual", "@homeCalibrating": { "description": "Ring value shown while a metric is still building its baseline." @@ -7327,7 +7323,7 @@ } } }, - "homeGapNoReason": "No reading.", + "homeGapNoReason": "No reason recorded.", "@homeGapNoReason": { "description": "Fallback reason shown when a ring is empty and nothing explains why." }, @@ -7473,20 +7469,6 @@ "@homeStrainOf21": { "description": "Strain ring subtitle: out of the fixed 21-point scale." }, - "homeSleepNoTarget": "No target yet", - "@homeSleepNoTarget": { - "description": "Sleep ring subtitle when no computed sleep-need target exists yet." - }, - "homeOfSpan": "of {duration}", - "@homeOfSpan": { - "description": "Sleep ring subtitle: duration measured against a computed sleep-need target.", - "placeholders": { - "duration": { - "type": "String", - "example": "7h 45m" - } - } - }, "homeLoadFailedTitle": "Today could not be read", "@homeLoadFailedTitle": { "description": "Title of the card shown when today's data failed to load from the database." @@ -7611,7 +7593,7 @@ } } }, - "homeReadinessNoReason": "No reading.", + "homeReadinessNoReason": "No reason recorded.", "@homeReadinessNoReason": { "description": "Fallback body of the readiness-not-scored card when no reason is recorded." }, @@ -7667,11 +7649,11 @@ }, "homeIllnessBodyAbove": "Recent nights put your resting heart rate above your usual. That night was above it too. A pattern, not a cause.", "@homeIllnessBodyAbove": { - "description": "Illness watch body text when the flagged night's heart rate sat above baseline, with the deviation amount." + "description": "Illness watch body text when the flagged night's heart rate sat above baseline." }, "homeIllnessBodyBelow": "Recent nights together put your resting heart rate above your usual, though that night alone was below it. A pattern, not a cause.", "@homeIllnessBodyBelow": { - "description": "Illness watch body text when the flagged night's heart rate sat below baseline, with the deviation amount." + "description": "Illness watch body text when the flagged night's heart rate sat below baseline." }, "homeIllnessAdvice": "Worth noting if it continues past a couple of days.", "@homeIllnessAdvice": { diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 3a02ea0bc..aa2e927ad 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -1442,11 +1442,10 @@ "homeInsightsStaleOnDay": "El último resumen se generó el {day}, lo cual es demasiado antiguo para confiar en él.", "homeInsightsNoVersionStamp": "El resumen guardado no tiene marca de versión.", "homeSyncBand": "Sincronizar la correa", - "homeWhyLabel": "¿Por qué?", "homeCalibrating": "Calibrando", "homeCalibratingNights": "{have} de {need} noches", "homeCalibratingDays": "{have} de {need} días", - "homeGapNoReason": "Sin lectura.", + "homeGapNoReason": "Sin motivo registrado.", "homeRingRecovery": "Recuperación", "homeRingStrain": "Esfuerzo", "homeRingSleep": "Sueño", @@ -1529,8 +1528,6 @@ } }, "homeStrainOf21": "de 21", - "homeSleepNoTarget": "Aún sin objetivo", - "homeOfSpan": "de {duration}", "homeLoadFailedTitle": "No se pudo leer el día de hoy", "homeLoadFailedBody": "No se pudo cargar el día guardado. No se eliminó nada; fue un error de lectura, no datos perdidos.", "homeDayLoadFailedTitle": "No se pudo leer {day}", @@ -1554,7 +1551,7 @@ "homeSomeDayDataMissingTitle": "Faltan algunos datos de este día", "homeReadinessNotScoredTitle": "La recuperación no tiene puntuación hoy", "homeReadinessNeedBody": "{need} para saber qué es lo normal en tu caso.", - "homeReadinessNoReason": "Sin lectura.", + "homeReadinessNoReason": "Sin motivo registrado.", "homeSeeWhatWasMissing": "Ver qué faltaba", "homeAtAGlance": "De un vistazo", "homeTodaysPlan": "Plan de hoy", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 8542cd7d9..0f5b8af19 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -1442,11 +1442,10 @@ "homeInsightsStaleOnDay": "Le dernier récapitulatif a été généré le {day}, ce qui est trop ancien pour s'y fier.", "homeInsightsNoVersionStamp": "Le récapitulatif enregistré ne porte aucune marque de version.", "homeSyncBand": "Synchroniser le bracelet", - "homeWhyLabel": "Pourquoi ?", "homeCalibrating": "Étalonnage", "homeCalibratingNights": "{have, plural, one{{have} nuit sur {need}} other{{have} nuits sur {need}}}", "homeCalibratingDays": "{have, plural, one{{have} jour sur {need}} other{{have} jours sur {need}}}", - "homeGapNoReason": "Aucune mesure.", + "homeGapNoReason": "Aucune raison enregistrée.", "homeRingRecovery": "Récupération", "homeRingStrain": "Effort", "homeRingSleep": "Sommeil", @@ -1529,8 +1528,6 @@ } }, "homeStrainOf21": "sur 21", - "homeSleepNoTarget": "Pas encore d'objectif", - "homeOfSpan": "sur {duration}", "homeLoadFailedTitle": "Impossible de lire les données d'aujourd'hui", "homeLoadFailedBody": "La journée enregistrée n'a pas pu être chargée. Rien n'a été supprimé : c'est une erreur de lecture, pas une perte de données.", "homeDayLoadFailedTitle": "Impossible de lire {day}", @@ -1548,7 +1545,7 @@ "homeSomeDayDataMissingTitle": "Certaines données de ce jour manquent", "homeReadinessNotScoredTitle": "La récupération n'est pas évaluée aujourd'hui", "homeReadinessNeedBody": "{need} pour savoir ce qui est normal pour toi.", - "homeReadinessNoReason": "Aucune mesure.", + "homeReadinessNoReason": "Aucune raison enregistrée.", "homeSeeWhatWasMissing": "Voir ce qui manquait", "homeAtAGlance": "En un coup d'œil", "homeTodaysPlan": "Plan du jour", diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index bfe03d300..e3da24021 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -1442,11 +1442,10 @@ "homeInsightsStaleOnDay": "आखिरी रोलअप {day} को बना था, जो भरोसा करने के लिए बहुत पुराना है।", "homeInsightsNoVersionStamp": "सहेजे गए रोलअप पर कोई वर्शन स्टैंप नहीं है।", "homeSyncBand": "स्ट्रैप सिंक करें", - "homeWhyLabel": "क्यों?", "homeCalibrating": "कैलिब्रेट हो रहा है", "homeCalibratingNights": "{need} में से {have} रातें", "homeCalibratingDays": "{need} में से {have} दिन", - "homeGapNoReason": "कोई रीडिंग नहीं।", + "homeGapNoReason": "कोई कारण दर्ज नहीं।", "homeRingRecovery": "रिकवरी", "homeRingStrain": "स्ट्रेन", "homeRingSleep": "नींद", @@ -1529,8 +1528,6 @@ } }, "homeStrainOf21": "21 में से", - "homeSleepNoTarget": "अभी कोई लक्ष्य नहीं", - "homeOfSpan": "{duration} में से", "homeLoadFailedTitle": "आज का डेटा पढ़ा नहीं जा सका", "homeLoadFailedBody": "सहेजा गया दिन लोड नहीं हो सका। कुछ भी हटाया नहीं गया — यह एक रीड एरर है, डेटा खोना नहीं।", "homeDayLoadFailedTitle": "{day} का डेटा पढ़ा नहीं जा सका", @@ -1548,7 +1545,7 @@ "homeSomeDayDataMissingTitle": "इस दिन का कुछ डेटा मौजूद नहीं है", "homeReadinessNotScoredTitle": "आज रिकवरी का स्कोर नहीं है", "homeReadinessNeedBody": "आपके लिए सामान्य क्या है, यह जानने के लिए {need}।", - "homeReadinessNoReason": "कोई रीडिंग नहीं।", + "homeReadinessNoReason": "कोई कारण दर्ज नहीं।", "homeSeeWhatWasMissing": "देखें क्या कमी थी", "homeAtAGlance": "एक नज़र में", "homeTodaysPlan": "आज की योजना", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 15c49634c..66686cc17 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -3327,7 +3327,6 @@ }, "homeInsightsNoVersionStamp": "Тренды обновляются. После синхронизации это занимает около минуты.", "homeSyncBand": "Синхронизировать браслет", - "homeWhyLabel": "Почему?", "homeCalibrating": "Изучаем ваш обычный уровень", "homeCalibratingNights": "{have} из {need} ночей", "@homeCalibratingNights": { @@ -3351,7 +3350,7 @@ } } }, - "homeGapNoReason": "Нет показания.", + "homeGapNoReason": "Причина не указана.", "homeRingRecovery": "Восстановление", "homeRingStrain": "Нагрузка", "homeRingSleep": "Сон", @@ -3456,16 +3455,6 @@ } }, "homeStrainOf21": "из 21", - "homeSleepNoTarget": "Цель пока не задана", - "homeOfSpan": "из {duration}", - "@homeOfSpan": { - "placeholders": { - "duration": { - "type": "String", - "example": "7h 45m" - } - } - }, "homeLoadFailedTitle": "Не удалось прочитать сегодняшний день", "homeLoadFailedBody": "Сохранённый день не загрузился. Ничего не удалено: это ошибка чтения, а не отсутствие данных.", "homeDayLoadFailedTitle": "Не удалось прочитать {day}", @@ -3523,7 +3512,7 @@ } } }, - "homeReadinessNoReason": "Нет показания.", + "homeReadinessNoReason": "Причина не указана.", "homeSeeWhatWasMissing": "Посмотреть, чего не хватило", "homeAtAGlance": "Краткий обзор", "homeTodaysPlan": "План на сегодня", @@ -7518,7 +7507,7 @@ } } }, - "homeCoachSetUpChip": "Настроить коуча", + "homeCoachSetUpChip": "Настроить тренера", "profileSetupCycleSub": "Отмечайте менструацию в разделе «Здоровье». Это можно изменить позже.", "homeRecoverySleeping": "Сон…", "homeRecoveryAfterWake": "Восстановление оценивается после пробуждения.", diff --git a/lib/l10n/app_th.arb b/lib/l10n/app_th.arb index 2663e39d5..ad9ecc89b 100644 --- a/lib/l10n/app_th.arb +++ b/lib/l10n/app_th.arb @@ -7283,10 +7283,6 @@ "@homeSyncBand": { "description": "Call-to-action button label: sync the band." }, - "homeWhyLabel": "เพราะอะไร?", - "@homeWhyLabel": { - "description": "Short label introducing the readiness driver list on Home." - }, "homeCalibrating": "กำลังเรียนรู้ช่วงปกติของคุณ", "@homeCalibrating": { "description": "Ring value shown while a metric is still building its baseline." @@ -7315,7 +7311,7 @@ } } }, - "homeGapNoReason": "ไม่มีค่าที่อ่านได้", + "homeGapNoReason": "ไม่มีการบันทึกเหตุผล", "@homeGapNoReason": { "description": "Fallback reason shown when a ring is empty and nothing explains why." }, @@ -7461,20 +7457,6 @@ "@homeStrainOf21": { "description": "Strain ring subtitle: out of the fixed 21-point scale." }, - "homeSleepNoTarget": "ยังไม่มีเป้าหมาย", - "@homeSleepNoTarget": { - "description": "Sleep ring subtitle when no computed sleep-need target exists yet." - }, - "homeOfSpan": "จาก {duration}", - "@homeOfSpan": { - "description": "Sleep ring subtitle: duration measured against a computed sleep-need target.", - "placeholders": { - "duration": { - "type": "String", - "example": "7h 45m" - } - } - }, "homeLoadFailedTitle": "อ่านข้อมูลวันนี้ไม่ได้", "@homeLoadFailedTitle": { "description": "Title of the card shown when today's data failed to load from the database." @@ -7599,7 +7581,7 @@ } } }, - "homeReadinessNoReason": "ไม่มีค่าที่อ่านได้", + "homeReadinessNoReason": "ไม่มีการบันทึกเหตุผล", "@homeReadinessNoReason": { "description": "Fallback body of the readiness-not-scored card when no reason is recorded." }, diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 31689538e..8ec1037ce 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -1442,11 +1442,10 @@ "homeInsightsStaleOnDay": "上次汇总是在 {day} 生成的,太旧了,不宜依赖。", "homeInsightsNoVersionStamp": "已保存的汇总没有版本标记。", "homeSyncBand": "同步腕带", - "homeWhyLabel": "为什么?", "homeCalibrating": "校准中", "homeCalibratingNights": "已有 {have}/{need} 晚", "homeCalibratingDays": "已有 {have}/{need} 天", - "homeGapNoReason": "没有读数。", + "homeGapNoReason": "未记录原因。", "homeRingRecovery": "恢复", "homeRingStrain": "消耗", "homeRingSleep": "睡眠", @@ -1529,8 +1528,6 @@ } }, "homeStrainOf21": "满分 21", - "homeSleepNoTarget": "暂无目标", - "homeOfSpan": "共 {duration}", "homeLoadFailedTitle": "今日数据读取失败", "homeLoadFailedBody": "已保存的当日数据加载失败。没有任何内容被删除——这是读取出错,不是数据丢失。", "homeDayLoadFailedTitle": "{day}的数据读取失败", @@ -1548,7 +1545,7 @@ "homeSomeDayDataMissingTitle": "这一天的部分数据缺失", "homeReadinessNotScoredTitle": "今天的恢复度未评分", "homeReadinessNeedBody": "{need},才能了解你的正常水平。", - "homeReadinessNoReason": "没有读数。", + "homeReadinessNoReason": "未记录原因。", "homeSeeWhatWasMissing": "查看缺失的内容", "homeAtAGlance": "概览", "homeTodaysPlan": "今日计划", diff --git a/lib/ui2/charts.dart b/lib/ui2/charts.dart index 06acfc874..edb26f390 100644 --- a/lib/ui2/charts.dart +++ b/lib/ui2/charts.dart @@ -618,8 +618,6 @@ class Ring extends CustomPainter { Ring(this.v, this.color, this.track, {this.stroke = 10, this.t = 1, this.solid = false, this.background}); - List get pieces => SpiralGeometry(stroke).pieces([v * t.clamp(0.0, 1.0)]); - @override void paint(Canvas cv, Size s) { paintSpiral(cv, s, stroke: stroke, track: track, colors: [color], @@ -662,13 +660,15 @@ class StageRing extends CustomPainter { StageRing(this.arcs, this.track, {this.stroke = 10, this.t = 1, this.background}); - List get pieces => SpiralGeometry(stroke).pieces(arcs.map((a) => a.fraction * t.clamp(0.0, 1.0)).toList()); - @override void paint(Canvas cv, Size s) { - paintSpiral(cv, s, stroke: stroke, track: track, - colors: arcs.map((a) => a.color).toList(), fractions: arcs.map((a) => a.fraction).toList(), t: t, - background: background); + paintSpiral(cv, s, + stroke: stroke, + track: track, + colors: arcs.map((a) => a.color).toList(), + fractions: arcs.map((a) => a.fraction).toList(), + t: t, + background: background); } @override diff --git a/lib/ui2/screens/home_screen.dart b/lib/ui2/screens/home_screen.dart index 6483ac1a9..c5f7adc5e 100644 --- a/lib/ui2/screens/home_screen.dart +++ b/lib/ui2/screens/home_screen.dart @@ -1035,7 +1035,7 @@ String _recoveryWhy(HomeData d, AppLocalizations? l) { } String _strainWhy(HomeData d, AppLocalizations? l) { - String n(num v) => v == v.round() ? '${v.round()}' : v.toStringAsFixed(1); + String n(num v) => v == v.round() ? '${v.round()}' : displayFixed(v, 1); final t = d.strainTarget; final low = t?['low'], high = t?['high']; final range = @@ -1407,7 +1407,7 @@ _RingState _gap(HomeRingKind k, String label, IconData icon, Color color, why: whyFromNote(m.note, unit: unit) ?? (fallbackWhy.isNotEmpty ? fallbackWhy - : (l?.homeGapNoReason ?? 'No reading.'))); + : (l?.homeGapNoReason ?? 'No reason recorded.'))); } /// The scored night split by stage, in the hypnogram's own colours — or null @@ -1467,10 +1467,6 @@ class _Dial extends StatelessWidget { } } -/// The default: three across, the number under the ring rather than inside it. -/// Inside is where a duration overflows its own circle at the first -/// accessibility step, and nothing about "7h 45m" gets shorter. - /// The default: three across, the number under the ring rather than inside it. /// Inside is where a duration overflows its own circle at the first /// accessibility step, and nothing about "7h 45m" gets shorter. @@ -1574,21 +1570,6 @@ class _CaptionSpec { return ok; } - double height(String text, double width) { - final tp = TextPainter( - text: TextSpan(text: text, style: style), - textAlign: align, - textDirection: dir, - textScaler: scaler, - locale: locale, - textWidthBasis: basis, - textHeightBehavior: heightBehavior, - )..layout(maxWidth: width); - final h = tp.height; - tp.dispose(); - return h; - } - Text build(String text, {bool bounded = true}) => Text(text, style: style, textAlign: align, @@ -1603,10 +1584,7 @@ class _RingText extends StatelessWidget { const _RingText(this.r, {required this.align}); @override - Widget build(BuildContext c) => LayoutBuilder( - builder: (c, box) => _build(c, box.maxWidth)); - - Widget _build(BuildContext c, double colWidth) { + Widget build(BuildContext c) { final p = P.of(c); final cross = align == TextAlign.center ? CrossAxisAlignment.center @@ -1630,7 +1608,7 @@ class _RingText extends StatelessWidget { if (r.sub.isNotEmpty) LayoutBuilder(builder: (c, box) { final l = AppLocalizations.of(c); - // The two-line bound is the centred column's; the big-text + // The three-line bound is the centred column's; the big-text // row has the width it needs and wraps without a limit. final spec = _CaptionSpec.of(c, p.ink3, align, align == TextAlign.center ? 3 : null); @@ -1673,24 +1651,6 @@ SleepStageMin? sleepStagesOf(Map night) { : s; } -/// The learned sleep need and the coach bedtime out of `getInsights()`, shared -/// by every loader so a dated view cannot forget them (it did, and drew an -/// empty bar under a night that had a need). -({Metric need, Metric bedtime}) sleepCoachOf(Map insights) { - final coach = insights['sleep_coach']; - final needEnv = coach is Map ? coach['need'] : null; - final bedEnv = coach is Map ? coach['bedtime'] : null; - final needSec = envValue(needEnv)?['need_sec'] as num?; - return ( - // sleep_coach.need is the COMPUTED need. `sleep.need_min` is a hardcoded - // 480 and must never be shown as "your sleep need". - need: envMetric(needEnv, needSec == null ? null : needSec / 60, - unit: 'min'), - bedtime: envMetric( - bedEnv, envValue(bedEnv)?['bedtime_min_of_day'] as num?), - ); -} - class HomeData { final String? name; final String? dayId; @@ -1833,16 +1793,10 @@ class HomeData { final absentDiag = readiness.value != null ? null : await LocalDb.readinessAbsentDiag(date); - // The learned need: the day's own when the night carries one, otherwise - // the current one off the coach, like [load]. A failed read leaves it - // absent, and the ring then shows the labelled default, never an invented - // need. - var need = metricOf(sleep['need_min']); - if (need.value == null) { - try { - need = sleepCoachOf(await repo.getInsights()).need; - } catch (_) {} - } + // The learned need comes with the night: getDaySleepV2 reads the same + // sleep_coach.need (same staleness gate) as getInsights. Absent stays + // absent: the ring shows the labelled default, never an invented need. + final need = metricOf(sleep['need_min']); return HomeData( name: profile['name']?.toString(), dayId: date, @@ -2572,7 +2526,7 @@ class _HomeScreenState extends State with RevisionReload { // is what actually answers it. : shortfall ?? whyFromNote(d.readiness.note) ?? - (l?.homeReadinessNoReason ?? 'No reading.'), + (l?.homeReadinessNoReason ?? 'No reason recorded.'), fix: l?.homeSeeWhatWasMissing ?? 'See what was missing', icon: LucideIcons.batteryCharging, onFix: () => diff --git a/lib/ui2/spiral_ring.dart b/lib/ui2/spiral_ring.dart index 7d6c263f2..1d874a135 100644 --- a/lib/ui2/spiral_ring.dart +++ b/lib/ui2/spiral_ring.dart @@ -177,7 +177,7 @@ void paintSpiral(Canvas canvas, Size size, Color colourAt(SpiralPiece p, double s) { final c = colors[p.seg]; if (solid) return c; - final a = .55 + .45 * gradientAt(s, total); + final a = .55 + .45 * SpiralGeometry.gradientAt(s, total); final faded = c.withValues(alpha: c.a * a); return background == null ? faded : Color.alphaBlend(faded, background); } @@ -268,5 +268,3 @@ void paintSpiral(Canvas canvas, Size size, if (once) canvas.restore(); canvas.restore(); } - -double gradientAt(double s, double total) => SpiralGeometry.gradientAt(s, total); diff --git a/test/home_labels_test.dart b/test/home_labels_test.dart index 911638b34..3baabb9bf 100644 --- a/test/home_labels_test.dart +++ b/test/home_labels_test.dart @@ -161,8 +161,8 @@ void main() { test('WHY: the English strings are the plain ones', () { expect(l.homeCoachSetUpChip, 'Set up the coach'); expect(l.homeDbRebuiltTitle, 'Your database was rebuilt'); - expect(l.homeGapNoReason, 'No reading.'); - expect(l.homeReadinessNoReason, 'No reading.'); + expect(l.homeGapNoReason, 'No reason recorded.'); + expect(l.homeReadinessNoReason, 'No reason recorded.'); expect(l.homeInsightsRebuildingTitle, 'Updating your trends'); expect(l.homeInsightsRebuildingAlgoVersion, 'Updating after the latest app update.'); diff --git a/test/home_ring_card_test.dart b/test/home_ring_card_test.dart index e4bd2c30e..25ffcdcc1 100644 --- a/test/home_ring_card_test.dart +++ b/test/home_ring_card_test.dart @@ -46,6 +46,7 @@ import 'package:openstrap_edge/data/local_repository.dart'; import 'package:openstrap_edge/l10n/app_localizations.dart'; import 'package:openstrap_edge/models/metric.dart'; import 'package:openstrap_edge/state/clock_format.dart' show formatClockOf; +import 'package:openstrap_edge/state/locale_controller.dart'; import 'package:openstrap_edge/ui2/screens/screens.dart'; import 'package:openstrap_edge/ui2/ui2.dart'; @@ -152,8 +153,6 @@ Map _ringPos(WidgetTester t) => { Map _drv(String label, double c, String detail) => {'label': label, 'contribution': c, 'detail': detail}; -Map _z(String l, double c, double z) => - _drv(l, c, 'oriented robust-z (median+MAD)=$z'); const _drag = 'dragging your score down'; const _lift = 'lifting your score'; @@ -336,6 +335,21 @@ void main() { findsOneWidget); }); + testWidgets('WHY: the strain and its target range share one decimal ' + 'separator (de)', (t) async { + final c = LocaleController.seed('de')..useForPresentation(); + addTearDown(c.dispose); + await _rings( + t, + _data( + strain: 15.2, + target: const {'value': 12, 'low': 10.5, 'high': 14.5}), + locale: const Locale('de')); + final body = _txt(t, _body('strain')); + expect(body, contains('15,2')); + expect(body, contains('10,5–14,5')); + }); + testWidgets('WHY: panels order by how pressing, not by ring order', (t) async { await _rings(t, _rec2Sleep3); @@ -407,32 +421,19 @@ void main() { }); testWidgets( - 'WHY: readiness_glassbox drivers belong to the percentile score, not ' - 'to the composite ring: a divergent percentile is never blamed', - (t) async { - // The ring shows the composite (22). The rollup's percentile score says - // 80 and its drivers drag: a different calculation, so no attribution. - final d = await t.runAsync(() => HomeData.load(_Repo([ + 'WHY: HomeData.load ignores readiness_glassbox, a different score from ' + 'the composite ring, whatever its drivers or rollup age', (t) async { + final withBox = await t.runAsync(() => HomeData.load(_Repo([ _drv('hrv', -6.2, _drag), _drv('rhr', 3.1, _lift), - _drv('temp', -1.4, _drag), - ], score: 80.0))); - await _rings(t, d!); - expect(_txt(t, _body('recovery')), 'Recovery 22 of 100'); - expect(find.textContaining('Pulling it down'), findsNothing); - expect(find.textContaining('Biggest drag'), findsNothing); - }); - - testWidgets( - 'WHY: a usable rollup up to 7 days old is not evidence for today\'s ' - 'ring', (t) async { - final d = await t.runAsync(() => HomeData.load(_Repo([ - _z('hrv', -6.0, -2.0), - _z('rhr', -3.0, -1.0), - ], rollupDay: '2026-09-29'))); - await _rings(t, d!); + ], score: 80.0, rollupDay: '2026-09-29'))); + final without = await t.runAsync(() => HomeData.load(_Repo(const []))); + expect(withBox!.readiness.value, 22); + expect(withBox.readiness.value, without!.readiness.value); + expect(withBox.strain.value, without.strain.value); + expect(withBox.sleepMin.value, without.sleepMin.value); + await _rings(t, withBox); expect(_txt(t, _body('recovery')), 'Recovery 22 of 100'); - expect(find.textContaining('Biggest drag'), findsNothing); }); }); diff --git a/test/home_sleep_ring_widget_test.dart b/test/home_sleep_ring_widget_test.dart index 30e529940..b6d04ec3b 100644 --- a/test/home_sleep_ring_widget_test.dart +++ b/test/home_sleep_ring_widget_test.dart @@ -315,10 +315,9 @@ void main() { }); test('HomeData.loadForDay (a dated view of a slept night) carries the ' - 'learned need — it used to drop it and draw an empty bar', () async { + 'need the night itself reports', () async { final repo = _Repo( - insights: _needInsights, - night: _nightMap, + night: {..._nightMap, 'need_min': _env(462)}, ); final d = await HomeData.loadForDay(repo, '2026-10-08'); expect(d.sleepMin.value, 442); diff --git a/test/ui2_spiral_ring_round2_test.dart b/test/ui2_spiral_ring_round2_test.dart deleted file mode 100644 index 2a5ff82cc..000000000 --- a/test/ui2_spiral_ring_round2_test.dart +++ /dev/null @@ -1,150 +0,0 @@ -// Round 2 on the spiral ring: what is VISIBLE at stage joins, lap boundaries -// and on small rings at high ratios. Pixel probes, not call counts. - -import 'dart:math'; -import 'dart:typed_data'; -import 'dart:ui' as ui; - -import 'package:flutter/foundation.dart' show listEquals; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:openstrap_edge/ui2/charts.dart'; -import 'package:openstrap_edge/ui2/spiral_ring.dart'; - -const _a = Color(0xFF0000FF); -const _b = Color(0xFFFF0000); -const _bg = Color(0xFFFF00FF); -const _none = Color(0x00000000); -const _side = 200; -const _w = 20.0; -const _r0 = 100 - _w / 2; - -Future _raster(CustomPainter p, {int px = _side}) async { - final rec = ui.PictureRecorder(); - p.paint(Canvas(rec), Size(px.toDouble(), px.toDouble())); - final img = await rec.endRecording().toImage(px, px); - return (await img.toByteData())!; -} - -List _px(ByteData d, double x, double y, {int px = _side}) { - final i = (y.floor() * px + x.floor()) * 4; - return [for (var k = 0; k < 4; k++) d.getUint8(i + k)]; -} - -List _polar(ByteData d, double u, double r) => - _px(d, 100 + r * sin(2 * pi * u), 100 - r * cos(2 * pi * u)); - -bool _is(List c, Color k) => - c[3] > 200 && - (c[0] - (k.r * 255)).abs() < 40 && - (c[1] - (k.g * 255)).abs() < 40 && - (c[2] - (k.b * 255)).abs() < 40; - -class _Count implements Canvas { - int paths = 0; - @override - void drawPath(Path p, Paint paint) => paths++; - @override - dynamic noSuchMethod(Invocation invocation) => null; -} - -void main() { - const g = SpiralGeometry(_w); - - testWidgets('separator halos never cut a hole in the clockwise neighbour', - (tester) async { - await tester.runAsync(() async { - final d = await _raster(StageRing(const [ - RingArc(_a, 1.5), - RingArc(_b, .5), - ], _none, stroke: _w, background: _bg)); - // B's centre-line just clockwise of A's end (inside A's old halo). - for (final u in [.5228, .53, .56, .6, .8]) { - final c = _polar(d, u, g.radiusAt(1 + u, _r0)); - expect(_is(c, _b), isTrue, reason: 'u=$u px=$c'); - } - }); - }); - - testWidgets('a segment ending exactly on a lap boundary keeps its round cap ' - 'over the neighbour', (tester) async { - await tester.runAsync(() async { - final d = await _raster(StageRing(const [ - RingArc(_a, 1.0), - RingArc(_b, .5), - ], _none, stroke: _w, background: _bg)); - // A ends at 12 o'clock. Its disc (r 10) covers (105,12), which B's lap-1 - // band starts under; the cap must be on top. - expect(_is(_px(d, 105, 12), _a), isTrue, reason: _px(d, 105, 12).toString()); - expect(_is(_px(d, 100, 16), _a), isTrue); - // Away from the cap B is on top. - expect(_is(_polar(d, .3, g.radiusAt(1.3, _r0)), _b), isTrue); - }); - }); - - testWidgets('full-circle seam: the last segment\'s round end is visible over ' - 'the first segment\'s start', (tester) async { - await tester.runAsync(() async { - final d = await _raster(StageRing(const [ - RingArc(_a, .5), - RingArc(_b, .5), - ], _none, stroke: _w, background: _bg)); - // B ends at 12 o'clock; its disc covers (105,12) clockwise of the seam. - expect(_is(_px(d, 105, 12), _b), isTrue, reason: _px(d, 105, 12).toString()); - // The first segment's own start is square: well clockwise it is A. - expect(_is(_polar(d, .1, _r0), _a), isTrue); - }); - }); - - group('small rings saturate', () { - const small = SpiralGeometry(3); - const r0 = 10 - 1.5; - - test('saturationLaps: the lap after which every lap looks the same', () { - // Hold radius of lap L is r0 - 2.25 L; the floor is 4.5: lap 2 sits on it. - expect(small.saturationLaps(r0), 3); - expect(g.saturationLaps(_r0), 5); // 90 - 15 L <= 30 -> L = 4 -> 5 laps - // A ring too small to inset at all is one lap. - expect(const SpiralGeometry(10).saturationLaps(12), 1); - }); - - testWidgets('1000% builds a bounded number of paths, same as the ' - 'saturation point', (tester) async { - Ring ring(double v) => Ring(v, _a, _none, - stroke: 3, solid: true, background: _bg); - int paths(double v) { - final c = _Count(); - ring(v).paint(c, const Size(20, 20)); - return c.paths; - } - - expect(paths(3.0), greaterThan(paths(1.0)), - reason: 'extra laps are extra bands'); - expect(paths(200), lessThanOrEqualTo(paths(3.0))); - expect(paths(200), lessThan(30)); - expect(paths(10), paths(3.0)); - await tester.runAsync(() async { - final sat = await _raster(ring(3.0), px: 20); - final one = await _raster(ring(1.0), px: 20); - expect(listEquals(sat.buffer.asUint8List(), one.buffer.asUint8List()), - isFalse, - reason: 'the saturated picture is a spiral, not one flat lap'); - // Radius 5 (u = .3, 20 px) is inside the saturated laps and outside - // lap 0's band (7..10): only a spiral paints it. - int a(ByteData d) => _px(d, 10 + 5 * sin(2 * pi * .3), - 10 - 5 * cos(2 * pi * .3), px: 20)[3]; - expect(a(one), 0); - expect(a(sat), greaterThan(200)); - for (final v in [5.0, 10.0, 200.0]) { - final d = await _raster(ring(v), px: 20); - expect(d.buffer.asUint8List(), sat.buffer.asUint8List(), - reason: 'v=$v'); - } - }); - }); - - test('the value itself is still reported unclamped by pieces', () { - expect(Ring(10, _a, _none, stroke: 3).pieces.last.to, 10.0); - }); - }); -} diff --git a/test/ui2_spiral_ring_round3_test.dart b/test/ui2_spiral_ring_round3_test.dart deleted file mode 100644 index 79986367e..000000000 --- a/test/ui2_spiral_ring_round3_test.dart +++ /dev/null @@ -1,201 +0,0 @@ -// Round 3 on the spiral ring: the separator is a thin line, not an eraser, and -// a translucent (gradient) ring composites each coloured region once. - -import 'dart:math'; -import 'dart:typed_data'; -import 'dart:ui' as ui; - -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:openstrap_edge/ui2/charts.dart'; -import 'package:openstrap_edge/ui2/spiral_ring.dart'; - -const _green = Color(0xFF00AA00); -const _bg = Color(0xFFFF00FF); -const _none = Color(0x00000000); - -Future _raster(CustomPainter p, int px) async { - final rec = ui.PictureRecorder(); - p.paint(Canvas(rec), Size(px.toDouble(), px.toDouble())); - final img = await rec.endRecording().toImage(px, px); - return (await img.toByteData())!; -} - -List _at(ByteData d, int px, double u, double r) { - final x = (px / 2 + r * sin(2 * pi * u)).floor().clamp(0, px - 1); - final y = (px / 2 - r * cos(2 * pi * u)).floor().clamp(0, px - 1); - final i = (y * px + x) * 4; - return [for (var k = 0; k < 4; k++) d.getUint8(i + k)]; -} - -bool _magenta(List c) => - c[3] > 100 && c[0] > 200 && c[2] > 200 && c[1] < 80; - -void main() { - testWidgets('20 px / stroke 3 ring at 500%: no empty or background pixel ' - 'between the outer edge and the innermost lap', (tester) async { - await tester.runAsync(() async { - final d = await _raster( - Ring(5.0, _green, _none, stroke: 3, solid: true, background: _bg), - 20); - for (var k = 0; k < 12; k++) { - final u = k / 12 + .02; - double? inner; - for (var r = 1.5; r < 10; r += .25) { - if (_at(d, 20, u, r)[3] > 200) { - inner = r; - break; - } - } - expect(inner, isNotNull, reason: 'u=$u'); - // A flat ring's innermost pixel is lap 0's inner edge (~7). - expect(inner!, lessThan(6), reason: 'u=$u: no inner laps'); - for (var r = inner + .5; r < 8.8; r += .25) { - final c = _at(d, 20, u, r); - expect(c[3], greaterThan(200), reason: 'gap at u=$u r=$r $c'); - expect(_magenta(c), isFalse, reason: 'separator at u=$u r=$r'); - } - } - }); - }); - - testWidgets('200 px / stroke 20: the separator is present and at most ' - '1.5 px wide', (tester) async { - await tester.runAsync(() async { - const g = SpiralGeometry(20); - final d = await _raster( - Ring(1.3, _green, _none, stroke: 20, solid: true, background: _bg), - 200); - for (final u in [.15, .2, .25]) { - final edge = 90 - g.inset(1 + u) + 10; - // Width = sum of the magenta share (the red channel; the ring is - // green) along the ray, so antialiased edge pixels count fractionally. - var w = 0.0; - for (var r = edge - 4; r < edge + 4; r += .1) { - w += _at(d, 200, u, r)[0] / 255 * .1; - } - expect(w, greaterThan(.6), reason: 'u=$u: no separator'); - expect(w, lessThanOrEqualTo(1.8), reason: 'u=$u width $w'); - } - }); - }); - - test('separator width scales with stroke and vanishes on tiny rings', () { - expect(kSpiralSeparator, 1.5); - expect(spiralSeparatorWidth(20), closeTo(1.5, 1e-9)); - expect(spiralSeparatorWidth(40), closeTo(1.5, 1e-9)); - expect(spiralSeparatorWidth(14), closeTo(1.05, 1e-9)); - expect(spiralSeparatorWidth(10), closeTo(.75, 1e-9)); - expect(spiralSeparatorWidth(3), 0); - expect(spiralSeparatorWidth(6), 0); - }); - - // Round 5: a gradient ring is pre-blended against `background` and drawn - // fully opaque, with ordinary discs and bands. No cap subtraction, so no - // crack at the join and the 100% end cap is back. - - testWidgets('gradient ring at 66%: start and end caps blend into the band ' - 'with the same colour (once)', (tester) async { - await tester.runAsync(() async { - final d = await _raster( - Ring(.66, _green, _none, stroke: 20, background: _bg), 200); - int red(double u, double r) => _at(d, 200, u, r)[0]; - expect((red(.008, 90) - red(.05, 90)).abs(), lessThan(16), - reason: 'start cap overlap vs band'); - expect((red(.99, 90) - red(.05, 90)).abs(), lessThan(24), - reason: 'cap-only side vs band'); - expect((red(.655, 90) - red(.6, 90)).abs(), lessThan(16), - reason: 'end cap overlap vs band'); - }); - }); - - for (final v in [.66, 1.3]) { - testWidgets('gradient ring at ${(v * 100).round()}%: no crack along the ' - 'cap/band joins', (tester) async { - await tester.runAsync(() async { - const g = SpiralGeometry(20); - final d = await _raster( - Ring(v, _green, _none, stroke: 20, background: _bg), 200); - // Probes chosen from the geometry alone (never from what was drawn): - // along the tangent at offset `a` px and radially at `dr` px from the - // cap centre. Behind the join (a <= 0 at the start, a <= 0 at the end) - // is band; ahead of it only the disc covers, so it counts as interior - // within 6.5 px of the centre. Every such pixel must be fully opaque and - // match its horizontal neighbours (a crack is a lighter or more - // transparent hairline). - final end = v % 1; - const half = 10.0, reach = 6.5; - final probes = <(double, double)>[]; - void region(double centreU, double radius, {required bool bandBehind}) { - for (var a = -7.0; a <= 7; a += 1) { - for (var dr = -6.0; dr <= 6; dr += 1) { - final behind = bandBehind ? a <= 0 : a >= 0; - final inDisc = sqrt(a * a + dr * dr) <= reach; - if ((behind && dr.abs() <= half - 3) || inDisc) { - probes.add((centreU + a / (2 * pi * radius), radius + dr)); - } - } - } - } - - region(0, 90, bandBehind: false); // start cap; the band runs ahead - region(end, g.radiusAt(v, 90).toDouble(), bandBehind: true); - expect(probes.length, greaterThan(100)); - for (final (u, rr) in probes) { - final c = _at(d, 200, u, rr); - expect(c[3], 255, reason: 'transparent join pixel at u=$u r=$rr'); - final x = (100 + rr * sin(2 * pi * u)).floor(); - final y = (100 - rr * cos(2 * pi * u)).floor(); - for (final nx in [x - 1, x + 1]) { - final i = (y * 200 + nx) * 4; - expect(d.getUint8(i + 3), 255, - reason: 'transparent neighbour of u=$u r=$rr'); - for (var ch = 0; ch < 3; ch++) { - expect((d.getUint8(i + ch) - c[ch]).abs(), lessThan(40), - reason: 'crack at u=$u r=$rr ch=$ch'); - } - } - } - // Inside both band and cap the pixel is opaque and not background. - for (final (u, r) in [(.02, 90.0), (end - .02, g.radiusAt(v, 90))]) { - final c = _at(d, 200, u, r); - expect(c[3], 255, reason: 'u=$u'); - expect(c[0] < 200, isTrue, reason: 'background shows at u=$u $c'); - } - }); - }); - } - - testWidgets('gradient ring at 100% keeps its round end cap over the start', - (tester) async { - await tester.runAsync(() async { - final d = await _raster( - Ring(1.0, _green, _none, stroke: 20, background: _bg), 200); - // The end disc (full colour) covers (105,12); the start band there is - // faint (mostly background pink), so the disc must be on top. - final c = _at(d, 200, 5 / (2 * pi * 90), 88); - expect(c[0], lessThan(40), reason: 'end cap missing: $c'); - // And it is round: the disc reaches just past the straight seam but not - // beyond its radius. - expect(_at(d, 200, 14 / (2 * pi * 90), 90)[0], greaterThan(60)); - }); - }); - - // Callers that pass no `background` (Readiness, Coach figures, Live, the - // gallery) get a translucent fade. Bands and caps must still composite ONCE: - // an overlap may not be denser than the band beside it. - testWidgets('gradient ring with NO background at 66%: cap overlaps are as ' - 'translucent as the band beside them', (tester) async { - await tester.runAsync(() async { - final d = await _raster(Ring(.66, _green, _none, stroke: 20), 200); - int alpha(double u, double r) => _at(d, 200, u, r)[3]; - final band = alpha(.05, 90); - expect(band, inInclusiveRange(120, 200), - reason: 'the 55%+ fade is translucent, not opaque'); - expect((alpha(.008, 90) - band).abs(), lessThan(24), - reason: 'start cap over the band: ${alpha(.008, 90)} vs $band'); - expect((alpha(.655, 90) - alpha(.6, 90)).abs(), lessThan(24), - reason: 'end cap over the band: ${alpha(.655, 90)} vs ${alpha(.6, 90)}'); - }); - }); -} diff --git a/test/ui2_spiral_ring_test.dart b/test/ui2_spiral_ring_test.dart index c59dc2b12..dca5c5417 100644 --- a/test/ui2_spiral_ring_test.dart +++ b/test/ui2_spiral_ring_test.dart @@ -11,12 +11,13 @@ // and no inset; nothing is drawn past the value; nothing is clamped to 100%. // // Geometry is asserted through [SpiralGeometry] (lib/ui2/spiral_ring.dart), -// then through Ring.pieces / StageRing.pieces, then a pixel smoke test. +// then through pixels drawn by Ring / StageRing. import 'dart:math'; import 'dart:typed_data'; import 'dart:ui' as ui; +import 'package:flutter/foundation.dart' show listEquals; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:openstrap_edge/ui2/charts.dart'; @@ -222,68 +223,6 @@ void main() { }); }); - group('one implementation behind both painters', () { - const track = Color(0x00000000); - - test('Ring draws exactly the fraction given, unclamped', () { - for (final v in [.66, 1.0, 1.5, 2.0, 3.0]) { - final r = Ring(v, Colors.green, track); - expect(_sweep(r.pieces), closeTo(v, 1e-9), reason: 'v=$v'); - } - }); - - test('Ring scales by the draw-in progress t, never past the value', () { - expect(_sweep(Ring(1.5, Colors.green, track, t: .5).pieces), - closeTo(.75, 1e-9)); - expect(Ring(1.5, Colors.green, track, t: 0).pieces, isEmpty); - expect(_sweep(Ring(1.5, Colors.green, track, t: 2).pieces), - closeTo(1.5, 1e-9)); - }); - - test('Ring draws nothing for zero, negative or NaN (no invented overflow)', - () { - for (final v in [0.0, -1.0, double.nan]) { - expect(Ring(v, Colors.green, track).pieces, isEmpty, reason: '$v'); - } - }); - - test('Ring <= 100% never produces a second lap', () { - for (final v in [.01, .5, 1.0]) { - expect(Ring(v, Colors.green, track).pieces, isNotEmpty); - expect(Ring(v, Colors.green, track).pieces.every((p) => p.lap == 0), - isTrue); - } - }); - - test('StageRing no longer clips: arcs summing to 1.5 draw 1.5 laps', () { - final r = StageRing(const [ - RingArc(Colors.blue, .6), - RingArc(Colors.red, .5), - RingArc(Colors.green, .4), - ], track); - expect(_sweep(r.pieces), closeTo(1.5, 1e-9)); - expect(r.pieces.map((p) => p.lap).toSet(), {0, 1}); - }); - - test('StageRing under 100%: one lap, round caps, same rules as Ring', () { - final r = StageRing(const [ - RingArc(Colors.blue, .3), - RingArc(Colors.red, .2), - ], track); - expect(_sweep(r.pieces), closeTo(.5, 1e-9)); - expect(r.pieces.every((p) => p.lap == 0 && p.capEnd), isTrue); - expect(r.pieces.singleWhere((p) => p.seg == 0).capStart, isTrue); - }); - - test('StageRing scales by t and keeps segment order', () { - final r = StageRing(const [ - RingArc(Colors.blue, .8), - RingArc(Colors.red, .8), - ], track, t: .5); - expect(_sweep(r.pieces), closeTo(.8, 1e-9)); - }); - }); - group('render smoke', () { const side = 100.0; @@ -315,7 +254,6 @@ void main() { .whereType() .single; expect(painter.v, v); - expect(_sweep(painter.pieces), closeTo(v, 1e-9), reason: 'v=$v'); } }); @@ -342,7 +280,9 @@ void main() { .map((e) => e.painter) .whereType() .single; - expect(_sweep(painter.pieces), closeTo(v, 1e-9), reason: 'v=$v'); + expect(painter.arcs.fold(0.0, (a, e) => a + e.fraction), + closeTo(v, 1e-9), + reason: 'v=$v'); } }); @@ -372,6 +312,22 @@ void main() { }); }); + testWidgets('pixels: the draw-in progress t scales every arc, never past ' + 'the value', (tester) async { + await tester.runAsync(() async { + const tr = Color(0x00000000); + Future> ring(double v, {double t = 1}) => + raster(Ring(v, Colors.green, tr, t: t, solid: true)); + expect(await ring(1.5, t: .5), await ring(.75)); + expect(await ring(1.5, t: 2), await ring(1.5)); + Future> stage(double f, {double t = 1}) => raster(StageRing([ + RingArc(Colors.blue, f), + RingArc(Colors.red, f), + ], tr, t: t)); + expect(await stage(.8, t: .5), await stage(.4)); + }); + }); + testWidgets('pixels: nothing is drawn past the value, and t=0 is empty', (tester) async { await tester.runAsync(() async { @@ -606,6 +562,326 @@ void main() { .shouldRepaint(base), isTrue); }); }); + + group('stage joins, lap boundaries and small rings (pixel probes)', () { + const a = Color(0xFF0000FF); + const b = Color(0xFFFF0000); + const bg = Color(0xFFFF00FF); + const none = Color(0x00000000); + const side = 200; + const w = 20.0; + const ringR0 = 100 - w / 2; + + Future raster(CustomPainter p, {int px = side}) async { + final rec = ui.PictureRecorder(); + p.paint(Canvas(rec), Size(px.toDouble(), px.toDouble())); + final img = await rec.endRecording().toImage(px, px); + return (await img.toByteData())!; + } + + List px(ByteData d, double x, double y, {int px = side}) { + final i = (y.floor() * px + x.floor()) * 4; + return [for (var k = 0; k < 4; k++) d.getUint8(i + k)]; + } + + List polar(ByteData d, double u, double r) => + px(d, 100 + r * sin(2 * pi * u), 100 - r * cos(2 * pi * u)); + + bool isColour(List c, Color k) => + c[3] > 200 && + (c[0] - (k.r * 255)).abs() < 40 && + (c[1] - (k.g * 255)).abs() < 40 && + (c[2] - (k.b * 255)).abs() < 40; + + const g = SpiralGeometry(w); + + testWidgets('separator halos never cut a hole in the clockwise neighbour', + (tester) async { + await tester.runAsync(() async { + final d = await raster(StageRing(const [ + RingArc(a, 1.5), + RingArc(b, .5), + ], none, stroke: w, background: bg)); + // B's centre-line just clockwise of A's end (inside A's old halo). + for (final u in [.5228, .53, .56, .6, .8]) { + final c = polar(d, u, g.radiusAt(1 + u, ringR0)); + expect(isColour(c, b), isTrue, reason: 'u=$u px=$c'); + } + }); + }); + + testWidgets('a segment ending exactly on a lap boundary keeps its round cap ' + 'over the neighbour', (tester) async { + await tester.runAsync(() async { + final d = await raster(StageRing(const [ + RingArc(a, 1.0), + RingArc(b, .5), + ], none, stroke: w, background: bg)); + // A ends at 12 o'clock. Its disc (r 10) covers (105,12), which B's lap-1 + // band starts under; the cap must be on top. + expect(isColour(px(d, 105, 12), a), isTrue, reason: px(d, 105, 12).toString()); + expect(isColour(px(d, 100, 16), a), isTrue); + // Away from the cap B is on top. + expect(isColour(polar(d, .3, g.radiusAt(1.3, ringR0)), b), isTrue); + }); + }); + + testWidgets('full-circle seam: the last segment\'s round end is visible over ' + 'the first segment\'s start', (tester) async { + await tester.runAsync(() async { + final d = await raster(StageRing(const [ + RingArc(a, .5), + RingArc(b, .5), + ], none, stroke: w, background: bg)); + // B ends at 12 o'clock; its disc covers (105,12) clockwise of the seam. + expect(isColour(px(d, 105, 12), b), isTrue, reason: px(d, 105, 12).toString()); + // The first segment's own start is square: well clockwise it is A. + expect(isColour(polar(d, .1, ringR0), a), isTrue); + }); + }); + + group('small rings saturate', () { + const small = SpiralGeometry(3); + const r0 = 10 - 1.5; + + test('saturationLaps: the lap after which every lap looks the same', () { + // Hold radius of lap L is r0 - 2.25 L; the floor is 4.5: lap 2 sits on it. + expect(small.saturationLaps(r0), 3); + expect(g.saturationLaps(ringR0), 5); // 90 - 15 L <= 30 -> L = 4 -> 5 laps + // A ring too small to inset at all is one lap. + expect(const SpiralGeometry(10).saturationLaps(12), 1); + }); + + testWidgets('1000% builds a bounded number of paths, same as the ' + 'saturation point', (tester) async { + Ring ring(double v) => Ring(v, a, none, + stroke: 3, solid: true, background: bg); + int paths(double v) { + final c = _Count(); + ring(v).paint(c, const Size(20, 20)); + return c.paths; + } + + expect(paths(3.0), greaterThan(paths(1.0)), + reason: 'extra laps are extra bands'); + expect(paths(200), lessThanOrEqualTo(paths(3.0))); + expect(paths(200), lessThan(30)); + expect(paths(10), paths(3.0)); + await tester.runAsync(() async { + final sat = await raster(ring(3.0), px: 20); + final one = await raster(ring(1.0), px: 20); + expect(listEquals(sat.buffer.asUint8List(), one.buffer.asUint8List()), + isFalse, + reason: 'the saturated picture is a spiral, not one flat lap'); + // Radius 5 (u = .3, 20 px) is inside the saturated laps and outside + // lap 0's band (7..10): only a spiral paints it. + int a(ByteData d) => px(d, 10 + 5 * sin(2 * pi * .3), + 10 - 5 * cos(2 * pi * .3), px: 20)[3]; + expect(a(one), 0); + expect(a(sat), greaterThan(200)); + for (final v in [5.0, 10.0, 200.0]) { + final d = await raster(ring(v), px: 20); + expect(d.buffer.asUint8List(), sat.buffer.asUint8List(), + reason: 'v=$v'); + } + }); + }); + + test('the geometry still reports the value unclamped', () { + expect(SpiralGeometry(3).pieces([10]).last.to, 10.0); + }); + }); + }); + + group('separator line and single-composite translucency (pixel probes)', () { + const green = Color(0xFF00AA00); + const bg = Color(0xFFFF00FF); + const none = Color(0x00000000); + + Future raster(CustomPainter p, int px) async { + final rec = ui.PictureRecorder(); + p.paint(Canvas(rec), Size(px.toDouble(), px.toDouble())); + final img = await rec.endRecording().toImage(px, px); + return (await img.toByteData())!; + } + + List at(ByteData d, int px, double u, double r) { + final x = (px / 2 + r * sin(2 * pi * u)).floor().clamp(0, px - 1); + final y = (px / 2 - r * cos(2 * pi * u)).floor().clamp(0, px - 1); + final i = (y * px + x) * 4; + return [for (var k = 0; k < 4; k++) d.getUint8(i + k)]; + } + + bool magenta(List c) => + c[3] > 100 && c[0] > 200 && c[2] > 200 && c[1] < 80; + + testWidgets('20 px / stroke 3 ring at 500%: no empty or background pixel ' + 'between the outer edge and the innermost lap', (tester) async { + await tester.runAsync(() async { + final d = await raster( + Ring(5.0, green, none, stroke: 3, solid: true, background: bg), + 20); + for (var k = 0; k < 12; k++) { + final u = k / 12 + .02; + double? inner; + for (var r = 1.5; r < 10; r += .25) { + if (at(d, 20, u, r)[3] > 200) { + inner = r; + break; + } + } + expect(inner, isNotNull, reason: 'u=$u'); + // A flat ring's innermost pixel is lap 0's inner edge (~7). + expect(inner!, lessThan(6), reason: 'u=$u: no inner laps'); + for (var r = inner + .5; r < 8.8; r += .25) { + final c = at(d, 20, u, r); + expect(c[3], greaterThan(200), reason: 'gap at u=$u r=$r $c'); + expect(magenta(c), isFalse, reason: 'separator at u=$u r=$r'); + } + } + }); + }); + + testWidgets('200 px / stroke 20: the separator is present and at most ' + '1.5 px wide', (tester) async { + await tester.runAsync(() async { + const g = SpiralGeometry(20); + final d = await raster( + Ring(1.3, green, none, stroke: 20, solid: true, background: bg), + 200); + for (final u in [.15, .2, .25]) { + final edge = 90 - g.inset(1 + u) + 10; + // Width = sum of the magenta share (the red channel; the ring is + // green) along the ray, so antialiased edge pixels count fractionally. + var w = 0.0; + for (var r = edge - 4; r < edge + 4; r += .1) { + w += at(d, 200, u, r)[0] / 255 * .1; + } + expect(w, greaterThan(.6), reason: 'u=$u: no separator'); + expect(w, lessThanOrEqualTo(1.8), reason: 'u=$u width $w'); + } + }); + }); + + test('separator width scales with stroke and vanishes on tiny rings', () { + expect(kSpiralSeparator, 1.5); + expect(spiralSeparatorWidth(20), closeTo(1.5, 1e-9)); + expect(spiralSeparatorWidth(40), closeTo(1.5, 1e-9)); + expect(spiralSeparatorWidth(14), closeTo(1.05, 1e-9)); + expect(spiralSeparatorWidth(10), closeTo(.75, 1e-9)); + expect(spiralSeparatorWidth(3), 0); + expect(spiralSeparatorWidth(6), 0); + }); + + // Round 5: a gradient ring is pre-blended against `background` and drawn + // fully opaque, with ordinary discs and bands. No cap subtraction, so no + // crack at the join and the 100% end cap is back. + + testWidgets('gradient ring at 66%: start and end caps blend into the band ' + 'with the same colour (once)', (tester) async { + await tester.runAsync(() async { + final d = await raster( + Ring(.66, green, none, stroke: 20, background: bg), 200); + int red(double u, double r) => at(d, 200, u, r)[0]; + expect((red(.008, 90) - red(.05, 90)).abs(), lessThan(16), + reason: 'start cap overlap vs band'); + expect((red(.99, 90) - red(.05, 90)).abs(), lessThan(24), + reason: 'cap-only side vs band'); + expect((red(.655, 90) - red(.6, 90)).abs(), lessThan(16), + reason: 'end cap overlap vs band'); + }); + }); + + for (final v in [.66, 1.3]) { + testWidgets('gradient ring at ${(v * 100).round()}%: no crack along the ' + 'cap/band joins', (tester) async { + await tester.runAsync(() async { + const g = SpiralGeometry(20); + final d = await raster( + Ring(v, green, none, stroke: 20, background: bg), 200); + // Probes chosen from the geometry alone (never from what was drawn): + // along the tangent at offset `a` px and radially at `dr` px from the + // cap centre. Behind the join (a <= 0 at the start, a <= 0 at the end) + // is band; ahead of it only the disc covers, so it counts as interior + // within 6.5 px of the centre. Every such pixel must be fully opaque and + // match its horizontal neighbours (a crack is a lighter or more + // transparent hairline). + final end = v % 1; + const half = 10.0, reach = 6.5; + final probes = <(double, double)>[]; + void region(double centreU, double radius, {required bool bandBehind}) { + for (var a = -7.0; a <= 7; a += 1) { + for (var dr = -6.0; dr <= 6; dr += 1) { + final behind = bandBehind ? a <= 0 : a >= 0; + final inDisc = sqrt(a * a + dr * dr) <= reach; + if ((behind && dr.abs() <= half - 3) || inDisc) { + probes.add((centreU + a / (2 * pi * radius), radius + dr)); + } + } + } + } + + region(0, 90, bandBehind: false); // start cap; the band runs ahead + region(end, g.radiusAt(v, 90).toDouble(), bandBehind: true); + expect(probes.length, greaterThan(100)); + for (final (u, rr) in probes) { + final c = at(d, 200, u, rr); + expect(c[3], 255, reason: 'transparent join pixel at u=$u r=$rr'); + final x = (100 + rr * sin(2 * pi * u)).floor(); + final y = (100 - rr * cos(2 * pi * u)).floor(); + for (final nx in [x - 1, x + 1]) { + final i = (y * 200 + nx) * 4; + expect(d.getUint8(i + 3), 255, + reason: 'transparent neighbour of u=$u r=$rr'); + for (var ch = 0; ch < 3; ch++) { + expect((d.getUint8(i + ch) - c[ch]).abs(), lessThan(40), + reason: 'crack at u=$u r=$rr ch=$ch'); + } + } + } + // Inside both band and cap the pixel is opaque and not background. + for (final (u, r) in [(.02, 90.0), (end - .02, g.radiusAt(v, 90))]) { + final c = at(d, 200, u, r); + expect(c[3], 255, reason: 'u=$u'); + expect(c[0] < 200, isTrue, reason: 'background shows at u=$u $c'); + } + }); + }); + } + + testWidgets('gradient ring at 100% keeps its round end cap over the start', + (tester) async { + await tester.runAsync(() async { + final d = await raster( + Ring(1.0, green, none, stroke: 20, background: bg), 200); + // The end disc (full colour) covers (105,12); the start band there is + // faint (mostly background pink), so the disc must be on top. + final c = at(d, 200, 5 / (2 * pi * 90), 88); + expect(c[0], lessThan(40), reason: 'end cap missing: $c'); + // And it is round: the disc reaches just past the straight seam but not + // beyond its radius. + expect(at(d, 200, 14 / (2 * pi * 90), 90)[0], greaterThan(60)); + }); + }); + + // Callers that pass no `background` (Readiness, Coach figures, Live, the + // gallery) get a translucent fade. Bands and caps must still composite ONCE: + // an overlap may not be denser than the band beside it. + testWidgets('gradient ring with NO background at 66%: cap overlaps are as ' + 'translucent as the band beside them', (tester) async { + await tester.runAsync(() async { + final d = await raster(Ring(.66, green, none, stroke: 20), 200); + int alpha(double u, double r) => at(d, 200, u, r)[3]; + final band = alpha(.05, 90); + expect(band, inInclusiveRange(120, 200), + reason: 'the 55%+ fade is translucent, not opaque'); + expect((alpha(.008, 90) - band).abs(), lessThan(24), + reason: 'start cap over the band: ${alpha(.008, 90)} vs $band'); + expect((alpha(.655, 90) - alpha(.6, 90)).abs(), lessThan(24), + reason: 'end cap over the band: ${alpha(.655, 90)} vs ${alpha(.6, 90)}'); + }); + }); + }); } class _Discs implements Canvas { @@ -624,3 +900,11 @@ class _Discs implements Canvas { @override dynamic noSuchMethod(Invocation invocation) => null; } + +class _Count implements Canvas { + int paths = 0; + @override + void drawPath(Path p, Paint paint) => paths++; + @override + dynamic noSuchMethod(Invocation invocation) => null; +} diff --git a/test/ui2_wiring_r2_test.dart b/test/ui2_wiring_r2_test.dart index 815c500f5..0767fbea3 100644 --- a/test/ui2_wiring_r2_test.dart +++ b/test/ui2_wiring_r2_test.dart @@ -847,7 +847,7 @@ void main() { body: HomeScreen(data: HomeData(dayId: '2026-05-20'), hour: 20)), )); - testWidgets('no model: the chip says what the coach is for', (t) async { + testWidgets('no model: the chip invites setting the coach up', (t) async { await t.pumpWidget(frame(false)); expect(find.byIcon(LucideIcons.sparkles), findsOneWidget); expect(find.text('Set up the coach'), From 6aca1a6d46009f5e006d09cdf9b8084b68db7b7d Mon Sep 17 00:00:00 2001 From: MikenRafter Date: Sun, 8 Nov 2026 22:52:33 -0700 Subject: [PATCH 14/17] test(home): the withheld-rollup card reads "Updating your trends" --- test/ui2_wellness_recovery_paint_test.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/ui2_wellness_recovery_paint_test.dart b/test/ui2_wellness_recovery_paint_test.dart index 7b170344e..23367fed8 100644 --- a/test/ui2_wellness_recovery_paint_test.dart +++ b/test/ui2_wellness_recovery_paint_test.dart @@ -309,7 +309,7 @@ void main() { ); expect(find.text('No breakdown to show'), findsOneWidget); - expect(find.text('Your cross-day insights are being rebuilt'), + expect(find.text('Updating your trends'), findsOneWidget); expect(find.text('No recovery breakdown yet'), findsNothing); expect(find.text('No sleep need yet'), findsNothing); From 96b497efc7a5a285fdf5d4cbd9994b5d3e12e004 Mon Sep 17 00:00:00 2001 From: MikenRafter Date: Tue, 10 Nov 2026 17:21:51 -0700 Subject: [PATCH 15/17] fix(home): awake shares the sleep ring's sweep instead of extending it The sleep ring added the awake arc after the asleep arcs, so the ring read fuller than its own "N% of target" label (a 94% night closed the circle). All staged minutes, awake included, now divide the asleep / target sweep, so the picture matches the percentage. A learned need that rounds to zero (or is not finite) falls back to the labelled default target. Split from Sol's review pass; the sleep-shortfall provenance change is left for a follow-up that handles ring staleness as a whole. Co-Authored-By: Claude Opus 5.5 --- lib/ui2/sleep_ring_model.dart | 21 ++++++-------- test/home_review_cr1_test.dart | 32 ++++++++++++++++++++ test/home_sleep_ring_widget_test.dart | 13 +++++---- test/sleep_ring_model_test.dart | 42 +++++++++++++-------------- 4 files changed, 69 insertions(+), 39 deletions(-) create mode 100644 test/home_review_cr1_test.dart diff --git a/lib/ui2/sleep_ring_model.dart b/lib/ui2/sleep_ring_model.dart index 6c6290ad4..71c9586c3 100644 --- a/lib/ui2/sleep_ring_model.dart +++ b/lib/ui2/sleep_ring_model.dart @@ -3,7 +3,6 @@ // PURE. No clock, no I/O, no widgets, no l10n: the ring turns this into shapes // and strings. Kept apart so every rule below is a fixed-input unit test. // -import 'dart:math' as math; /// The owner's explicit default target: 5 cycles + 30 min. Used ONLY when no /// learned need exists, and always labelled as a default where it is shown. @@ -54,7 +53,7 @@ SleepRingModel sleepRingModel({ SleepStageMin? stages, num? needMin, }) { - final learnedNeed = needMin != null && needMin > 0; + final learnedNeed = needMin != null && needMin.isFinite && needMin.round() > 0; final target = learnedNeed ? needMin.round() : kDefaultSleepTargetMin; return _slept(durationMin, stages, target, !learnedNeed); } @@ -72,21 +71,19 @@ SleepRingModel _slept( final arcs = []; if (split > 0 && asleep > 0) { - // Rescale to the night's duration so rounding in the stage totals cannot - // move the sweep. Fractions are minutes / NEED and stay unscaled past a - // full circle: 12 h against an 8 h need is 150%, a second lap, as the - // label says. - final k = asleepFrac / split; + // Stage proportions, including awake, divide the earned sleep sweep. + // Awake describes the night without adding progress toward the target. + // Keep sweeps above one lap so the picture agrees with percentages >100. + final awake = stages?.awake; + final awakeMin = awake != null && awake > 0 ? awake : 0; + final k = asleepFrac / (split + awakeMin); arcs.addAll([ SleepArc(SleepArcKind.deep, d! * k), SleepArc(SleepArcKind.rem, r! * k), SleepArc(SleepArcKind.light, l! * k), ]); - final awake = stages!.awake; - final room = 1 - math.min(1.0, asleepFrac); - if (awake != null && awake > 0) { - final f = math.min(awake / target, room).toDouble(); - if (f > 1e-12) arcs.add(SleepArc(SleepArcKind.awake, f)); + if (awakeMin > 0) { + arcs.add(SleepArc(SleepArcKind.awake, awakeMin * k)); } } else { arcs.add(SleepArc(SleepArcKind.solid, asleepFrac)); diff --git a/test/home_review_cr1_test.dart b/test/home_review_cr1_test.dart new file mode 100644 index 000000000..adffd9729 --- /dev/null +++ b/test/home_review_cr1_test.dart @@ -0,0 +1,32 @@ +// Review regressions for the Home sleep ring. +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/ui2/sleep_ring_model.dart'; + +void main() { + test('4510399136: a need that rounds to zero uses the labelled default', () { + for (final need in [.001, .49, double.nan, double.infinity]) { + expect(() => sleepRingModel(durationMin: 360, needMin: need), + returnsNormally, reason: 'invalid need $need must not crash Home'); + final m = sleepRingModel(durationMin: 360, needMin: need); + expect(m.targetMin, 480); + expect(m.targetIsDefault, isTrue); + expect(m.pct, 75); + } + final boundary = sleepRingModel(durationMin: 1, needMin: .5); + expect(boundary.targetMin, 1); + expect(boundary.targetIsDefault, isFalse); + expect(boundary.pct, 100); + }); + + test('4510399166: awake stays inside the 75 percent sleep sweep', () { + final m = sleepRingModel(durationMin: 360, needMin: 480, + stages: const SleepStageMin(deep: 60, rem: 90, light: 210, awake: 120)); + expect(m.pct, 75); + expect(m.sweep, closeTo(.75, 1e-12)); + // 480 total staged minutes divide a 0.75-lap sweep: awake gets 120/480 + // of that sweep, and deep gets 60/480 of it. + expect(m.arcs.last.kind, SleepArcKind.awake); + expect(m.arcs.last.fraction, closeTo(.1875, 1e-12)); + expect(m.arcs.first.fraction, closeTo(.09375, 1e-12)); + }); +} diff --git a/test/home_sleep_ring_widget_test.dart b/test/home_sleep_ring_widget_test.dart index b6d04ec3b..f4bb098a6 100644 --- a/test/home_sleep_ring_widget_test.dart +++ b/test/home_sleep_ring_widget_test.dart @@ -209,12 +209,15 @@ void main() { cols[SleepStage.light], cols[SleepStage.awake], ]); + // All 472 staged minutes divide the earned 442/480-lap sweep. expect([for (final a in ring.arcs) a.fraction], [ - closeTo(70 / 480, 1e-9), - closeTo(95 / 480, 1e-9), - closeTo(277 / 480, 1e-9), - closeTo(30 / 480, 1e-9), + closeTo(442 / 480 * 70 / 472, 1e-9), + closeTo(442 / 480 * 95 / 472, 1e-9), + closeTo(442 / 480 * 277 / 472, 1e-9), + closeTo(442 / 480 * 30 / 472, 1e-9), ]); + expect(ring.arcs.fold(0, (s, a) => s + a.fraction), + closeTo(442 / 480, 1e-9)); expect(ring.track, _p(t).track); }); @@ -231,7 +234,7 @@ void main() { expect(find.text('96% of 7h 42m'), findsOneWidget); expect(find.textContaining('default'), findsNothing); final ring = _sleepPainter(t) as StageRing; - expect(ring.arcs.first.fraction, closeTo(70 / 462, 1e-9)); + expect(ring.arcs.first.fraction, closeTo(442 / 462 * 70 / 472, 1e-9)); }); testWidgets('no stage totals => ONE solid arc in the sleep colour, never ' diff --git a/test/sleep_ring_model_test.dart b/test/sleep_ring_model_test.dart index 54764c13f..7d5efbafd 100644 --- a/test/sleep_ring_model_test.dart +++ b/test/sleep_ring_model_test.dart @@ -3,7 +3,7 @@ // // SLEPT today has a scored night. The ring is that night's stages as // contiguous arcs grouped Deep, REM, Light, then Awake; the total -// sweep of the asleep stages is asleep / target, NOT capped: above a +// sweep of all stages is asleep / target, NOT capped: above a // full circle it is a spiral. Stage totals absent => ONE solid arc, // never an invented split. Sub-line percentage is asleep / target. // @@ -84,19 +84,17 @@ void main() { ); }); - test('each stage is its minutes / target of the full circle', () { + test('stage proportions divide the earned sleep sweep, including awake', () { final m = _slept(); - expect(m.arcs[0].fraction, closeTo(70 / 480, 1e-9)); - expect(m.arcs[1].fraction, closeTo(95 / 480, 1e-9)); - expect(m.arcs[2].fraction, closeTo(277 / 480, 1e-9)); - expect(m.arcs[3].fraction, closeTo(30 / 480, 1e-9)); + expect(m.arcs[0].fraction, closeTo(442 / 480 * 70 / 472, 1e-9)); + expect(m.arcs[1].fraction, closeTo(442 / 480 * 95 / 472, 1e-9)); + expect(m.arcs[2].fraction, closeTo(442 / 480 * 277 / 472, 1e-9)); + expect(m.arcs[3].fraction, closeTo(442 / 480 * 30 / 472, 1e-9)); }); - test('the ASLEEP arcs together sweep exactly asleep / target', () { + test('all arcs together sweep exactly asleep / target', () { final m = _slept(); - final asleep = m.arcs - .where((a) => a.kind != SleepArcKind.awake) - .fold(0, (s, a) => s + a.fraction); + final asleep = m.arcs.fold(0, (s, a) => s + a.fraction); expect(asleep, closeTo(442 / 480, 1e-9)); }); @@ -113,10 +111,9 @@ void main() { expect(m.arcs[2].fraction / m.arcs[1].fraction, closeTo(270 / 95, 1e-9)); }); - test('Awake is drawn after Light with its own length, and never pushes the ' - 'ring past a full circle', () { - // Asleep covers 0.9208 of the circle; 38 min awake would be 0.0792 - // more, which is exactly the room left. 60 min awake must be clipped. + test('Awake is drawn after Light within the earned sleep sweep', () { + // The 442 asleep + 60 awake staged minutes divide a 442/480-lap sweep. + // Awake gets 60/502 of that sweep; it does not fill the remaining track. final m = _slept( stages: const SleepStageMin( deep: 70, @@ -126,11 +123,11 @@ void main() { ), ); expect(m.arcs.last.kind, SleepArcKind.awake); - expect(m.sweep, closeTo(1.0, 1e-9)); - expect(m.arcs.last.fraction, closeTo(1 - 442 / 480, 1e-9)); + expect(m.sweep, closeTo(442 / 480, 1e-9)); + expect(m.arcs.last.fraction, closeTo(442 / 480 * 60 / 502, 1e-9)); }); - test('asleep >= target: the circle is full of sleep and Awake gets no arc', + test('asleep >= target: Awake still shares the earned spiral sweep', () { final m = _slept( duration: 540, @@ -141,12 +138,13 @@ void main() { awake: 20, ), ); - expect(m.arcs.any((a) => a.kind == SleepArcKind.awake), isFalse); + expect(m.arcs.last.kind, SleepArcKind.awake); + expect(m.arcs.last.fraction, closeTo(540 / 480 * 20 / 560, 1e-9)); expect(m.sweep, closeTo(540 / 480, 1e-9)); - // Stage fractions are minutes / NEED, not rescaled to the circle. - expect(m.arcs[0].fraction, closeTo(90 / 480, 1e-9)); - expect(m.arcs[1].fraction, closeTo(120 / 480, 1e-9)); - expect(m.arcs[2].fraction, closeTo(330 / 480, 1e-9)); + // Stage proportions divide the same sweep even beyond one lap. + expect(m.arcs[0].fraction, closeTo(540 / 480 * 90 / 560, 1e-9)); + expect(m.arcs[1].fraction, closeTo(540 / 480 * 120 / 560, 1e-9)); + expect(m.arcs[2].fraction, closeTo(540 / 480 * 330 / 560, 1e-9)); }); test('no awake figure => no awake arc (not a zero-length one)', () { From 4c5aa6da86f82e1e0072d06f57629eacc16415c5 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Fri, 13 Nov 2026 14:34:00 +0530 Subject: [PATCH 16/17] month grid strip gets a full tap target, spanish coach chip had a stale duplicate, ring why test for a night with no stored need --- lib/l10n/app_es.arb | 1 - lib/ui2/screens/home_screen.dart | 7 ++++--- lib/ui2/screens/month_grid.dart | 26 +++++++++++++++++--------- test/graphs_scrub_month_grid_test.dart | 12 ++++++++++++ test/home_ring_card_test.dart | 14 ++++++++++++-- 5 files changed, 45 insertions(+), 15 deletions(-) diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 0158ab378..44aa42692 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -3270,7 +3270,6 @@ "homeFirstWeekRecovery": "Recuperación: a partir de la noche {night}, cuando conozca tu normalidad", "homeFirstWeekSoFar": "{count, plural, =1{1 día registrado hasta ahora} other{{count} días registrados hasta ahora}}", "homeFirstWeekDone": "Hecho: {step}", - "homeCoachSetUpChip": "Haz preguntas sobre tus datos: configura el coach", "homeRecoverySleeping": "Durmiendo…", "homeRecoveryAfterWake": "La recuperación se puntúa cuando te despiertas.", "homeRecoveryFinishingUp": "Terminando", diff --git a/lib/ui2/screens/home_screen.dart b/lib/ui2/screens/home_screen.dart index c5f7adc5e..d5ff203e9 100644 --- a/lib/ui2/screens/home_screen.dart +++ b/lib/ui2/screens/home_screen.dart @@ -1793,9 +1793,10 @@ class HomeData { final absentDiag = readiness.value != null ? null : await LocalDb.readinessAbsentDiag(date); - // The learned need comes with the night: getDaySleepV2 reads the same - // sleep_coach.need (same staleness gate) as getInsights. Absent stays - // absent: the ring shows the labelled default, never an invented need. + // The need comes with the night: today's is the live estimate, a past + // night's is the one stored for that day, and absent when none was (see + // getDaySleepV2). Absent stays absent: the ring shows the labelled + // default, and never measures an old night against today's need. final need = metricOf(sleep['need_min']); return HomeData( name: profile['name']?.toString(), diff --git a/lib/ui2/screens/month_grid.dart b/lib/ui2/screens/month_grid.dart index 8b0e8e229..9799a9731 100644 --- a/lib/ui2/screens/month_grid.dart +++ b/lib/ui2/screens/month_grid.dart @@ -259,7 +259,7 @@ class MonthGrid extends StatelessWidget { 'above your usual range.', // A cell is a day: the finger reads where that day sat // against the usual range, in the panel under the strip. - // The 22 pt belongs to the strip alone. + // The strip is 22 pt; its hit area is a full tap target. child: SlotScrub( key: ValueKey('grid-scrub:${r.key}'), series: [for (final s in r.cells) s?.index.toDouble()], @@ -271,15 +271,23 @@ class MonthGrid extends StatelessWidget { builder: (c, pick, scrub, panel) => Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + // The finger gets a full tap target; only the + // painted strip is 22 pt, centred inside it. SizedBox( - height: 22, - child: scrub(CustomPaint( - size: Size.infinite, - painter: _SideStrip( - r.cells, - (s) => _cellInk(p, r, s), - p.line, - p.ink, + height: S.tap, + child: scrub(Center( + child: SizedBox( + height: 22, + width: double.infinity, + child: CustomPaint( + size: Size.infinite, + painter: _SideStrip( + r.cells, + (s) => _cellInk(p, r, s), + p.line, + p.ink, + ), + ), ), )), ), diff --git a/test/graphs_scrub_month_grid_test.dart b/test/graphs_scrub_month_grid_test.dart index 65f06aa29..9ffcd0da7 100644 --- a/test/graphs_scrub_month_grid_test.dart +++ b/test/graphs_scrub_month_grid_test.dart @@ -88,6 +88,18 @@ void main() { expect(find.byKey(panelKey), findsNWidgets(3)); }); + testWidgets('the strip is 22 pt tall but its scrub area is a full tap target', + (t) async { + phone(t, 390, 900); + await t.pumpWidget(scaledApp(1, _page([_row('readiness')]))); + await t.pumpAndSettle(); + final area = t.getSize(find.byType(Scrubber)); + expect(area.height, greaterThanOrEqualTo(S.tap)); + final strip = find.descendant( + of: find.byType(Scrubber), matching: find.byType(CustomPaint)); + expect(t.getSize(strip.first).height, 22); + }); + testWidgets('a row without enough history draws no strip to scrub and no ' 'panel: nothing is invented', (t) async { phone(t, 390, 900); diff --git a/test/home_ring_card_test.dart b/test/home_ring_card_test.dart index 25ffcdcc1..8522c79eb 100644 --- a/test/home_ring_card_test.dart +++ b/test/home_ring_card_test.dart @@ -57,7 +57,7 @@ const _target = {'value': 10, 'low': 8, 'high': 12}; HomeData _data({ num? recovery = 80, num? sleep = 450, // minutes - num need = 480, + num? need = 480, num? strain = 5.0, Map? target, String? recoveryState, @@ -68,7 +68,7 @@ HomeData _data({ recoveryState: recoveryState, readiness: recovery == null ? Metric.empty : _m(recovery), sleepMin: sleep == null ? Metric.empty : _m(sleep), - sleepNeedMin: _m(need), + sleepNeedMin: need == null ? Metric.empty : _m(need), strain: strain == null ? Metric.empty : _m(strain), strainTarget: target, readinessUpdate: update, @@ -335,6 +335,16 @@ void main() { findsOneWidget); }); + testWidgets( + 'WHY: a night with no stored need is never measured against one', + (t) async { + // A past night the crossday pass never wrote a need for: the ring has + // nothing to be short of, so it neither ranks nor claims a shortfall. + await _rings(t, _data(recovery: 80, sleep: 300, need: null)); + expect(_why('sleep'), findsNothing); + expect(find.textContaining('under your'), findsNothing); + }); + testWidgets('WHY: the strain and its target range share one decimal ' 'separator (de)', (t) async { final c = LocaleController.seed('de')..useForPresentation(); From 94d91e8039c17925a013de04154c4e0913c148c6 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Fri, 13 Nov 2026 19:12:21 +0530 Subject: [PATCH 17/17] illness text in thai and chinese no longer says several nights, drop the stale long coach chip lines the french and chinese translations brought in --- lib/l10n/app_fr.arb | 1 - lib/l10n/app_th.arb | 6 +++--- lib/l10n/app_zh.arb | 7 +++---- 3 files changed, 6 insertions(+), 8 deletions(-) diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 20fd9094b..cab2edd5e 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -3299,7 +3299,6 @@ "homeFirstWeekRecovery": "Récupération : à partir de la nuit {night}, une fois votre normale connue", "homeFirstWeekSoFar": "{count, plural, =1{1 jour enregistré jusqu’ici} other{{count} jours enregistrés jusqu’ici}}", "homeFirstWeekDone": "Terminé : {step}", - "homeCoachSetUpChip": "Posez des questions sur vos données : configurez le coach", "homeRecoverySleeping": "Sommeil en cours…", "homeRecoveryAfterWake": "La récupération est évaluée après votre réveil.", "homeRecoveryFinishingUp": "Finalisation", diff --git a/lib/l10n/app_th.arb b/lib/l10n/app_th.arb index b2c411eb6..04a7806c2 100644 --- a/lib/l10n/app_th.arb +++ b/lib/l10n/app_th.arb @@ -7661,15 +7661,15 @@ } } }, - "homeIllnessBodyNoZ": "อัตราการเต้นหัวใจขณะพักสูงกว่าปกติในหลายคืนล่าสุด เป็นรูปแบบ ไม่ใช่สาเหตุ", + "homeIllnessBodyNoZ": "ข้อมูลช่วงคืนล่าสุดแสดงอัตราการเต้นหัวใจขณะพักสูงกว่าปกติ เป็นรูปแบบ ไม่ใช่สาเหตุ", "@homeIllnessBodyNoZ": { "description": "Illness watch body text when no standardised-deviation figure is available." }, - "homeIllnessBodyAbove": "หลายคืนล่าสุดแสดงอัตราการเต้นหัวใจขณะพักสูงกว่าปกติ คืนนั้นก็สูงกว่าเช่นกัน เป็นรูปแบบ ไม่ใช่สาเหตุ", + "homeIllnessBodyAbove": "ข้อมูลช่วงคืนล่าสุดแสดงอัตราการเต้นหัวใจขณะพักสูงกว่าปกติ คืนนั้นก็สูงกว่าเช่นกัน เป็นรูปแบบ ไม่ใช่สาเหตุ", "@homeIllnessBodyAbove": { "description": "Illness watch body text when the flagged night's heart rate sat above baseline, with the deviation amount." }, - "homeIllnessBodyBelow": "หลายคืนล่าสุดรวมกันแสดงอัตราการเต้นหัวใจขณะพักสูงกว่าปกติ แม้คืนนั้นเพียงคืนเดียวจะต่ำกว่า เป็นรูปแบบ ไม่ใช่สาเหตุ", + "homeIllnessBodyBelow": "ข้อมูลช่วงคืนล่าสุดรวมกันแสดงอัตราการเต้นหัวใจขณะพักสูงกว่าปกติ แม้คืนนั้นเพียงคืนเดียวจะต่ำกว่า เป็นรูปแบบ ไม่ใช่สาเหตุ", "@homeIllnessBodyBelow": { "description": "Illness watch body text when the flagged night's heart rate sat below baseline, with the deviation amount." }, diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 5923dd509..744ecc206 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -1584,9 +1584,9 @@ "homeIllnessRedTitle": "最近几晚累计显示静息心率偏高", "homeIllnessAmberSameNight": "昨晚超出了你的正常范围", "homeIllnessAmberOtherNight": "{day} 超出了你的正常范围", - "homeIllnessBodyNoZ": "最近几晚你的静息心率高于平时。这是一种趋势,不是原因。", - "homeIllnessBodyAbove": "最近几晚你的静息心率高于平时,那一晚也高于平时。这是一种趋势,不是原因。", - "homeIllnessBodyBelow": "最近几晚合起来使你的静息心率高于平时,尽管那一晚单独看低于平时。这是一种趋势,不是原因。", + "homeIllnessBodyNoZ": "近期夜间的数据显示你的静息心率高于平时。这是一种趋势,不是原因。", + "homeIllnessBodyAbove": "近期夜间的数据显示你的静息心率高于平时,那一晚也高于平时。这是一种趋势,不是原因。", + "homeIllnessBodyBelow": "近期夜间的数据合起来显示你的静息心率高于平时,尽管那一晚单独看低于平时。这是一种趋势,不是原因。", "homeIllnessAdvice": "如果持续几天,值得留意。", "homeHeartRate": "心率", "homeRestingSub": "静息", @@ -3306,7 +3306,6 @@ "homeFirstWeekRecovery": "恢复:从第 {night} 晚起,了解你的正常水平之后", "homeFirstWeekSoFar": "{count, plural, other{目前已记录 {count} 天}}", "homeFirstWeekDone": "已完成:{step}", - "homeCoachSetUpChip": "就你的数据提问:设置教练", "homeRecoverySleeping": "睡眠中…", "homeRecoveryAfterWake": "恢复会在你醒来后评分。", "homeRecoveryFinishingUp": "即将完成",