diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index bb0aeda51..4e546f267 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -1505,33 +1505,105 @@ "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.", "homeSyncBand": "Band synchronisieren", - "homeWhyLabel": "Warum?", "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 Grund erfasst.", "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" + } + } + }, + "homeWhyRecoveryTitle": "Erholung ist niedrig", + "homeWhyRecoveryPlain": "Erholung {score} von 100", + "@homeWhyRecoveryPlain": { + "placeholders": { + "score": { + "type": "int" + } + } + }, + "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}", "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", @@ -1551,13 +1623,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 Grund erfasst.", "homeSeeWhatWasMissing": "Anzeigen, was gefehlt hat", "homeAtAGlance": "Auf einen Blick", "homeTodaysPlan": "Plan für heute", @@ -1568,9 +1640,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", @@ -3312,7 +3384,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 7dc473837..2f34a8b46 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -7391,7 +7391,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." }, @@ -7433,15 +7433,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." }, @@ -7467,10 +7467,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." @@ -7499,7 +7495,7 @@ } } }, - "homeGapNoReason": "Nothing recorded says why this is missing.", + "homeGapNoReason": "No reason recorded.", "@homeGapNoReason": { "description": "Fallback reason shown when a ring is empty and nothing explains why." }, @@ -7527,24 +7523,124 @@ "@homeSleepGapFallback": { "description": "Fallback reason the sleep ring is empty: no night long enough to score." }, - "homeStrainOf21": "of 21", - "@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." + "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" + } + } }, - "homeOfSpan": "of {duration}", - "@homeOfSpan": { - "description": "Sleep ring subtitle: duration measured against a computed sleep-need target.", + "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": { - "duration": { + "pct": { + "type": "int" + }, + "target": { "type": "String", - "example": "7h 45m" + "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" + } + } + }, + "homeWhyRecoveryTitle": "Recovery is low", + "@homeWhyRecoveryTitle": { + "description": "Title of the Home card row that explains a low recovery score." + }, + "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" + } + } + }, + "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." + }, "homeLoadFailedTitle": "Today could not be read", "@homeLoadFailedTitle": { "description": "Title of the card shown when today's data failed to load from the database." @@ -7633,7 +7729,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": { @@ -7669,7 +7765,7 @@ } } }, - "homeReadinessNoReason": "Nothing recorded says why.", + "homeReadinessNoReason": "No reason recorded.", "@homeReadinessNoReason": { "description": "Fallback body of the readiness-not-scored card when no reason is recorded." }, @@ -7719,29 +7815,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." }, - "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." }, "homeIllnessAdvice": "Worth noting if it continues past a couple of days.", "@homeIllnessAdvice": { @@ -15727,7 +15811,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 bd23b7c57..a9a2413a3 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -1458,33 +1458,106 @@ "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", + "homeCoachSetUpChip": "Configurar el entrenador", + "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.", "homeSyncBand": "Sincronizar la correa", - "homeWhyLabel": "¿Por qué?", "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 motivo registrado.", "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" + } + } + }, + "homeWhyRecoveryTitle": "La recuperación es baja", + "homeWhyRecoveryPlain": "Recuperación {score} de 100", + "@homeWhyRecoveryPlain": { + "placeholders": { + "score": { + "type": "int" + } + } + }, + "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}", "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}", @@ -1502,13 +1575,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 motivo registrado.", "homeSeeWhatWasMissing": "Ver qué faltaba", "homeAtAGlance": "De un vistazo", "homeTodaysPlan": "Plan de hoy", @@ -1517,9 +1590,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", @@ -3230,7 +3303,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/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 274104dc5..cab2edd5e 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -1458,33 +1458,106 @@ "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", + "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}.", "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.", "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": "Rien n'a été enregistré pour expliquer cette absence.", + "homeGapNoReason": "Aucune raison enregistrée.", "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" + } + } + }, + "homeWhyRecoveryTitle": "Récupération faible", + "homeWhyRecoveryPlain": "Récupération {score} sur 100", + "@homeWhyRecoveryPlain": { + "placeholders": { + "score": { + "type": "int" + } + } + }, + "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}", "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}", @@ -1496,13 +1569,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 raison enregistrée.", "homeSeeWhatWasMissing": "Voir ce qui manquait", "homeAtAGlance": "En un coup d'œil", "homeTodaysPlan": "Plan du jour", @@ -1511,9 +1584,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", @@ -3226,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_hi.arb b/lib/l10n/app_hi.arb index 507d558d6..f09fcf064 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -1458,33 +1458,106 @@ "homeDriverRhr": "आराम की हृदय गति", "homeDriverResp": "श्वसन दर", "homeDriverTemp": "त्वचा का तापमान", - "homeDbRebuiltTitle": "ऐप शुरू करने के लिए आपका डेटाबेस फिर से बनाया गया", + "homeCoachSetUpChip": "कोच सेट अप करें", + "homeDbRebuiltTitle": "आपका डेटाबेस दोबारा बनाया गया", "homeDbRebuiltNothingRecovered": "कुछ भी वापस नहीं पढ़ा जा सका।", "homeDbRebuiltRecovered": "पुनर्प्राप्त: {list}।", "homeDbRebuiltEmpty": "खाली: {list}।", "homeDbRebuiltKept": "मूल फ़ाइल {path} पर सुरक्षित रखी गई है — कुछ भी हटाया नहीं गया।", "homeWorkoutHoldTitle": "एक वर्कआउट अभी भी चल रहा है", - "homeWorkoutHoldBody": "वर्कआउट चलते समय आज का दिन रुका हुआ है: स्ट्रैप रिकॉर्ड करना जारी रखता है, लेकिन आंकड़े सेशन खत्म होने पर ही गणना होते हैं। नीचे दिए गए बार से वर्कआउट पूरा करें, तभी आज का डेटा भरेगा — केवल सिंक करने से नहीं होगा।", - "homeInsightsRebuildingTitle": "आपकी क्रॉस-डे इनसाइट्स फिर से बनाई जा रही हैं", - "homeInsightsRebuildingAlgoVersion": "इसकी गणना का तरीका पिछले अपडेट के साथ बदल गया है।", + "homeWorkoutHoldBody": "वर्कआउट खत्म करने पर आज का दिन भर जाएगा।", + "homeInsightsRebuildingTitle": "आपके ट्रेंड अपडेट हो रहे हैं", + "homeInsightsRebuildingAlgoVersion": "ऐप के ताज़ा अपडेट के बाद अपडेट हो रहा है।", "homeInsightsStaleOverWeek": "आखिरी रोलअप एक हफ़्ते से भी पहले बना था, जो भरोसा करने के लिए बहुत पुराना है।", "homeInsightsStaleOnDay": "आखिरी रोलअप {day} को बना था, जो भरोसा करने के लिए बहुत पुराना है।", "homeInsightsNoVersionStamp": "सहेजे गए रोलअप पर कोई वर्शन स्टैंप नहीं है।", "homeSyncBand": "स्ट्रैप सिंक करें", - "homeWhyLabel": "क्यों?", "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" + } + } + }, + "homeWhyRecoveryTitle": "रिकवरी कम है", + "homeWhyRecoveryPlain": "रिकवरी {score}/100", + "@homeWhyRecoveryPlain": { + "placeholders": { + "score": { + "type": "int" + } + } + }, + "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} में से", "homeLoadFailedTitle": "आज का डेटा पढ़ा नहीं जा सका", "homeLoadFailedBody": "सहेजा गया दिन लोड नहीं हो सका। कुछ भी हटाया नहीं गया — यह एक रीड एरर है, डेटा खोना नहीं।", "homeDayLoadFailedTitle": "{day} का डेटा पढ़ा नहीं जा सका", @@ -1496,13 +1569,13 @@ "homeAskCoach": "कोच से पूछें", "homeProfileSettings": "प्रोफ़ाइल और सेटिंग्स", "homeNothingTodayTitle": "आज के लिए कुछ भी दर्ज नहीं", - "homeNothingTodayBody": "इस ऐप ने जिस आखिरी रात को स्कोर किया वह {day} थी। तब से कुछ भी नहीं पहुँचा है।", + "homeNothingTodayBody": "आखिरी स्कोर की गई रात: {day}।", "homeNoDataThisDayTitle": "इस दिन का कोई डेटा नहीं", "homeNoDataThisDayBody": "इस दिन कुछ भी दर्ज नहीं हुआ।", "homeSomeDayDataMissingTitle": "इस दिन का कुछ डेटा मौजूद नहीं है", "homeReadinessNotScoredTitle": "आज रिकवरी का स्कोर नहीं है", "homeReadinessNeedBody": "आपके लिए सामान्य क्या है, यह जानने के लिए {need}।", - "homeReadinessNoReason": "इसे समझाने के लिए कुछ भी दर्ज नहीं है।", + "homeReadinessNoReason": "कोई कारण दर्ज नहीं।", "homeSeeWhatWasMissing": "देखें क्या कमी थी", "homeAtAGlance": "एक नज़र में", "homeTodaysPlan": "आज की योजना", @@ -1511,9 +1584,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 e8c327518..98c27d028 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -3362,7 +3362,7 @@ "homeDriverRhr": "Пульс в покое", "homeDriverResp": "Частота дыхания", "homeDriverTemp": "Температура кожи", - "homeDbRebuiltTitle": "Для запуска приложения база восстановлена заново", + "homeDbRebuiltTitle": "Ваша база данных пересоздана", "homeDbRebuiltNothingRecovered": "Ничего не удалось прочитать.", "homeDbRebuiltRecovered": "Восстановлено: {list}.", "@homeDbRebuiltRecovered": { @@ -3392,9 +3392,9 @@ } }, "homeWorkoutHoldTitle": "Тренировка ещё идёт", - "homeWorkoutHoldBody": "Пока идёт тренировка, расчёт дня на паузе. Браслет продолжает запись; завершите тренировку на панели ниже, и день заполнится.", - "homeInsightsRebuildingTitle": "Выводы за несколько дней пересчитываются", - "homeInsightsRebuildingAlgoVersion": "После обновления способ расчёта изменился.", + "homeWorkoutHoldBody": "Сегодняшний день заполнится, когда вы завершите тренировку.", + "homeInsightsRebuildingTitle": "Обновляем ваши тренды", + "homeInsightsRebuildingAlgoVersion": "Обновляется после последнего обновления приложения.", "homeInsightsStaleOverWeek": "Тренды обновлялись больше недели назад. Синхронизируйте браслет, чтобы обновить их.", "homeInsightsStaleOnDay": "Тренды обновлялись {day}. Синхронизируйте браслет, чтобы обновить их.", "@homeInsightsStaleOnDay": { @@ -3407,7 +3407,6 @@ }, "homeInsightsNoVersionStamp": "Тренды обновляются. После синхронизации это занимает около минуты.", "homeSyncBand": "Синхронизировать браслет", - "homeWhyLabel": "Почему?", "homeCalibrating": "Изучаем ваш обычный уровень", "homeCalibratingNights": "{have} из {need} ночей", "@homeCalibratingNights": { @@ -3431,24 +3430,111 @@ } } }, - "homeGapNoReason": "Причина отсутствия данных не записана.", + "homeGapNoReason": "Причина не указана.", "homeRingRecovery": "Восстановление", "homeRingStrain": "Нагрузка", "homeRingSleep": "Сон", "homeRingNoStrain": "Нет нагрузки", "homeRingNoSleep": "Нет сна", "homeSleepGapFallback": "Не записана ночь с достаточной длительностью сна для оценки.", - "homeStrainOf21": "из 21", - "homeSleepNoTarget": "Цель пока не задана", - "homeOfSpan": "из {duration}", - "@homeOfSpan": { + "homeSleepPctOf": "{pct}% от {target}", + "@homeSleepPctOf": { "placeholders": { - "duration": { + "pct": { + "type": "int" + }, + "target": { "type": "String", - "example": "7h 45m" + "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" + } + } + }, + "homeWhyRecoveryTitle": "Низкое восстановление", + "homeWhyRecoveryPlain": "Восстановление {score} из 100", + "@homeWhyRecoveryPlain": { + "placeholders": { + "score": { + "type": "int" + } + } + }, + "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", "homeLoadFailedTitle": "Не удалось прочитать сегодняшний день", "homeLoadFailedBody": "Сохранённый день не загрузился. Ничего не удалено: это ошибка чтения, а не отсутствие данных.", "homeDayLoadFailedTitle": "Не удалось прочитать {day}", @@ -3484,7 +3570,7 @@ "homeAskCoach": "Спросить тренера", "homeProfileSettings": "Профиль и настройки", "homeNothingTodayTitle": "За сегодня ничего не записано", - "homeNothingTodayBody": "Последняя оценённая ночь: {day}. С тех пор данные не поступали.", + "homeNothingTodayBody": "Последняя оценённая ночь: {day}.", "homeNoDataThisDayTitle": "Нет данных за этот день", "homeNoDataThisDayBody": "В этот день ничего не было записано.", "homeSomeDayDataMissingTitle": "Часть данных за этот день отсутствует", @@ -3506,7 +3592,7 @@ } } }, - "homeReadinessNoReason": "Причина не записана.", + "homeReadinessNoReason": "Причина не указана.", "homeSeeWhatWasMissing": "Посмотреть, чего не хватило", "homeAtAGlance": "Краткий обзор", "homeTodaysPlan": "План на сегодня", @@ -3525,25 +3611,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": "В покое", @@ -7519,7 +7589,7 @@ } } }, - "homeCoachSetUpChip": "Задавайте вопросы о своих данных: настройте тренера", + "homeCoachSetUpChip": "Настроить тренера", "profileSetupCycleSub": "Отмечайте менструацию в разделе «Здоровье». Это можно изменить позже.", "homeRecoverySleeping": "Сон…", "homeRecoveryAfterWake": "Восстановление оценивается после пробуждения.", diff --git a/lib/l10n/app_th.arb b/lib/l10n/app_th.arb index 98cdfc54f..04a7806c2 100644 --- a/lib/l10n/app_th.arb +++ b/lib/l10n/app_th.arb @@ -7237,7 +7237,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." }, @@ -7279,15 +7279,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." }, @@ -7313,10 +7313,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." @@ -7345,7 +7341,7 @@ } } }, - "homeGapNoReason": "ไม่มีข้อมูลระบุว่าเหตุใดจึงไม่มีค่านี้", + "homeGapNoReason": "ไม่มีการบันทึกเหตุผล", "@homeGapNoReason": { "description": "Fallback reason shown when a ring is empty and nothing explains why." }, @@ -7373,24 +7369,124 @@ "@homeSleepGapFallback": { "description": "Fallback reason the sleep ring is empty: no night long enough to score." }, - "homeStrainOf21": "จาก 21", - "@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." + "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" + } + } }, - "homeOfSpan": "จาก {duration}", - "@homeOfSpan": { - "description": "Sleep ring subtitle: duration measured against a computed sleep-need target.", + "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": { - "duration": { + "pct": { + "type": "int" + }, + "target": { "type": "String", - "example": "7h 45m" + "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" + } + } + }, + "homeWhyRecoveryTitle": "การฟื้นตัวต่ำ", + "@homeWhyRecoveryTitle": { + "description": "Title of the Home card row that explains a low recovery score." + }, + "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" + } + } + }, + "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." + }, "homeLoadFailedTitle": "อ่านข้อมูลวันนี้ไม่ได้", "@homeLoadFailedTitle": { "description": "Title of the card shown when today's data failed to load from the database." @@ -7479,7 +7575,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": { @@ -7515,7 +7611,7 @@ } } }, - "homeReadinessNoReason": "ไม่มีข้อมูลระบุเหตุผล", + "homeReadinessNoReason": "ไม่มีการบันทึกเหตุผล", "@homeReadinessNoReason": { "description": "Fallback body of the readiness-not-scored card when no reason is recorded." }, @@ -7565,29 +7661,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": { @@ -15567,7 +15651,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 9490b7bfe..744ecc206 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -1458,33 +1458,106 @@ "homeDriverRhr": "静息心率", "homeDriverResp": "呼吸频率", "homeDriverTemp": "皮肤温度", - "homeDbRebuiltTitle": "为启动应用,你的数据库已被重建", + "homeCoachSetUpChip": "设置教练", + "homeDbRebuiltTitle": "你的数据库已重建", "homeDbRebuiltNothingRecovered": "没有任何数据可以恢复。", "homeDbRebuiltRecovered": "已恢复:{list}。", "homeDbRebuiltEmpty": "为空:{list}。", "homeDbRebuiltKept": "原始文件仍保存在 {path},没有任何内容被删除。", "homeWorkoutHoldTitle": "有一项运动仍在进行中", - "homeWorkoutHoldBody": "运动进行期间,今日数据处于暂停状态:腕带仍在记录,但数值要等到运动结束后才会计算。请在下方的进度条中结束运动,今日数据便会补全——仅同步是不够的。", - "homeInsightsRebuildingTitle": "你的跨日汇总数据正在重建", - "homeInsightsRebuildingAlgoVersion": "计算方式已随最新更新发生变化。", + "homeWorkoutHoldBody": "结束运动后,今天的数据就会补全。", + "homeInsightsRebuildingTitle": "正在更新你的趋势", + "homeInsightsRebuildingAlgoVersion": "正在根据最近的应用更新进行更新。", "homeInsightsStaleOverWeek": "上次汇总是一周多以前生成的,太旧了,不宜依赖。", "homeInsightsStaleOnDay": "上次汇总是在 {day} 生成的,太旧了,不宜依赖。", "homeInsightsNoVersionStamp": "已保存的汇总没有版本标记。", "homeSyncBand": "同步腕带", - "homeWhyLabel": "为什么?", "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" + } + } + }, + "homeWhyRecoveryTitle": "恢复状态偏低", + "homeWhyRecoveryPlain": "恢复 {score}/100", + "@homeWhyRecoveryPlain": { + "placeholders": { + "score": { + "type": "int" + } + } + }, + "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}", "homeLoadFailedTitle": "今日数据读取失败", "homeLoadFailedBody": "已保存的当日数据加载失败。没有任何内容被删除——这是读取出错,不是数据丢失。", "homeDayLoadFailedTitle": "{day}的数据读取失败", @@ -1496,13 +1569,13 @@ "homeAskCoach": "咨询教练", "homeProfileSettings": "个人资料与设置", "homeNothingTodayTitle": "今天没有记录到数据", - "homeNothingTodayBody": "应用评分的最后一晚是 {day}。此后没有收到任何新数据。", + "homeNothingTodayBody": "最近一次评分的夜晚:{day}。", "homeNoDataThisDayTitle": "这一天没有数据", "homeNoDataThisDayBody": "这一天没有记录到任何数据。", "homeSomeDayDataMissingTitle": "这一天的部分数据缺失", "homeReadinessNotScoredTitle": "今天的恢复度未评分", "homeReadinessNeedBody": "{need},才能了解你的正常水平。", - "homeReadinessNoReason": "没有记录说明原因。", + "homeReadinessNoReason": "未记录原因。", "homeSeeWhatWasMissing": "查看缺失的内容", "homeAtAGlance": "概览", "homeTodaysPlan": "今日计划", @@ -1511,9 +1584,9 @@ "homeIllnessRedTitle": "最近几晚累计显示静息心率偏高", "homeIllnessAmberSameNight": "昨晚超出了你的正常范围", "homeIllnessAmberOtherNight": "{day} 超出了你的正常范围", - "homeIllnessBodyNoZ": "你最近几晚的夜间静息心率累计显示高于自身基线。这只反映了单一信号,说明存在某种趋势,但并不能说明原因。", - "homeIllnessBodyAbove": "你最近几晚的夜间静息心率累计显示高于自身基线;那一晚高出基线 {z} 个标准差。这只反映了单一信号,说明存在某种趋势,但并不能说明原因。", - "homeIllnessBodyBelow": "你最近几晚的夜间静息心率累计显示高于自身基线;那一晚低于基线 {z} 个标准差。这只反映了单一信号,说明存在某种趋势,但并不能说明原因。", + "homeIllnessBodyNoZ": "近期夜间的数据显示你的静息心率高于平时。这是一种趋势,不是原因。", + "homeIllnessBodyAbove": "近期夜间的数据显示你的静息心率高于平时,那一晚也高于平时。这是一种趋势,不是原因。", + "homeIllnessBodyBelow": "近期夜间的数据合起来显示你的静息心率高于平时,尽管那一晚单独看低于平时。这是一种趋势,不是原因。", "homeIllnessAdvice": "如果持续几天,值得留意。", "homeHeartRate": "心率", "homeRestingSub": "静息", @@ -3233,7 +3306,6 @@ "homeFirstWeekRecovery": "恢复:从第 {night} 晚起,了解你的正常水平之后", "homeFirstWeekSoFar": "{count, plural, other{目前已记录 {count} 天}}", "homeFirstWeekDone": "已完成:{step}", - "homeCoachSetUpChip": "就你的数据提问:设置教练", "homeRecoverySleeping": "睡眠中…", "homeRecoveryAfterWake": "恢复会在你醒来后评分。", "homeRecoveryFinishingUp": "即将完成", diff --git a/lib/ui2/charts.dart b/lib/ui2/charts.dart index b26ded4d7..edb26f390 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,48 +612,80 @@ class Ring extends CustomPainter { /// calibrating" and "this is missing" into one look. final bool solid; + /// 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.stroke = 10, this.t = 1, this.solid = false, this.background}); @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; +} + +/// 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); +} + +/// 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}); + + @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); + } + + @override + 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/profile/gallery.dart b/lib/ui2/profile/gallery.dart index fdb4ed8ba..dedef43b2 100644 --- a/lib/ui2/profile/gallery.dart +++ b/lib/ui2/profile/gallery.dart @@ -91,11 +91,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), @@ -989,6 +984,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 20a13feff..d5ff203e9 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'; @@ -506,7 +508,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 +522,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 +557,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 +576,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 +586,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 +619,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( @@ -852,7 +868,6 @@ class RingTrio extends StatelessWidget { @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 gaps = rings.where((r) => r.why != null).toList(); @@ -863,13 +878,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)) + _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) + _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++) ...[ @@ -887,41 +928,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, + ]), ); } @@ -931,6 +939,219 @@ class RingTrio extends StatelessWidget { } } +class _HomeWhyPanel extends StatelessWidget { + final HomeRingKind kind; + final HomeData data; + final VoidCallback? 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); + 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), + ])); + } +} + +/// 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 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()}' : displayFixed(v, 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. @@ -961,10 +1182,51 @@ 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 } +/// 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) { + 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]; +} + /// 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 + /// 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; @@ -983,6 +1245,10 @@ 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; + const _RingState( this.kind, this.label, @@ -990,12 +1256,14 @@ 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, }); /// A number that may still move (the night is still draining), drawn grey. @@ -1033,7 +1301,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 @@ -1057,7 +1327,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. @@ -1065,24 +1335,48 @@ _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 label = l?.homeRingSleep ?? '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); + if (v == null) { + return _gap(k, label, LucideIcons.moon, C.blue, d.sleepMin, + l?.homeRingNoSleep ?? 'No sleep', l, + fallbackWhy: l?.homeSleepGapFallback ?? + 'No night long enough to score was recorded.'); + } + final m = sleepRingModel( + durationMin: v, + stages: d.sleepStages, + needMin: d.sleepNeedMin.value, + ); + 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); } } @@ -1113,7 +1407,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 reason recorded.'))); +} + +/// 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 @@ -1138,8 +1453,14 @@ 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)), ]); @@ -1199,6 +1520,63 @@ 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; + } + + 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; @@ -1215,7 +1593,7 @@ 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), @@ -1224,76 +1602,59 @@ class _RingText extends StatelessWidget { : 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 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); + 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 ═══════════════════ - 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 @@ -1346,11 +1707,16 @@ class HomeData { /// `{from, to, at}` when today's final recovery changed after it was shown. final Map? readinessUpdate; + /// 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; + const HomeData({ this.name, this.dayId, this.readiness = Metric.empty, - this.drivers = const [], this.sleepMin = Metric.empty, this.rhr = Metric.empty, this.steps = Metric.empty, @@ -1369,6 +1735,7 @@ class HomeData { this.absentDiag, this.recoveryState, this.readinessUpdate, + this.sleepStages, }); /// The three illness fields, replaced together. Test-facing sugar, and they @@ -1378,7 +1745,6 @@ class HomeData { name: name, dayId: dayId, readiness: readiness, - drivers: drivers, sleepMin: sleepMin, rhr: rhr, steps: steps, @@ -1389,6 +1755,7 @@ class HomeData { sleepNeedMin: sleepNeedMin, bedtime: bedtime, strainTarget: strainTarget, + sleepStages: sleepStages, heldOverNight: heldOverNight, illnessState: state, illnessDay: day, @@ -1403,7 +1770,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 @@ -1426,6 +1793,11 @@ class HomeData { final absentDiag = readiness.value != null ? null : await LocalDb.readinessAbsentDiag(date); + // 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(), dayId: date, @@ -1437,16 +1809,39 @@ 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, ); } 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(); @@ -1455,9 +1850,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; @@ -1467,6 +1859,7 @@ class HomeData { final heldOver = heldOverNightOf(today); + final sleepMin = overnightMetric(today, s('duration_min'), l); // 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. @@ -1499,12 +1892,9 @@ 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: overnightMetric(today, s('duration_min'), l), + sleepMin: sleepMin, + sleepStages: stages, rhr: overnightMetric(today, d('resting_hr'), l), steps: metricOf(d('steps')), calories: metricOf(d('calories')), @@ -1846,8 +2236,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), @@ -2138,7 +2527,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 reason recorded.'), fix: l?.homeSeeWhatWasMissing ?? 'See what was missing', icon: LucideIcons.batteryCharging, onFix: () => @@ -2231,7 +2620,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( @@ -2243,12 +2631,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')), ), @@ -2675,7 +3063,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/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/lib/ui2/sleep_ring_model.dart b/lib/ui2/sleep_ring_model.dart new file mode 100644 index 000000000..71c9586c3 --- /dev/null +++ b/lib/ui2/sleep_ring_model.dart @@ -0,0 +1,98 @@ +// sleep_ring_model.dart — what the Home sleep ring says and draws, as data. +// +// 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. +// + +/// 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; + +enum SleepArcKind { deep, rem, light, awake, solid } + +/// 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; + const SleepArc(this.kind, this.fraction); +} + +class SleepRingModel { + final List arcs; + final int targetMin; + final bool targetIsDefault; + + /// Minutes asleep. + final int asleepMin; + + /// Whole percent of [targetMin]. + final int pct; + + const SleepRingModel({ + 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); +} + +/// 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 num durationMin, + SleepStageMin? stages, + num? needMin, +}) { + final learnedNeed = needMin != null && needMin.isFinite && needMin.round() > 0; + final target = learnedNeed ? needMin.round() : kDefaultSleepTargetMin; + return _slept(durationMin, stages, target, !learnedNeed); +} + +SleepRingModel _slept( + num durationMin, SleepStageMin? stages, int target, bool isDefault) { + 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) { + // 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), + ]); + if (awakeMin > 0) { + arcs.add(SleepArc(SleepArcKind.awake, awakeMin * k)); + } + } else { + arcs.add(SleepArc(SleepArcKind.solid, asleepFrac)); + } + return SleepRingModel( + arcs: arcs, + targetMin: target, + targetIsDefault: isDefault, + asleepMin: asleep, + pct: pct, + ); +} diff --git a/lib/ui2/spiral_ring.dart b/lib/ui2/spiral_ring.dart new file mode 100644 index 000000000..1d874a135 --- /dev/null +++ b/lib/ui2/spiral_ring.dart @@ -0,0 +1,270 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; + +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; + 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 = .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, + required List colors, + required List fractions, + required double t, + bool solid = true, + 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; + // 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; + final a = .55 + .45 * SpiralGeometry.gradientAt(s, total); + final faded = c.withValues(alpha: c.a * a); + return background == null ? faded : Color.alphaBlend(faded, background); + } + + 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(outer)); + if (once) canvas.saveLayer(outer.inflate(1), Paint()); + + 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) + ..blendMode = once ? BlendMode.src : BlendMode.srcOver); + + 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 + ..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); + } 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); + } + } + if (once) canvas.restore(); + canvas.restore(); +} 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/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_day_switch_decode_once_test.dart b/test/home_day_switch_decode_once_test.dart index 1b1d2a9a7..d86547096 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,109 @@ void main() { await repo.getDayStrain(day); 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': { + '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, and only for a scored night', + () async { + final scored = _CountingRepo(today(slept: true)); + await HomeData.load(scored); + expect(scored.nightReads, 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(d.sleepStages, isNull); + }); + + 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.sleepMin.value, 442); + }); + }); +} + +class _CountingRepo extends LocalRepository { + _CountingRepo(this._today, {this.throwing = false}); + final Map _today; + final bool throwing; + int nightReads = 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}; + } } diff --git a/test/home_labels_test.dart b/test/home_labels_test.dart new file mode 100644 index 000000000..3baabb9bf --- /dev/null +++ b/test/home_labels_test.dart @@ -0,0 +1,261 @@ +// 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)", 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": +// 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 keys this PR adds (sleep ring wording + why panels). +const _newKeys = [ + 'homeSleepPctOf', + 'homeSleepPctOfDefault', + 'homeSleepPctCompact', + 'homeSleepPctDefaultCompact', + 'homeSleepPctDefaultTight', + 'homeWhyRecoveryTitle', + 'homeWhyRecoveryPlain', + '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 (name != 'app_en.arb') { + expect(v, isNot(ref), reason: '$k is still English in $name'); + } + } + expect(missing, isEmpty, reason: 'missing or empty in $name'); + }); + } + }); + + 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'); + }); + }); + + group('2. plainer Home copy', () { + final l = lookupAppLocalizations(const Locale('en')); + + 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 reason recorded.'); + expect(l.homeReadinessNoReason, 'No reason recorded.'); + expect(l.homeInsightsRebuildingTitle, 'Updating your trends'); + expect(l.homeInsightsRebuildingAlgoVersion, + 'Updating after the latest app update.'); + expect(l.homeWorkoutHoldBody, 'Today fills in when you finish the workout.'); + expect(l.homeNothingTodayBody('Saturday'), 'Last scored night: Saturday.'); + expect(l.homeIllnessBodyNoZ, + 'Your resting heart rate has been above your usual over recent nights. A pattern, not a cause.'); + }); + + test( + 'WHY: the illness texts no longer print a z multiple, so the getters ' + 'take no argument', () { + final above = _get('homeIllnessBodyAbove', + () => (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) { + expect(j[k], isA(), + 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}'); + } + } + }); + }); + + 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_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_ring_card_test.dart b/test/home_ring_card_test.dart new file mode 100644 index 000000000..8522c79eb --- /dev/null +++ b/test/home_ring_card_test.dart @@ -0,0 +1,654 @@ +// 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 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. +// 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 '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'; + +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'; + +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, + 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: need == null ? Metric.empty : _m(need), + strain: strain == null ? Metric.empty : _m(strain), + strainTarget: target, + 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); +} + +/// 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, + double scale = 1.0, + Locale? locale}) async { + _surface(t, w, scale: scale); + await t.pumpWidget(MaterialApp( + locale: locale, + theme: _themeWithThai(), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: SingleChildScrollView( + child: RingTrio(d: d, 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}; +const _drag = 'dragging your score down'; +const _lift = 'lifting your score'; + +class _Repo extends LocalRepository { + _Repo(this.drivers, {this.score = 22.0, this.rollupDay}); + final List> drivers; + final double score; + final String? rollupDay; + @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 => { + 'rollup_day': rollupDay, + 'readiness_glassbox': { + 'score': score, + 'breakdown': const [], + 'drivers': drivers, + 'narrative': '', + 'inputs_used': 3, + }, + }; + @override + Future> getProfile() async => const {}; +} + +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) 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 = _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(); + if (RegExp(r'\.(ttf|otf)$', caseSensitive: false).hasMatch(f)) { + candidates.add(f); + } + } + } + } on ProcessException { + // no fontconfig + } + // 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() && _coversThai(c)) return f; + } + return null; +} + +void main() { + // ── 0. what "pressing" means (pure) ─────────────────────────────────────── + group('pressingRings', () { + test('WHY: a healthy day explains nothing', () { + expect(pressingRings(_healthy), isEmpty); + }); + + test('WHY: recovery is pressing below 37; 36 yes, 37 no', () { + expect(pressingRings(_data(recovery: 10)), + [HomeRingKind.recovery]); + expect(pressingRings(_data(recovery: 36)), + [HomeRingKind.recovery]); + 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)), + [HomeRingKind.sleep]); + 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)), + [HomeRingKind.strain]); + 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)), + isEmpty); + expect(pressingRings(_data(strain: 20)), isEmpty); + }); + + test('WHY: severity decides the order, ties go recovery then sleep', () { + expect(pressingRings(_rec2Sleep3), + [HomeRingKind.sleep, HomeRingKind.recovery]); + expect(pressingRings(_recAndSleep), + [HomeRingKind.recovery, HomeRingKind.sleep]); + }); + + test('WHY: at most two; the least pressing is dropped', () { + 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)), + isEmpty); + expect( + pressingRings(_data(recovery: 10, recoveryState: 'provisional')), + isEmpty); + expect( + pressingRings( + _data(recovery: null, recoveryState: 'night_in_progress')), + isEmpty); + }); + }); + + // ── 1. the why panels ───────────────────────────────────────────────────── + group('why panels', () { + testWidgets('WHY: the loose "Why?" footer is gone, on any day', + (t) async { + await _rings(t, _data()); + 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: 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(); + 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); + 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), 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('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)); + // 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 of THIS ring, or the plain score ─────────── + group('recovery why copy', () { + 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: 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), + ], 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'); + }); + }); + + // ── 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', () { + // 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(); + } + // 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(); + _thaiFamily = 'ThaiTestFace'; + } + }); + tearDownAll(() => _thaiFamily = null); + + // 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. + 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; + 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/home_sleep_ring_widget_test.dart b/test/home_sleep_ring_widget_test.dart new file mode 100644 index 000000000..f4bb098a6 --- /dev/null +++ b/test/home_sleep_ring_widget_test.dart @@ -0,0 +1,424 @@ +// 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): +// * 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:flutter/material.dart'; +import 'package:flutter_test/flutter_test.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/ui2/screens/screens.dart'; +import 'package:openstrap_edge/ui2/ui2.dart'; + +// ── fixtures ─────────────────────────────────────────────────────────────── + +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 qualifier). 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({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, + ); + +Future _pump( + WidgetTester t, + HomeData d, { + 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), + ), + ), + ), + )); + 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 {}, + }); + + final Map today, insights, night; + + @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 {}; +} + +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, +}; + +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], + ]); + // All 472 staged minutes divide the earned 442/480-lap sweep. + expect([for (final a in ring.arcs) a.fraction], [ + 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); + }); + + 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(442 / 462 * 70 / 472, 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 ' + 'need the night itself reports', () async { + final repo = _Repo( + night: {..._nightMap, 'need_min': _env(462)}, + ); + 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: no stale stages', () { + 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('no scored night', () { + testWidgets('an honest absence and an empty ring — never a guessed time', + (t) async { + 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 night long enough to score was recorded.')), + findsOneWidget); + final ring = _sleepPainter(t) as Ring; + expect(ring.v, 0); + }); + + 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: 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', (t) async { + await _pump( + t, + _unslept(sleepMin: const Metric(note: 'need_baseline:have=3,need=14')), + ); + final p = _sleepPainter(t); + expect(p, isA()); + expect((p as DashedRing).segments, 14); + }); + }); +} diff --git a/test/sleep_ring_model_test.dart b/test/sleep_ring_model_test.dart new file mode 100644 index 000000000..7d5efbafd --- /dev/null +++ b/test/sleep_ring_model_test.dart @@ -0,0 +1,190 @@ +// 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. +// +// 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 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. +// +// TARGET: the learned need when there is one, otherwise the owner's explicit +// default of 8 h (5 cycles + 30 min), flagged `targetIsDefault`. + +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/ui2/sleep_ring_model.dart'; + +SleepRingModel _slept({ + num duration = 442, + SleepStageMin? stages = const SleepStageMin( + deep: 70, + rem: 95, + light: 277, + awake: 30, + ), + num? need, +}) => + sleepRingModel(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('asleep minutes and whole-percent of the default target', () { + final m = _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('stage proportions divide the earned sleep sweep, including awake', () { + final m = _slept(); + 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('all arcs together sweep exactly asleep / target', () { + final m = _slept(); + final asleep = m.arcs.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 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, + rem: 95, + light: 277, + awake: 60, + ), + ); + expect(m.arcs.last.kind, SleepArcKind.awake); + expect(m.sweep, closeTo(442 / 480, 1e-9)); + expect(m.arcs.last.fraction, closeTo(442 / 480 * 60 / 502, 1e-9)); + }); + + test('asleep >= target: Awake still shares the earned spiral sweep', + () { + final m = _slept( + duration: 540, + stages: const SleepStageMin( + deep: 90, + rem: 120, + light: 330, + awake: 20, + ), + ); + 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 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)', () { + 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.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)); + }); + }); +} diff --git a/test/ui2_home_day_switcher_test.dart b/test/ui2_home_day_switcher_test.dart index e735ff28a..f1dd368dc 100644 --- a/test/ui2_home_day_switcher_test.dart +++ b/test/ui2_home_day_switcher_test.dart @@ -410,6 +410,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_spiral_ring_test.dart b/test/ui2_spiral_ring_test.dart new file mode 100644 index 000000000..dca5c5417 --- /dev/null +++ b/test/ui2_spiral_ring_test.dart @@ -0,0 +1,910 @@ +// 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 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'; +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('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); + } + }); + + 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(painter.arcs.fold(0.0, (a, e) => a + e.fraction), + 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])); + // 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'); + }); + }); + + 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 { + 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 + // ...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)); + }); + }); + }); + + // ── 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'); + // 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'); + } + }); + }); + }); + + 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); + }); + }); + + 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 { + 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; +} + +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_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); diff --git a/test/ui2_wiring_r2_test.dart b/test/ui2_wiring_r2_test.dart index cf3f5163e..0767fbea3 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); }); @@ -847,10 +847,10 @@ 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('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);