diff --git a/lib/data/local_repository.dart b/lib/data/local_repository.dart index 24453af85..9b93ed30f 100644 --- a/lib/data/local_repository.dart +++ b/lib/data/local_repository.dart @@ -49,6 +49,12 @@ bool rmssdFromSessionEstimator(Object? sessionEnvelope) => /// The local data contract for every insights screen + the AI Coach. /// Return types mirror the cloud ApiClient exactly (defensive Map/List blobs). abstract class LocalRepository { + /// Runs [body] with each stored day it reads fetched and decoded at most + /// once. For a caller that reads several per-day getters for the same day + /// back to back (the Home day switcher), each of which would otherwise + /// decode that day's whole bundle again. The reads inside must not write. + Future sharingDayReads(Future Function() body) => body(); + Future pendingActivityCount() async => (await pendingActivities()).length; Future> pendingActivities() => diff --git a/lib/data/local_repository_impl.dart b/lib/data/local_repository_impl.dart index 30e75281e..ceb77017d 100644 --- a/lib/data/local_repository_impl.dart +++ b/lib/data/local_repository_impl.dart @@ -18,6 +18,8 @@ import 'dart:convert'; import 'dart:isolate'; import 'dart:math' as math; +import 'package:flutter/foundation.dart' show visibleForTesting; + import '../compute/derivation_engine.dart'; import '../compute/findings.dart' show servedReadiness; import '../compute/hr_max.dart'; @@ -97,10 +99,38 @@ class LocalRepositoryImpl extends LocalRepository { // ── helpers ──────────────────────────────────────────────────────────────── + static const _dayBundleMemo = #localRepositoryDayBundleMemo; + + /// Bundle decodes performed by [_bundle], for tests. + @visibleForTesting + static int bundleDecodes = 0; + + @override + Future sharingDayReads(Future Function() body) => + Zone.current[_dayBundleMemo] != null + ? body() + : runZoned(body, zoneValues: { + _dayBundleMemo: ?>>{}, + }); + /// Decode a day_result row's payload bundle (latest algo_version), or null. - Future?> _bundle(String date) async { + /// + /// Inside [sharingDayReads] the decoded bundle is shared by every getter for + /// that day: a full decode expands every stored curve into per-sample maps, + /// and the day switcher used to pay for it three times per tap on the UI + /// isolate. Readers only ever read the bundle, which is what makes the + /// sharing safe. + Future?> _bundle(String date) { + final memo = Zone.current[_dayBundleMemo] + as Map?>>?; + if (memo == null) return _loadBundle(date); + return memo[date] ??= _loadBundle(date); + } + + Future?> _loadBundle(String date) async { final row = await LocalDb.dayResult(date); if (row == null) return null; + bundleDecodes++; final b = _decode(row['payload_json']); return b == null ? null : withoutWearableEstimates(b); } diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 2917b4520..c06f9cb27 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -44,7 +44,7 @@ "pairingFailedAdviceTitle": "Band gefunden, aber Verbindung nicht abgeschlossen", "pairingFailedAdviceBody": "Meist klappt es, wenn du aus etwa einem Meter Entfernung erneut suchst.", "profileSetupTitle": "Über dich", - "profileSetupBody": "Vier Angaben beeinflussen, wie deine Daten ausgewertet werden. Lässt du eine leer, bleiben nur die Werte aus, die sie benötigen.", + "profileSetupBody": "Das Geschlecht ist die einzige Angabe, die zum Fortfahren nötig ist. Alter, Größe und Gewicht beeinflussen, wie deine Daten ausgewertet werden; lässt du eine davon leer, bleiben nur die Werte aus, die sie benötigen.", "profileSetupSexHeader": "GESCHLECHT", "profileSetupSexMale": "Männlich", "profileSetupSexFemale": "Weiblich", @@ -773,6 +773,8 @@ "journalComposeTodaySection": "Heute", "journalComposeTrackSomethingElse": "Etwas anderes erfassen", "journalComposeAnythingElseLabel": "Sonstiges", + "journalComposeTalkItThroughWithAi": "Mit KI durchsprechen", + "supplementTalkItThrough88a9d2": "Durchsprechen", "journalComposeAnythingElseHint": "Ein paar Worte zum Tag.", "journalComposeSavingLabel": "Wird gespeichert", "journalComposeHowAreYouFeeling": "Wie fühlst du dich?", @@ -854,6 +856,8 @@ "nutritionExpenditureSub": "HEUTE, AUS HERZFREQUENZ UND DEINEM PROFIL", "nutritionRemoveTitle": "{label} entfernen?", "nutritionRemoveBody": "Die Mahlzeit verschwindet aus dem Tag und aus allen Durchschnitten, in die sie eingeflossen ist. Das lässt sich nicht rückgängig machen.", + "nutritionSaveFailed": "Nicht gespeichert – die Mahlzeit ist noch da. Versuch es erneut.", + "nutritionRemoveFailed": "Nicht entfernt – die Mahlzeit ist noch da. Versuch es erneut.", "nutritionNothingToMeasure": "Noch keine Daten, mit denen sich {label} vergleichen lässt", "nutritionFloorAverageBody": "An jedem vollständigen Tag fehlte bei einer Mahlzeit die Angabe zu {nutrient}, daher wäre der Durchschnitt nur eine Untergrenze.", "nutritionCountedNoFigureBody": "{count} der letzten {span} Tage wurden gezählt, aber keiner davon hatte eine Angabe zu {nutrient}.", @@ -912,6 +916,7 @@ "coachLocalSub": "In diesem Netzwerk. Nichts verlässt deinen Rechner.", "coachMenuSemantic": "Chats und KI-Einstellungen", "coachModelHint": "Suchen oder ID eingeben", + "coachModelSelected": "Beim Speichern wird {model} verwendet", "coachModelLabel": "Modell", "coachModelsFound": "{n, plural, one{{n} Modell. Zum Auswählen antippen.} other{{n} Modelle. Zum Auswählen antippen.}}", "coachNavTitle": "Coach", @@ -1210,13 +1215,13 @@ "cycleNextPeriodBetween": "NÄCHSTE PERIODE, ERWARTET ZWISCHEN", "cycleNextPeriodAround": "NÄCHSTE PERIODE, ERWARTET ETWA", "cycleFromOneMeasuredGap": "basierend auf einem einzigen gemessenen Zyklus – daran lässt sich nicht erkennen, wie stark dein Zyklus schwankt", - "cyclePastEndOfIt": "{days} Tage nach Ende des Zeitraums · ", + "cyclePastEndOfIt": "{days, plural, one{1 Tag nach Ende des Zeitraums} other{{days} Tage nach Ende des Zeitraums}} · ", "cycleInsideItNow": "du bist gerade in diesem Zeitraum · ", "cycleInDaysRange": "in {lo}–{hi} Tagen · ", "cycleHalfOfMeasuredGaps": "die Hälfte deiner {n} gemessenen Zyklen lag innerhalb einer so breiten Spanne", - "cycleLeadDaysLate": "{days} Tage überfällig · ", + "cycleLeadDaysLate": "{days, plural, one{1 Tag überfällig} other{{days} Tage überfällig}} · ", "cycleLeadToday": "heute · ", - "cycleLeadInDays": "in {days} Tagen · ", + "cycleLeadInDays": "{days, plural, one{in 1 Tag} other{in {days} Tagen}} · ", "cycleWhatYouUsuallyNotice": "Was dir normalerweise auffällt", "cycleSymptomShapeSummary": "Vier Werte, einer pro Zykluswoche, zurückgerechnet ab deinen eingetragenen Periodenbeginnen. Du hast über {cycles} Zyklen an {daysByWeek} Tagen pro Woche etwas eingetragen – nur diese Tage fließen hier ein.", "cycleRemoveLoggedDay": "{date} entfernen", @@ -1278,6 +1283,7 @@ "healthTryAgain": "Erneut versuchen", "healthWhatVitals": "Vitalwerte", "healthWhatLabResults": "Laborergebnisse", + "cycleWhatCycleData": "Zyklusdaten", "healthMeasuresUnit": "Messwerte", "healthRowRestingHr": "Ruhepuls", "healthRowHrv": "HRV", @@ -1477,6 +1483,9 @@ "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", + "wellnessLoadFailedTitle": "Konnte nicht gelesen werden", + "wellnessLoadFailedBody": "Die gespeicherten Daten konnten nicht gelesen werden. Es wurde nichts gelöscht – das ist ein Ladefehler, es fehlen keine Daten.", "homeTryAgain": "Erneut versuchen", "homeNothingDerivedTitle": "Noch nichts ausgewertet", "homeNothingDerivedBody": "Es wurden noch keine Daten vom Band verarbeitet.", @@ -1492,6 +1501,9 @@ "homeProfileSettings": "Profil und Einstellungen", "homeNothingTodayTitle": "Für heute nichts aufgezeichnet", "homeNothingTodayBody": "Die zuletzt ausgewertete Nacht war {day}. Seitdem ist nichts Neues angekommen.", + "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.", @@ -1723,6 +1735,7 @@ "sleepDetailHrv": "HRV", "sleepDetailBreathing": "Atmung", "sleepDetailTemp": "Temp.", + "sleepDetailTempRelative": "Temp. (rel.)", "sleepDetailNotMeasuredCap": "Nicht gemessen", "sleepDetailNoSignalAtMoment": "Zu diesem Zeitpunkt wurde kein Signal aufgezeichnet.", "sleepDetailStageAwake": "Wach", @@ -1742,6 +1755,7 @@ "sleepDetailAsleepWhileInBed": "Schlafanteil im Bett", "sleepDetailLowerThanUsual": "niedriger als üblich", "sleepDetailHigherThanUsual": "höher als üblich", + "sleepDetailPointsDelta": "{n} Prozentpunkte", "sleepDetailFellAsleep": "Eingeschlafen", "sleepDetailEarlierThanUsual": "früher als üblich", "sleepDetailLaterThanUsual": "später als üblich", @@ -2789,10 +2803,98 @@ "dataReanalyzeFailed": "Neuanalyse fehlgeschlagen: {error}", "dataReanalyzeDaysFailed": "{n, plural, one{1 Tag konnte nicht analysiert werden. Wenn das wiederholt passiert, melde es bitte.} other{{n} Tage konnten nicht analysiert werden. Wenn das wiederholt passiert, melde es bitte.}}", "napsInFuture": "Dieses Nickerchen ist noch nicht vorbei. Trage es ein, wenn du wach bist.", + "journalComposeEntrySaveFailed": "Nicht gespeichert – dein Eintrag ist noch da. Versuch es erneut.", + "journalComposeLoadFailed": "Das Journal für diesen Tag konnte nicht geöffnet werden.", + "journalComposeTryAgain": "Erneut versuchen", + "journalComposeAddToJournal": "Ins Journal übernehmen", + "workoutLoadFailedTitle": "Aktivität konnte nicht gelesen werden", + "workoutLoadFailedBody": "Deine Einheiten konnten nicht geladen werden. Nichts wurde gelöscht – das ist ein fehlgeschlagener Lesevorgang, keine fehlenden Daten.", + "activitySummaryClimbedFt": "+{ft} ft Anstieg", + "liveHrNoReadingTitle": "Kein Live-Wert", + "liveHrNotPairedWhy": "Es ist kein Band gekoppelt.", + "liveHrNotPairedFix": "Kopple eines im Profil, um Live-Herzschläge zu lesen.", + "liveHrNotConnectedWhy": "Dein Band ist nicht verbunden.", + "liveHrNotConnectedFix": "Live-Herzschläge brauchen eine offene Verbindung — die App verbindet sich, wenn du sie mit dem Band in Reichweite öffnest.", + "liveHrNoRecentBeatWhy": "Kein Herzschlag in den letzten {seconds} Sekunden.", + "liveHrStreamingFix": "Das Band sendet, solange es am Handgelenk ist und die App geöffnet ist.", + "liveHrTraceCaption": "Die letzten {count} Werte — {min}–{max} bpm. Nicht gespeichert; das ist der Live-Stream, keine Aufzeichnung deines Tages.", "notifRecoveryReadyBody": "Erholung {score}.", "notifRecoveryReadySleptBody": "Erholung {score}, geschlafen {hours} h {minutes} min.", "notifStepGoalBody": "Du hast etwa {steps} Schritte erreicht – mindestens dein Ziel von {goal}.", "notifLowBatteryBody": "Dein Band ist bei {percent} %. Lade es bald auf.", + "supplementUnreadableFields": "„{fields}“ sind keine Zahlen. Es wurde nichts gespeichert.", + "presentationHeightCm": "Größe (cm)", + "presentationHeightIn": "Größe (in)", + "presentationWeightLb": "Gewicht (lb)", + "supplementUnreadableField": "„{field}“ ist keine Zahl. Es wurde nichts gespeichert.", + "settingsGroupApp": "App", + "settingsGroupData": "Daten", + "settingsGroupAdvanced": "Erweitert", + "settingsCycleTrackingRowSubHealth": "Fügt „Zyklus“ unter „Gesundheit“ hinzu. Ausgeschaltet wird es ausgeblendet, bisherige Einträge bleiben erhalten", + "profileSetupCycleSub": "Trag deine Periode unter „Gesundheit“ ein. Das lässt sich später ändern.", + "settingsReportIssueRowTitle": "Problem melden", + "settingsReportIssueRowSub": "Sendet, was du auswählst, an die Entwickler; deine Daten werden verschlüsselt, die Angaben zur Meldung nicht", + "reportNavTitle": "Problem melden", + "reportCategoryGroup": "Worum es geht", + "reportCategorySync": "Synchronisierung", + "reportCategorySleep": "Schlaf", + "reportCategoryRecovery": "Erholung", + "reportCategoryWorkout": "Training", + "reportCategoryPairing": "Kopplung", + "reportCategoryOther": "Sonstiges", + "reportTitleLabel": "Titel", + "reportTitleHint": "Eine Zeile dazu, was schiefging", + "reportDescriptionLabel": "Was passiert ist", + "reportDescriptionHint": "Was du getan hast, was du erwartet hast, was du gesehen hast", + "reportAttachGroup": "Anhänge", + "reportScreenshotRow": "Screenshot hinzufügen", + "reportScreenshotRemove": "Screenshot entfernen", + "reportDatabaseRow": "Gesundheitsdaten der letzten 2 Wochen mitsenden", + "reportDatabaseWarning": "Deine Schlaf-, Herzfrequenz- und anderen Gesundheitsdaten der letzten 14 Tage (weniger Tage sekundengenauer Daten, falls das Ticket sonst zu groß wäre), dazu deine Tageswerte und Einstellungen. Sie werden nur gesendet, wenn dies aktiviert ist, und der Server löscht sie nach 14 Tagen. Ein Ticket mit Gesundheitsdaten kann bis zu 95 MB groß sein, nutze also nach Möglichkeit WLAN.", + "reportIncludedGroup": "Was enthalten ist", + "reportIncludedNote": "Kategorie, Titel, Beschreibung, App-Version, Plattform, Systemversion, Smartphone-Modell, Installations-ID und ob eine Datenbank oder ein Screenshot angehängt ist, werden unverschlüsselt übertragen, damit wir die Meldung zuordnen können, und 90 Tage aufbewahrt. Alles andere wird auf diesem Smartphone verschlüsselt, bevor es das Gerät verlässt, sodass nur die Entwickler es lesen können, und nach 30 Tagen gelöscht, oder nach 14 Tagen, wenn deine Datenbank angehängt ist. „Lokal teilen“ ist anders: Das ZIP, das an das Teilen-Menü übergeben wird, ist nicht verschlüsselt.", + "reportFileManifest": "Diese Dateiliste", + "reportFileMeta": "App-Version, Smartphone-Modell, Systemversion, Sprache, Zeitzone und eine zufällige Installations-ID", + "reportFileDevice": "Dein gekoppeltes Band und deine Sensoren: Typ, Verbindung, Akku und letzte Synchronisierung", + "reportFileState": "Synchronisierungs- und Auswertungsstatus sowie aktuelle Fehler", + "reportFileSyncLog": "Das aktuelle Synchronisierungsprotokoll der App", + "reportFileTelemetry": "Fehler und Ereignisse, die dieses Smartphone in den letzten 7 Tagen aufgezeichnet hat", + "reportFileUser": "Deine Kategorie, dein Titel und deine Beschreibung", + "reportFileScreenshot": "Der ausgewählte Screenshot", + "reportFileDatabase": "Deine vollständige Gesundheitsdatenbank, komprimiert", + "reportFileDatabaseSlice": "Gesundheitsdaten der letzten 14 Tage, dazu Tageswerte und Einstellungen, komprimiert", + "reportFullDatabaseRow": "Vollständige Gesundheitsdatenbank (nur lokal teilen)", + "reportFullDatabaseWarning": "Deine gesamten Gesundheitsdaten. Sie landen nur in der Datei, die „Lokal teilen“ erstellt, niemals auf unserem Server.", + "reportFullDatabaseNoUpload": "Die vollständige Datenbank ist zu groß zum Hochladen. Nutze „Lokal teilen“ und schick uns die Datei.", + "reportDatabaseCovered": "In der vollständigen Datenbank unten enthalten.", + "reportStageSlice": "Letzte 2 Wochen werden vorbereitet…", + "reportSend": "Ticket erstellen", + "reportSaveInstead": "Lokal teilen", + "reportNeedTitle": "Gib zuerst einen Titel ein.", + "reportSentTitle": "Ticket erstellt", + "reportSentBody": "Deine Ticket-ID ist {id}. Gib sie an, wenn du uns dazu kontaktierst.", + "reportCopyId": "ID kopieren", + "reportNotSentTitle": "Ticket nicht erstellt", + "reportTooLarge": "Dieses Ticket ist zu groß zum Hochladen. Nutze „Lokal teilen“ und schick uns die Datei, oder schalte die Gesundheitsdaten aus bzw. entferne den Screenshot.", + "reportRateLimited": "Du hast heute schon 3 Tickets erstellt. Versuch es morgen wieder oder teile dieses lokal.", + "reportStorageFull": "Der Meldeserver hat gerade keinen Platz für eine Datenbank. Du kannst das Ticket ohne sie erstellen.", + "reportSendWithoutDb": "Ticket ohne Datenbank erstellen", + "reportSendFailed": "Das Ticket konnte nicht erstellt werden. Prüfe deine Verbindung oder teile es stattdessen lokal.", + "reportMyReportsGroup": "Meine Meldungen", + "reportMyReportsSub": "Tippen, um sie jetzt zu löschen. Sonst wird sie am {date} gelöscht", + "reportDelete": "Löschen", + "reportDeleteFailed": "Löschen fehlgeschlagen. Versuch es später erneut.", + "reportSaveFailedTitle": "Meldung konnte nicht geteilt werden", + "reportSaveFailedBody": "Beim Erstellen oder Teilen der Datei ist etwas schiefgelaufen. Es wurde kein Ticket erstellt.", + "reportSaveRetry": "Erneut versuchen", + "reportStageCollecting": "App-Infos werden gesammelt…", + "reportStageLogs": "Protokolle werden gelesen…", + "reportStageDatabase": "Datenbank wird komprimiert…", + "reportStagePacking": "Dateien werden gepackt…", + "reportStageEncrypting": "Wird verschlüsselt…", + "reportStageUploading": "{size} MB werden hochgeladen…", + "reportStageSharing": "Teilen wird vorbereitet…", + "reportIdCopied": "{id} kopiert", "reportDeleteConfirmTitle": "Bericht {id} löschen?", "reportDeleteConfirmBody": "Er wird sofort vom Server entfernt und lässt sich danach nicht mehr über seine ID öffnen. Das lässt sich nicht rückgängig machen.", "profileImplausibleFields": "Bitte {fields} prüfen: außerhalb des gültigen Bereichs, daher wurde nichts gespeichert.", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 5872edb88..57857ce09 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -176,7 +176,7 @@ "@profileSetupTitle": { "description": "Profile setup screen header." }, - "profileSetupBody": "Four numbers change how your data is scored. Leave any of them blank and only the metrics that need it stay unavailable.", + "profileSetupBody": "Sex is the one choice you need to make to continue. Age, height and weight change how your data is scored; leave any of them blank and only the metrics that need it stay unavailable.", "@profileSetupBody": { "description": "Profile setup intro body." }, @@ -3700,6 +3700,10 @@ "@journalComposeAnythingElseLabel": { "description": "Label for the free-text journal note field." }, + "journalComposeTalkItThroughWithAi": "Talk it through with AI", + "@journalComposeTalkItThroughWithAi": { + "description": "Link above the journal note field that opens an AI chat about the day. Shown only when an AI key is configured." + }, "journalComposeAnythingElseHint": "A line about the day.", "@journalComposeAnythingElseHint": { "description": "Placeholder hint for the free-text journal note field." @@ -4095,6 +4099,14 @@ "@nutritionRemoveBody": { "description": "Nutrition screen: confirmation sheet body warning the removal cannot be undone" }, + "nutritionSaveFailed": "Not saved — the meal is still here. Try again.", + "@nutritionSaveFailed": { + "description": "Log food sheet: snackbar when saving a meal fails; the sheet stays open with the input intact" + }, + "nutritionRemoveFailed": "Not removed — the meal is still there. Try again.", + "@nutritionRemoveFailed": { + "description": "Log food sheet: snackbar when removing a meal fails after the user confirmed" + }, "nutritionNothingToMeasure": "Nothing to measure {label} against yet", "@nutritionNothingToMeasure": { "description": "Nutrition Goals tab: title shown when a target has no mean to compare against yet", @@ -4424,6 +4436,15 @@ "@coachModelHint": { "description": "Coach AI settings: placeholder hint in the model search field" }, + "coachModelSelected": "Saving model: {model}", + "@coachModelSelected": { + "description": "Coach AI settings: line under the model field naming the model Save will store when nothing is typed", + "placeholders": { + "model": { + "type": "String" + } + } + }, "coachModelLabel": "Model", "@coachModelLabel": { "description": "Coach AI settings: text field label for searching or typing a model id" @@ -5911,7 +5932,7 @@ "@cycleFromOneMeasuredGap": { "description": "Trailing clause of the prediction footnote when only one cycle gap has been measured." }, - "cyclePastEndOfIt": "{days} days past the end of it · ", + "cyclePastEndOfIt": "{days, plural, one{1 day past the end of it} other{{days} days past the end of it}} · ", "@cyclePastEndOfIt": { "description": "Leading clause of the prediction footnote: the predicted window has already passed.", "placeholders": { @@ -5945,7 +5966,7 @@ } } }, - "cycleLeadDaysLate": "{days} days late · ", + "cycleLeadDaysLate": "{days, plural, one{1 day late} other{{days} days late}} · ", "@cycleLeadDaysLate": { "description": "Leading clause used when the single measured-gap prediction is overdue.", "placeholders": { @@ -5958,7 +5979,7 @@ "@cycleLeadToday": { "description": "Leading clause used when the single measured-gap prediction is due today." }, - "cycleLeadInDays": "in {days} days · ", + "cycleLeadInDays": "{days, plural, one{in 1 day} other{in {days} days}} · ", "@cycleLeadInDays": { "description": "Leading clause used when the single measured-gap prediction is still ahead.", "placeholders": { @@ -6311,6 +6332,10 @@ "@healthWhatLabResults": { "description": "Noun 'lab results' plugged into the read-failure title" }, + "cycleWhatCycleData": "cycle data", + "@cycleWhatCycleData": { + "description": "Noun 'cycle data' plugged into the read-failure title on the Cycle tab" + }, "healthMeasuresUnit": "measures", "@healthMeasuresUnit": { "description": "Noun 'measures' — used both as the read-failure title noun and as the Explore consistency-tile unit" @@ -7320,6 +7345,24 @@ "@homeLoadFailedBody": { "description": "Body of the card shown when today's data failed to load from the database." }, + "homeDayLoadFailedTitle": "{day} could not be read", + "@homeDayLoadFailedTitle": { + "description": "Title of the card shown on Home when a past day picked with the day switcher failed to load from the database.", + "placeholders": { + "day": { + "type": "String", + "example": "Wednesday, 15 October" + } + } + }, + "wellnessLoadFailedTitle": "This could not be read", + "@wellnessLoadFailedTitle": { + "description": "Title of the card shown when the Wellness screen or the sleep plan failed to load from the database." + }, + "wellnessLoadFailedBody": "Reading your stored data failed. Nothing was deleted — this is a read that went wrong, not missing data.", + "@wellnessLoadFailedBody": { + "description": "Body of the card shown when the Wellness screen or the sleep plan failed to load from the database." + }, "homeTryAgain": "Try again", "@homeTryAgain": { "description": "Button label to retry loading today's data." @@ -7392,6 +7435,18 @@ } } }, + "homeNoDataThisDayTitle": "No data for this day", + "@homeNoDataThisDayTitle": { + "description": "Today tab, stepped back to a past day: title shown when that day has nothing recorded." + }, + "homeNoDataThisDayBody": "Nothing was recorded on this day.", + "@homeNoDataThisDayBody": { + "description": "Today tab, stepped back to a past day: body shown when that day has nothing recorded." + }, + "homeSomeDayDataMissingTitle": "Some of this day's data is missing", + "@homeSomeDayDataMissingTitle": { + "description": "Today tab, stepped back to a past day: one card listing every missing reading and why." + }, "homeReadinessNotScoredTitle": "Recovery is not scored today", "@homeReadinessNotScoredTitle": { "description": "Title of the card shown when readiness has no score today." @@ -8601,6 +8656,10 @@ "@sleepDetailTemp": { "description": "Short label for skin temperature in the scrub readout card." }, + "sleepDetailTempRelative": "Temp (rel.)", + "@sleepDetailTempRelative": { + "description": "Short label for the scrub readout card's skin-temperature value, which is a relative sensor deviation from that day's median, not degrees. Keep the 'relative' meaning." + }, "sleepDetailNotMeasuredCap": "Not measured", "@sleepDetailNotMeasuredCap": { "description": "Capitalized, standalone label shown in place of a stage pill when the instant was not recorded." @@ -8693,6 +8752,15 @@ "@sleepDetailHigherThanUsual": { "description": "Verdict phrase: sleep efficiency was higher than usual." }, + "sleepDetailPointsDelta": "{n} percentage points", + "@sleepDetailPointsDelta": { + "description": "Verdict prefix: how far sleep efficiency was from the usual median, in percentage points; followed by the lower/higher-than-usual phrase.", + "placeholders": { + "n": { + "type": "String" + } + } + }, "sleepDetailFellAsleep": "Fell asleep", "@sleepDetailFellAsleep": { "description": "Row label in the 'against your usual' comparison: sleep onset time." @@ -10096,6 +10164,15 @@ } } }, + "activitySummaryClimbedFt": "+{ft} ft climbed", + "@activitySummaryClimbedFt": { + "description": "Hero caption showing feet climbed on a journey/hike session, for imperial units", + "placeholders": { + "ft": { + "type": "int" + } + } + }, "activitySummaryLapsCaption": "{n, plural, one{{n} lap} other{{n} laps}}", "@activitySummaryLapsCaption": { "description": "Hero caption showing lap count on a swim/row session", @@ -12752,6 +12829,15 @@ } } }, + "supplementClimbedExportFeet": "+{ft} ft elevation", + "@supplementClimbedExportFeet": { + "description": "Share poster caption: elevation gained, in feet for imperial units", + "placeholders": { + "ft": { + "type": "String" + } + } + }, "supplementLapsExport": "{n} laps", "@supplementLapsExport": { "description": "Presentation-only text; interpolated values remain unchanged.", @@ -15527,7 +15613,7 @@ "@settingsReportIssueRowTitle": { "description": "Settings row that opens the issue report screen." }, - "settingsReportIssueRowSub": "Sends what you choose to the maintainers, encrypted", + "settingsReportIssueRowSub": "Sends what you choose to the maintainers; your data is encrypted, the report details are not", "@settingsReportIssueRowSub": { "description": "Settings row subtitle for Report an issue." }, @@ -15847,6 +15933,58 @@ "@napsInFuture": { "description": "Validation error when a manually logged nap ends after the current time." }, + "journalComposeEntrySaveFailed": "Not saved — your entry is still here. Try again.", + "@journalComposeEntrySaveFailed": { + "description": "Snackbar shown when saving the day's journal entry fails; the screen stays open with the entry intact." + }, + "journalComposeLoadFailed": "Could not open this day's journal.", + "@journalComposeLoadFailed": { + "description": "Shown in place of the journal form when reading the day's entry fails." + }, + "journalComposeTryAgain": "Try again", + "@journalComposeTryAgain": { + "description": "Button that retries loading the journal entry after a failure." + }, + "journalComposeAddToJournal": "Add to journal", + "@journalComposeAddToJournal": { + "description": "Journal AI chat sheet: primary button that copies the proposed tags and note into the day's entry." + }, + "workoutLoadFailedTitle": "Activity could not be read", + "@workoutLoadFailedTitle": { + "description": "Title of the card shown on the Activity tab when the sessions failed to load from the database." + }, + "workoutLoadFailedBody": "Your sessions failed to load. Nothing was deleted — this is a read that went wrong, not missing data.", + "@workoutLoadFailedBody": { + "description": "Body of the card shown on the Activity tab when the sessions failed to load from the database." + }, + "liveHrNoReadingTitle": "No live reading", + "liveHrNotPairedWhy": "No band is paired.", + "liveHrNotPairedFix": "Pair one from Profile to read live beats.", + "liveHrNotConnectedWhy": "Your band is not connected.", + "liveHrNotConnectedFix": "Live beats need an open link — the app connects when you open it with the band in range.", + "liveHrNoRecentBeatWhy": "No beat in the last {seconds} seconds.", + "@liveHrNoRecentBeatWhy": { + "placeholders": { + "seconds": { + "type": "int" + } + } + }, + "liveHrStreamingFix": "The band streams while it is on your wrist and the app is open.", + "liveHrTraceCaption": "The last {count} readings — {min}–{max} bpm. Not stored; this is the live stream, not a record of your day.", + "@liveHrTraceCaption": { + "placeholders": { + "count": { + "type": "int" + }, + "min": { + "type": "int" + }, + "max": { + "type": "int" + } + } + }, "notifRecoveryReadyBody": "Recovery {score}.", "@notifRecoveryReadyBody": { "description": "OS notification body when the day's recovery score is ready.", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index f71b45c34..20537115e 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -44,7 +44,7 @@ "pairingFailedAdviceTitle": "Se encontró la banda pero la sesión no terminó", "pairingFailedAdviceBody": "Volver a buscar desde un metro de distancia suele funcionar.", "profileSetupTitle": "Sobre ti", - "profileSetupBody": "Cuatro datos cambian cómo se puntúan tus datos. Deja alguno en blanco y solo las métricas que lo necesiten quedarán sin disponibilidad.", + "profileSetupBody": "El sexo es la única opción obligatoria para continuar. La edad, la altura y el peso cambian cómo se puntúan tus datos; deja alguno en blanco y solo las métricas que lo necesiten quedarán sin disponibilidad.", "profileSetupSexHeader": "SEXO", "profileSetupSexMale": "Hombre", "profileSetupSexFemale": "Mujer", @@ -744,6 +744,8 @@ "journalComposeTodaySection": "Hoy", "journalComposeTrackSomethingElse": "Registrar algo más", "journalComposeAnythingElseLabel": "Algo más", + "journalComposeTalkItThroughWithAi": "Hablarlo con la IA", + "supplementTalkItThrough88a9d2": "Hablarlo", "journalComposeAnythingElseHint": "Una línea sobre el día.", "journalComposeSavingLabel": "Guardando", "journalComposeHowAreYouFeeling": "¿Cómo te sientes?", @@ -825,6 +827,8 @@ "nutritionExpenditureSub": "HOY, SEGÚN LA FRECUENCIA CARDÍACA Y TU PERFIL", "nutritionRemoveTitle": "¿Eliminar {label}?", "nutritionRemoveBody": "Elimina el día y toda media que lo contara. No se puede deshacer.", + "nutritionSaveFailed": "No se guardó — la comida sigue aquí. Inténtalo de nuevo.", + "nutritionRemoveFailed": "No se eliminó — la comida sigue ahí. Inténtalo de nuevo.", "nutritionNothingToMeasure": "Nada que medir aún frente a {label}", "nutritionFloorAverageBody": "Todos los días completos tuvieron una comida registrada sin cifra de {nutrient}, así que la media solo sería un límite inferior.", "nutritionCountedNoFigureBody": "{count} de los últimos {span} días contaron, pero ninguno llevaba una cifra de {nutrient}.", @@ -883,6 +887,7 @@ "coachLocalSub": "En esta red. Nada sale de tu máquina.", "coachMenuSemantic": "Chats y ajustes de IA", "coachModelHint": "buscar, o escribir un id", + "coachModelSelected": "Modelo que se guardará: {model}", "coachModelLabel": "Modelo", "coachModelsFound": "{n, plural, one{{n} modelo. Toca uno.} other{{n} modelos. Toca uno.}}", "coachNavTitle": "Coach", @@ -1175,13 +1180,13 @@ "cycleNextPeriodBetween": "PRÓXIMO PERÍODO, PREVISTO ENTRE", "cycleNextPeriodAround": "PRÓXIMO PERÍODO, PREVISTO ALREDEDOR DE", "cycleFromOneMeasuredGap": "a partir de tu único intervalo medido, que no puede mostrar cuánto varía tu propio ciclo", - "cyclePastEndOfIt": "{days} días después del final · ", + "cyclePastEndOfIt": "{days, plural, one{1 día después del final} other{{days} días después del final}} · ", "cycleInsideItNow": "ahora estás dentro de él · ", "cycleInDaysRange": "en {lo}–{hi} días · ", "cycleHalfOfMeasuredGaps": "la mitad de tus {n} intervalos medidos cayó dentro de un rango tan amplio como este", - "cycleLeadDaysLate": "{days} días de retraso · ", + "cycleLeadDaysLate": "{days, plural, one{1 día de retraso} other{{days} días de retraso}} · ", "cycleLeadToday": "hoy · ", - "cycleLeadInDays": "en {days} días · ", + "cycleLeadInDays": "{days, plural, one{en 1 día} other{en {days} días}} · ", "cycleWhatYouUsuallyNotice": "Lo que sueles notar", "cycleSymptomShapeSummary": "Cuatro números, uno por cada semana del ciclo, contados a partir de tus propios inicios registrados. Registraste algo en {daysByWeek} días de cada semana a lo largo de {cycles} ciclos: esos son los únicos días que se tienen en cuenta aquí.", "cycleRemoveLoggedDay": "Eliminar {date}", @@ -1243,6 +1248,7 @@ "healthTryAgain": "Intentar de nuevo", "healthWhatVitals": "vitales", "healthWhatLabResults": "resultados de laboratorio", + "cycleWhatCycleData": "datos del ciclo", "healthMeasuresUnit": "medidas", "healthRowRestingHr": "Frecuencia cardíaca en reposo", "healthRowHrv": "VFC", @@ -1442,6 +1448,9 @@ "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}", + "wellnessLoadFailedTitle": "No se pudo leer", + "wellnessLoadFailedBody": "No se pudieron leer los datos guardados. No se eliminó nada; fue un error de lectura, no datos perdidos.", "homeTryAgain": "Intentar de nuevo", "homeNothingDerivedTitle": "Aún no se ha calculado nada", "homeNothingDerivedBody": "Todavía no se ha procesado ninguna grabación de la correa.", @@ -1455,6 +1464,9 @@ "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.", + "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.", @@ -1683,6 +1695,7 @@ "sleepDetailHrv": "VFC", "sleepDetailBreathing": "Respiración", "sleepDetailTemp": "Temp.", + "sleepDetailTempRelative": "Temp. (rel.)", "sleepDetailNotMeasuredCap": "No medido", "sleepDetailNoSignalAtMoment": "No se registró ninguna señal en este momento.", "sleepDetailStageAwake": "Despierto", @@ -1702,6 +1715,7 @@ "sleepDetailAsleepWhileInBed": "Dormido mientras estabas en cama", "sleepDetailLowerThanUsual": "más bajo de lo habitual", "sleepDetailHigherThanUsual": "más alto de lo habitual", + "sleepDetailPointsDelta": "{n} puntos porcentuales", "sleepDetailFellAsleep": "Te dormiste", "sleepDetailEarlierThanUsual": "más temprano de lo habitual", "sleepDetailLaterThanUsual": "más tarde de lo habitual", @@ -2693,10 +2707,106 @@ "dataReanalyzeFailed": "El reanálisis falló: {error}", "dataReanalyzeDaysFailed": "{n, plural, one{No se pudo analizar 1 día. Si vuelve a pasar, por favor infórmalo.} other{No se pudieron analizar {n} días. Si vuelve a pasar, por favor infórmalo.}}", "napsInFuture": "Esa siesta todavía no ha terminado. Regístrala cuando te despiertes.", + "journalComposeEntrySaveFailed": "No se guardó — tu entrada sigue aquí. Inténtalo de nuevo.", + "journalComposeLoadFailed": "No se pudo abrir el diario de este día.", + "journalComposeTryAgain": "Reintentar", + "journalComposeAddToJournal": "Añadir al diario", + "workoutLoadFailedTitle": "No se pudo leer la actividad", + "workoutLoadFailedBody": "Tus sesiones no se pudieron cargar. No se ha borrado nada: es una lectura que falló, no datos que falten.", + "activitySummaryClimbedFt": "+{ft} ft de ascenso", + "liveHrNoReadingTitle": "Sin lectura en directo", + "liveHrNotPairedWhy": "No hay ninguna banda emparejada.", + "liveHrNotPairedFix": "Empareja una desde Perfil para leer latidos en directo.", + "liveHrNotConnectedWhy": "Tu banda no está conectada.", + "liveHrNotConnectedFix": "Los latidos en directo necesitan una conexión abierta: la app se conecta al abrirla con la banda cerca.", + "liveHrNoRecentBeatWhy": "Ningún latido en los últimos {seconds} segundos.", + "liveHrStreamingFix": "La banda transmite mientras está en tu muñeca y la app está abierta.", + "liveHrTraceCaption": "Las últimas {count} lecturas: {min}–{max} lpm. No se guardan; es la transmisión en directo, no un registro de tu día.", "notifRecoveryReadyBody": "Recuperación {score}.", "notifRecoveryReadySleptBody": "Recuperación {score}, dormiste {hours} h {minutes} min.", "notifStepGoalBody": "Llegaste a unos {steps} pasos, al menos tu objetivo de {goal}.", "notifLowBatteryBody": "Tu banda está al {percent} %. Cárgala pronto.", + "settingsZoneAlertRowTitle": "Aviso de zona de FC", + "settingsZoneAlertRowSub": "Vibra cuando tu frecuencia cardíaca entra o sale de la zona objetivo durante un entrenamiento en vivo", + "settingsZoneAlertTargetRowTitle": "Zona objetivo", + "settingsZoneAlertTargetRowValue": "Zona {zone}", + "settingsAlarmLatchFailedRowTitle": "Alarma no confirmada", + "settingsAlarmLatchFailedRowSub": "Avisa cuando la banda no confirma una alarma que la app acaba de activar", + "settingsAlarmNightCheckRowTitle": "Comprobación sin alarma", + "settingsAlarmNightCheckRowSub": "Un aviso a las 19:00 cualquier noche sin alarma activada; si no, nada", + "supplementUnreadableFields": "«{fields}» no son números. No se guardó nada.", + "presentationHeightCm": "Altura (cm)", + "presentationHeightIn": "Altura (in)", + "presentationWeightLb": "Peso (lb)", + "supplementUnreadableField": "«{field}» no es un número. No se guardó nada.", + "settingsGroupApp": "App", + "settingsGroupData": "Datos", + "settingsGroupAdvanced": "Avanzado", + "settingsCycleTrackingRowSubHealth": "Agrega Ciclo a Salud. Desactivado lo oculta y conserva todo lo ya registrado", + "profileSetupCycleSub": "Registra tu periodo en Salud. Puedes cambiarlo más tarde.", + "settingsReportIssueRowTitle": "Informar de un problema", + "settingsReportIssueRowSub": "Envía lo que elijas a los responsables; tus datos van cifrados, los detalles del informe no", + "reportNavTitle": "Informar de un problema", + "reportCategoryGroup": "De qué se trata", + "reportCategorySync": "Sincronización", + "reportCategorySleep": "Sueño", + "reportCategoryRecovery": "Recuperación", + "reportCategoryWorkout": "Entrenamiento", + "reportCategoryPairing": "Emparejamiento", + "reportCategoryOther": "Otro", + "reportTitleLabel": "Título", + "reportTitleHint": "Una línea sobre qué salió mal", + "reportDescriptionLabel": "Qué pasó", + "reportDescriptionHint": "Qué hiciste, qué esperabas, qué viste", + "reportAttachGroup": "Adjuntos", + "reportScreenshotRow": "Añadir una captura de pantalla", + "reportScreenshotRemove": "Quitar la captura de pantalla", + "reportDatabaseRow": "Incluir los datos de salud de las últimas 2 semanas", + "reportDatabaseWarning": "Tus datos de sueño, frecuencia cardíaca y otros datos de salud de los últimos 14 días (menos días de datos segundo a segundo si el ticket fuera demasiado grande), además de tus puntuaciones diarias y ajustes. Solo se envían si esto está activado, y el servidor los borra a los 14 días. Un ticket con datos de salud puede ocupar hasta 95 MB, así que usa wifi si puedes.", + "reportIncludedGroup": "Qué se incluye", + "reportIncludedNote": "La categoría, el título, la descripción, la versión de la app, la plataforma, la versión del sistema, el modelo de teléfono, el ID de instalación y si hay una base de datos o captura adjunta viajan sin cifrar para poder hacer seguimiento del informe, y se guardan 90 días. Todo lo demás se cifra en este teléfono antes de salir, así que solo los responsables pueden leerlo, y se borra a los 30 días, o a los 14 si adjuntas tu base de datos. «Compartir localmente» es distinto: el ZIP que pasa al menú de compartir no está cifrado.", + "reportFileManifest": "Esta lista de archivos", + "reportFileMeta": "Versión de la app, modelo de teléfono, versión del sistema, idioma, zona horaria y un ID de instalación aleatorio", + "reportFileDevice": "Tu banda y sensores emparejados: tipo, conexión, batería y última sincronización", + "reportFileState": "Estado de sincronización y de cálculo, y errores recientes", + "reportFileSyncLog": "El registro de sincronización reciente de la app", + "reportFileTelemetry": "Errores y eventos que este teléfono registró en los últimos 7 días", + "reportFileUser": "Tu categoría, título y descripción", + "reportFileScreenshot": "La captura de pantalla que elegiste", + "reportFileDatabase": "Tu base de datos de salud completa, comprimida", + "reportFileDatabaseSlice": "Datos de salud de los últimos 14 días, más puntuaciones diarias y ajustes, comprimidos", + "reportFullDatabaseRow": "Base de datos de salud completa (solo compartir localmente)", + "reportFullDatabaseWarning": "Todo tu historial de salud. Solo va en el archivo que crea «Compartir localmente», nunca a nuestro servidor.", + "reportFullDatabaseNoUpload": "La base de datos completa es demasiado grande para subirla. Usa «Compartir localmente» y envíanos el archivo.", + "reportDatabaseCovered": "Incluido en la base de datos completa de abajo.", + "reportStageSlice": "Preparando las últimas 2 semanas…", + "reportSend": "Crear ticket", + "reportSaveInstead": "Compartir localmente", + "reportNeedTitle": "Añade primero un título.", + "reportSentTitle": "Ticket creado", + "reportSentBody": "El ID de tu ticket es {id}. Indícalo si nos escribes sobre esto.", + "reportCopyId": "Copiar ID", + "reportNotSentTitle": "Ticket no creado", + "reportTooLarge": "Este ticket es demasiado grande para subirlo. Usa «Compartir localmente» y envíanos el archivo, o desactiva los datos de salud o quita la captura de pantalla.", + "reportRateLimited": "Hoy ya creaste 3 tickets. Vuelve a intentarlo mañana o comparte este localmente.", + "reportStorageFull": "El servidor de informes no tiene espacio para una base de datos ahora mismo. Puedes crear el ticket sin ella.", + "reportSendWithoutDb": "Crear ticket sin la base de datos", + "reportSendFailed": "No se pudo crear el ticket. Revisa tu conexión o compártelo localmente.", + "reportMyReportsGroup": "Mis informes", + "reportMyReportsSub": "Toca para borrarlo ahora. Si no, se borrará el {date}", + "reportDelete": "Borrar", + "reportDeleteFailed": "No se pudo borrar. Inténtalo más tarde.", + "reportSaveFailedTitle": "No se pudo compartir el informe", + "reportSaveFailedBody": "Algo falló al crear o compartir el archivo. No se creó ningún ticket.", + "reportSaveRetry": "Reintentar", + "reportStageCollecting": "Recopilando información de la app…", + "reportStageLogs": "Leyendo registros…", + "reportStageDatabase": "Comprimiendo la base de datos…", + "reportStagePacking": "Empaquetando archivos…", + "reportStageEncrypting": "Cifrando…", + "reportStageUploading": "Subiendo {size} MB…", + "reportStageSharing": "Preparando para compartir…", + "reportIdCopied": "{id} copiado", "reportDeleteConfirmTitle": "¿Eliminar el informe {id}?", "reportDeleteConfirmBody": "Se borra del servidor ahora mismo y ya no se podrá abrir con su ID. No se puede deshacer.", "profileImplausibleFields": "Revisa {fields}: fuera de rango, así que no se guardó nada.", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 3cfeba4b3..2d24ffb0e 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -44,7 +44,7 @@ "pairingFailedAdviceTitle": "Le bracelet a été trouvé mais la session ne s’est pas terminée", "pairingFailedAdviceBody": "Relancer la recherche à un mètre de distance fonctionne généralement.", "profileSetupTitle": "À propos de vous", - "profileSetupBody": "Quatre chiffres modifient la façon dont vos données sont notées. Laissez-en un vide et seules les métriques qui en ont besoin resteront indisponibles.", + "profileSetupBody": "Le sexe est le seul choix obligatoire pour continuer. L'âge, la taille et le poids modifient la façon dont vos données sont notées ; laissez-en un vide et seules les métriques qui en ont besoin resteront indisponibles.", "profileSetupSexHeader": "SEXE", "profileSetupSexMale": "Homme", "profileSetupSexFemale": "Femme", @@ -744,6 +744,8 @@ "journalComposeTodaySection": "Aujourd'hui", "journalComposeTrackSomethingElse": "Suivre autre chose", "journalComposeAnythingElseLabel": "Autre chose", + "journalComposeTalkItThroughWithAi": "En parler avec l'IA", + "supplementTalkItThrough88a9d2": "En parler", "journalComposeAnythingElseHint": "Une ligne sur la journée.", "journalComposeSavingLabel": "Enregistrement", "journalComposeHowAreYouFeeling": "Comment vous sentez-vous ?", @@ -825,6 +827,8 @@ "nutritionExpenditureSub": "AUJOURD'HUI, D'APRÈS LA FRÉQUENCE CARDIAQUE ET VOTRE PROFIL", "nutritionRemoveTitle": "Supprimer {label} ?", "nutritionRemoveBody": "Cela retire ce repas du jour et de toute moyenne qui le comptait. Aucun retour en arrière possible.", + "nutritionSaveFailed": "Non enregistré — le repas est toujours là. Réessayez.", + "nutritionRemoveFailed": "Non retiré — le repas est toujours là. Réessayez.", "nutritionNothingToMeasure": "Rien à comparer pour {label} pour l'instant", "nutritionFloorAverageBody": "Chaque jour complet avait un repas enregistré sans valeur de {nutrient}, donc la moyenne ne serait qu'un minimum.", "nutritionCountedNoFigureBody": "{count} des {span} derniers jours ont compté, mais aucun ne portait de valeur de {nutrient}.", @@ -883,6 +887,7 @@ "coachLocalSub": "Sur ce réseau. Rien ne quitte votre machine.", "coachMenuSemantic": "Discussions et réglages IA", "coachModelHint": "chercher, ou saisir un identifiant", + "coachModelSelected": "Modèle qui sera enregistré : {model}", "coachModelLabel": "Modèle", "coachModelsFound": "{n, plural, one{{n} modèle. Touchez-en un.} other{{n} modèles. Touchez-en un.}}", "coachNavTitle": "Coach", @@ -1175,13 +1180,13 @@ "cycleNextPeriodBetween": "PROCHAINES RÈGLES, PRÉVUES ENTRE", "cycleNextPeriodAround": "PROCHAINES RÈGLES, PRÉVUES VERS", "cycleFromOneMeasuredGap": "d'après votre seul intervalle mesuré, qui ne peut pas indiquer à quel point votre cycle varie", - "cyclePastEndOfIt": "{days} jours après sa fin · ", + "cyclePastEndOfIt": "{days, plural, one{{days} jour après sa fin} other{{days} jours après sa fin}} · ", "cycleInsideItNow": "vous êtes actuellement dedans · ", "cycleInDaysRange": "dans {lo} à {hi} jours · ", "cycleHalfOfMeasuredGaps": "la moitié de vos {n} intervalles mesurés se situait dans une fourchette aussi large que celle-ci", - "cycleLeadDaysLate": "{days} jours de retard · ", + "cycleLeadDaysLate": "{days, plural, one{{days} jour de retard} other{{days} jours de retard}} · ", "cycleLeadToday": "aujourd'hui · ", - "cycleLeadInDays": "dans {days} jours · ", + "cycleLeadInDays": "{days, plural, one{dans {days} jour} other{dans {days} jours}} · ", "cycleWhatYouUsuallyNotice": "Ce que vous remarquez habituellement", "cycleSymptomShapeSummary": "Quatre chiffres, un par semaine du cycle, comptés depuis vos propres débuts enregistrés. Vous avez enregistré quelque chose {daysByWeek} jours de chaque semaine sur {cycles} cycles — ce sont les seuls jours pris en compte ici.", "cycleRemoveLoggedDay": "Supprimer {date}", @@ -1243,6 +1248,7 @@ "healthTryAgain": "Réessayer", "healthWhatVitals": "vitaux", "healthWhatLabResults": "résultats de laboratoire", + "cycleWhatCycleData": "données de cycle", "healthMeasuresUnit": "mesures", "healthRowRestingHr": "Fréquence cardiaque au repos", "healthRowHrv": "VFC", @@ -1442,6 +1448,9 @@ "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}", + "wellnessLoadFailedTitle": "Lecture impossible", + "wellnessLoadFailedBody": "Les données enregistrées n'ont pas pu être lues. Rien n'a été supprimé : c'est une erreur de lecture, pas une perte de données.", "homeTryAgain": "Réessayer", "homeNothingDerivedTitle": "Rien n'a encore été calculé", "homeNothingDerivedBody": "Aucun enregistrement du bracelet n'a encore été traité.", @@ -1449,6 +1458,9 @@ "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.", + "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.", @@ -1673,6 +1685,7 @@ "sleepDetailHrv": "VFC", "sleepDetailBreathing": "Respiration", "sleepDetailTemp": "Temp.", + "sleepDetailTempRelative": "Temp. (rel.)", "sleepDetailNotMeasuredCap": "Non mesuré", "sleepDetailNoSignalAtMoment": "Aucun signal enregistré à cet instant.", "sleepDetailStageAwake": "Éveil", @@ -1692,6 +1705,7 @@ "sleepDetailAsleepWhileInBed": "Endormi pendant le temps au lit", "sleepDetailLowerThanUsual": "plus bas que d'habitude", "sleepDetailHigherThanUsual": "plus haut que d'habitude", + "sleepDetailPointsDelta": "{n} points de pourcentage", "sleepDetailFellAsleep": "Endormissement", "sleepDetailEarlierThanUsual": "plus tôt que d'habitude", "sleepDetailLaterThanUsual": "plus tard que d'habitude", @@ -2664,10 +2678,106 @@ "dataReanalyzeFailed": "La réanalyse a échoué : {error}", "dataReanalyzeDaysFailed": "{n, plural, one{{n} jour n’a pas pu être analysé. Si cela se reproduit, merci de le signaler.} other{{n} jours n’ont pas pu être analysés. Si cela se reproduit, merci de le signaler.}}", "napsInFuture": "Cette sieste n'est pas encore terminée. Enregistrez-la une fois réveillé.", + "journalComposeEntrySaveFailed": "Non enregistré — votre saisie est toujours là. Réessayez.", + "journalComposeLoadFailed": "Impossible d'ouvrir le journal de ce jour.", + "journalComposeTryAgain": "Réessayer", + "journalComposeAddToJournal": "Ajouter au journal", + "workoutLoadFailedTitle": "Impossible de lire l'activité", + "workoutLoadFailedBody": "Vos séances n'ont pas pu être chargées. Rien n'a été supprimé : c'est une lecture qui a échoué, pas des données manquantes.", + "activitySummaryClimbedFt": "+{ft} pi de dénivelé", + "liveHrNoReadingTitle": "Aucune mesure en direct", + "liveHrNotPairedWhy": "Aucun bracelet n'est appairé.", + "liveHrNotPairedFix": "Appairez-en un depuis le Profil pour lire les battements en direct.", + "liveHrNotConnectedWhy": "Votre bracelet n'est pas connecté.", + "liveHrNotConnectedFix": "Les battements en direct exigent une liaison ouverte : l'app se connecte quand vous l'ouvrez avec le bracelet à portée.", + "liveHrNoRecentBeatWhy": "Aucun battement dans les {seconds} dernières secondes.", + "liveHrStreamingFix": "Le bracelet transmet tant qu'il est à votre poignet et que l'app est ouverte.", + "liveHrTraceCaption": "Les {count} dernières mesures : {min}–{max} bpm. Non enregistrées ; c'est le flux en direct, pas un relevé de votre journée.", "notifRecoveryReadyBody": "Récupération {score}.", "notifRecoveryReadySleptBody": "Récupération {score}, sommeil {hours} h {minutes} min.", "notifStepGoalBody": "Vous avez fait environ {steps} pas, au moins votre objectif de {goal}.", "notifLowBatteryBody": "Votre bracelet est à {percent} %. Rechargez-le bientôt.", + "settingsZoneAlertRowTitle": "Alerte de zone cardiaque", + "settingsZoneAlertRowSub": "Vibre quand votre fréquence cardiaque entre dans la zone cible ou en sort pendant un entraînement en direct", + "settingsZoneAlertTargetRowTitle": "Zone cible", + "settingsZoneAlertTargetRowValue": "Zone {zone}", + "settingsAlarmLatchFailedRowTitle": "Alarme non confirmée", + "settingsAlarmLatchFailedRowSub": "Prévient quand le bracelet ne confirme pas une alarme que l’app vient de programmer", + "settingsAlarmNightCheckRowTitle": "Vérification sans alarme", + "settingsAlarmNightCheckRowSub": "Un rappel à 19 h les soirs sans alarme de réveil programmée ; rien sinon", + "supplementUnreadableFields": "« {fields} » ne sont pas des nombres. Rien n’a été enregistré.", + "presentationHeightCm": "Taille (cm)", + "presentationHeightIn": "Taille (in)", + "presentationWeightLb": "Poids (lb)", + "supplementUnreadableField": "« {field} » n’est pas un nombre. Rien n’a été enregistré.", + "settingsGroupApp": "App", + "settingsGroupData": "Données", + "settingsGroupAdvanced": "Avancé", + "settingsCycleTrackingRowSubHealth": "Ajoute Cycle à Santé. Désactivé le masque et conserve tout ce qui est déjà enregistré", + "profileSetupCycleSub": "Enregistrez vos règles dans Santé. Vous pourrez changer cela plus tard.", + "settingsReportIssueRowTitle": "Signaler un problème", + "settingsReportIssueRowSub": "Envoie ce que vous choisissez aux mainteneurs ; vos données sont chiffrées, les détails du signalement non", + "reportNavTitle": "Signaler un problème", + "reportCategoryGroup": "Sujet", + "reportCategorySync": "Synchronisation", + "reportCategorySleep": "Sommeil", + "reportCategoryRecovery": "Récupération", + "reportCategoryWorkout": "Entraînement", + "reportCategoryPairing": "Appairage", + "reportCategoryOther": "Autre", + "reportTitleLabel": "Titre", + "reportTitleHint": "Une ligne sur ce qui n’a pas marché", + "reportDescriptionLabel": "Ce qui s’est passé", + "reportDescriptionHint": "Ce que vous avez fait, ce que vous attendiez, ce que vous avez vu", + "reportAttachGroup": "Pièces jointes", + "reportScreenshotRow": "Ajouter une capture d’écran", + "reportScreenshotRemove": "Retirer la capture d’écran", + "reportDatabaseRow": "Inclure les données de santé des 2 dernières semaines", + "reportDatabaseWarning": "Vos données de sommeil, de fréquence cardiaque et autres données de santé des 14 derniers jours (moins de jours de données seconde par seconde si le ticket était trop lourd), ainsi que vos scores quotidiens et réglages. Elles ne partent que si cette option est activée, et le serveur les supprime au bout de 14 jours. Un ticket avec données de santé peut atteindre 95 Mo, utilisez donc le Wi-Fi si possible.", + "reportIncludedGroup": "Ce qui est inclus", + "reportIncludedNote": "La catégorie, le titre, la description, la version de l’app, la plateforme, la version du système, le modèle de téléphone, l’identifiant d’installation et la présence d’une base de données ou d’une capture jointe circulent en clair pour que nous puissions suivre le signalement, et sont conservés 90 jours. Tout le reste est chiffré sur ce téléphone avant de partir, de sorte que seuls les mainteneurs peuvent le lire, et est supprimé au bout de 30 jours, ou de 14 jours si votre base de données est jointe. « Partager localement » est différent : le ZIP transmis au menu de partage n’est pas chiffré.", + "reportFileManifest": "Cette liste de fichiers", + "reportFileMeta": "Version de l’app, modèle de téléphone, version du système, langue, fuseau horaire et un identifiant d’installation aléatoire", + "reportFileDevice": "Votre bracelet et vos capteurs appairés : type, connexion, batterie et dernière synchronisation", + "reportFileState": "État de la synchronisation et du calcul des scores, et erreurs récentes", + "reportFileSyncLog": "Le journal de synchronisation récent de l’app", + "reportFileTelemetry": "Erreurs et événements enregistrés par ce téléphone ces 7 derniers jours", + "reportFileUser": "Votre catégorie, titre et description", + "reportFileScreenshot": "La capture d’écran choisie", + "reportFileDatabase": "Votre base de données de santé complète, compressée", + "reportFileDatabaseSlice": "Données de santé des 14 derniers jours, plus scores quotidiens et réglages, compressées", + "reportFullDatabaseRow": "Base de données de santé complète (partage local uniquement)", + "reportFullDatabaseWarning": "Tout votre historique de santé. Il n’entre que dans le fichier créé par « Partager localement », jamais sur notre serveur.", + "reportFullDatabaseNoUpload": "La base de données complète est trop lourde pour être envoyée. Utilisez « Partager localement » et envoyez-nous le fichier.", + "reportDatabaseCovered": "Inclus dans la base de données complète ci-dessous.", + "reportStageSlice": "Préparation des 2 dernières semaines…", + "reportSend": "Créer le ticket", + "reportSaveInstead": "Partager localement", + "reportNeedTitle": "Ajoutez d’abord un titre.", + "reportSentTitle": "Ticket créé", + "reportSentBody": "L’identifiant de votre ticket est {id}. Indiquez-le si vous nous contactez à ce sujet.", + "reportCopyId": "Copier l’identifiant", + "reportNotSentTitle": "Ticket non créé", + "reportTooLarge": "Ce ticket est trop lourd pour être envoyé. Utilisez « Partager localement » et envoyez-nous le fichier, ou désactivez les données de santé ou retirez la capture d’écran.", + "reportRateLimited": "Vous avez déjà créé 3 tickets aujourd’hui. Réessayez demain, ou partagez celui-ci localement.", + "reportStorageFull": "Le serveur de signalement n’a pas de place pour une base de données pour l’instant. Vous pouvez créer le ticket sans elle.", + "reportSendWithoutDb": "Créer le ticket sans la base de données", + "reportSendFailed": "Impossible de créer le ticket. Vérifiez votre connexion, ou partagez-le localement.", + "reportMyReportsGroup": "Mes signalements", + "reportMyReportsSub": "Touchez pour le supprimer maintenant. Sinon, il sera supprimé le {date}", + "reportDelete": "Supprimer", + "reportDeleteFailed": "Suppression impossible. Réessayez plus tard.", + "reportSaveFailedTitle": "Impossible de partager le signalement", + "reportSaveFailedBody": "Un problème est survenu lors de la création ou du partage du fichier. Aucun ticket n’a été créé.", + "reportSaveRetry": "Réessayer", + "reportStageCollecting": "Collecte des infos de l’app…", + "reportStageLogs": "Lecture des journaux…", + "reportStageDatabase": "Compression de la base de données…", + "reportStagePacking": "Assemblage des fichiers…", + "reportStageEncrypting": "Chiffrement…", + "reportStageUploading": "Envoi de {size} Mo…", + "reportStageSharing": "Préparation du partage…", + "reportIdCopied": "{id} copié", "reportDeleteConfirmTitle": "Supprimer le rapport {id} ?", "reportDeleteConfirmBody": "Il est retiré du serveur tout de suite et ne pourra plus être ouvert avec son identifiant. Impossible d'annuler.", "profileImplausibleFields": "Vérifiez {fields} : hors limites, rien n'a donc été enregistré.", diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index 5390bbf71..55fa376d9 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -44,7 +44,7 @@ "pairingFailedAdviceTitle": "बैंड मिल गया लेकिन सत्र पूरा नहीं हुआ", "pairingFailedAdviceBody": "एक मीटर की दूरी से दोबारा स्कैन करना आमतौर पर काम करता है।", "profileSetupTitle": "आपके बारे में", - "profileSetupBody": "चार संख्याएं आपके डेटा के स्कोर होने के तरीके को बदलती हैं। किसी को भी खाली छोड़ें, केवल उसकी आवश्यकता वाले मेट्रिक्स ही अनुपलब्ध रहेंगे।", + "profileSetupBody": "आगे बढ़ने के लिए केवल लिंग चुनना ज़रूरी है। उम्र, लंबाई और वज़न आपके डेटा के स्कोर होने के तरीके को बदलते हैं; इनमें से किसी को भी खाली छोड़ें, केवल उसकी आवश्यकता वाले मेट्रिक्स ही अनुपलब्ध रहेंगे।", "profileSetupSexHeader": "लिंग", "profileSetupSexMale": "पुरुष", "profileSetupSexFemale": "महिला", @@ -744,6 +744,8 @@ "journalComposeTodaySection": "आज", "journalComposeTrackSomethingElse": "कुछ और ट्रैक करें", "journalComposeAnythingElseLabel": "कुछ और", + "journalComposeTalkItThroughWithAi": "AI के साथ बात करें", + "supplementTalkItThrough88a9d2": "बात करें", "journalComposeAnythingElseHint": "इस दिन के बारे में एक पंक्ति लिखें।", "journalComposeSavingLabel": "सेव हो रहा है", "journalComposeHowAreYouFeeling": "आप कैसा महसूस कर रहे हैं?", @@ -825,6 +827,8 @@ "nutritionExpenditureSub": "आज, हृदय गति और आपकी प्रोफ़ाइल के आधार पर", "nutritionRemoveTitle": "{label} हटाएँ?", "nutritionRemoveBody": "इससे यह उस दिन और उसे गिनने वाले हर औसत से हट जाएगा। यह पूर्ववत नहीं किया जा सकता।", + "nutritionSaveFailed": "सेव नहीं हुआ — भोजन अभी भी यहीं है। फिर से कोशिश करें।", + "nutritionRemoveFailed": "हटाया नहीं गया — भोजन अभी भी वहीं है। फिर से कोशिश करें।", "nutritionNothingToMeasure": "अभी {label} की तुलना करने के लिए कुछ भी नहीं है", "nutritionFloorAverageBody": "हर पूरे दिन में कम से कम एक भोजन में {nutrient} का आँकड़ा दर्ज नहीं था, इसलिए औसत केवल एक निचली सीमा होगी।", "nutritionCountedNoFigureBody": "पिछले {span} दिनों में से {count} दिन गिने गए, पर किसी में भी {nutrient} का आँकड़ा नहीं था।", @@ -883,6 +887,7 @@ "coachLocalSub": "इसी नेटवर्क पर। आपकी मशीन से कुछ भी बाहर नहीं जाता।", "coachMenuSemantic": "चैट और AI सेटिंग्स", "coachModelHint": "खोजें, या एक आईडी टाइप करें", + "coachModelSelected": "सहेजा जाने वाला मॉडल: {model}", "coachModelLabel": "मॉडल", "coachModelsFound": "{n} मॉडल मिले। किसी एक पर टैप करें।", "coachNavTitle": "कोच", @@ -1181,7 +1186,7 @@ "cycleHalfOfMeasuredGaps": "आपके {n} मापे गए अंतरालों में से आधे इतनी ही चौड़ी सीमा के भीतर आए", "cycleLeadDaysLate": "{days} दिन देर से · ", "cycleLeadToday": "आज · ", - "cycleLeadInDays": "{days} दिनों में · ", + "cycleLeadInDays": "{days, plural, one{{days} दिन में} other{{days} दिनों में}} · ", "cycleWhatYouUsuallyNotice": "आप आमतौर पर क्या महसूस करती हैं", "cycleSymptomShapeSummary": "चार आँकड़े, साइकल के हर हफ़्ते के लिए एक, आपकी अपनी दर्ज शुरुआतों से गिने गए। {cycles} साइकल में आपने हर हफ़्ते {daysByWeek} दिन कुछ न कुछ दर्ज किया — यही एकमात्र दिन हैं जो यहाँ गिने जाते हैं।", "cycleRemoveLoggedDay": "{date} हटाएँ", @@ -1243,6 +1248,7 @@ "healthTryAgain": "फिर से कोशिश करें", "healthWhatVitals": "जीवन संकेत", "healthWhatLabResults": "लैब परिणाम", + "cycleWhatCycleData": "चक्र डेटा", "healthMeasuresUnit": "माप", "healthRowRestingHr": "आराम के समय हृदय गति", "healthRowHrv": "एचआरवी", @@ -1442,6 +1448,9 @@ "homeOfSpan": "{duration} में से", "homeLoadFailedTitle": "आज का डेटा पढ़ा नहीं जा सका", "homeLoadFailedBody": "सहेजा गया दिन लोड नहीं हो सका। कुछ भी हटाया नहीं गया — यह एक रीड एरर है, डेटा खोना नहीं।", + "homeDayLoadFailedTitle": "{day} का डेटा पढ़ा नहीं जा सका", + "wellnessLoadFailedTitle": "इसे पढ़ा नहीं जा सका", + "wellnessLoadFailedBody": "आपका सहेजा गया डेटा पढ़ने में विफलता हुई। कुछ भी हटाया नहीं गया — यह पढ़ने में हुई गड़बड़ी है, डेटा गायब नहीं है।", "homeTryAgain": "फिर से कोशिश करें", "homeNothingDerivedTitle": "अभी तक कुछ भी नहीं निकाला गया", "homeNothingDerivedBody": "अभी तक स्ट्रैप की कोई रिकॉर्डिंग प्रोसेस नहीं हुई है।", @@ -1449,6 +1458,9 @@ "homeProfileSettings": "प्रोफ़ाइल और सेटिंग्स", "homeNothingTodayTitle": "आज के लिए कुछ भी दर्ज नहीं", "homeNothingTodayBody": "इस ऐप ने जिस आखिरी रात को स्कोर किया वह {day} थी। तब से कुछ भी नहीं पहुँचा है।", + "homeNoDataThisDayTitle": "इस दिन का कोई डेटा नहीं", + "homeNoDataThisDayBody": "इस दिन कुछ भी दर्ज नहीं हुआ।", + "homeSomeDayDataMissingTitle": "इस दिन का कुछ डेटा मौजूद नहीं है", "homeReadinessNotScoredTitle": "आज रिकवरी का स्कोर नहीं है", "homeReadinessNeedBody": "आपके लिए सामान्य क्या है, यह जानने के लिए {need}।", "homeReadinessNoReason": "इसे समझाने के लिए कुछ भी दर्ज नहीं है।", @@ -1673,6 +1685,7 @@ "sleepDetailHrv": "HRV", "sleepDetailBreathing": "श्वसन", "sleepDetailTemp": "तापमान", + "sleepDetailTempRelative": "तापमान (सापेक्ष)", "sleepDetailNotMeasuredCap": "मापा नहीं गया", "sleepDetailNoSignalAtMoment": "इस पल कोई संकेत रिकॉर्ड नहीं हुआ।", "sleepDetailStageAwake": "जागृत", @@ -1692,6 +1705,7 @@ "sleepDetailAsleepWhileInBed": "बिस्तर पर रहते हुए सोया हुआ", "sleepDetailLowerThanUsual": "सामान्य से कम", "sleepDetailHigherThanUsual": "सामान्य से ज़्यादा", + "sleepDetailPointsDelta": "{n} प्रतिशत अंक", "sleepDetailFellAsleep": "नींद आई", "sleepDetailEarlierThanUsual": "सामान्य से जल्दी", "sleepDetailLaterThanUsual": "सामान्य से देर से", @@ -2664,10 +2678,106 @@ "dataReanalyzeFailed": "फिर से विश्लेषण विफल: {error}", "dataReanalyzeDaysFailed": "{n, plural, one{1 दिन का विश्लेषण नहीं हो सका। अगर यह बार-बार हो, तो कृपया इसकी रिपोर्ट करें।} other{{n} दिनों का विश्लेषण नहीं हो सका। अगर यह बार-बार हो, तो कृपया इसकी रिपोर्ट करें।}}", "napsInFuture": "यह झपकी अभी खत्म नहीं हुई है। जागने के बाद इसे दर्ज करें।", + "journalComposeEntrySaveFailed": "सेव नहीं हुआ — आपकी एंट्री अभी भी यहीं है। फिर से कोशिश करें।", + "journalComposeLoadFailed": "इस दिन का जर्नल नहीं खुल सका।", + "journalComposeTryAgain": "फिर से कोशिश करें", + "journalComposeAddToJournal": "जर्नल में जोड़ें", + "workoutLoadFailedTitle": "गतिविधि पढ़ी नहीं जा सकी", + "workoutLoadFailedBody": "आपके सत्र लोड नहीं हो सके। कुछ भी हटाया नहीं गया — यह पढ़ने में हुई गड़बड़ी है, डेटा गायब नहीं है।", + "activitySummaryClimbedFt": "+{ft} फ़ुट चढ़ाई", + "liveHrNoReadingTitle": "कोई लाइव रीडिंग नहीं", + "liveHrNotPairedWhy": "कोई बैंड पेयर नहीं है।", + "liveHrNotPairedFix": "लाइव धड़कनें पढ़ने के लिए प्रोफ़ाइल से एक बैंड पेयर करें।", + "liveHrNotConnectedWhy": "आपका बैंड कनेक्ट नहीं है।", + "liveHrNotConnectedFix": "लाइव धड़कनों के लिए खुला लिंक चाहिए — बैंड पास होने पर ऐप खोलते ही वह कनेक्ट हो जाता है।", + "liveHrNoRecentBeatWhy": "पिछले {seconds} सेकंड में कोई धड़कन नहीं।", + "liveHrStreamingFix": "बैंड तब तक स्ट्रीम करता है जब तक वह आपकी कलाई पर है और ऐप खुला है।", + "liveHrTraceCaption": "पिछली {count} रीडिंग — {min}–{max} bpm. सहेजी नहीं जातीं; यह लाइव स्ट्रीम है, आपके दिन का रिकॉर्ड नहीं।", "notifRecoveryReadyBody": "रिकवरी {score}.", "notifRecoveryReadySleptBody": "रिकवरी {score}, नींद {hours} घं {minutes} मि.", "notifStepGoalBody": "आपने लगभग {steps} कदम पूरे किए — आपके {goal} के लक्ष्य जितने या उससे ज़्यादा।", "notifLowBatteryBody": "आपका बैंड {percent}% पर है। जल्द चार्ज करें।", + "settingsZoneAlertRowTitle": "HR ज़ोन अलर्ट", + "settingsZoneAlertRowSub": "लाइव वर्कआउट के दौरान हृदय गति लक्ष्य ज़ोन में आने या उससे बाहर जाने पर वाइब्रेट करता है", + "settingsZoneAlertTargetRowTitle": "लक्ष्य ज़ोन", + "settingsZoneAlertTargetRowValue": "ज़ोन {zone}", + "settingsAlarmLatchFailedRowTitle": "अलार्म की पुष्टि नहीं हुई", + "settingsAlarmLatchFailedRowSub": "जब बैंड ऐप द्वारा अभी सेट किए गए अलार्म की पुष्टि न करे, तब चेतावनी देता है", + "settingsAlarmNightCheckRowTitle": "बिना अलार्म वाली रात की जाँच", + "settingsAlarmNightCheckRowSub": "जिस रात कोई वेक अलार्म सेट न हो, उस रात शाम 7 बजे सूचना — वरना कुछ नहीं", + "supplementUnreadableFields": "{fields} संख्याएँ नहीं हैं। कुछ भी सेव नहीं हुआ।", + "presentationHeightCm": "लंबाई (सेमी)", + "presentationHeightIn": "लंबाई (इंच)", + "presentationWeightLb": "वज़न (पाउंड)", + "supplementUnreadableField": "{field} एक संख्या नहीं है। कुछ भी सेव नहीं हुआ।", + "settingsGroupApp": "ऐप", + "settingsGroupData": "डेटा", + "settingsGroupAdvanced": "उन्नत", + "settingsCycleTrackingRowSubHealth": "स्वास्थ्य में साइकिल जोड़ता है। बंद करने पर यह छिप जाता है और पहले से लॉग किया गया सब कुछ बना रहता है", + "profileSetupCycleSub": "अपना पीरियड स्वास्थ्य में लॉग करें। इसे आप बाद में बदल सकते हैं।", + "settingsReportIssueRowTitle": "समस्या की रिपोर्ट करें", + "settingsReportIssueRowSub": "आप जो चुनें उसे मेंटेनर्स को भेजता है; आपका डेटा एन्क्रिप्ट होता है, रिपोर्ट का विवरण नहीं", + "reportNavTitle": "समस्या की रिपोर्ट करें", + "reportCategoryGroup": "यह किस बारे में है", + "reportCategorySync": "सिंक", + "reportCategorySleep": "नींद", + "reportCategoryRecovery": "रिकवरी", + "reportCategoryWorkout": "वर्कआउट", + "reportCategoryPairing": "पेयरिंग", + "reportCategoryOther": "अन्य", + "reportTitleLabel": "शीर्षक", + "reportTitleHint": "एक पंक्ति में बताएँ कि क्या गलत हुआ", + "reportDescriptionLabel": "क्या हुआ", + "reportDescriptionHint": "आपने क्या किया, क्या उम्मीद थी, क्या दिखा", + "reportAttachGroup": "अटैचमेंट", + "reportScreenshotRow": "स्क्रीनशॉट जोड़ें", + "reportScreenshotRemove": "स्क्रीनशॉट हटाएँ", + "reportDatabaseRow": "पिछले 2 हफ़्तों का स्वास्थ्य डेटा शामिल करें", + "reportDatabaseWarning": "पिछले 14 दिनों का आपकी नींद, हृदय गति और अन्य स्वास्थ्य डेटा (अगर टिकट बहुत बड़ा हो जाए तो सेकंड-दर-सेकंड डेटा कम दिनों का), साथ में आपके दैनिक स्कोर और सेटिंग्स। यह तभी भेजा जाता है जब यह विकल्प चालू हो, और सर्वर इसे 14 दिनों बाद मिटा देता है। स्वास्थ्य डेटा वाला टिकट 95 MB तक का हो सकता है, इसलिए हो सके तो Wi-Fi इस्तेमाल करें।", + "reportIncludedGroup": "क्या शामिल है", + "reportIncludedNote": "श्रेणी, शीर्षक, विवरण, ऐप वर्ज़न, प्लेटफ़ॉर्म, सिस्टम वर्ज़न, फ़ोन मॉडल, इंस्टॉल ID, और डेटाबेस या स्क्रीनशॉट अटैच है या नहीं — ये बिना एन्क्रिप्शन के जाते हैं ताकि रिपोर्ट को ट्रैक किया जा सके, और 90 दिनों तक रखे जाते हैं। बाकी सब कुछ भेजे जाने से पहले इसी फ़ोन पर एन्क्रिप्ट होता है, ताकि केवल मेंटेनर्स ही इसे पढ़ सकें, और 30 दिनों बाद मिटा दिया जाता है, या डेटाबेस अटैच होने पर 14 दिनों बाद। “लोकल शेयर करें” अलग है: शेयर शीट को दी जाने वाली ZIP एन्क्रिप्टेड नहीं होती।", + "reportFileManifest": "फ़ाइलों की यह सूची", + "reportFileMeta": "ऐप वर्ज़न, फ़ोन मॉडल, सिस्टम वर्ज़न, भाषा, टाइम ज़ोन और एक रैंडम इंस्टॉल ID", + "reportFileDevice": "आपका पेयर किया गया बैंड और सेंसर: प्रकार, कनेक्शन, बैटरी और आखिरी सिंक", + "reportFileState": "सिंक और स्कोरिंग की स्थिति, और हाल की त्रुटियाँ", + "reportFileSyncLog": "ऐप का हाल का सिंक लॉग", + "reportFileTelemetry": "पिछले 7 दिनों में इस फ़ोन द्वारा दर्ज त्रुटियाँ और इवेंट", + "reportFileUser": "आपकी श्रेणी, शीर्षक और विवरण", + "reportFileScreenshot": "आपका चुना हुआ स्क्रीनशॉट", + "reportFileDatabase": "आपका पूरा स्वास्थ्य डेटाबेस, कंप्रेस किया हुआ", + "reportFileDatabaseSlice": "पिछले 14 दिनों का स्वास्थ्य डेटा, साथ में दैनिक स्कोर और सेटिंग्स, कंप्रेस किया हुआ", + "reportFullDatabaseRow": "पूरा स्वास्थ्य डेटाबेस (केवल लोकल शेयर)", + "reportFullDatabaseWarning": "आपका पूरा स्वास्थ्य रिकॉर्ड। यह केवल “लोकल शेयर करें” से बनी फ़ाइल में जाता है, हमारे सर्वर पर कभी नहीं।", + "reportFullDatabaseNoUpload": "पूरा डेटाबेस अपलोड करने के लिए बहुत बड़ा है। “लोकल शेयर करें” इस्तेमाल करें और फ़ाइल हमें भेजें।", + "reportDatabaseCovered": "नीचे दिए पूरे डेटाबेस में शामिल है।", + "reportStageSlice": "पिछले 2 हफ़्ते तैयार हो रहे हैं…", + "reportSend": "टिकट बनाएँ", + "reportSaveInstead": "लोकल शेयर करें", + "reportNeedTitle": "पहले एक शीर्षक जोड़ें।", + "reportSentTitle": "टिकट बन गया", + "reportSentBody": "आपकी टिकट ID {id} है। इस बारे में हमसे बात करते समय इसका ज़िक्र करें।", + "reportCopyId": "ID कॉपी करें", + "reportNotSentTitle": "टिकट नहीं बना", + "reportTooLarge": "यह टिकट अपलोड करने के लिए बहुत बड़ा है। “लोकल शेयर करें” इस्तेमाल करें और फ़ाइल हमें भेजें, या स्वास्थ्य डेटा बंद करें या स्क्रीनशॉट हटाएँ।", + "reportRateLimited": "आज आप 3 टिकट बना चुके हैं। कल फिर कोशिश करें, या इसे लोकल शेयर करें।", + "reportStorageFull": "रिपोर्ट सर्वर में अभी डेटाबेस के लिए जगह नहीं है। आप इसके बिना टिकट बना सकते हैं।", + "reportSendWithoutDb": "डेटाबेस के बिना टिकट बनाएँ", + "reportSendFailed": "टिकट नहीं बन सका। अपना कनेक्शन जाँचें, या इसे लोकल शेयर करें।", + "reportMyReportsGroup": "मेरी रिपोर्ट्स", + "reportMyReportsSub": "अभी मिटाने के लिए टैप करें। वरना यह {date} को मिटा दी जाएगी", + "reportDelete": "मिटाएँ", + "reportDeleteFailed": "मिटाया नहीं जा सका। बाद में फिर कोशिश करें।", + "reportSaveFailedTitle": "रिपोर्ट शेयर नहीं हो सकी", + "reportSaveFailedBody": "फ़ाइल बनाते या शेयर करते समय कुछ गलत हो गया। कोई टिकट नहीं बना।", + "reportSaveRetry": "फिर कोशिश करें", + "reportStageCollecting": "ऐप की जानकारी जुटाई जा रही है…", + "reportStageLogs": "लॉग पढ़े जा रहे हैं…", + "reportStageDatabase": "डेटाबेस कंप्रेस हो रहा है…", + "reportStagePacking": "फ़ाइलें पैक हो रही हैं…", + "reportStageEncrypting": "एन्क्रिप्ट हो रहा है…", + "reportStageUploading": "{size} MB अपलोड हो रहा है…", + "reportStageSharing": "शेयर करने की तैयारी…", + "reportIdCopied": "{id} कॉपी हो गया", "reportDeleteConfirmTitle": "रिपोर्ट {id} हटाएँ?", "reportDeleteConfirmBody": "यह अभी सर्वर से हट जाएगी और इसके ID से फिर नहीं खुलेगी। इसे वापस नहीं लाया जा सकता।", "profileImplausibleFields": "{fields} जाँचें: सीमा से बाहर, इसलिए कुछ भी सहेजा नहीं गया।", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 4b81c12cb..571765f1b 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -43,7 +43,7 @@ "pairingFailedAdviceTitle": "Браслет найден, но подключение не завершилось", "pairingFailedAdviceBody": "Обычно помогает повторный поиск с расстояния около метра.", "profileSetupTitle": "О вас", - "profileSetupBody": "Четыре параметра влияют на расчёт ваших показателей. Если оставить какой-либо из них пустым, недоступными останутся только зависящие от него показатели.", + "profileSetupBody": "Чтобы продолжить, нужно указать только пол. Возраст, рост и вес влияют на расчёт ваших показателей; если оставить какой-либо из них пустым, недоступными останутся только зависящие от него показатели.", "profileSetupSexHeader": "ПОЛ", "profileSetupSexMale": "Мужской", "profileSetupSexFemale": "Женский", @@ -1591,6 +1591,7 @@ "journalComposeTodaySection": "Сегодня", "journalComposeTrackSomethingElse": "Добавить свой показатель", "journalComposeAnythingElseLabel": "Что-нибудь ещё", + "journalComposeTalkItThroughWithAi": "Обсудить с ИИ", "journalComposeAnythingElseHint": "Несколько слов о дне.", "journalComposeSavingLabel": "Сохранение", "journalComposeHowAreYouFeeling": "Как вы себя чувствуете?", @@ -1769,6 +1770,8 @@ } }, "nutritionRemoveBody": "Запись исчезнет из дня и всех средних, где учитывалась. Отменить удаление нельзя.", + "nutritionSaveFailed": "Не сохранено — приём пищи остался на экране. Повторите попытку.", + "nutritionRemoveFailed": "Не удалено — приём пищи остался на месте. Повторите попытку.", "nutritionNothingToMeasure": "Пока не с чем сравнить {label}", "@nutritionNothingToMeasure": { "placeholders": { @@ -1956,6 +1959,14 @@ "coachLocalSub": "В этой сети. Данные не покидают ваш компьютер.", "coachMenuSemantic": "Чаты и настройки ИИ", "coachModelHint": "найдите или введите идентификатор", + "coachModelSelected": "Будет сохранена модель: {model}", + "@coachModelSelected": { + "placeholders": { + "model": { + "type": "String" + } + } + }, "coachModelLabel": "Модель", "coachModelsFound": "{n, plural, one{Найдена {n} модель. Выберите её.} few{Найдено {n} модели. Выберите одну.} many{Найдено {n} моделей. Выберите одну.} other{Найдено {n} модели. Выберите одну.}}", "@coachModelsFound": { @@ -2859,6 +2870,7 @@ "healthTryAgain": "Повторить", "healthWhatVitals": "показатели", "healthWhatLabResults": "результаты анализов", + "cycleWhatCycleData": "данные цикла", "healthMeasuresUnit": "измерения", "healthRowRestingHr": "Пульс в покое", "healthRowHrv": "ВСР", @@ -3350,6 +3362,17 @@ }, "homeLoadFailedTitle": "Не удалось прочитать сегодняшний день", "homeLoadFailedBody": "Сохранённый день не загрузился. Ничего не удалено: это ошибка чтения, а не отсутствие данных.", + "homeDayLoadFailedTitle": "Не удалось прочитать {day}", + "@homeDayLoadFailedTitle": { + "placeholders": { + "day": { + "type": "String", + "example": "Wednesday, 15 October" + } + } + }, + "wellnessLoadFailedTitle": "Не удалось прочитать", + "wellnessLoadFailedBody": "Не удалось прочитать сохранённые данные. Ничего не удалено — это сбой чтения, а не отсутствие данных.", "homeTryAgain": "Повторить", "homeNothingDerivedTitle": "Ночей с оценкой пока нет", "homeNothingDerivedBody": "Наденьте браслет на ночь и откройте приложение утром.", @@ -3373,6 +3396,9 @@ "homeProfileSettings": "Профиль и настройки", "homeNothingTodayTitle": "За сегодня ничего не записано", "homeNothingTodayBody": "Последняя оценённая ночь: {day}. С тех пор данные не поступали.", + "homeNoDataThisDayTitle": "Нет данных за этот день", + "homeNoDataThisDayBody": "В этот день ничего не было записано.", + "homeSomeDayDataMissingTitle": "Часть данных за этот день отсутствует", "@homeNothingTodayBody": { "placeholders": { "day": { @@ -3976,6 +4002,7 @@ "sleepDetailHrv": "ВСР", "sleepDetailBreathing": "Дыхание", "sleepDetailTemp": "Температура", + "sleepDetailTempRelative": "Темп. (отн.)", "sleepDetailNotMeasuredCap": "Не измерено", "sleepDetailNoSignalAtMoment": "В этот момент сигнал не записан.", "sleepDetailStageAwake": "Бодрствование", @@ -3999,6 +4026,14 @@ "sleepDetailAsleepWhileInBed": "Сон во время нахождения в постели", "sleepDetailLowerThanUsual": "ниже обычного", "sleepDetailHigherThanUsual": "выше обычного", + "sleepDetailPointsDelta": "на {n} п.п.", + "@sleepDetailPointsDelta": { + "placeholders": { + "n": { + "type": "String" + } + } + }, "sleepDetailFellAsleep": "Засыпание", "sleepDetailEarlierThanUsual": "раньше обычного", "sleepDetailLaterThanUsual": "позже обычного", @@ -4697,6 +4732,14 @@ } } }, + "activitySummaryClimbedFt": "Набор высоты: +{ft} фт", + "@activitySummaryClimbedFt": { + "placeholders": { + "ft": { + "type": "int" + } + } + }, "activitySummaryLapsCaption": "{n, plural, one{{n} круг} few{{n} круга} many{{n} кругов} other{{n} круга}}", "@activitySummaryLapsCaption": { "placeholders": { @@ -5857,6 +5900,14 @@ } } }, + "supplementClimbedExportFeet": "+{ft} фт высоты", + "@supplementClimbedExportFeet": { + "placeholders": { + "ft": { + "type": "String" + } + } + }, "supplementLapsExport": "Кругов: {n}", "@supplementLapsExport": { "placeholders": { @@ -7430,7 +7481,7 @@ "deviceUseSheetConfirm": "Использовать", "deviceUseSheetCancel": "Не сейчас", "settingsReportIssueRowTitle": "Сообщить о проблеме", - "settingsReportIssueRowSub": "Отправляет выбранное вами разработчикам в зашифрованном виде", + "settingsReportIssueRowSub": "Отправляет выбранное вами разработчикам; ваши данные шифруются, сведения о сообщении — нет", "reportNavTitle": "Сообщить о проблеме", "reportCategoryGroup": "О чём сообщение", "reportCategorySync": "Синхронизация", @@ -7556,6 +7607,40 @@ } }, "napsInFuture": "Этот дневной сон ещё не закончился. Запишите его, когда проснётесь.", + "journalComposeEntrySaveFailed": "Не сохранено — запись осталась на экране. Повторите попытку.", + "journalComposeLoadFailed": "Не удалось открыть дневник за этот день.", + "journalComposeTryAgain": "Повторить", + "journalComposeAddToJournal": "Добавить в дневник", + "workoutLoadFailedTitle": "Не удалось прочитать активность", + "workoutLoadFailedBody": "Не удалось загрузить ваши тренировки. Ничего не удалено — это ошибка чтения, а не отсутствие данных.", + "liveHrNoReadingTitle": "Нет живых данных", + "liveHrNotPairedWhy": "Браслет не подключён к приложению.", + "liveHrNotPairedFix": "Подключите его в Профиле, чтобы видеть пульс в реальном времени.", + "liveHrNotConnectedWhy": "Браслет не на связи.", + "liveHrNotConnectedFix": "Для пульса в реальном времени нужно открытое соединение — приложение подключается, когда вы открываете его рядом с браслетом.", + "liveHrNoRecentBeatWhy": "Нет ударов за последние {seconds} с.", + "@liveHrNoRecentBeatWhy": { + "placeholders": { + "seconds": { + "type": "int" + } + } + }, + "liveHrStreamingFix": "Браслет передаёт данные, пока он на запястье и приложение открыто.", + "liveHrTraceCaption": "Последние значения ({count}) — {min}–{max} уд/мин. Не сохраняются: это поток в реальном времени, а не запись вашего дня.", + "@liveHrTraceCaption": { + "placeholders": { + "count": { + "type": "int" + }, + "min": { + "type": "int" + }, + "max": { + "type": "int" + } + } + }, "notifRecoveryReadyBody": "Восстановление {score}.", "@notifRecoveryReadyBody": { "placeholders": { diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 0661eb5af..f18b09f5f 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -44,7 +44,7 @@ "pairingFailedAdviceTitle": "找到了腕带,但会话未完成", "pairingFailedAdviceBody": "从一米距离再次扫描通常有效。", "profileSetupTitle": "关于你", - "profileSetupBody": "四个数字会影响你的数据评分方式。留空其中任何一个,只有需要它的指标会不可用。", + "profileSetupBody": "继续前只需选择性别。年龄、身高和体重会影响你的数据评分方式;留空其中任何一个,只有需要它的指标会不可用。", "profileSetupSexHeader": "性别", "profileSetupSexMale": "男性", "profileSetupSexFemale": "女性", @@ -744,6 +744,8 @@ "journalComposeTodaySection": "今天", "journalComposeTrackSomethingElse": "记录其他内容", "journalComposeAnythingElseLabel": "其他备注", + "journalComposeTalkItThroughWithAi": "和 AI 聊聊", + "supplementTalkItThrough88a9d2": "聊一聊", "journalComposeAnythingElseHint": "写一句关于这一天的话。", "journalComposeSavingLabel": "保存中", "journalComposeHowAreYouFeeling": "你感觉怎么样?", @@ -825,6 +827,8 @@ "nutritionExpenditureSub": "今天,根据心率和你的个人资料估算", "nutritionRemoveTitle": "删除「{label}」?", "nutritionRemoveBody": "这会把它从这一天和每个统计过它的平均值中移除,且无法撤销。", + "nutritionSaveFailed": "未保存 —— 这餐仍在此处,请重试。", + "nutritionRemoveFailed": "未移除 —— 这餐仍然保留,请重试。", "nutritionNothingToMeasure": "目前还没有可用来衡量{label}的数据", "nutritionFloorAverageBody": "每个完整的一天里都有进食记录缺少{nutrient}数值,所以平均值只能是一个下限。", "nutritionCountedNoFigureBody": "最近 {span} 天中有 {count} 天被计入,但没有一天记录了{nutrient}数值。", @@ -883,6 +887,7 @@ "coachLocalSub": "在本地网络中运行,不会离开你的设备。", "coachMenuSemantic": "聊天记录与 AI 设置", "coachModelHint": "搜索或输入 ID", + "coachModelSelected": "将保存的模型:{model}", "coachModelLabel": "模型", "coachModelsFound": "共 {n} 个模型,点选其中一个。", "coachNavTitle": "教练", @@ -1243,6 +1248,7 @@ "healthTryAgain": "重试", "healthWhatVitals": "生命体征", "healthWhatLabResults": "化验结果", + "cycleWhatCycleData": "周期数据", "healthMeasuresUnit": "指标", "healthRowRestingHr": "静息心率", "healthRowHrv": "心率变异性", @@ -1442,6 +1448,9 @@ "homeOfSpan": "共 {duration}", "homeLoadFailedTitle": "今日数据读取失败", "homeLoadFailedBody": "已保存的当日数据加载失败。没有任何内容被删除——这是读取出错,不是数据丢失。", + "homeDayLoadFailedTitle": "{day}的数据读取失败", + "wellnessLoadFailedTitle": "无法读取", + "wellnessLoadFailedBody": "读取已存储的数据失败。没有删除任何内容——这是一次读取出错,而不是数据缺失。", "homeTryAgain": "重试", "homeNothingDerivedTitle": "尚未计算出任何数据", "homeNothingDerivedBody": "尚未处理任何腕带记录。", @@ -1449,6 +1458,9 @@ "homeProfileSettings": "个人资料与设置", "homeNothingTodayTitle": "今天没有记录到数据", "homeNothingTodayBody": "应用评分的最后一晚是 {day}。此后没有收到任何新数据。", + "homeNoDataThisDayTitle": "这一天没有数据", + "homeNoDataThisDayBody": "这一天没有记录到任何数据。", + "homeSomeDayDataMissingTitle": "这一天的部分数据缺失", "homeReadinessNotScoredTitle": "今天的恢复度未评分", "homeReadinessNeedBody": "{need},才能了解你的正常水平。", "homeReadinessNoReason": "没有记录说明原因。", @@ -1673,6 +1685,7 @@ "sleepDetailHrv": "心率变异性", "sleepDetailBreathing": "呼吸", "sleepDetailTemp": "体温", + "sleepDetailTempRelative": "体温(相对)", "sleepDetailNotMeasuredCap": "未测量", "sleepDetailNoSignalAtMoment": "此刻没有记录到信号。", "sleepDetailStageAwake": "清醒", @@ -1692,6 +1705,7 @@ "sleepDetailAsleepWhileInBed": "卧床期间的入睡比例", "sleepDetailLowerThanUsual": "比平时低", "sleepDetailHigherThanUsual": "比平时高", + "sleepDetailPointsDelta": "{n} 个百分点", "sleepDetailFellAsleep": "入睡时间", "sleepDetailEarlierThanUsual": "比平时早", "sleepDetailLaterThanUsual": "比平时晚", @@ -2664,10 +2678,106 @@ "dataReanalyzeFailed": "重新分析失败:{error}", "dataReanalyzeDaysFailed": "{n, plural, other{有 {n} 天无法分析。如果反复出现,请报告此问题。}}", "napsInFuture": "这次小睡还没有结束。醒来后再记录。", + "journalComposeEntrySaveFailed": "未保存 —— 你的记录仍在此处,请重试。", + "journalComposeLoadFailed": "无法打开这一天的日志。", + "journalComposeTryAgain": "重试", + "journalComposeAddToJournal": "添加到日志", + "workoutLoadFailedTitle": "无法读取活动", + "workoutLoadFailedBody": "你的训练记录加载失败。没有删除任何内容——这是读取出错,不是数据缺失。", + "activitySummaryClimbedFt": "+{ft} 英尺爬升", + "liveHrNoReadingTitle": "无实时读数", + "liveHrNotPairedWhy": "尚未配对手环。", + "liveHrNotPairedFix": "在“个人资料”中配对手环以读取实时心跳。", + "liveHrNotConnectedWhy": "手环未连接。", + "liveHrNotConnectedFix": "实时心跳需要保持连接——手环在附近时打开应用即可自动连接。", + "liveHrNoRecentBeatWhy": "过去 {seconds} 秒内没有检测到心跳。", + "liveHrStreamingFix": "手环戴在手腕上且应用处于打开状态时会持续传输数据。", + "liveHrTraceCaption": "最近 {count} 个读数 — {min}–{max} bpm。不会保存;这是实时数据流,不是你一天的记录。", "notifRecoveryReadyBody": "恢复 {score}。", "notifRecoveryReadySleptBody": "恢复 {score},睡眠 {hours} 小时 {minutes} 分钟。", "notifStepGoalBody": "你走了约 {steps} 步,达到或超过 {goal} 步的目标。", "notifLowBatteryBody": "手环电量 {percent}%。请尽快充电。", + "settingsZoneAlertRowTitle": "心率区间提醒", + "settingsZoneAlertRowSub": "实时锻炼中心率进入或离开目标区间时振动", + "settingsZoneAlertTargetRowTitle": "目标区间", + "settingsZoneAlertTargetRowValue": "区间 {zone}", + "settingsAlarmLatchFailedRowTitle": "闹钟未确认", + "settingsAlarmLatchFailedRowSub": "手环未确认本应用刚设置的闹钟时提醒", + "settingsAlarmNightCheckRowTitle": "无闹钟提醒", + "settingsAlarmNightCheckRowSub": "当晚未设置起床闹钟时,晚上 7 点提醒一次;否则不打扰", + "supplementUnreadableFields": "“{fields}”不是数字。未保存任何内容。", + "presentationHeightCm": "身高(厘米)", + "presentationHeightIn": "身高(英寸)", + "presentationWeightLb": "体重(磅)", + "supplementUnreadableField": "“{field}”不是数字。未保存任何内容。", + "settingsGroupApp": "应用", + "settingsGroupData": "数据", + "settingsGroupAdvanced": "高级", + "settingsCycleTrackingRowSubHealth": "在“健康”中添加“周期”。关闭后会隐藏,但已记录的内容仍会保留", + "profileSetupCycleSub": "在“健康”中记录经期。之后可以更改。", + "settingsReportIssueRowTitle": "报告问题", + "settingsReportIssueRowSub": "将你选择的内容发送给维护者;你的数据会加密,报告详情不会", + "reportNavTitle": "报告问题", + "reportCategoryGroup": "问题类型", + "reportCategorySync": "同步", + "reportCategorySleep": "睡眠", + "reportCategoryRecovery": "恢复", + "reportCategoryWorkout": "锻炼", + "reportCategoryPairing": "配对", + "reportCategoryOther": "其他", + "reportTitleLabel": "标题", + "reportTitleHint": "用一句话说明出了什么问题", + "reportDescriptionLabel": "发生了什么", + "reportDescriptionHint": "你做了什么、预期是什么、看到了什么", + "reportAttachGroup": "附件", + "reportScreenshotRow": "添加截图", + "reportScreenshotRemove": "移除截图", + "reportDatabaseRow": "附上最近 2 周的健康数据", + "reportDatabaseWarning": "最近 14 天的睡眠、心率和其他健康数据(如果工单过大,逐秒数据的天数会减少),以及每日分数和设置。仅在开启此项时发送,服务器会在 14 天后删除。附带健康数据的工单最大可达 95 MB,请尽量使用 Wi-Fi。", + "reportIncludedGroup": "包含内容", + "reportIncludedNote": "类别、标题、描述、应用版本、平台、系统版本、手机型号、安装 ID 以及是否附带数据库或截图会以未加密方式传输,以便我们跟踪报告,并保留 90 天。其他所有内容在离开本机前都会在这部手机上加密,只有维护者能读取,并在 30 天后删除;如果附带了数据库,则在 14 天后删除。“本地分享”不同:交给分享菜单的 ZIP 未加密。", + "reportFileManifest": "此文件列表", + "reportFileMeta": "应用版本、手机型号、系统版本、语言、时区和一个随机安装 ID", + "reportFileDevice": "已配对的手环和传感器:类型、连接、电量和上次同步时间", + "reportFileState": "同步和评分状态,以及最近的错误", + "reportFileSyncLog": "应用最近的同步日志", + "reportFileTelemetry": "这部手机最近 7 天记录的错误和事件", + "reportFileUser": "你填写的类别、标题和描述", + "reportFileScreenshot": "你选择的截图", + "reportFileDatabase": "完整的健康数据库(已压缩)", + "reportFileDatabaseSlice": "最近 14 天的健康数据,以及每日分数和设置(已压缩)", + "reportFullDatabaseRow": "完整健康数据库(仅限本地分享)", + "reportFullDatabaseWarning": "你的全部健康记录。它只会放进“本地分享”生成的文件中,绝不会发送到我们的服务器。", + "reportFullDatabaseNoUpload": "完整数据库太大,无法上传。请使用“本地分享”,然后把文件发给我们。", + "reportDatabaseCovered": "已包含在下方的完整数据库中。", + "reportStageSlice": "正在准备最近 2 周的数据…", + "reportSend": "创建工单", + "reportSaveInstead": "本地分享", + "reportNeedTitle": "请先填写标题。", + "reportSentTitle": "工单已创建", + "reportSentBody": "你的工单 ID 是 {id}。就此事联系我们时请提供。", + "reportCopyId": "复制 ID", + "reportNotSentTitle": "工单未创建", + "reportTooLarge": "此工单太大,无法上传。请使用“本地分享”并把文件发给我们,或关闭健康数据、移除截图。", + "reportRateLimited": "你今天已创建 3 个工单。请明天再试,或改为本地分享。", + "reportStorageFull": "报告服务器目前没有空间存放数据库。你可以不附带数据库创建工单。", + "reportSendWithoutDb": "不附带数据库创建工单", + "reportSendFailed": "无法创建工单。请检查网络连接,或改为本地分享。", + "reportMyReportsGroup": "我的报告", + "reportMyReportsSub": "点按立即删除。否则将于 {date} 删除", + "reportDelete": "删除", + "reportDeleteFailed": "无法删除。请稍后再试。", + "reportSaveFailedTitle": "无法分享报告", + "reportSaveFailedBody": "生成或分享文件时出错。未创建工单。", + "reportSaveRetry": "重试", + "reportStageCollecting": "正在收集应用信息…", + "reportStageLogs": "正在读取日志…", + "reportStageDatabase": "正在压缩数据库…", + "reportStagePacking": "正在打包文件…", + "reportStageEncrypting": "正在加密…", + "reportStageUploading": "正在上传 {size} MB…", + "reportStageSharing": "正在准备分享…", + "reportIdCopied": "已复制 {id}", "reportDeleteConfirmTitle": "删除报告 {id}?", "reportDeleteConfirmBody": "它会立即从服务器上删除,之后无法再通过 ID 打开。此操作无法撤销。", "profileImplausibleFields": "请检查{fields}。数值超出范围,因此未保存任何内容。", diff --git a/lib/l10n/decimal_text.dart b/lib/l10n/decimal_text.dart index 584960d54..7eea236e7 100644 --- a/lib/l10n/decimal_text.dart +++ b/lib/l10n/decimal_text.dart @@ -7,3 +7,30 @@ String displayFixed(num value, int places) => NumberFormat( places == 0 ? '0' : '0.${'0' * places}', LocaleController.displayLanguageCode, ).format(value); + +final RegExp _commaDecimal = RegExp(r'^([+-]?)(\d+),(\d+)$'); + +/// A number a person typed, or null when it cannot be read as one. +/// +/// A decimal comma is accepted when it can only be a decimal: the iOS decimal +/// pad on a German, French, Spanish or Russian region phone has a ',' key and +/// no '.', so rejecting every comma left those users unable to type any +/// fraction at all. "4,5" is never a thousands grouping (a group is always +/// three digits), so it is 4.5. "1,200" is a grouping to half the world and a +/// decimal to the other half, so it stays unreadable and the user is asked, +/// never guessed for. Anything else falls through to [double.tryParse], minus +/// the non-finite literals it accepts ("Infinity", "NaN", "1e400"). +double? parseTypedNumber(String text) { + final s = text.trim(); + final direct = double.tryParse(s); + if (direct != null) return direct.isFinite ? direct : null; + final m = _commaDecimal.firstMatch(s); + if (m == null) return null; + final whole = m.group(2)!; + final frac = m.group(3)!; + final couldBeGrouping = + frac.length == 3 && whole.length <= 3 && !whole.startsWith('0'); + if (couldBeGrouping) return null; + final v = double.tryParse('${m.group(1)}$whole.$frac'); + return v != null && v.isFinite ? v : null; +} diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index 2c20af562..a2fb7c240 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -1204,9 +1204,19 @@ class AppState extends ChangeNotifier { if (ResetGate.changedSince(resetGen)) { throw StateError('profile not saved: the app data was reset'); } + // Read right before the merge, after the await: an overlapping update can + // change the goal while this one waits. + final goalBefore = user?['step_goal']; user = {...?user, ...fields}; await prefs.setString(_kProfile, jsonEncode(user)); notifyListeners(); + // Home bakes the step goal into its loaded data and re-reads only on the + // insights revision; `notifyListeners` alone left its "% of goal", "steps + // left" and goal ring on the old goal until the next derive, while the + // steps detail screen one tap away already showed the new one. + if (fields.containsKey('step_goal') && fields['step_goal'] != goalBefore) { + bumpInsights(); + } return user!; } diff --git a/lib/state/units_controller.dart b/lib/state/units_controller.dart index 276b94fc0..f8e88c9f0 100644 --- a/lib/state/units_controller.dart +++ b/lib/state/units_controller.dart @@ -6,6 +6,8 @@ import 'package:flutter/foundation.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import '../l10n/decimal_text.dart' show parseTypedNumber; + enum UnitSystem { metric, imperial } extension UnitSystemLabel on UnitSystem { @@ -18,6 +20,7 @@ class UnitsController extends ChangeNotifier { static const double _cmPerIn = 2.54; static const double _metersPerKm = 1000.0; static const double _metersPerMile = 1609.344; + static const double _metersPerFoot = 0.3048; UnitSystem _system; UnitsController._(this._system); @@ -88,6 +91,16 @@ class UnitsController extends ChangeNotifier { /// "/km" / "/mi". String get paceUnit => '/$distanceUnit'; + // ── elevation (GPS altitude) ─────────────────────────────────────────────── + // Its own unit, not the distance one: a climb in miles is unreadable, and a + // bare "m" printed under "3.12 mi" reads as miles to an imperial user. + /// "m" / "ft". + String get elevationUnit => isImperial ? 'ft' : 'm'; + + /// Elevation in the user's unit (m or ft), unformatted. + double elevationValue(double meters) => + isImperial ? meters / _metersPerFoot : meters; + /// Above this, a "pace" is meaningless noise, not a real number — e.g. a /// few metres of GPS jitter (stationary) divided into real elapsed time /// produces something like 1000 min/km. 60 min/km is already far slower @@ -164,7 +177,7 @@ class UnitsController extends ChangeNotifier { /// Parse a weight field (display units) → kg for storage. double? weightToKg(String text) { - final v = double.tryParse(text.trim()); + final v = parseTypedNumber(text); if (v == null) return null; return isImperial ? v * _kgPerLb : v; } @@ -186,7 +199,7 @@ class UnitsController extends ChangeNotifier { /// Parse a height field (display units = total inches when imperial) → cm. double? heightToCm(String text) { - final v = double.tryParse(text.trim()); + final v = parseTypedNumber(text); if (v == null) return null; return isImperial ? v * _cmPerIn : v; } diff --git a/lib/ui2/activity/day_strain.dart b/lib/ui2/activity/day_strain.dart index 77856f91b..914938f53 100644 --- a/lib/ui2/activity/day_strain.dart +++ b/lib/ui2/activity/day_strain.dart @@ -25,6 +25,7 @@ import '../../data/day_label.dart'; import '../../data/local_repository.dart'; import '../../l10n/app_localizations.dart'; import '../../l10n/date_text.dart'; +import '../../l10n/decimal_text.dart'; import '../../models/metric.dart' show whyFromNote; import '../screens/home_screen.dart' show repoOf; import '../screens/metric_detail.dart' show detailScaffold; @@ -93,8 +94,37 @@ class DayStrainData { bool get hasCurve => curve.any((v) => v != null); - static Future load(LocalRepository repo) async { - final asked = todayLabel(); + /// Lays the curve's epoch-second points onto the fixed 00:00–24:00 axis, + /// one slot per WALL-CLOCK minute of the first point's local day. + /// + /// The slot is the local `hour * 60 + minute`, not minutes elapsed since + /// midnight: a fall-back day is 1500 minutes long and a spring-forward day + /// 1380, so elapsed minutes pushed everything after the transition an hour + /// off the axis labels, and on fall-back cut 23:00–24:00 — the end of a + /// cumulative curve, which left the trace short of the day strain printed + /// under it. On the repeated hour the later sample wins (points arrive in + /// time order), which is the right one for a running total. + static (DateTime?, List) gridCurve(List<(int, double)> pts) { + if (pts.isEmpty) return (null, const []); + final first = DateTime.fromMillisecondsSinceEpoch(pts.first.$1 * 1000); + final day = DateTime(first.year, first.month, first.day); + final out = List.filled(1440, null); + for (final p in pts) { + final t = DateTime.fromMillisecondsSinceEpoch(p.$1 * 1000); + if (t.year != day.year || t.month != day.month || t.day != day.day) { + continue; + } + out[t.hour * 60 + t.minute] = p.$2; + } + return (day, out); + } + + /// [forDay] is the label the caller is looking at; null means today. Home's + /// day switcher passes it, so a past day's strain ring opens that day's + /// curve rather than today's. + static Future load(LocalRepository repo, + {String? forDay}) async { + final asked = forDay ?? todayLabel(); final s = await repo.getDayStrain(asked); if (s.isEmpty) return const DayStrainData(); @@ -104,20 +134,7 @@ class DayStrainData { ((e['t'] as num).toInt(), (e['v'] as num).toDouble()), ]; - DateTime? day; - var grid = const []; - if (pts.isNotEmpty) { - final first = - DateTime.fromMillisecondsSinceEpoch(pts.first.$1 * 1000); - day = DateTime(first.year, first.month, first.day); - final dayStart = day.millisecondsSinceEpoch ~/ 1000; - final out = List.filled(1440, null); - for (final p in pts) { - final i = (p.$1 - dayStart) ~/ 60; - if (i >= 0 && i < 1440) out[i] = p.$2; - } - grid = out; - } + final (day, grid) = gridCurve(pts); // Wear for THE DAY THE CURVE CAME FROM, not the day we asked for — both // reads fall back the same way, so asking for the resolved label is what @@ -168,7 +185,10 @@ class DayStrainData { class DayStrainDetail extends StatefulWidget { /// Preloaded, for goldens. Null means read the repo on open. final DayStrainData? data; - const DayStrainDetail({super.key, this.data}); + + /// The day to read, as a local label. Null means today. + final String? day; + const DayStrainDetail({super.key, this.data, this.day}); @override State createState() => _DayStrainDetailState(); @@ -196,7 +216,7 @@ class _DayStrainDetailState extends State { return; } try { - final d = await DayStrainData.load(repo); + final d = await DayStrainData.load(repo, forDay: widget.day); if (mounted) setState(() => (_d = d, _loading = false)); } catch (_) { if (mounted) setState(() => _loading = false); @@ -267,8 +287,8 @@ class _DayStrainDetailState extends State { ? why ?? (l?.dayStrainNoReasonBody ?? 'Nothing recorded says why this day produced no strain.') - : (l?.dayStrainScoredNoTraceBody(s.toStringAsFixed(1)) ?? - 'The day strain is ${s.toStringAsFixed(1)}. The waking minutes ' + : (l?.dayStrainScoredNoTraceBody(displayFixed(s, 1)) ?? + 'The day strain is ${displayFixed(s, 1)}. The waking minutes ' 'it was built from are not stored for this day.'), fix: (s == null && !saw) ? (l?.dayStrainWearBandFix ?? 'Wear the band through the day') @@ -303,7 +323,7 @@ class _DayStrainDetailState extends State { const SizedBox(height: S.x4), InlineMetrics([ if (d.strain != null) - (l?.dayStrainTitle ?? 'Day strain', d.strain!.toStringAsFixed(1), + (l?.dayStrainTitle ?? 'Day strain', displayFixed(d.strain!, 1), C.purple), if (d.peakHr != null) (l?.dayStrainPeakHr ?? 'Peak HR', '${d.peakHr} bpm', C.red), diff --git a/lib/ui2/activity/live.dart b/lib/ui2/activity/live.dart index 735a307f7..3aac28114 100644 --- a/lib/ui2/activity/live.dart +++ b/lib/ui2/activity/live.dart @@ -32,6 +32,7 @@ import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../gps/gps_source.dart' show GpsPermissionStatus; import '../../l10n/app_localizations.dart'; import '../../l10n/display_text.dart'; +import '../../l10n/decimal_text.dart'; import '../../state/prefs.dart'; import '../../state/units_controller.dart'; import '../screens/home_screen.dart' show unitsOf; @@ -987,7 +988,7 @@ List<(String, String)> _commonStats( return [ if (kcal != null) ('$kcal', l?.activityLiveKcalEstUnit ?? 'kcal · est'), if (feed.strain != null) - (feed.strain!.toStringAsFixed(1), l?.activityLiveStrainUnit ?? 'strain'), + (displayFixed(feed.strain!, 1), l?.activityLiveStrainUnit ?? 'strain'), if (feed.steps != null) ('${feed.steps}', l?.activityLiveStepsUnit ?? 'steps'), ]; } diff --git a/lib/ui2/activity/share.dart b/lib/ui2/activity/share.dart index db9fac85a..3c32c32b3 100644 --- a/lib/ui2/activity/share.dart +++ b/lib/ui2/activity/share.dart @@ -434,7 +434,7 @@ List<(String, String)> _available(ActivityResult r, [UnitsController? u]) => [ if (r.avgHr != null) ('Heart rate', '${r.avgHr} bpm'), // With its unit. A bare "612" on a card is a number nobody can read back. if (r.calories != null) ('Calories', '${grouped(r.calories!)} kcal'), - if (r.gainM != null) ('Elevation', '+${r.gainM!.round()} m'), + if (r.gainM != null) ('Elevation', '+${elevationText(r.gainM!, u)}'), if (r.strength.volumeKg != null) ( 'Volume', @@ -459,7 +459,7 @@ List<(String, String)> _available(ActivityResult r, [UnitsController? u]) => [ u?.distanceUnit ?? 'km', r.gainM == null ? r.activity.name - : '+${r.gainM!.round()} m elevation', + : '+${elevationText(r.gainM!, u)} elevation', ), Arch.strength => r.strength.volumeKg == null @@ -489,9 +489,12 @@ List<(String, String)> _available(ActivityResult r, [UnitsController? u]) => [ String localizedExportCaption(BuildContext c, ActivityResult r, String text) { final l = AppLocalizations.of(c); if (text == r.activity.name) return r.activity.displayName(c); - final elevation = RegExp(r'^\+(.*?) m elevation$').firstMatch(text); + final elevation = RegExp(r'^\+(.*?) (m|ft) elevation$').firstMatch(text); if (elevation != null) { - return l?.supplementClimbedExport(elevation[1]!) ?? text; + return (elevation[2] == 'ft' + ? l?.supplementClimbedExportFeet(elevation[1]!) + : l?.supplementClimbedExport(elevation[1]!)) ?? + text; } final laps = RegExp(r'^(.*?) laps$').firstMatch(text); if (laps != null) return l?.supplementLapsExport(laps[1]!) ?? text; diff --git a/lib/ui2/activity/summary.dart b/lib/ui2/activity/summary.dart index 7caf098a9..80618f8e2 100644 --- a/lib/ui2/activity/summary.dart +++ b/lib/ui2/activity/summary.dart @@ -22,6 +22,7 @@ import '../../l10n/display_text.dart'; import '../../state/locale_controller.dart'; import 'package:intl/intl.dart'; import '../../l10n/date_text.dart'; +import '../../l10n/decimal_text.dart'; import 'dart:convert' show utf8; import 'dart:typed_data' show Uint8List; @@ -546,6 +547,11 @@ String grouped(num v) => NumberFormat.decimalPattern( LocaleController.displayLanguageCode, ).format(v.round()); +/// "120 m" / "394 ft" — an elevation in the reader's unit, grouped. Null [u] +/// (a golden) is metric, which is what the store holds. +String elevationText(double meters, UnitsController? u) => + '${grouped(u?.elevationValue(meters) ?? meters)} ${u?.elevationUnit ?? 'm'}'; + // ── THE SUPPORTING STATS ─────────────────────────────────────────────────── /// The glyph for a stat name. @@ -605,7 +611,8 @@ List<(String, String)> sessionStats(ActivityResult r, UnitsController? u) { case Arch.laps: add('Laps', r.lapCount?.toString()); case Arch.journey: - add('Elevation', r.gainM == null ? null : '+${r.gainM!.round()} m'); + add('Elevation', + r.gainM == null ? null : '+${elevationText(r.gainM!, u)}'); case Arch.interval: add('Rounds', r.rounds.isEmpty ? null : '${r.rounds.length}'); case Arch.flow: @@ -642,7 +649,7 @@ List<(String, String)> sessionStats(ActivityResult r, UnitsController? u) { add('VO2max (est.)', r.vo2max == null ? null : '${r.vo2max!.toStringAsFixed(1)} ml/kg/min'); add('Calories', r.calories == null ? null : '${grouped(r.calories!)} kcal'); - add('Strain', r.strain?.toStringAsFixed(1)); + add('Strain', r.strain == null ? null : displayFixed(r.strain!, 1)); // TS-09 — last, under the measurements, and named 'Your rating' rather than // 'RPE' so the row cannot read as something the band found out. It is not on // the share card: `shareStats` is its own list and a self-report is not one @@ -789,6 +796,27 @@ class _ActivitySummaryState extends State { String get _distanceUnit => _u?.distanceUnit ?? 'km'; + /// The hero caption under a journey's distance. Feet get their own string + /// rather than a unit placeholder: several locales spell the metre out + /// ("米", "मी", "м") inside the sentence. + String _climbed(AppLocalizations? l, double gainM) { + final u = _u; + if (u != null && u.isImperial) { + final ft = u.elevationValue(gainM).round(); + return l?.activitySummaryClimbedFt(ft) ?? '+$ft ft climbed'; + } + return l?.activitySummaryClimbed(gainM.round()) ?? + '+${gainM.round()} m climbed'; + } + + /// An altitude series in the user's elevation unit. + List _elevSeries(List m) { + final u = _u; + return u == null ? m : [for (final v in m) u.elevationValue(v)]; + } + + String get _elevUnit => _u?.elevationUnit ?? 'm'; + // ─────────────────── TS-09 · SESSION RPE ─────────────────── // // Scores the sessions heart rate cannot see — lifting, climbing, anything @@ -917,8 +945,16 @@ class _ActivitySummaryState extends State { // Only true once a pick actually landed — pressing back out of the picker // must return to this screen, not fall through and pop it too. var picked = false; + // A second tap on a row while the first write is still queued behind a + // derive or drain transaction would run this closure again. The first + // pick pops the picker and then this screen; the second one's pop would + // land on the route underneath (the picker is still mounted through its + // exit transition), so only one pick may be in flight or land at all. + var busy = false; await Navigator.of(c).push(MaterialPageRoute( builder: (_) => ActivityPicker(onPick: (pc, newActivity) async { + if (busy || picked) return; + busy = true; // The stored key everywhere else uses — `startWorkout(type: // a.typeKey)` is the live path's own write. `a.name` here would still // resolve through `activityByName`'s normalized lookup, but it would @@ -930,6 +966,7 @@ class _ActivitySummaryState extends State { // picker open rather than close it over a write that never // happened — `onPick` is a `void Function`, so there is no caller // to hand this failure back to. + busy = false; return; } picked = true; @@ -1276,8 +1313,7 @@ class _ActivitySummaryState extends State { _distance!.$1.toStringAsFixed(2), _distance!.$2, arch == Arch.journey && r.gainM != null - ? (l?.activitySummaryClimbed(r.gainM!.round()) ?? - '+${r.gainM!.round()} m climbed') + ? _climbed(l, r.gainM!) : a.displayName(context), ), Arch.strength => r.strength.volumeKg == null @@ -1526,35 +1562,36 @@ class _ActivitySummaryState extends State { ), ]; } + final elev = _elevSeries(r.elevationM); final peak = r.elevationM.reduce((x, y) => x > y ? x : y); - final axis = AxisSpec.of(r.elevationM); + final axis = AxisSpec.of(elev); return [ Surface( child: Column(children: [ ChartFrame( title: l?.activitySummaryElevationTitle ?? 'Elevation', - unit: 'm', + unit: _elevUnit, height: 130, yAxis: axis, xLabels: [ l?.activitySummaryStart ?? 'Start', l?.activitySummaryFinish ?? 'Finish', ], - series: r.elevationM, + series: elev, child: CustomPaint( size: Size.infinite, - painter: Elevation(r.elevationM, p.on(C.green), + painter: Elevation(elev, p.on(C.green), markerInk: p.card, axis: axis)), ), const SizedBox(height: S.x4), InlineMetrics([ if (r.gainM != null) (l?.activitySummaryGain ?? 'Gain', - '+${r.gainM!.round()} m', C.green), + '+${elevationText(r.gainM!, _u)}', C.green), if (r.lossM != null) (l?.activitySummaryLoss ?? 'Loss', - '−${r.lossM!.round()} m', C.orange), - (l?.activitySummaryPeak ?? 'Peak', '${grouped(peak)} m', + '−${elevationText(r.lossM!, _u)}', C.orange), + (l?.activitySummaryPeak ?? 'Peak', elevationText(peak, _u), C.n500), ]), ]), @@ -2186,7 +2223,7 @@ class _ActivitySummaryState extends State { final series = <(String, String, Color, List)>[ if (r.hr.length > 1) ('Heart rate', 'bpm', C.red, r.hr), if (r.elevationM.length > 1) - ('Elevation', 'm', C.teal, r.elevationM), + ('Elevation', _elevUnit, C.teal, _elevSeries(r.elevationM)), ]; if (series.isEmpty) { // Same distinction the overview draws: a session with one minute in it diff --git a/lib/ui2/grammar.dart b/lib/ui2/grammar.dart index 9d6c2b4e3..46e6afce8 100644 --- a/lib/ui2/grammar.dart +++ b/lib/ui2/grammar.dart @@ -31,6 +31,7 @@ import '../l10n/display_text.dart'; import '../l10n/app_localizations.dart'; +import '../l10n/decimal_text.dart' show parseTypedNumber; import '../l10n/presentation_text.dart'; import 'dart:math' as math; @@ -1789,9 +1790,10 @@ class Consistency extends StatelessWidget { /// occasion, and a lab value with its unit on it dropped the whole result — /// each of them under a screen that closed as if it had saved. /// -/// Nothing here guesses. "1,5" is 1.5 to half of Europe and 15 to the other -/// half, so it is [bad] and the user is asked, never averaged into a number -/// they did not type. +/// Nothing here guesses. A decimal comma is read only where it cannot be a +/// thousands separator ("4,5"); "1,200" is 1.2 to half of Europe and 1200 to +/// the other half, so it is [bad] and the user is asked, never averaged into a +/// number they did not type. See [parseTypedNumber]. class Typed { const Typed._(this.value, this.bad); @@ -1806,12 +1808,8 @@ class Typed { static Typed of(String text) { final s = text.trim(); if (s.isEmpty) return const Typed._(null, false); - final v = double.tryParse(s); - // tryParse accepts "Infinity"/"-Infinity"/"NaN" and overflows huge - // literals ("1e400") to infinity — none of those are a real number. - return v == null || !v.isFinite - ? const Typed._(null, true) - : Typed._(v, false); + final v = parseTypedNumber(s); + return v == null ? const Typed._(null, true) : Typed._(v, false); } } @@ -1999,7 +1997,14 @@ class _SubTabsState extends State { builder: (_) { final off = disabled.contains(i); final on = i == index && !off; - return Pressable( + // The active pill differs from the rest only by fill and + // weight, so without the flag a screen reader read every + // pill as the same plain button and there was no way to + // hear which range or section was showing. + return Semantics( + selected: on, + inMutuallyExclusiveGroup: true, + child: Pressable( key: _keys.putIfAbsent(i, () => GlobalKey()), onTap: off ? null : () => widget.onTap(i), // `Pressable` drops `Semantics(button: true)` when `onTap` is @@ -2040,6 +2045,7 @@ class _SubTabsState extends State { ), ), ), + ), ); }, ), diff --git a/lib/ui2/live_hr.dart b/lib/ui2/live_hr.dart index ef294944a..5939d0e21 100644 --- a/lib/ui2/live_hr.dart +++ b/lib/ui2/live_hr.dart @@ -83,12 +83,13 @@ class LiveHrCard extends StatelessWidget { final l = AppLocalizations.of(c); final hr = _preview ? _hr : c.select((a) => a.liveHr); if (hr == null) { - if (_preview) return _absent(paired: true, connected: true); + if (_preview) return _absent(l, paired: true, connected: true); // SELECTED, not read: with no reading the only thing this widget watched // was `liveHr`, which stays null through both pairing and connecting — so // the card went on saying "No band is paired" after the band was paired // and connected. These are what change in that state. return _absent( + l, paired: c.select((a) => a.isPaired), connected: c.select((a) => a.isConnected), ); @@ -176,9 +177,14 @@ class LiveHrCard extends StatelessWidget { ), const SizedBox(height: S.x2), Text( - 'The last ${trace.length} readings — ${trace.reduce(math.min)}' - '–${trace.reduce(math.max)} bpm. Not stored; this is ' - 'the live stream, not a record of your day.', + l?.liveHrTraceCaption( + trace.length, + trace.reduce(math.min), + trace.reduce(math.max), + ) ?? + 'The last ${trace.length} readings — ${trace.reduce(math.min)}' + '–${trace.reduce(math.max)} bpm. Not stored; this is ' + 'the live stream, not a record of your day.', style: F.over.copyWith(color: p.ink3), ), ], @@ -188,22 +194,32 @@ class LiveHrCard extends StatelessWidget { /// No live reading. Three different facts, and only the one the app can /// actually see is stated. - Widget _absent({required bool paired, required bool connected}) { + // StatusCard's uiText/presentationText only translate strings they already + // know, so these go through AppLocalizations here or a translated screen + // shows this card in English. + Widget _absent(AppLocalizations? l, + {required bool paired, required bool connected}) { + final seconds = AppState.liveHrMaxAge.inSeconds; final (String why, String fix) = !paired - ? ('No band is paired.', 'Pair one from Profile to read live beats.') + ? ( + l?.liveHrNotPairedWhy ?? 'No band is paired.', + l?.liveHrNotPairedFix ?? 'Pair one from Profile to read live beats.' + ) : !connected ? ( - 'Your band is not connected.', - 'Live beats need an open link — the app connects when you open ' - 'it with the band in range.' + l?.liveHrNotConnectedWhy ?? 'Your band is not connected.', + l?.liveHrNotConnectedFix ?? + 'Live beats need an open link — the app connects when you ' + 'open it with the band in range.' ) : ( - 'No beat in the last ${AppState.liveHrMaxAge.inSeconds} ' - 'seconds.', - 'The band streams while it is on your wrist and the app is ' - 'open.' + l?.liveHrNoRecentBeatWhy(seconds) ?? + 'No beat in the last $seconds seconds.', + l?.liveHrStreamingFix ?? + 'The band streams while it is on your wrist and the app ' + 'is open.' ); - return StatusCard('No live reading', why, + return StatusCard(l?.liveHrNoReadingTitle ?? 'No live reading', why, fix: fix, icon: LucideIcons.heartOff); } } diff --git a/lib/ui2/onboarding/profile_setup.dart b/lib/ui2/onboarding/profile_setup.dart index 429de0f83..e8f16fb12 100644 --- a/lib/ui2/onboarding/profile_setup.dart +++ b/lib/ui2/onboarding/profile_setup.dart @@ -125,21 +125,30 @@ class _ProfileSetupViewState extends State { super.dispose(); } - /// Only what was actually entered. A blank field writes nothing, so the - /// dependent metric abstains rather than scoring somebody else's body. The - /// fields are typed in the units on their labels and stored in metric. - Map _fields() => { - if (_sex != null) 'sex': _sex, - if (Typed.of(_age.text).value case final v?) 'age': v.round(), - 'height_cm': ?_heightCm(), - 'weight_kg': ?_weightKg(), - }; + /// Only what was actually entered. A field that opened filled and was + /// cleared writes an explicit null: the save merges into the stored + /// profile, so leaving it out kept the old value feeding calories and load. + /// A field that opened blank and stayed blank writes nothing, so a value + /// that arrived meanwhile (a scale weighing in) is not nulled. The fields + /// are typed in the units on their labels and stored in metric. + Map _fields() { + final out = {if (_sex != null) 'sex': _sex}; + void put(String key, Object? v) { + if (v != null || widget.initial[key] != null) out[key] = v; + } + + put('age', Typed.of(_age.text).value?.round()); + put('height_cm', _heightCm()); + put('weight_kg', _weightKg()); + return out; + } /// Continue, unless something typed cannot be read — a typo is not a blank, /// and dropping it silently is how a body ends up half-described. Future _continue() async { final bad = [ - if (Typed.of(_age.text).bad) 'Age', + if (Typed.of(_age.text).bad) + AppLocalizations.of(context)?.settingsAgeFieldLabel ?? 'Age', if (Typed.of(_height.text).bad) uiText(context, _u.heightLabel), if (Typed.of(_weight.text).bad) uiText(context, _u.weightLabel), ]; @@ -182,8 +191,10 @@ class _ProfileSetupViewState extends State { const SizedBox(height: S.x3), Text( l?.profileSetupBody ?? - 'Four numbers change how your data is scored. Leave any of them ' - 'blank and only the metrics that need it stay unavailable.', + 'Sex is the one choice you need to make to continue. Age, ' + 'height and weight change how your data is scored; leave ' + 'any of them blank and only the metrics that need it stay ' + 'unavailable.', style: F.body.copyWith(color: p.ink2), ), const SizedBox(height: S.x6), @@ -226,6 +237,7 @@ class _ProfileSetupViewState extends State { uiText(c, _u.isImperial ? 'in' : 'cm'), l?.profileSetupHeightConsequence ?? 'Without it: stride length, and distance from steps.', + decimal: true, ), _Field( _weight, @@ -233,6 +245,7 @@ class _ProfileSetupViewState extends State { uiText(c, _u.isImperial ? 'lb' : 'kg'), l?.profileSetupWeightConsequence ?? 'Without it: calories and training load.', + decimal: true, ), // Offered to everyone, on by default for a female profile. // Changeable any time in Settings; switching it off keeps what @@ -298,7 +311,12 @@ class _Choice extends StatelessWidget { class _Field extends StatelessWidget { final TextEditingController controller; final String label, unit, consequence; - const _Field(this.controller, this.label, this.unit, this.consequence); + + /// Height and weight take a decimal; age does not. A plain number keyboard + /// on iOS has no separator key at all, so 72.5 kg could not be typed. + final bool decimal; + const _Field(this.controller, this.label, this.unit, this.consequence, + {this.decimal = false}); @override Widget build(BuildContext c) { @@ -314,7 +332,9 @@ class _Field extends StatelessWidget { const SizedBox(height: S.x1), TextField( controller: controller, - keyboardType: TextInputType.number, + keyboardType: decimal + ? const TextInputType.numberWithOptions(decimal: true) + : TextInputType.number, style: F.head.copyWith(color: p.ink), decoration: InputDecoration( hintText: unit, diff --git a/lib/ui2/onboarding/welcome.dart b/lib/ui2/onboarding/welcome.dart index 97c3e5aaa..e290466d8 100644 --- a/lib/ui2/onboarding/welcome.dart +++ b/lib/ui2/onboarding/welcome.dart @@ -115,6 +115,15 @@ class ImportOutcome { corruptTables.isNotEmpty || archiveBucketsSkipped > 0; + /// Rows landed, but part of the import was refused or left half-done. On a + /// first run this report is the only place the user ever hears about it, so + /// the gate waits for them instead of moving on to pairing. + bool get needsReading => + lostSomething || + rejectedRows.isNotEmpty || + readError != null || + rollupError != null; + /// Nothing at all landed. A zero under a green tick is a no-op that reads as /// a success, which is the one thing an import report must never do. bool get nothingLanded => @@ -256,6 +265,11 @@ class _WelcomeScreenState extends State { bool _busy = false; ImportOutcome? _outcome; + /// Rows landed but the report has something to say. Onboarding is finished + /// only once the user moves on, because finishing it swaps this screen for + /// pairing in the same frame and the report is never drawn. + bool _holding = false; + Future _import() async { final app = context.read(); FilePickerResult? picked; @@ -275,6 +289,10 @@ class _WelcomeScreenState extends State { .toList(); if (paths.isEmpty) return; // cancelled — not a failure, say nothing + // A retry after a partial import keeps what is held: that history has + // already landed, so cancelling or failing the retry must not take the + // report and Continue away or put "new" on record. + final held = _outcome; setState(() { _busy = true; _outcome = null; @@ -283,13 +301,15 @@ class _WelcomeScreenState extends State { final outcome = await runImport(app, paths, askPassphrase: () => askBackupPassphrase(context)); if (!mounted) return; - setState(() => _outcome = outcome); - // Anything that landed counts as bringing history in — a workouts-only - // vendor export writes sessions and no days, and the gate used to sit - // there as though the import had not happened. - if (!outcome.nothingLanded) await app.completeImportOnboard(); + final hold = await settleOnboardImport(app, outcome); + if (!mounted) return; + setState(() { + _outcome = outcome; + _holding = _holding || hold; + }); } on PassphraseCancelled { // Closing the prompt is a decision, not a failure. Say nothing. + if (mounted) setState(() => _outcome = held); } catch (e) { if (mounted) { setState(() => @@ -301,12 +321,31 @@ class _WelcomeScreenState extends State { } @override - Widget build(BuildContext c) => WelcomeView( - busy: _busy, - outcome: _outcome, - onNew: () => context.read().chooseNewUser(), - onImport: _import, - ); + Widget build(BuildContext c) { + final app = context.read(); + return WelcomeView( + busy: _busy, + outcome: _outcome, + // History already landed, so "Set up my band" from here is the same + // step as Continue, and the choice on record stays 'imported'. + onNew: _holding ? app.completeImportOnboard : app.chooseNewUser, + onImport: _import, + onContinue: _holding ? app.completeImportOnboard : null, + ); + } +} + +/// Finish onboarding after an import, unless the report has to be read first. +/// Returns true when it held back so the caller can offer Continue. +/// +/// Anything that landed counts as bringing history in — a workouts-only +/// vendor export writes sessions and no days, and the gate used to sit there +/// as though the import had not happened. +Future settleOnboardImport(AppState app, ImportOutcome outcome) async { + if (outcome.nothingLanded) return false; + if (outcome.needsReading) return true; + await app.completeImportOnboard(); + return false; } /// Route [paths] to the importer that understands them and normalise the @@ -604,12 +643,17 @@ class WelcomeView extends StatelessWidget { final VoidCallback onNew; final VoidCallback onImport; + /// Shown under the report when the import landed but the report has to be + /// read before the gate moves on to pairing. + final VoidCallback? onContinue; + const WelcomeView({ super.key, required this.onNew, required this.onImport, this.busy = false, this.outcome, + this.onContinue, }); @override @@ -637,11 +681,15 @@ class WelcomeView extends StatelessWidget { Pill(l?.pillLocalNoCloud ?? 'Local · no cloud', C.green, icon: LucideIcons.shieldCheck), const SizedBox(height: S.x8), - BigButton(l?.welcomeSetUpMyBand ?? 'Set up my band', - icon: LucideIcons.bluetooth, - color: C.green, - onTap: busy ? null : onNew), - const SizedBox(height: S.x3), + // With a report to read, the only way on is Continue below it, so + // nobody leaves before seeing what was rejected. + if (onContinue == null) ...[ + BigButton(l?.welcomeSetUpMyBand ?? 'Set up my band', + icon: LucideIcons.bluetooth, + color: C.green, + onTap: busy ? null : onNew), + const SizedBox(height: S.x3), + ], // One primary path. Importing first is a real choice but a rarer // one, so it is a text link rather than a second button of equal // weight beside "Set up my band". @@ -674,6 +722,13 @@ class WelcomeView extends StatelessWidget { if (o != null) ...[ const SizedBox(height: S.x6), ImportReport(o), + if (onContinue != null) ...[ + const SizedBox(height: S.x4), + BigButton(l?.actionContinue ?? 'Continue', + icon: LucideIcons.arrowRight, + color: C.green, + onTap: busy ? null : onContinue), + ], ], ], ), diff --git a/lib/ui2/profile/devices.dart b/lib/ui2/profile/devices.dart index 56c86d985..2bb2e1f75 100644 --- a/lib/ui2/profile/devices.dart +++ b/lib/ui2/profile/devices.dart @@ -2194,17 +2194,59 @@ Future _renameBand(BuildContext c, AppState app, String current) async { // Captured before the dialog: `c` is not safe to touch after the await. final messenger = ScaffoldMessenger.maybeOf(c); final l = AppLocalizations.of(c); - final ctl = TextEditingController(text: current); - final err = ValueNotifier(null); final name = await showDialog( context: c, - builder: (d) => AlertDialog( + builder: (_) => RenameBandDialog(current: current), + ); + if (name == null) return; + try { + await app.renameStrap(name); + messenger?.showSnackBar( + SnackBar(content: Text(l?.devicesRenamedTo(name) ?? 'Renamed to $name'))); + } catch (e) { + // The write goes to the strap, so a dropped link loses it. Say that, + // rather than leaving the old name on screen with no explanation. + messenger?.showSnackBar(SnackBar( + content: Text(l?.devicesCouldNotRename(e.toString()) ?? + 'Could not rename the band: $e'))); + } +} + +/// The rename sheet owns its controller and error notifier so they are +/// disposed with the route. `showDialog` completes at pop time, while the exit +/// transition is still rebuilding the field; disposing them right after the +/// await threw "used after being disposed" on every Save and Cancel. +class RenameBandDialog extends StatefulWidget { + const RenameBandDialog({super.key, required this.current}); + + final String current; + + @override + State createState() => _RenameBandDialogState(); +} + +class _RenameBandDialogState extends State { + late final TextEditingController _ctl = + TextEditingController(text: widget.current); + final ValueNotifier _err = ValueNotifier(null); + + @override + void dispose() { + _ctl.dispose(); + _err.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext d) { + final l = AppLocalizations.of(d); + return AlertDialog( title: Text(l?.devicesNameThisBand ?? 'Name this band'), content: Column(mainAxisSize: MainAxisSize.min, children: [ ValueListenableBuilder( - valueListenable: err, + valueListenable: _err, builder: (_, e, _) => TextField( - controller: ctl, + controller: _ctl, autofocus: true, maxLength: 20, decoration: InputDecoration( @@ -2222,11 +2264,11 @@ Future _renameBand(BuildContext c, AppState app, String current) async { child: Text(l?.actionCancel ?? 'Cancel')), TextButton( onPressed: () { - final v = ctl.text.trim(); + final v = _ctl.text.trim(); if (v.isEmpty) { - err.value = l?.devicesGiveItAName ?? 'Give it a name'; + _err.value = l?.devicesGiveItAName ?? 'Give it a name'; } else if (cleanDeviceLabel(v) == null) { - err.value = l?.devicesNameCharsetError ?? + _err.value = l?.devicesNameCharsetError ?? "Only letters, numbers, space, and ' . _ -"; } else { Navigator.of(d).pop(v); @@ -2235,21 +2277,7 @@ Future _renameBand(BuildContext c, AppState app, String current) async { child: Text(l?.actionSave ?? 'Save'), ), ], - ), - ); - ctl.dispose(); - err.dispose(); - if (name == null) return; - try { - await app.renameStrap(name); - messenger?.showSnackBar( - SnackBar(content: Text(l?.devicesRenamedTo(name) ?? 'Renamed to $name'))); - } catch (e) { - // The write goes to the strap, so a dropped link loses it. Say that, - // rather than leaving the old name on screen with no explanation. - messenger?.showSnackBar(SnackBar( - content: Text(l?.devicesCouldNotRename(e.toString()) ?? - 'Could not rename the band: $e'))); + ); } } diff --git a/lib/ui2/profile/gallery.dart b/lib/ui2/profile/gallery.dart index 960216945..02809f51c 100644 --- a/lib/ui2/profile/gallery.dart +++ b/lib/ui2/profile/gallery.dart @@ -403,6 +403,8 @@ Map _coachFigureCases() => { '![chart](https://example.invalid/hr.png)\n\n' '- sleep held at 7 h 12 min\n- strain eased off midweek', ), + // The band rename sheet's body, outside the dialog route that hosts it. + 'rename_band_dialog': const RenameBandDialog(current: 'WHOOP 4A1B2C'), // The past-chats list, open and while a reply is still coming in (locked: // no open, no delete control). 'coach_chat_list': CoachChatList( diff --git a/lib/ui2/profile/settings.dart b/lib/ui2/profile/settings.dart index 160d1b2c8..1133ff387 100644 --- a/lib/ui2/profile/settings.dart +++ b/lib/ui2/profile/settings.dart @@ -1662,6 +1662,10 @@ class _EditProfileViewState extends State { sayUnreadable(context, bad); return; } + // An untouched field keeps the stored number. Imperial pre-fills are + // rounded to whole inches and pounds, so converting them back rewrote + // 72.5 kg as 72.57 and 178 cm as 177.8 on every save and a rename alone + // moved the stored body. final heightCm = height.value == null ? null @@ -1756,7 +1760,9 @@ class _EditProfileViewState extends State { l?.localeName.startsWith('ru') == true ? uiText(c, _u.heightLabel) : uiText(c, _u.heightLabel).toUpperCase(), - TextInputType.number, + // A plain number keyboard on iOS is digits only, so a weight + // pre-filled as 72.5 could not be retyped as 73.4. + const TextInputType.numberWithOptions(decimal: true), ), const SizedBox(height: S.x4), _text( @@ -1765,7 +1771,7 @@ class _EditProfileViewState extends State { l?.localeName.startsWith('ru') == true ? uiText(c, _u.weightLabel) : uiText(c, _u.weightLabel).toUpperCase(), - TextInputType.number, + const TextInputType.numberWithOptions(decimal: true), ), ..._importBlock(p, c), const SizedBox(height: S.x6), diff --git a/lib/ui2/profile/wearable_numbers.dart b/lib/ui2/profile/wearable_numbers.dart index f048b536f..38af31dd9 100644 --- a/lib/ui2/profile/wearable_numbers.dart +++ b/lib/ui2/profile/wearable_numbers.dart @@ -22,6 +22,7 @@ import '../../compute/inputs/validation_bands.dart' show kTruthBandRows; import '../../data/day_label.dart' show todayLabel; import '../../data/db.dart' show LocalDb; import '../../l10n/app_localizations.dart'; +import '../../l10n/decimal_text.dart'; import '../../state/prefs.dart'; import '../ui2.dart'; import 'profile.dart'; @@ -267,7 +268,7 @@ String whyText(AppLocalizations l, String? reason, String device) => 'skin_temp' => device ? (fixed1(v), l.wearableUnitCelsius) : ('${v > 0 ? '+' : ''}${fixed1(v)}', l.wearableUnitSd), - 'strain' => (fixed1(v), ''), + 'strain' => (displayFixed(v, 1), ''), 'calories' => (int0(v), l.wearableUnitKcal), // A ring's movement is the share of the night's records with steps in // them; a watch's is minutes in a heart-rate zone. diff --git a/lib/ui2/screens/calm_breathing.dart b/lib/ui2/screens/calm_breathing.dart index 3b6186340..925fecd17 100644 --- a/lib/ui2/screens/calm_breathing.dart +++ b/lib/ui2/screens/calm_breathing.dart @@ -253,6 +253,32 @@ class _CalmBreathingState extends State bool _paceRead = false; + /// Set by the first X tap or back swipe. The teardown behind it (banking the + /// session, scoring both quiet windows, re-reading the effect) takes long + /// enough with nothing changing on screen that a second tap is natural, and + /// each exit ends in its own `pop()` — the second one removed the route + /// underneath (Wellness, or the home route behind a /breathing deep link). + bool _closing = false; + + /// The one exit for both the X and the back gesture. + Future _close() async { + if (_closing) return; + setState(() => _closing = true); + // The pop sits in `finally`: `_closing` is never cleared and `canPop` is + // false while it is set, so a throw from the teardown (the session insert, + // or the effect re-read) would otherwise leave the user on a screen with a + // dead X and a blocked back gesture. The error still propagates. + try { + if (_running) { + await _stop(abort: true); + } else if (_quiet != null) { + await _abortQuiet(); + } + } finally { + if (mounted) Navigator.of(context).pop(); + } + } + @override void initState() { super.initState(); @@ -519,15 +545,13 @@ class _CalmBreathingState extends State final l = AppLocalizations.of(c); final busy = _running || _quiet != null; return PopScope( - canPop: !busy, - onPopInvokedWithResult: (didPop, _) async { - if (didPop || !busy) return; - if (_running) { - await _stop(abort: true); - } else { - await _abortQuiet(); - } - if (mounted) Navigator.of(context).pop(); + // Held closed for the whole teardown, not just while busy: `_stop` clears + // `_running` before its last `_loadEffect`, and a swipe in that gap would + // pop natively ahead of the pending `pop()` in `_close`. + canPop: !busy && !_closing, + onPopInvokedWithResult: (didPop, _) { + if (didPop) return; + _close(); }, child: Scaffold( backgroundColor: p.bg, @@ -542,14 +566,7 @@ class _CalmBreathingState extends State child: Pressable( semanticLabel: l?.calmBreathingCloseBreathing ?? 'Close breathing', - onTap: () async { - if (_running) { - await _stop(abort: true); - } else if (_quiet != null) { - await _abortQuiet(); - } - if (c.mounted) Navigator.of(c).pop(); - }, + onTap: _close, child: Icon(LucideIcons.x, size: 22, color: p.ink2), ), ), diff --git a/lib/ui2/screens/coach.dart b/lib/ui2/screens/coach.dart index 6b003bcfe..75e1410d2 100644 --- a/lib/ui2/screens/coach.dart +++ b/lib/ui2/screens/coach.dart @@ -301,18 +301,38 @@ class _CoachScreenState extends State { return ok ?? false; } + // Switching or clearing the session while a reply is in flight would hand + // the engine's live history and transcript to another chat: `send` keeps + // appending its tool calls and answer to them, and the `persist()` in + // `_send`'s finally writes them under whichever session is current then, + // so the reply lands in the wrong chat and the question is lost. void _newChat() { + if (_busy) return; _engine?.newSession(); setState(_items.clear); } + // Opening or deleting a chat swaps the engine's open chat, so the composer + // stays locked until that is done, the same way the menu does during a send. + Future _sessionChange(Future Function() change) async { + setState(() => _busy = true); + try { + await change(); + } finally { + if (mounted) setState(() => _busy = false); + } + } + Future _openSession(String id) async { - await _engine?.openSession(id); + if (_busy) return; + final engine = _engine; + if (engine == null) return; + await _sessionChange(() => engine.openSession(id)); if (!mounted) return; setState(() { _items ..clear() - ..addAll(_engine?.transcript ?? const []); + ..addAll(engine.transcript); }); _scrollDown(); } @@ -337,7 +357,9 @@ class _CoachScreenState extends State { // While a reply is coming in, the open chat stays put: the running send // keeps writing into the engine's working chat, so starting, opening or // deleting one under it would put the reply's tail in the wrong chat or - // save a deleted one back. + // save a deleted one back. The sheet doesn't rebuild with this screen, so + // the rows read this snapshot and stay disabled until it is reopened, even + // if the reply finishes meanwhile. The handlers re-check `_busy` regardless. final locked = _busy; await showModalBottomSheet( context: context, @@ -403,12 +425,14 @@ class _CoachScreenState extends State { sessions: list.take(20).toList(), locked: locked, onOpen: (id) { + if (_busy) return; Navigator.of(sheet).pop(); _openSession(id); }, onDelete: (id) async { + if (_busy) return; final wasOpen = id == engine.sessionId; - await engine.deleteSession(id); + await _sessionChange(() => engine.deleteSession(id)); setSheet(() {}); if (wasOpen && mounted) setState(_items.clear); }, @@ -904,7 +928,12 @@ class _CoachSetupState extends State { late final TextEditingController _search; late final TextEditingController _timeout; late CoachApi _api; + /// The model Save will store when nothing has been typed: the saved one at + /// first, then whatever list row was last tapped. Typing in [_search] + /// clears it (see [_onSearchChanged]) so a typed id is never silently + /// overridden by a selection the form wasn't showing. String _model = ''; + String _lastSearch = ''; List _models = const []; bool _loading = false; String? _msg; @@ -939,11 +968,7 @@ class _CoachSetupState extends State { _keyOrigin = coachEndpointOrigin(cfg.baseUrl); // The base URL decides which preset is lit and whether a key is needed, and // the search box filters the list — both are read during build, so both - // have to rebuild it. - void redraw() { - if (mounted) setState(() {}); - } - + // have to rebuild it (both listeners call setState). _base.addListener(_onBaseChanged); // Deliberately NOT tracked via a _key listener: an early attempt cleared // _pendingKeyDelete as soon as the user typed a replacement, but typing @@ -952,7 +977,23 @@ class _CoachSetupState extends State { // already derives "is there a real replacement right now" correctly by // checking the trimmed text itself; _pendingKeyDelete only needs to say // whether the endpoint changed, not track the field's history. - _search.addListener(redraw); + _search.addListener(_onSearchChanged); + } + + /// The model field is both a filter and a free-text id, and Save prefers + /// [_model]. Left set, a saved 'gpt-4o' would win over anything typed, so a + /// retyped id (or a local model typed after pointing the base URL at + /// Ollama) was dropped while the screen still closed as if it had saved. + /// Tapping a row after filtering doesn't touch the text, so that pick + /// survives. Only a real text edit counts — the controller also notifies on + /// cursor and selection moves. + void _onSearchChanged() { + final text = _search.text; + if (text != _lastSearch) { + _lastSearch = text; + _model = ''; + } + if (mounted) setState(() {}); } /// Clears a carried-over key rather than letting Save silently send it to a @@ -965,6 +1006,10 @@ class _CoachSetupState extends State { if (origin != _keyOrigin) { _keyOrigin = origin; _api = CoachApi.defaultFor(_base.text); + // A list fetched from the old endpoint names models the new one may + // not serve. The selected model itself stays, shown under the Model + // field, so a same-provider URL edit doesn't force retyping it. + _models = const []; _pendingKeyDelete = true; if (_key.text.isNotEmpty) _key.clear(); _msg = 'The API key was cleared because the endpoint changed.'; @@ -988,9 +1033,12 @@ class _CoachSetupState extends State { _loading = true; _msg = null; }); + // A reply for an endpoint the user has since edited away from would list + // models the new one may not serve. + final asked = _base.text; try { - final ids = await CoachEngine.fetchModels(_base.text, _key.text); - if (!mounted) return; + final ids = await CoachEngine.fetchModels(asked, _key.text); + if (!mounted || _base.text != asked) return; final l = AppLocalizations.of(context); setState(() { _models = ids; @@ -1000,7 +1048,7 @@ class _CoachSetupState extends State { : (l?.coachModelsFound(ids.length) ?? '${ids.length} models. Tap one.'); }); } catch (e) { - if (!mounted) return; + if (!mounted || _base.text != asked) return; final l = AppLocalizations.of(context); setState( () => _msg = e is CoachException @@ -1235,6 +1283,13 @@ class _CoachSetupState extends State { label: l?.coachModelLabel ?? 'Model', hint: l?.coachModelHint ?? 'search, or type an id', ), + if (_model.isNotEmpty) ...[ + const SizedBox(height: S.x2), + Text( + l?.coachModelSelected(_model) ?? 'Saving model: $_model', + style: F.cap.copyWith(color: p.ink3), + ), + ], const SizedBox(height: S.x1), Builder( builder: (_) => Padding( diff --git a/lib/ui2/screens/cycle_screen.dart b/lib/ui2/screens/cycle_screen.dart index e0fadce0b..f9f18cdfe 100644 --- a/lib/ui2/screens/cycle_screen.dart +++ b/lib/ui2/screens/cycle_screen.dart @@ -186,7 +186,12 @@ class CycleData { /// screen's own `ListView` the way the other four tabs do. class CycleTab extends StatefulWidget { final CycleData? data; - const CycleTab({super.key, this.data}); + + /// Replaces the repository read, so a test can make it throw. + @visibleForTesting + final Future Function()? loader; + + const CycleTab({super.key, this.data, this.loader}); @override State createState() => _CycleTabState(); @@ -195,6 +200,11 @@ class CycleTab extends StatefulWidget { class _CycleTabState extends State with RevisionReload { CycleData? _d; + /// The last read threw. Kept apart from [_d] because an empty `CycleData` + /// is `enabled: false`, and rendering that would tell her tracking is off + /// and offer to turn it on, when it is on and only the read went wrong. + bool _failed = false; + /// The symptom look-back is folded away by default — the chips above it are /// what she opened the tab to tap. bool _showShape = false; @@ -225,10 +235,11 @@ class _CycleTabState extends State with RevisionReload { Future _load() async { final t = beginRead(#cycle); try { - final d = await CycleData.load(context.read()); - if (stillNewest(#cycle, t)) setState(() => _d = d); + final d = await (widget.loader?.call() ?? + CycleData.load(context.read())); + if (stillNewest(#cycle, t)) setState(() => (_d = d, _failed = false)); } catch (_) { - if (stillNewest(#cycle, t)) setState(() => _d = const CycleData()); + if (stillNewest(#cycle, t)) setState(() => _failed = true); } } @@ -349,6 +360,22 @@ class _CycleTabState extends State with RevisionReload { final p = P.of(c); final l = AppLocalizations.of(c); final d = _d; + if (_failed) { + return StatusCard( + l == null + ? 'Could not read your cycle data' + : l.healthCouldNotRead(l.cycleWhatCycleData), + l?.healthReadFailedBody ?? + 'The stored rows failed to load. Nothing was deleted — this is a ' + 'read that went wrong.', + fix: l?.healthTryAgain ?? 'Try again', + icon: LucideIcons.databaseZap, + onFix: () { + setState(() => _failed = false); + _load(); + }, + ); + } if (d == null) return const Center(child: CircularProgressIndicator()); if (!d.enabled) { diff --git a/lib/ui2/screens/day_timeline.dart b/lib/ui2/screens/day_timeline.dart index c4b7b6a12..65e0c4b5d 100644 --- a/lib/ui2/screens/day_timeline.dart +++ b/lib/ui2/screens/day_timeline.dart @@ -49,7 +49,7 @@ import '../profile/devices.dart' show DeviceFilter, DeviceOption, signalCandidates; import '../profile/wearable_numbers.dart' show deviceScoreLabel, observationTitle; import '../ui2.dart'; -import 'home_screen.dart' show clockOfTs, repoOf; +import 'home_screen.dart' show clockOfTs, prettyDay, repoOf; import 'metric_detail.dart' show dayNavRow, detailScaffold, pickDay; /// One thing that happened, at a time it is known to have happened at. @@ -749,6 +749,11 @@ class _DayTimelineScreenState extends State { bool _loading = true; String? _day; + /// The last read threw. Without it a failed day step drew the previous + /// day's timeline under the new date, and a failed first read drew an empty + /// day. + bool _failed = false; + /// The devices that could serve `hr` on this screen — registry-only, no /// query. Empty on every single-device install, which keeps the filter row /// absent (M6 §7.3). @@ -840,10 +845,13 @@ class _DayTimelineScreenState extends State { _deviceBounded = false, _deviceOldest = null, _loading = false, + _failed = false, )); } } catch (_) { - if (mounted && token == _loadToken) setState(() => _loading = false); + if (mounted && token == _loadToken) { + setState(() => (_loading = false, _failed = true)); + } } } @@ -905,6 +913,7 @@ class _DayTimelineScreenState extends State { setState(() { _day = day; _loading = true; + _failed = false; }); _load(); } @@ -919,6 +928,23 @@ class _DayTimelineScreenState extends State { if (_loading) ...[ const SizedBox(height: S.x8), const Center(child: CircularProgressIndicator()), + ] else if (_failed) ...[ + const SizedBox(height: S.x2), + StatusCard( + (_day ?? d.day) == null + ? (l?.wellnessLoadFailedTitle ?? 'This could not be read') + : (l?.homeDayLoadFailedTitle(prettyDay(_day ?? d.day, l)) ?? + '${prettyDay(_day ?? d.day, l)} could not be read'), + l?.homeLoadFailedBody ?? + 'The stored day failed to load. Nothing was deleted — this is a ' + 'read that went wrong, not missing data.', + fix: l?.homeTryAgain ?? 'Try again', + icon: LucideIcons.databaseZap, + onFix: () { + setState(() => (_loading = true, _failed = false)); + _load(); + }, + ), ] else ...[ if (_candidates.length >= 2) ...[ const SizedBox(height: S.x2), diff --git a/lib/ui2/screens/detected_activities.dart b/lib/ui2/screens/detected_activities.dart index b414112db..ba0be02fc 100644 --- a/lib/ui2/screens/detected_activities.dart +++ b/lib/ui2/screens/detected_activities.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../l10n/app_localizations.dart'; +import '../../compute/manual_session.dart' + show ManualWindowErrorMessage, ManualWindowException; import '../../compute/nap_edits.dart'; import '../../models/activity_suggestion.dart'; import '../../theme/theme_switcher.dart' show themedRoute; @@ -77,6 +79,7 @@ class _DetectedActivitiesScreenState extends State if (_busy) return; final repo = repoOf(context); if (repo == null) return; + final l = AppLocalizations.of(context); setState(() => _busy = true); String? error; try { @@ -90,8 +93,14 @@ class _DetectedActivitiesScreenState extends State workoutType: activityByName(s.sport)?.typeKey ?? 'other', ); } - } catch (e) { - error = e.toString(); + } on ActivityReviewException catch (e) { + error = e.message; + } on ManualWindowException catch (e) { + // toString() carries the class name; the card shows only the reason. + error = e.error.message; + } catch (_) { + // Anything else (database, health export) has no copy fit for a person. + error = l?.logWorkoutCouldNotSave ?? 'Could not save that — try again.'; } if (!mounted) return; setState(() => _busy = false); diff --git a/lib/ui2/screens/health_screen.dart b/lib/ui2/screens/health_screen.dart index ec0bef8fd..6221660a7 100644 --- a/lib/ui2/screens/health_screen.dart +++ b/lib/ui2/screens/health_screen.dart @@ -517,6 +517,7 @@ class _HealthScreenState extends State with RevisionReload { super.initState(); _d = widget.data; _v = widget.vitals; + _vDays = widget.vitals?.days; _l = widget.labs; _e = widget.explore; if (widget.data != null) { @@ -579,6 +580,11 @@ class _HealthScreenState extends State with RevisionReload { /// default. Null means "whatever the loader resolves", which is today. String? _vDay; + /// The day list from the last read that worked. Kept when a picked day's + /// readings are dropped, so a day that keeps failing can still be stepped + /// off. + List? _vDays; + Future _loadVitals({bool force = false}) async { final repo = repoOf(context); if (repo == null || (_v != null && !force)) return; @@ -587,14 +593,24 @@ class _HealthScreenState extends State with RevisionReload { final t = beginRead(#vitals); try { final v = await VitalsData.load(repo, want: _vDay); - if (stillNewest(#vitals, t)) setState(() => (_v = v, _vFailed = false)); + if (stillNewest(#vitals, t)) { + setState(() => (_v = v, _vDays = v.days, _vFailed = false)); + } } catch (_) { if (stillNewest(#vitals, t)) setState(() => _vFailed = true); } } void _goVitalsDay(String day) { - setState(() => _vDay = day); + // Stored as null on today, as Home does: Health is kept alive by the shell + // and a literal date would pin Vitals to it once the calendar moves on. + // The shown readings belong to the old day: drop them, so neither the + // load nor a failed read can show them under the new one. + setState(() { + _vDay = homeSwitcherDay(day); + _v = null; + _vFailed = false; + }); _loadVitals(force: true); } @@ -774,7 +790,11 @@ class _HealthScreenState extends State with RevisionReload { LucideIcons.brain, C.purple, l?.healthRowStress ?? 'Stress', - ofNight((stressBlock is Map ? stressBlock['level']?.toString() : null) ?? + // The level is analytics' English token; it goes through the same + // localized mapping Wellness uses, or a German row read 'elevated'. + ofNight((stressBlock is Map && stressBlock['level'] is String + ? stressLevelLabel(l, stressBlock['level'] as String) + : null) ?? (l?.healthRowStress ?? 'Stress')), stressScore == null ? '' : '${stressScore.round()}', // 0–100, and the scale has to be on the row. Wellness has always shown @@ -1107,15 +1127,21 @@ class _HealthScreenState extends State with RevisionReload { final l = AppLocalizations.of(c); final v = _v; if (v == null) { - return _vFailed - ? _readFailed(l?.healthWhatVitals ?? 'vitals', () { - setState(() => _vFailed = false); - _loadVitals(); - }) - : const Padding( - padding: EdgeInsets.only(top: S.x8), - child: Center(child: CircularProgressIndicator()), - ); + final days = _vDays; + return Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + // Null `_vDay` is today, the newest day in the list. + if (days != null && days.isNotEmpty) + ...dayNavRow(_vDay ?? days.first, days, _goVitalsDay), + _vFailed + ? _readFailed(l?.healthWhatVitals ?? 'vitals', () { + setState(() => _vFailed = false); + _loadVitals(); + }) + : const Padding( + padding: EdgeInsets.only(top: S.x8), + child: Center(child: CircularProgressIndicator()), + ), + ]); } // WHICH DAY this tab is showing. Every row here used to say "Today" for a diff --git a/lib/ui2/screens/home_screen.dart b/lib/ui2/screens/home_screen.dart index c8b11cb35..66ffc5f1f 100644 --- a/lib/ui2/screens/home_screen.dart +++ b/lib/ui2/screens/home_screen.dart @@ -65,7 +65,7 @@ import '../../theme/theme_switcher.dart' show themedRoute; import '../activity/day_strain.dart' show DayStrainDetail; import '../profile/alarm.dart' show AlarmArmState, alarmArmOf, alarmDoor, alarmGlanceCard; -import '../profile/devices.dart' show formatDayTime; +import '../profile/devices.dart' show RePair, formatDayTime; import '../profile/profile.dart'; import '../profile/wearable_numbers.dart' show WearableCells, kHomeWearableRows; @@ -136,6 +136,27 @@ VoidCallback? syncOf(BuildContext c) { } } +/// The card Home shows instead of "Sync the band" when no band is paired, or +/// null when one is (or there is no AppState above us). Onboarding's "Skip for +/// now" opens the app with nothing paired; a sync then returns at once, the +/// tap grace held "Connecting to your band" up for 20 s over nothing, and +/// nothing on Home pointed at pairing. +Widget? noBandCard(BuildContext c, AppLocalizations? l) { + try { + if (c.read().isPaired) return null; + } catch (_) { + return null; + } + return StatusCard( + l?.devicesNoBandPaired ?? 'No band is paired', + l?.pairingSkipNote ?? + 'The app opens without a band. Nothing is measured until one is paired.', + fix: l?.devicesPairABand ?? 'Pair a band', + icon: LucideIcons.watch, + onFix: () => goto(c, const RePair()), + ); +} + /// Whether the database had to be rebuilt to start this launch, or null in a /// golden. Same shape as [repoOf] and [syncOf]. DbRebuild? dbRebuildOf(BuildContext c) { @@ -910,6 +931,8 @@ class RingTrio extends StatelessWidget { } } +String _fixed1OrEmpty(Object? v) => v is num ? displayFixed(v, 1) : ''; + /// "Updated 8:12 · 2 → 28" off a `todayHeadlineOf` `recovery_update`, or null. String? recoveryUpdatedLine(Object? update, AppLocalizations? l) { if (update case { @@ -1042,7 +1065,7 @@ _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: v.toStringAsFixed(1), sub: l?.homeStrainOf21 ?? 'of 21', frac: v / 21); + value: displayFixed(v, 1), sub: l?.homeStrainOf21 ?? 'of 21', frac: v / 21); case HomeRingKind.sleep: final v = d.sleepMin.value; final need = d.sleepNeedMin.value; @@ -1389,9 +1412,13 @@ class HomeData { static Future loadForDay(LocalRepository repo, String date, [AppLocalizations? l]) async { final profile = await repo.getProfile(); - final overview = await repo.getDayOverview(date); - final strain = await repo.getDayStrain(date); - final sleep = await repo.getDaySleepV2(date); + // The three getters read the same stored day: decode it once, not three + // times per arrow tap. + final (overview, strain, sleep) = await repo.sharingDayReads(() async => ( + await repo.getDayOverview(date), + await repo.getDayStrain(date), + await repo.getDaySleepV2(date), + )); final readiness = metricOf(overview['readiness']); // Same lookup [load] does for today, for the same reason: the ring's // shortfall explanation needs the stored diagnostic, and a day the @@ -1410,6 +1437,10 @@ class HomeData { calories: metricOf(strain['calories']), caloriesTotal: metricOf(strain['calories_total']), sleepMin: metricOf(sleep['duration_min']), + // The learned need `getDaySleepV2` already carries (absent until one is + // learned). Left unset, the ring told a user who has a need that there + // was "No target yet" on every day but today. + sleepNeedMin: metricOf(sleep['need_min']), stepGoal: (profile['step_goal'] as num?)?.toInt() ?? kDefaultStepGoal, ); } @@ -1518,6 +1549,14 @@ class HomeScreen extends StatefulWidget { State createState() => _HomeScreenState(); } +/// What Home's switcher stores for [day]: null when it is today, so "today" +/// keeps following the clock. Home lives in the shell's IndexedStack and is +/// never disposed — a literal today's date stored by "Back to today" turned +/// into yesterday at midnight, and the tab silently became a past-day view +/// (no plan, no alarm, pull-to-refresh no longer synced). +String? homeSwitcherDay(String day, {DateTime? now}) => + day == todayLabel(now) ? null : day; + class _HomeScreenState extends State with RevisionReload { HomeData? _d; bool _loading = true; @@ -1611,8 +1650,14 @@ class _HomeScreenState extends State with RevisionReload { /// already restricts the arrows to [_days] — so this just re-loads for it. void _goDay(String day) { setState(() { - _day = day; + _day = homeSwitcherDay(day); + // The day on screen goes with the switch. Kept, it stayed under the new + // day's stepper for the whole load with no spinner, and for good if the + // load threw: the rings, tiles and greeting date were still the old + // day's, and the failure card only renders when there is no day. + _d = null; _loading = true; + _failed = false; }); _load(); } @@ -1686,15 +1731,16 @@ class _HomeScreenState extends State with RevisionReload { // install's live pipeline catching up, which has nothing to do with a day // the switcher stepped back onto. if (pastDay) { - return const StatusCard( - 'No data for this day', - 'Nothing was recorded on this day.', + return StatusCard( + l?.homeNoDataThisDayTitle ?? 'No data for this day', + l?.homeNoDataThisDayBody ?? 'Nothing was recorded on this day.', fix: '', icon: LucideIcons.calendarOff, ); } final phase = _phaseStatusCard(c, l); if (phase != null) return phase; + if (noBandCard(c, l) case final noBand?) return noBand; final sync = syncOf(c); return StatusCard( @@ -1754,7 +1800,10 @@ class _HomeScreenState extends State with RevisionReload { const Center(child: CircularProgressIndicator()) else if (_failed) StatusCard( - l?.homeLoadFailedTitle ?? 'Today could not be read', + _day == null + ? (l?.homeLoadFailedTitle ?? 'Today could not be read') + : (l?.homeDayLoadFailedTitle(prettyDay(_day, l)) ?? + '${prettyDay(_day, l)} could not be read'), l?.homeLoadFailedBody ?? 'The stored day failed to load. Nothing was deleted — this is a ' 'read that went wrong, not missing data.', @@ -1769,6 +1818,7 @@ class _HomeScreenState extends State with RevisionReload { Builder(builder: (c) { final phase = _phaseStatusCard(c, l); if (phase != null) return phase; + if (noBandCard(c, l) case final noBand?) return noBand; final sync = syncOf(c); return StatusCard( l?.homeNothingDerivedTitle ?? 'No nights scored yet', @@ -1778,6 +1828,16 @@ class _HomeScreenState extends State with RevisionReload { onFix: sync == null ? null : () => _tapSync(sync), ); }), + // A failed read of a day stepped back onto has no stepper on this + // path, so this is the way home. + if (_day != null && _day != todayLabel()) + Align( + alignment: Alignment.centerLeft, + child: TextButton( + onPressed: () => _goDay(todayLabel()), + child: Text(l?.homeBackToToday ?? 'Back to today'), + ), + ), if (!_loading && !_failed && widget.data == null) ...[ const SizedBox(height: S.x3), firstWeekCard(c, days: _days.length, strainScored: false), @@ -1944,8 +2004,12 @@ class _HomeScreenState extends State with RevisionReload { onOpen: (k) => go( c, switch (k) { - HomeRingKind.recovery => const ReadinessDetail(), - HomeRingKind.strain => const DayStrainDetail(), + // A past day's rings open that day's screens: the number + // tapped and the number shown must be the same day's. + HomeRingKind.recovery => + ReadinessDetail(day: isToday ? null : _day), + HomeRingKind.strain => + DayStrainDetail(day: isToday ? null : _day), // The night the ring was drawn from, not last night. Today // stays null even after the switcher steps back onto it: // today's ring is the held-over night, and only the default @@ -1981,7 +2045,8 @@ class _HomeScreenState extends State with RevisionReload { (l?.homeReadinessNoReason ?? 'Nothing recorded says why.'), fix: l?.homeSeeWhatWasMissing ?? 'See what was missing', icon: LucideIcons.batteryCharging, - onFix: () => go(c, const ReadinessDetail()), + onFix: () => + go(c, ReadinessDetail(day: isToday ? null : _day)), ); }), @@ -2143,6 +2208,12 @@ class _HomeScreenState extends State with RevisionReload { // was held over from an older night. It cannot be an older night any more // — the loader refuses those — so every tile in this row is today's and // none of them needs a date. + // + // On a past day every tile is THAT day's, and the trend screen opens on + // today's window — tapping "11,204" landed on today's count. A past day's + // tile opens the screen for the day it is showing instead. + Widget detail(String key) => + isToday || _day == null ? MetricDetail(key) : metricDayScreen(key, _day!); // Sleep is a RING now, duration and all — the card here was the same // number twice on one screen, and the ring is the one that says what the @@ -2153,7 +2224,7 @@ class _HomeScreenState extends State with RevisionReload { '${d.rhr.value!.round()}', unit: 'bpm', sub: l?.homeRestingSub ?? 'Resting', - onTap: () => go(c, const MetricDetail('resting_hr'))), + onTap: () => go(c, detail('resting_hr'))), // "no sleep was recorded" was stated as fact, unconditionally — and it // was rendered directly beside a Sleep card showing that night's // duration. Sleep duration and nocturnal RHR are gated separately: a @@ -2193,7 +2264,7 @@ class _HomeScreenState extends State with RevisionReload { '${((d.steps.value! / d.stepGoal) * 100).clamp(0, 999).round()}% of goal', ?stepSensorLabel(d.steps, l), ].join(' · '), - onTap: () => go(c, const MetricDetail('steps')), + onTap: () => go(c, detail('steps')), trailing: d.steps.value == null || d.stepGoal <= 0 ? null : SizedBox( @@ -2215,7 +2286,7 @@ class _HomeScreenState extends State with RevisionReload { ? (l?.homeCaloriesEstimated ?? 'Estimated') : (l?.homeCaloriesTotal(thousands(d.caloriesTotal.value)) ?? '${thousands(d.caloriesTotal.value)} total'), - onTap: () => go(c, const MetricDetail('calories'))), + onTap: () => go(c, detail('calories'))), // No `why:`. It said "Needs your weight and age" — and the measured run // printed that to a profile carrying both, because energy had gone absent // for an entirely different reason that the card never asked for. @@ -2256,7 +2327,13 @@ class _HomeScreenState extends State with RevisionReload { else if (absent.length > 1) ...[ const SizedBox(height: S.x3), StatusCard( - l?.homeSomeDataMissingTitle ?? 'Some of today\'s data is missing', + // The glance also renders for a day the switcher stepped back + // onto, where "today's" names the wrong day. + isToday + ? (l?.homeSomeDataMissingTitle ?? + 'Some of today\'s data is missing') + : (l?.homeSomeDayDataMissingTitle ?? + 'Some of this day\'s data is missing'), [ for (final s in absent) [s.what, s.why, s.fix].where((t) => t.isNotEmpty).join('. '), @@ -2304,8 +2381,8 @@ class _HomeScreenState extends State with RevisionReload { C.purple, met ? (l?.homeStrainTargetMet ?? 'Strain target met') - : (l?.homeAimForStrain(aim.toStringAsFixed(1)) ?? - 'Aim for ${aim.toStringAsFixed(1)} strain'), + : (l?.homeAimForStrain(displayFixed(aim, 1)) ?? + 'Aim for ${displayFixed(aim, 1)} strain'), // Built on a wearable's estimated TRIMP: say so. [ l?.homeTraining ?? 'Training', @@ -2313,8 +2390,8 @@ class _HomeScreenState extends State with RevisionReload { ].join(' · '), met ? (l?.actionDone ?? 'Done') - : '${(target['low'] as num?)?.toStringAsFixed(1) ?? ''}–' - '${(target['high'] as num?)?.toStringAsFixed(1) ?? ''}', + : '${_fixed1OrEmpty(target['low'])}–' + '${_fixed1OrEmpty(target['high'])}', met)); } diff --git a/lib/ui2/screens/journal_compose.dart b/lib/ui2/screens/journal_compose.dart index 6c565f672..0b9ddc44e 100644 --- a/lib/ui2/screens/journal_compose.dart +++ b/lib/ui2/screens/journal_compose.dart @@ -21,6 +21,7 @@ import '../../coach/coach_config.dart'; import '../../data/day_label.dart'; import '../../data/db.dart'; import '../../data/journal_fields.dart'; +import '../../data/local_repository.dart'; import '../../l10n/app_localizations.dart'; import '../../state/app_state.dart'; import '../../state/units_controller.dart'; @@ -51,6 +52,7 @@ class _JournalComposeState extends State { Map _loadedValues = const {}; Set _loadedTags = const {}; bool _loading = true; + bool _loadFailed = false; bool _saving = false; @override @@ -71,9 +73,24 @@ class _JournalComposeState extends State { setState(() => _loading = false); return; } - final specs = await repo.getJournalFields(); - final values = await repo.getJournalMetrics(_date); - final entries = await repo.getJournal(range: '30d'); + final List specs; + final Map values; + final List> entries; + try { + specs = await repo.getJournalFields(); + values = await repo.getJournalMetrics(_date); + entries = await repo.getJournal(range: '30d'); + } catch (_) { + // A locked or swapped-out database used to leave the form on its + // spinner for good. Say so and offer a retry instead. + if (mounted) { + setState(() { + _loading = false; + _loadFailed = true; + }); + } + return; + } Map? today; for (final e in entries) { if (e['date'] == _date) today = e; @@ -87,6 +104,7 @@ class _JournalComposeState extends State { _loadedTags = {..._tags}; _note.text = (today?['note'] as String?) ?? ''; _loading = false; + _loadFailed = false; }); } @@ -207,6 +225,31 @@ class _JournalComposeState extends State { final repo = context.read().repo; if (repo == null) return; setState(() => _saving = true); + try { + await _write(repo); + } catch (_) { + // Any throw here used to escape unhandled and leave Save spinning with + // no message. The form keeps the user's edits so a retry writes them; + // the retry re-reads the day, so metrics a failed run already wrote are + // not doubled. + if (!mounted) return; + setState(() => _saving = false); + final l = AppLocalizations.of(context); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(l?.journalComposeEntrySaveFailed ?? + 'Not saved — your entry is still here. Try again.')), + ); + return; + } + if (!mounted) return; + // Today's "How was today?" is already armed; a rated day moves it to + // tomorrow, and only a re-arm does that. + unawaited(context.read().refreshAiReminders()); + Navigator.of(context).pop(true); + } + + Future _write(LocalRepository repo) async { // Both writes REPLACE the day, and the strap's double-tap (a glass of // water, a moment tag) writes the same day while this screen is open. // Re-read and lay only this screen's own edits over it, or Save puts the @@ -232,11 +275,6 @@ class _JournalComposeState extends State { ..removeAll(_loadedTags.difference(_tags)) ..addAll(_tags); await repo.postJournal(_date, tags.toList(), _note.text.trim()); - if (!mounted) return; - // Today's "How was today?" is already armed; a rated day moves it to - // tomorrow, and only a re-arm does that. - unawaited(context.read().refreshAiReminders()); - Navigator.of(context).pop(true); } @override @@ -253,6 +291,35 @@ class _JournalComposeState extends State { Expanded( child: _loading ? const Center(child: CircularProgressIndicator()) + : _loadFailed + ? Center( + child: Padding( + padding: const EdgeInsets.all(S.x6), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + l?.journalComposeLoadFailed ?? + "Could not open this day's journal.", + textAlign: TextAlign.center, + ), + const SizedBox(height: S.x4), + BigButton( + l?.journalComposeTryAgain ?? 'Try again', + icon: LucideIcons.refreshCw, + color: C.domMind, + onTap: () { + setState(() { + _loading = true; + _loadFailed = false; + }); + _load(); + }, + ), + ], + ), + ), + ) : ListView( padding: const EdgeInsets.fromLTRB(S.x4, 0, S.x4, S.x8), children: [ @@ -357,9 +424,8 @@ class _JournalComposeState extends State { size: 16, color: p.on(C.domMind)), const SizedBox(width: S.x1), Text( - // Not localized yet — a new entry point, - // not a copy pass over the whole screen. - 'Talk it through with AI', + l?.journalComposeTalkItThroughWithAi ?? + 'Talk it through with AI', style: F.over .copyWith(color: p.on(C.domMind)), ), @@ -1183,6 +1249,8 @@ class _JournalAiSheetState extends State<_JournalAiSheet> { const SizedBox(width: S.x2), Pressable( onTap: _busy ? null : () => unawaited(_send()), + semanticLabel: + AppLocalizations.of(c)?.coachSendLabel ?? 'Send', child: Container( width: 44, height: 44, @@ -1207,7 +1275,8 @@ class _JournalAiSheetState extends State<_JournalAiSheet> { Padding( padding: const EdgeInsets.fromLTRB(S.x4, 0, S.x4, S.x4), child: BigButton( - 'Add to journal', + AppLocalizations.of(c)?.journalComposeAddToJournal ?? + 'Add to journal', icon: LucideIcons.check, color: C.domMind, onTap: last == null diff --git a/lib/ui2/screens/log_food.dart b/lib/ui2/screens/log_food.dart index 7bee02560..43140238f 100644 --- a/lib/ui2/screens/log_food.dart +++ b/lib/ui2/screens/log_food.dart @@ -197,13 +197,25 @@ class _LogFoodSheetState extends State { } else { await NutritionDb.put(db, e); } - } catch (_) { + } catch (err) { + // Every caller is a fire-and-forget onTap, so a rethrow only reached + // the global error log: the sheet sat there unchanged and a locked or + // full database looked exactly like a save that worked. + debugPrint('log food: save failed: $err'); _writing = false; - rethrow; + _sayFailed((l) => l?.nutritionSaveFailed ?? + 'Not saved — the meal is still here. Try again.'); + return; } if (mounted) Navigator.of(context).pop(true); } + void _sayFailed(String Function(AppLocalizations?) text) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(text(AppLocalizations.of(context))))); + } + /// Removing is destructive and there is no undo, so the entry is named back /// before it goes. Future _remove(FoodEntry e) async { @@ -216,7 +228,14 @@ class _LogFoodSheetState extends State { 'undo.', ); if (!ok) return; - await NutritionDb.delete(await LocalDb.instance, e.id); + try { + await NutritionDb.delete(await LocalDb.instance, e.id); + } catch (err) { + debugPrint('log food: remove failed: $err'); + _sayFailed((l) => l?.nutritionRemoveFailed ?? + 'Not removed — the meal is still there. Try again.'); + return; + } if (mounted) Navigator.of(context).pop(true); } diff --git a/lib/ui2/screens/log_workout.dart b/lib/ui2/screens/log_workout.dart index 0bab2f642..98957ca83 100644 --- a/lib/ui2/screens/log_workout.dart +++ b/lib/ui2/screens/log_workout.dart @@ -163,6 +163,10 @@ class _LogWorkoutState extends State { List _spans = const []; bool _saving = false; String? _wrote; + // Kept apart from [_wrote]: a refused or failed save must never be drawn + // under the "Times updated" / "Workout logged" check, since nothing was + // banked. + String? _failed; ActivitySuggestion? _suggestion; @override @@ -285,6 +289,7 @@ class _LogWorkoutState extends State { setState(() { _saving = true; _wrote = null; + _failed = null; }); try { if (_suggestion case final suggestion?) { @@ -322,7 +327,7 @@ class _LogWorkoutState extends State { if (!mounted) return; setState(() { _saving = false; - _wrote = l?.logWorkoutImportedHrGone ?? + _failed = l?.logWorkoutImportedHrGone ?? 'Heart rate for this workout is no longer stored, so the band ' 'cannot score it.'; }); @@ -368,18 +373,18 @@ class _LogWorkoutState extends State { // reload must not leave the save button permanently busy. } finally { if (mounted) { - setState(() { _saving = false; _wrote = e.message; }); + setState(() { _saving = false; _failed = e.message; }); } } } on ManualWindowException catch (e) { if (mounted) { - setState(() { _saving = false; _wrote = e.error.message; }); + setState(() { _saving = false; _failed = e.error.message; }); } } catch (_) { if (mounted) { setState(() { _saving = false; - _wrote = l?.logWorkoutCouldNotSave ?? 'Could not save that — try again.'; + _failed = l?.logWorkoutCouldNotSave ?? 'Could not save that — try again.'; }); } } @@ -442,6 +447,11 @@ class _LogWorkoutState extends State { l?.logWorkoutWindowInvalidTitle ?? 'That window will not save', bad.message, icon: LucideIcons.triangleAlert) + else if (_failed != null) + StatusCard( + l?.logWorkoutWindowInvalidTitle ?? 'That window will not save', + _failed!, + icon: LucideIcons.triangleAlert) else if (_wrote != null) StatusCard( retime diff --git a/lib/ui2/screens/metric_detail.dart b/lib/ui2/screens/metric_detail.dart index c7e363946..4717d6ce0 100644 --- a/lib/ui2/screens/metric_detail.dart +++ b/lib/ui2/screens/metric_detail.dart @@ -682,6 +682,22 @@ bool _labelSources(MetricData d) { return false; } +/// Where one day of a metric opens. Each metric lands on the screen that +/// actually shows that day — Nerd stats is the fallback because it is the one +/// screen that exists for every key. +/// +/// Shared with Home: a tile on a PAST day has to open that day, and +/// [MetricDetail] is anchored to today, so it cannot. +Widget metricDayScreen(String key, String day) => switch (key) { + 'sleep' || + 'deep' || + 'rem' || + 'efficiency' => + SleepDetail(day: day), + 'steps' => DayStepsDetail(day: day), + _ => Investigate(key, day: day), + }; + class MetricDetail extends StatefulWidget { final String metricKey; final MetricData? data; @@ -708,8 +724,9 @@ class _MetricDetailState extends State { ]; } - /// TODAY. A tile on Home shows today's number, so the screen behind that tap - /// opens on today's number — anything else is a different question than the + /// TODAY. A tile on Home showing today shows today's number, so the screen + /// behind that tap opens on today's number (a past day's tile opens + /// [metricDayScreen] instead) — anything else is a different question than the /// one that was asked. /// /// It used to open on 30 days, and worse, on the WIDEST range the install had @@ -1651,18 +1668,7 @@ class _MetricDetailState extends State { ); } - /// Where a day opens. Each metric lands on the screen that actually shows - /// that day — Nerd stats is the fallback because it is the one screen that - /// exists for every key. - Widget _dayScreen(String key, String day) => switch (key) { - 'sleep' || - 'deep' || - 'rem' || - 'efficiency' => - SleepDetail(day: day), - 'steps' => DayStepsDetail(day: day), - _ => Investigate(key, day: day), - }; + Widget _dayScreen(String key, String day) => metricDayScreen(key, day); /// [latestTs] is the stamp on the newest STORED point — the day the rank was /// computed for. `metric_series` gets a row only on a day that derives and @@ -1832,7 +1838,14 @@ class _StepGoalGaugeState extends State<_StepGoalGauge> { Future _save() async { final t = Typed.of(_ctrl.text); - final typed = (t.bad || t.value == null) ? null : t.value!.round(); + // "12k" or "12,000" is not a blank field: closing the editor here kept + // the old goal under a screen that looked as if it had saved the new one. + // Keep the editor open with what was typed and say it was not read. + if (t.bad) { + sayUnreadable(context, [uiText(context, 'Goal')]); + return; + } + final typed = t.value?.round(); if (typed == null) { setState(() => _editing = false); return; diff --git a/lib/ui2/screens/naps.dart b/lib/ui2/screens/naps.dart index c3e97e442..d8a6bda96 100644 --- a/lib/ui2/screens/naps.dart +++ b/lib/ui2/screens/naps.dart @@ -111,6 +111,10 @@ class _NapsScreenState extends State { bool _busy = false; String? _failed; + /// The last read threw. Shown as a retryable card: uncaught, `_d` stayed + /// null and the screen sat on its spinner with no way out. + bool _loadFailed = false; + @override void initState() { super.initState(); @@ -118,18 +122,41 @@ class _NapsScreenState extends State { _load(); } - Future _load() async { + /// False when a read threw — a busy or locked database is enough. [_edit] + /// needs that answer: comparing against a list that was never re-read would + /// call a stale screen the engine's verdict. + Future _load() async { final repo = repoOf(context); - if (repo == null) return; + if (repo == null) return true; final token = ++_loadToken; - final d = await NapsData.load(repo, want: _day); - if (mounted && token == _loadToken) setState(() => _d = d); + try { + final d = await NapsData.load(repo, want: _day); + if (mounted && token == _loadToken) { + setState(() { + _d = d; + _loadFailed = false; + }); + } + return true; + } catch (_) { + if (mounted && token == _loadToken) setState(() => _loadFailed = true); + return false; + } + } + + void _retry() { + setState(() { + _loadFailed = false; + _d = null; + }); + _load(); } void _goDay(String day) { setState(() { _day = day; _d = null; + _loadFailed = false; }); _load(); } @@ -159,10 +186,12 @@ class _NapsScreenState extends State { } finally { if (mounted) setState(() => _busy = false); } - if (mounted) await _load(); + final reloaded = mounted && await _load(); if (!mounted) return; final after = _d?.naps.map((n) => n['start']).toList(); - if (failed != null || '$before' == '$after') { + // A reload that threw left `_d` as the pre-edit list, so before == after + // says nothing about the edit; the read-failed card covers that case. + if (failed != null || (reloaded && '$before' == '$after')) { final l = AppLocalizations.of(context); setState(() => _failed = failed ?? l?.napsNotReanalysed ?? @@ -255,6 +284,28 @@ class _NapsScreenState extends State { final p = P.of(c); final l = AppLocalizations.of(c); final d = _d; + if (_loadFailed) { + return detailScaffold(c, l?.napsTitle ?? 'Naps', [ + const SizedBox(height: S.x4), + StatusCard( + l?.wellnessLoadFailedTitle ?? 'This could not be read', + l?.wellnessLoadFailedBody ?? + 'Reading your stored data failed. Nothing was deleted — this is ' + 'a read that went wrong, not missing data.', + fix: l?.homeTryAgain ?? 'Try again', + icon: LucideIcons.databaseZap, + onFix: _retry, + ), + // An edit can land on disk and then fail its re-read; what the edit + // itself said still has to reach the screen. + if (_failed != null) ...[ + const SizedBox(height: S.x3), + StatusCard(l?.napsNotAppliedTitle ?? 'That has not been applied', + _failed!, + icon: LucideIcons.triangleAlert), + ], + ]); + } if (d == null) { return detailScaffold(c, l?.napsTitle ?? 'Naps', const [ Padding( diff --git a/lib/ui2/screens/readiness_detail.dart b/lib/ui2/screens/readiness_detail.dart index 9cf9c5184..3e17a1a8f 100644 --- a/lib/ui2/screens/readiness_detail.dart +++ b/lib/ui2/screens/readiness_detail.dart @@ -19,6 +19,7 @@ import '../../l10n/app_localizations.dart'; import '../../models/metric.dart'; import '../../models/payloads.dart' show todayHeadlineOf; import '../ui2.dart'; +import 'driver_breakdown.dart' show driverAbsenceCard; import 'home_screen.dart'; import 'investigate.dart'; import 'metric_detail.dart'; @@ -56,9 +57,19 @@ class ReadinessData { /// Home shows beside this number. final Map head; + /// Why `getInsights()` withheld the cross-day rollup, or null when it did + /// not (`staleReasonOf`). A withheld rollup is why the breakdown is empty, + /// and "takes about two weeks of nights" is the wrong reason to give then. + final Map? insightsStale; + bool get provisional => head['recovery_state'] == 'provisional'; + /// The past day this was read for, or null for today. Set only by + /// [loadForDay]. + final String? day; + const ReadinessData({ + this.day, this.head = const {}, this.readiness = Metric.empty, this.breakdown = const [], @@ -66,8 +77,28 @@ class ReadinessData { this.heldOverNight, this.series = const [], this.absentDiag, + this.insightsStale, }); + /// A past day, as Home's day switcher shows it: the stored score for + /// [date] and that day's own absence diagnostic. No breakdown — the + /// percentile view only exists in today's insights, and borrowing today's + /// would put this morning's ranking under last Tuesday's number. + static Future loadForDay( + LocalRepository repo, String date) async { + final overview = await repo.getDayOverview(date); + final chart = await repo.getChart('recovery'); + final readiness = metricOf(overview['readiness']); + return ReadinessData( + day: date, + readiness: readiness, + series: denseDays(pointsOf(chart), 90), + absentDiag: readiness.value != null + ? null + : await LocalDb.readinessAbsentDiag(date), + ); + } + static Future load(LocalRepository repo) async { final today = await repo.getToday(); final cd = await repo.getInsights(); @@ -100,6 +131,7 @@ class ReadinessData { if (e is Map) e.cast(), ], inputsUsed: (v['inputs_used'] as num?)?.toInt() ?? 0, + insightsStale: staleReasonOf(cd), heldOverNight: heldOverNightOf(today), series: denseDays(pointsOf(chart), 90), // Only read when there is nothing to explain away — a scored day has no @@ -120,7 +152,10 @@ class ReadinessData { class ReadinessDetail extends StatefulWidget { final ReadinessData? data; - const ReadinessDetail({super.key, this.data}); + + /// The day to read, as a local label. Null means today. + final String? day; + const ReadinessDetail({super.key, this.data, this.day}); @override State createState() => _ReadinessDetailState(); @@ -151,7 +186,10 @@ class _ReadinessDetailState extends State { return; } try { - final d = await ReadinessData.load(repo); + final day = widget.day; + final d = day == null || day == todayLabel() + ? await ReadinessData.load(repo) + : await ReadinessData.loadForDay(repo, day); if (mounted) setState(() => (_d = d, _loading = false)); } catch (_) { if (mounted) setState(() => _loading = false); @@ -169,7 +207,10 @@ class _ReadinessDetailState extends State { // No date in the nav bar. It named the held-over night, and the headline // can no longer BE that night — a date up here now would be labelling // today's number with somebody else's day. - return detailScaffold(c, l?.readinessDetailTitle ?? 'Recovery', [ + // A day the person stepped back to IS named: the number below is that + // day's, not a held-over stand-in for today. + return detailScaffold(c, l?.readinessDetailTitle ?? 'Recovery', + sub: d.day == null ? '' : prettyDay(d.day, l), [ if (_loading && _d == null) ...[ const SizedBox(height: S.x8), const Center(child: CircularProgressIndicator()), @@ -297,16 +338,19 @@ class _ReadinessDetailState extends State { ), ]), ), - ] else if (v != null) + ] else if (v != null && d.day == null) Section( l?.readinessDetailWhatWentIntoIt ?? 'What went into it', - StatusCard( - l?.readinessDetailNoBreakdownTitle ?? 'No breakdown yet', - l?.readinessDetailNoBreakdownBody ?? - 'Ranking each input against your own history takes about two ' - 'weeks of nights.', - icon: LucideIcons.listTree, - ), + d.insightsStale != null + ? driverAbsenceCard(c, + stale: d.insightsStale, onSync: syncOf(c)) + : StatusCard( + l?.readinessDetailNoBreakdownTitle ?? 'No breakdown yet', + l?.readinessDetailNoBreakdownBody ?? + 'Ranking each input against your own history takes ' + 'about two weeks of nights.', + icon: LucideIcons.listTree, + ), ), // The header used to say "Last 90 days" over a chart of five points. @@ -323,7 +367,10 @@ class _ReadinessDetailState extends State { : Surface(child: _history(c, d)), ), const SizedBox(height: S.x5), - investigateRow(c, () => go(c, const Investigate('readiness'))), + // A past day opened from Home has to carry its day into Nerd stats; + // without it Investigate falls back to the newest derived day and its + // inputs stop matching the score above. d.day is null for today. + investigateRow(c, () => go(c, Investigate('readiness', day: d.day))), ], ]); } diff --git a/lib/ui2/screens/rough_night.dart b/lib/ui2/screens/rough_night.dart index 53e349b74..ea93b9605 100644 --- a/lib/ui2/screens/rough_night.dart +++ b/lib/ui2/screens/rough_night.dart @@ -203,6 +203,25 @@ class RoughNight { return (signs: signs, moved: moved, tempMoved: tempMoved); } +/// The journal day each [picked] tag is stored on when given as the cause of +/// the night ending the morning of [day], grouped by that day. +/// +/// The correlation engine reads a tag dated D against the night that follows +/// it for behaviour tags (alcohol, a late meal, caffeine...) and against the +/// night already over for retrospective ones (sick, stress). Writing every +/// pick on [day] would credit the behaviour to the next night instead of this +/// one, so each tag goes where the engine will look for it. +Map> roughNightTagDays( + String day, + Iterable picked, +) { + final out = >{}; + for (final t in picked) { + (out[ana.journalTagDayForNight(day, t) ?? day] ??= []).add(t); + } + return out; +} + /// When the last session that ran late into the evening BEFORE [day]'s night /// ended, or null. [day]'s night is the one that ended that morning, so it /// started the previous evening: only a session that began that previous @@ -400,7 +419,9 @@ class _RoughNightCardState extends State { /// Agreeing writes the tags the user picked onto that night, MERGED into /// whatever was already logged. /// - /// `postJournal` replaces the row's whole tag set, so the stored day is read + /// Each tag lands on the day [roughNightTagDays] picks for it — behaviour + /// tags on the evening before, so they pair with this night and not the next. + /// `postJournal` replaces the row's whole tag set, so each stored day is read /// first — the same read-merge the compose screen does. Nothing numeric is /// written: the card asked whether, not how much, and a quantity nobody gave /// is a fabricated one. "Add how much" leads to the editor that does ask. @@ -410,19 +431,24 @@ class _RoughNightCardState extends State { try { final repo = context.read().repo; if (repo == null) return; - Map? row; + final byDay = roughNightTagDays(widget.night.day, _picked); + final rows = >{}; for (final e in await repo.getJournal(range: '30d')) { - if (e['date'] == widget.night.day) row = e; + final d = e['date']; + if (d is String && byDay.containsKey(d)) rows[d] = e; + } + for (final MapEntry(key: day, value: picked) in byDay.entries) { + final row = rows[day]; + final tags = { + ...?(row?['tags'] as List?)?.map((t) => t.toString()), + ...picked, + }; + await repo.postJournal( + day, + tags.toList(), + (row?['note'] as String?) ?? '', + ); } - final tags = { - ...?(row?['tags'] as List?)?.map((t) => t.toString()), - ..._picked, - }; - await repo.postJournal( - widget.night.day, - tags.toList(), - (row?['note'] as String?) ?? '', - ); _dismiss(); } finally { if (mounted) setState(() => _saving = false); @@ -565,10 +591,16 @@ class _RoughNightCardState extends State { ), const SizedBox(height: S.x3), // The amounts live in the editor that already asks for them, and the - // amount is what the correlation engine actually reads. + // amount is what the correlation engine actually reads. Every dose the + // card's tags stand for (alcohol units, caffeine, screens) is read against + // the night after the day it is logged on, so the editor opens on the + // evening before this night. Pressable( onTap: () => Navigator.of(c).push( - MaterialPageRoute(builder: (_) => JournalCompose(date: n.day)), + MaterialPageRoute( + builder: (_) => + JournalCompose(date: ana.shiftDayLabel(n.day, -1) ?? n.day), + ), ), child: Text( l?.roughNightAddHowMuch ?? 'Add how much', diff --git a/lib/ui2/screens/sleep_detail.dart b/lib/ui2/screens/sleep_detail.dart index 5990af466..1dc514654 100644 --- a/lib/ui2/screens/sleep_detail.dart +++ b/lib/ui2/screens/sleep_detail.dart @@ -24,6 +24,7 @@ import '../../compute/vendor_sleep.dart' show vendorStagesReported; import '../../data/day_label.dart'; import '../../data/local_repository.dart'; import '../../l10n/app_localizations.dart'; +import '../../l10n/decimal_text.dart'; import '../../state/app_state.dart'; import '../../state/locale_controller.dart'; import '../../state/prefs.dart'; @@ -140,7 +141,21 @@ int? _noonOf(String? day) => day == null } String _pct(double v) => '${v.round()}%'; -String _pts(double v) => '${v.round()} points'; + +/// The efficiency gap in percentage points, in the reader's language. It is +/// glued in front of the localized lower/higher phrase, so an English unit +/// here left every other locale reading a half-English verdict. +String sleepEfficiencyDelta(AppLocalizations? l, double v) => + l?.sleepDetailPointsDelta('${v.round()}') ?? '${v.round()} percentage points'; + +/// A reading with its unit, in the display locale: metric detail prints the +/// same number through [displayFixed] and a translated unit, so a raw +/// "14.2 br/min" here showed a Russian user two different spellings of one +/// night. +@visibleForTesting +String sleepReadingText(AppLocalizations? l, num value, int places, + String unit) => + '${displayFixed(value, places)} ${localizedText(l, unit)}'; /// SLP-13 — the three staged figures of a night, as the intervals they are. /// @@ -178,6 +193,14 @@ String _capitalise(String s) => class SleepData { final String? day; + /// The night this screen opens on when nobody asked for one: the night + /// `getToday` served (a held-over night before the first sync of the day + /// or across a gap), else today. Tonight's plan belongs under this night + /// and anything newer, and under nothing older. Comparing [day] against + /// the wall-clock date instead hid the plan on the landing view every + /// morning a night was held over. + final String? latestDay; + /// Every day this install has derived, newest first — what [DayNav] steers /// over. Empty in a fixture, which is why a golden shows no stepper. final List days; @@ -206,6 +229,7 @@ class SleepData { const SleepData({ this.day, + this.latestDay, this.days = const [], this.night = const {}, this.timeline = const {}, @@ -225,6 +249,18 @@ class SleepData { bool get hasNight => night['duration_min'] is num; + /// Whether tonight's need, debt and bedtime belong under this night. They + /// come from today's cross-day rollup whichever night is selected, so on + /// an older night they are not that night's numbers. Without [latestDay] + /// (a fixture) the calendar decides. + bool get isLatestNight { + final d = day; + if (d == null) return true; + final top = latestDay; + if (top != null) return d.compareTo(top) >= 0; + return (daysBehind(_noonOf(d)) ?? 0) <= 0; + } + /// Stage samples for the painter — the segment list resampled onto a fixed /// number of columns so a four-hour segment and a four-minute one stay in /// proportion. @@ -290,12 +326,11 @@ class SleepData { // off overnight and the card said "7h 04m" while this screen answered "No // night to show" about the same tap. final days = await repo.availableDays(); - final day = pickDay( - days, - want, - heldOverNightOf(today) ?? - (today['status'] as Map?)?['today_day']?.toString()); - if (day == null) return SleepData(days: days); + final prefer = heldOverNightOf(today) ?? + (today['status'] as Map?)?['today_day']?.toString(); + final latest = pickDay(days, null, prefer); + final day = pickDay(days, want, prefer); + if (day == null) return SleepData(days: days, latestDay: latest); final night = await repo.getDaySleepV2(day); var deviceNights = const >[]; @@ -339,6 +374,7 @@ class SleepData { return SleepData( day: day, + latestDay: latest, days: days, night: night, timeline: timeline, @@ -359,6 +395,41 @@ class SleepData { } } +/// What the Sleep stepper stores for [day]: null when it is the newest night +/// on offer, so the screen keeps following the newest night. The tab lives in +/// the shell's IndexedStack and is never disposed — stepping back a night and +/// forward again stored that night's literal date, and the next morning's +/// sync reloaded onto it, so the tab sat on yesterday's night until the +/// arrows were touched again. +@visibleForTesting +String? sleepStepperDay(String day, String? latestDay) => + day == latestDay ? null : day; + +/// What the Sleep screen stores for its night once a correction to [edited] +/// has re-derived. The edit pins [edited] so the reload can't wander to +/// another night, but on the tab that pin is never cleared by anything else: +/// once a newer night arrived the tab stayed on the corrected one and lost +/// the sleep plan, until the arrows were touched. So the pin is released when +/// the edited night is still the newest one. A night the user stepped to +/// meanwhile ([current] != [edited]) is left alone. +@visibleForTesting +String? sleepDayAfterOverride( + String? current, String edited, String? latestDay) => + current == edited ? sleepStepperDay(edited, latestDay) : current; + +/// The scrub card's skin-temperature reading. The timeline value is the +/// sensor's raw count minus the day's median, already rounded to one decimal: +/// there is no °C behind it. Printed bare as "-12.40" beside "52 bpm" it read +/// as a twelve-degree drop, so it carries an explicit sign and no padding +/// past the precision it actually has, with the display locale's decimal mark +/// like the readings beside it. +@visibleForTesting +String sleepScrubTempDelta(num v) { + final s = displayFixed(v.abs(), 1); + if ((v.abs() * 10).round() == 0) return '±$s'; + return v < 0 ? '−$s' : '+$s'; +} + class SleepDetail extends StatefulWidget { final SleepData? data; @@ -368,16 +439,22 @@ class SleepDetail extends StatefulWidget { /// The Sleep tab's root rather than a pushed screen: a tab header with the /// profile avatar instead of a back arrow, pull to re-read, and [footer] - /// (the sleep plan, alarm and body clock) under the night. + /// (alarm and body clock) under the night. final bool embedded; final List footer; + /// Rendered above [footer], and only while the night on screen is the + /// latest one. The sleep plan is advice for tonight; under a night stepped + /// back to two weeks ago it read as that night's advice. + final List todayFooter; + const SleepDetail( {super.key, this.data, this.day, this.embedded = false, - this.footer = const []}); + this.footer = const [], + this.todayFooter = const []}); @override State createState() => _SleepDetailState(); @@ -411,6 +488,10 @@ class _SleepDetailState extends State with RevisionReload { child: Text(sub, style: F.over.copyWith(color: p.ink3)), ), ...body, + // Not under a read error: `_d` may still be the latest night while + // the asked-for one failed. + if (!_failed && (_d?.isLatestNight ?? _day == null)) + ...widget.todayFooter, ...widget.footer, ], ), @@ -418,8 +499,18 @@ class _SleepDetailState extends State with RevisionReload { } SleepData? _d; + + /// The last read that worked. A retry drops `_d` so the old night is not + /// drawn while it runs; the error view still steps through this one's days. + SleepData? _lastGood; bool _loading = true; String? _day; + + /// The last read threw. Kept apart from "no night": a read that failed + /// under a busy database must not tell someone with months of history that + /// the band recorded nothing, and on a day step it must not leave the + /// previous night drawn under the new date. + bool _failed = false; bool _saving = false; // an override write + its forced re-derive is in flight double? _scrub; // 0..1 across the night @@ -540,15 +631,19 @@ class _SleepDetailState extends State with RevisionReload { if (mounted && gen == _loadGen) { setState(() => ( _d = d, + _lastGood = d, _rough = rough, _correctable = correctable, _wholeNightKept = wholeNightKept, _confirmable = confirmable, _loading = false, + _failed = false, )); } } catch (_) { - if (mounted && gen == _loadGen) setState(() => _loading = false); + if (mounted && gen == _loadGen) { + setState(() => (_loading = false, _failed = true)); + } } } @@ -556,10 +651,11 @@ class _SleepDetailState extends State with RevisionReload { /// it goes with it. void _goDay(String day) { setState(() { - _day = day; + _day = sleepStepperDay(day, (_d ?? _lastGood)?.latestDay); _scrub = null; _overrideFailed = null; _loading = true; + _failed = false; }); _load(); } @@ -577,6 +673,36 @@ class _SleepDetailState extends State with RevisionReload { ]); } + if (_failed) { + // `_d` may still hold the night this screen stepped away from; only its + // day list is used, so the stepper stays and the day can be walked off. + // A step to the latest night stores null in `_day`, so that night is + // named from `latestDay`, not from the night stepped away from. + final nav = _d ?? _lastGood ?? d; + final shown = _day ?? nav.latestDay ?? nav.day; + return _frame(c, title, [ + ...dayNavRow(shown, nav.days, _goDay), + const SizedBox(height: S.x2), + StatusCard( + shown == null + ? (l?.wellnessLoadFailedTitle ?? 'This could not be read') + : (l?.homeDayLoadFailedTitle(prettyDay(shown, l)) ?? + '${prettyDay(shown, l)} could not be read'), + l?.homeLoadFailedBody ?? + 'The stored day failed to load. Nothing was deleted — this is a ' + 'read that went wrong, not missing data.', + fix: l?.homeTryAgain ?? 'Try again', + icon: LucideIcons.databaseZap, + onFix: () { + // `_d` is the night stepped away from; drawing it under the asked + // day while the retry runs would show the wrong readings. + setState(() => (_loading = true, _failed = false, _d = null)); + _load(); + }, + ), + ]); + } + if (!d.hasNight) { // "No night" also covers a night the user REJECTED outright (edge#248): // staging was skipped on purpose, so this day has no window at all — but @@ -617,7 +743,10 @@ class _SleepDetailState extends State with RevisionReload { ] else if (rejected) ...[ const SizedBox(height: S.x2), TextButton( - onPressed: _saving ? null : () => _clearWindow(rejectedDay), + // `_loading` too: mid-step this is still the previous day's undo. + onPressed: _saving || _loading + ? null + : () => _clearWindow(rejectedDay), child: Text( l?.sleepDetailUndoRejection ?? 'Undo — go back to automatic'), ), @@ -664,9 +793,10 @@ class _SleepDetailState extends State with RevisionReload { // The stepper names the night, so the nav bar does not say it twice. With // one night on disk there is no stepper, and then the subtitle is the only - // thing that dates the screen. + // thing that dates the screen, so it names the day the way the stepper + // would rather than printing the raw yyyy-MM-dd key. return _frame(c, title, - sub: d.days.length < 2 ? (d.day ?? '').toUpperCase() : '', [ + sub: d.days.length < 2 ? dayNavLabel(d.day, l).toUpperCase() : '', [ ...dayNavRow(_day ?? d.day, d.days, _goDay), // ── 1 · THE ANSWER ── @@ -732,7 +862,12 @@ class _SleepDetailState extends State with RevisionReload { _overnight(c, p, d)), // ── 7 · ONE TAKEAWAY ── - Section(l?.sleepDetailTonightSection ?? 'Tonight', _tonight(c, p, d)), + // Tonight's need, debt and bedtime come from today's cross-day rollup + // whichever night is selected, so on a past night they are not that + // night's numbers. The tab carries the full plan in its footer, and + // printing it here too said the same thing twice on one scroll. + if (d.isLatestNight && !widget.embedded) + Section(l?.sleepDetailTonightSection ?? 'Tonight', _tonight(c, p, d)), const SizedBox(height: S.x5), // The night this screen is steered to, not the newest one. Dropping it @@ -852,6 +987,10 @@ class _SleepDetailState extends State with RevisionReload { final vendor = source == 'vendor_staged'; final busy = _saving; + // Every button below acts on `day`, the night drawn. While a day step is + // loading that is still the night the user stepped away from, not the + // date the stepper reads, the same reason the naps button waits. + final locked = busy || _loading; return [ const SizedBox(height: S.x3), Surface( @@ -907,18 +1046,18 @@ class _SleepDetailState extends State with RevisionReload { Wrap(spacing: S.x2, children: [ if (fallback && _confirmable) TextButton( - onPressed: busy ? null : () => _confirmWindow(day), + onPressed: locked ? null : () => _confirmWindow(day), child: Text(l?.sleepDetailConfirmTimes ?? 'These times are right'), ), TextButton( - onPressed: busy ? null : () => _editWindow(day, t0, t1), + onPressed: locked ? null : () => _editWindow(day, t0, t1), child: Text(mine ? (l?.sleepDetailChangeTimes ?? 'Change the times') : (l?.sleepDetailSetTimesMyself ?? 'Set the times myself')), ), if (mine && _wholeNightKept) TextButton( - onPressed: busy ? null : () => _clearWindow(day), + onPressed: locked ? null : () => _clearWindow(day), child: Text(l?.sleepDetailBackToAutomatic ?? 'Back to automatic'), ), @@ -926,7 +1065,7 @@ class _SleepDetailState extends State with RevisionReload { // already have a reject action (sleep_nap source='rejected'); // a whole-night main-sleep session didn't (edge#248). TextButton( - onPressed: busy ? null : () => _rejectWindow(day), + onPressed: locked ? null : () => _rejectWindow(day), child: Text(l?.sleepDetailNotSleep ?? 'Not sleep'), ), ]), @@ -1076,6 +1215,10 @@ class _SleepDetailState extends State with RevisionReload { if (_day == null && _d?.day == day) _day = day; if (mounted) await _load(); if (!mounted) return; + // Only a reload that worked says which night is latest now; after a + // failed one `_d` is the old result and releasing the pin could make a + // retry open another night. + if (!_failed) _day = sleepDayAfterOverride(_day, day, _d?.latestDay); // EVERY failure below this screen is silent: the forced re-derive catches // and logs its own throw, and it returns at its first line when another // re-analysis is already running. Both leave the write in the database and @@ -1404,14 +1547,17 @@ class _SleepDetailState extends State with RevisionReload { : stages[(_scrub! * (stages.length - 1)).round()]; final items = <(String, String, Color)>[ if (at('hr') != null) - (l?.sleepDetailHeartRate ?? 'Heart rate', '${at('hr')!.round()} bpm', C.red), + (l?.sleepDetailHeartRate ?? 'Heart rate', + sleepReadingText(l, at('hr')!.round(), 0, 'bpm'), C.red), if (at('hrv') != null) - (l?.sleepDetailHrv ?? 'HRV', '${at('hrv')!.round()} ms', C.green), + (l?.sleepDetailHrv ?? 'HRV', + sleepReadingText(l, at('hrv')!.round(), 0, 'ms'), C.green), if (at('resp') != null) (l?.sleepDetailBreathing ?? 'Breathing', - '${at('resp')!.toStringAsFixed(1)} br/min', C.teal), + sleepReadingText(l, at('resp')!, 1, 'br/min'), C.teal), if (at('skin_temp') != null) - (l?.sleepDetailTemp ?? 'Temp', at('skin_temp')!.toStringAsFixed(2), C.orange), + (l?.sleepDetailTempRelative ?? 'Temp (rel.)', + sleepScrubTempDelta(at('skin_temp')!), C.orange), ]; return Padding( @@ -1759,7 +1905,7 @@ class _SleepDetailState extends State with RevisionReload { low: l?.sleepDetailLowerThanUsual ?? 'lower than usual', high: l?.sleepDetailHigherThanUsual ?? 'higher than usual', fmt: _pct, - dfmt: _pts, + dfmt: (v) => sleepEfficiencyDelta(l, v), )); } @@ -1918,8 +2064,8 @@ class _SleepDetailState extends State with RevisionReload { vsBase != null) { items.add(InsightCard( l?.sleepDetailSleepingHrHighTitle ?? 'Sleeping heart rate ran high', - l?.sleepDetailSleepingHrHighBody(vsBase.toStringAsFixed(1)) ?? - '${vsBase.toStringAsFixed(1)} bpm above your usual. Common after alcohol, a late meal, a hard session or an infection starting. A measurement, not a diagnosis.', + l?.sleepDetailSleepingHrHighBody(displayFixed(vsBase, 1)) ?? + '${displayFixed(vsBase, 1)} bpm above your usual. Common after alcohol, a late meal, a hard session or an infection starting. A measurement, not a diagnosis.', icon: LucideIcons.heartPulse, color: C.red, )); @@ -1988,13 +2134,15 @@ class _SleepDetailState extends State with RevisionReload { final summary = <(String, String, Color)>[ if (noc is Map && noc['sleeping_hr_avg'] != null) (loc?.sleepDetailSleepingHr ?? 'SLEEPING HR', - '${noc['sleeping_hr_avg']} bpm', C.red), + sleepReadingText(loc, noc['sleeping_hr_avg'] as num, 0, 'bpm'), + C.red), if (noc is Map && noc['sleeping_hr_min'] != null) - (loc?.sleepDetailLowest ?? 'LOWEST', '${noc['sleeping_hr_min']} bpm', + (loc?.sleepDetailLowest ?? 'LOWEST', + sleepReadingText(loc, noc['sleeping_hr_min'] as num, 0, 'bpm'), C.blue), if (respV != null) (loc?.sleepDetailBreathingCaps ?? 'BREATHING', - '${respV.toStringAsFixed(1)} br/min', C.teal), + sleepReadingText(loc, respV, 1, 'br/min'), C.teal), ]; if (all.isEmpty) { diff --git a/lib/ui2/screens/sleep_tab.dart b/lib/ui2/screens/sleep_tab.dart index 50fb74620..193b8ccbc 100644 --- a/lib/ui2/screens/sleep_tab.dart +++ b/lib/ui2/screens/sleep_tab.dart @@ -25,11 +25,13 @@ class SleepTab extends StatelessWidget { final l = AppLocalizations.of(c); return SleepDetail( embedded: true, - footer: [ + todayFooter: [ Section( l?.sleepTabPlanTitle ?? 'Your sleep plan', const WellnessScreen(sleepPlanOnly: true), ), + ], + footer: [ Section( l?.sleepTabMoreTitle ?? 'Alarm and body clock', Column(children: [ diff --git a/lib/ui2/screens/wellness_screen.dart b/lib/ui2/screens/wellness_screen.dart index a06af8f18..301fac188 100644 --- a/lib/ui2/screens/wellness_screen.dart +++ b/lib/ui2/screens/wellness_screen.dart @@ -34,11 +34,17 @@ import '../ui2.dart'; import 'calm_breathing.dart'; import 'driver_breakdown.dart'; import 'cycle_screen.dart'; -import 'home_screen.dart' show envValue, metricOf, weekdayShortName; +import 'home_screen.dart' + show + envValue, + metricOf, + staleInsightsCard, + staleReasonOf, + syncOf, + weekdayShortName; import 'journal_compose.dart'; import 'start_card.dart'; import 'metric_detail.dart' show detailScaffold; -import 'sleep_detail.dart'; class WellnessScreen extends StatefulWidget { /// Only the sleep plan (need, debt, bedtime, what charged and drained you), @@ -97,6 +103,9 @@ class _WellnessScreenState extends State with RevisionReload { String get _date => todayLabel(); bool _loading = true; + /// The last read threw. Shown as a retryable card rather than a spinner. + bool _failed = false; + /// One journal write at a time. `putJournalMetrics` deletes the day and /// re-inserts it, so two quick taps both read the same day and the second /// erased the first. @@ -161,86 +170,108 @@ class _WellnessScreenState extends State with RevisionReload { final t = beginRead(#wellness); final app = context.read(); final repo = app.repo; - final db = await LocalDb.instance; - - final meds = await MedDb.defs(db); - final doses = await MedDb.dosesForDay(db, _date); - final adherence = await MedDb.adherenceWindow(db, days: 7); - final breathing = await app.breathingHistory(limit: 5); - - var stress = const {}; - var insights = const {}; - var fields = {}; - var driverRows = const []; - var specs = const []; - if (repo != null) { - stress = await repo.getDayStress(_date); - insights = await repo.getInsights(); - // `readiness_glassbox.breakdown`, NOT `.drivers` — drivers is already - // filtered to the inputs that cleared the smallest-worthwhile-change - // gate, so an input that sat inside its usual spread was never in it and - // the screen could not say "and this one did nothing". Same array - // ReadinessDetail renders, so the two agree by construction. - final gb = envValue(insights['readiness_glassbox']); - final bd = gb?['breakdown']; - // The baselines block: centre, spread, delta and MDC per input. Written - // on every derive since long before anything read it. - final heart = await repo.getDayHeart(_date); - final charts = {}; - for (final k in driverChartKeys) { - charts[k] = await repo.getChart(k); + // Ten reads, any of which can throw (a malformed med schedule row, a + // locked database mid-migration). Uncaught, `_loading` never cleared and + // the sleep plan under the Sleep tab spun forever with no way out. + try { + final db = await LocalDb.instance; + + final meds = await MedDb.defs(db); + final doses = await MedDb.dosesForDay(db, _date); + final adherence = await MedDb.adherenceWindow(db, days: 7); + final breathing = await app.breathingHistory(limit: 5); + + var stress = const {}; + var insights = const {}; + var fields = {}; + var driverRows = const []; + var specs = const []; + if (repo != null) { + stress = await repo.getDayStress(_date); + insights = await repo.getInsights(); + // `readiness_glassbox.breakdown`, NOT `.drivers` — drivers is already + // filtered to the inputs that cleared the smallest-worthwhile-change + // gate, so an input that sat inside its usual spread was never in it and + // the screen could not say "and this one did nothing". Same array + // ReadinessDetail renders, so the two agree by construction. + final gb = envValue(insights['readiness_glassbox']); + final bd = gb?['breakdown']; + // The baselines block: centre, spread, delta and MDC per input. Written + // on every derive since long before anything read it. + final heart = await repo.getDayHeart(_date); + final charts = {}; + for (final k in driverChartKeys) { + charts[k] = await repo.getChart(k); + } + driverRows = driverFacts( + breakdown: [ + for (final r in (bd is List ? bd : const [])) + if (r is Map) r.cast(), + ], + baselines: heart['baselines'] is Map + ? (heart['baselines'] as Map).cast() + : null, + charts: charts, + ); + fields = await repo.getJournalMetrics(_date); + specs = await repo.getJournalFields(); } - driverRows = driverFacts( - breakdown: [ - for (final r in (bd is List ? bd : const [])) - if (r is Map) r.cast(), - ], - baselines: heart['baselines'] is Map - ? (heart['baselines'] as Map).cast() - : null, - charts: charts, + // Day arithmetic, not a subtracted duration: a DST day is 23 or 25 hours + // long and `now - 13 days` lands on the wrong calendar date across one. + final now = DateTime.now(); + final since = dayLabelOf( + DateTime(now.year, now.month, now.day - (_habitDays - 1)), ); - fields = await repo.getJournalMetrics(_date); - specs = await repo.getJournalFields(); + final history = await LocalDb.journalMetricsByDay(sinceDaysEpoch: since); + + if (!stillNewest(#wellness, t)) return; + setState(() { + _meds = meds; + _slots = slotsForDay(meds, _date, doses, now: DateTime.now()); + _adherence = adherence; + _breathing = breathing; + _stress = stress; + _insights = insights; + _drivers = driverRows; + _todayFields = {...fields}; + // A habit is a custom field with a ceiling of one — a per-day yes/no. + // That is exactly what journal_field_def already stores, which is why + // there is no habit table. + _habits = [ + for (final s in specs) + if (s.custom && s.max == 1) s, + ]; + _fields = specs; + _habitHistory = history; + _loading = false; + _failed = false; + }); + } catch (_) { + if (stillNewest(#wellness, t)) { + setState(() => (_loading = false, _failed = true)); + } } - // Day arithmetic, not a subtracted duration: a DST day is 23 or 25 hours - // long and `now - 13 days` lands on the wrong calendar date across one. - final now = DateTime.now(); - final since = dayLabelOf( - DateTime(now.year, now.month, now.day - (_habitDays - 1)), - ); - final history = await LocalDb.journalMetricsByDay(sinceDaysEpoch: since); - - if (!stillNewest(#wellness, t)) return; - setState(() { - _meds = meds; - _slots = slotsForDay(meds, _date, doses, now: DateTime.now()); - _adherence = adherence; - _breathing = breathing; - _stress = stress; - _insights = insights; - _drivers = driverRows; - _todayFields = {...fields}; - // A habit is a custom field with a ceiling of one — a per-day yes/no. - // That is exactly what journal_field_def already stores, which is why - // there is no habit table. - _habits = [ - for (final s in specs) - if (s.custom && s.max == 1) s, - ]; - _fields = specs; - _habitHistory = history; - _loading = false; - }); } + Widget _loadFailed(AppLocalizations? l) => StatusCard( + l?.wellnessLoadFailedTitle ?? 'This could not be read', + l?.wellnessLoadFailedBody ?? + 'Reading your stored data failed. Nothing was deleted — this is ' + 'a read that went wrong, not missing data.', + fix: l?.homeTryAgain ?? 'Try again', + icon: LucideIcons.databaseZap, + onFix: () { + setState(() => (_loading = true, _failed = false)); + _load(); + }, + ); + @override Widget build(BuildContext c) { final l = AppLocalizations.of(c); if (widget.sleepPlanOnly) { - return _loading - ? const Center(child: CircularProgressIndicator()) - : _recovery(c); + if (_loading) return const Center(child: CircularProgressIndicator()); + return _failed ? _loadFailed(l) : _recovery(c); } final last = _breathing.isEmpty ? null : _breathing.first; // `select`, not `watch`: AppState ticks at ~1 Hz during a live session and @@ -282,6 +313,8 @@ class _WellnessScreenState extends State with RevisionReload { const SizedBox(height: S.x5), if (_loading) const Center(child: CircularProgressIndicator()) + else if (_failed) + _loadFailed(l) else ...[ // Mind is the only tab here with something to START. The other // four are logs and reviews, and a "begin" card over a medication @@ -354,7 +387,7 @@ class _WellnessScreenState extends State with RevisionReload { final stress = _stress['stress']; final score = _reading(stress is Map ? stress['score'] : null); final level = stress is Map && stress['level'] is String - ? _stressLevelLabel(l, stress['level'] as String) + ? stressLevelLabel(l, stress['level'] as String) : null; return Column( crossAxisAlignment: CrossAxisAlignment.stretch, @@ -469,6 +502,12 @@ class _WellnessScreenState extends State with RevisionReload { final napMin = _reading(coachMap?['nap_credit_min']); final strainMin = _reading(coachMap?['strain_bonus_min']); final debtH = _nested(_insights, 'sleep_debt', 'debt_hours'); + // A WITHHELD rollup (algo-version change, unstamped, or over a week old) + // arrives as `{'stale': reason}` with every key above missing. The + // cold-start copy below would then tell a user with months of nights that + // there are not enough of them yet, one tab over from Home saying the + // insights are being rebuilt. + final stale = staleReasonOf(_insights); return Column( crossAxisAlignment: CrossAxisAlignment.stretch, @@ -488,9 +527,22 @@ class _WellnessScreenState extends State with RevisionReload { 'tonight\'s is ${_hm(needSec / 60)}.', l?.wellnessSeeWhatLastNightCost ?? 'See what last night cost you', color: C.indigo, - onTap: () => Navigator.of(c).push( - MaterialPageRoute(builder: (_) => const SleepDetail()), - ), + // This plan only ever renders as the Sleep tab's footer, under + // last night. Pushing SleepDetail opened a second copy of the + // screen already underneath, so go back up to the night instead. + onTap: () { + final pos = Scrollable.maybeOf(c)?.position; + if (pos == null) return; + // Reduced motion hands back a zero duration, and a scroll + // animation asserts on one — jump instead. + final d = motion(c, Motion.slow); + if (d == Duration.zero) { + pos.jumpTo(pos.minScrollExtent); + } else { + unawaited(pos.animateTo(pos.minScrollExtent, + duration: d, curve: Curves.easeOut)); + } + }, ), ), Section( @@ -499,7 +551,9 @@ class _WellnessScreenState extends State with RevisionReload { // usual, no direction, no size, and no way to tell a move that // mattered from one inside the noise — all of which were already // being written on every derive and read by nothing. - _drivers.isEmpty + _drivers.isEmpty && stale != null + ? driverAbsenceCard(c, stale: stale, onSync: syncOf(c)) + : _drivers.isEmpty ? StatusCard( l?.wellnessNoDriversTitle ?? 'No recovery breakdown yet', whyFromNote(metricOf(_stress['readiness']).note) ?? @@ -512,7 +566,9 @@ class _WellnessScreenState extends State with RevisionReload { ), Section( l?.wellnessSleepNeedTonight ?? 'Sleep need tonight', - needSec == null + needSec == null && stale != null + ? staleInsightsCard(stale, syncOf(c), l)! + : needSec == null // The coach's own reason for the absent need — it names the // input that is actually missing. "Not enough of them yet" named // nothing, and was printed for every cause the estimator has. @@ -1215,7 +1271,8 @@ class DriverRow extends StatelessWidget { /// The pinned analytics package returns 'low'/'normal'/'elevated'/'high' — /// English regardless of locale. Map to the localized word before display. -String? _stressLevelLabel(AppLocalizations? l, String raw) { +/// Shared with the Health tab's stress row, which printed the raw token. +String? stressLevelLabel(AppLocalizations? l, String raw) { switch (raw) { case 'low': return l?.wellnessStressLevelLow ?? raw; diff --git a/lib/ui2/screens/workout_screen.dart b/lib/ui2/screens/workout_screen.dart index d7a6d19d5..ec0c8308e 100644 --- a/lib/ui2/screens/workout_screen.dart +++ b/lib/ui2/screens/workout_screen.dart @@ -26,6 +26,7 @@ import '../../health/health_import_state.dart'; import '../../health/auto_workout_import.dart'; import '../../health/health_workout_import.dart'; import '../../l10n/app_localizations.dart'; +import '../../l10n/decimal_text.dart'; import '../../models/metric.dart'; import '../../state/app_state.dart'; import '../../state/clock_format.dart' show formatClockOf; @@ -90,6 +91,36 @@ class _WorkoutScreenState extends State with RevisionReload { builder: (c, snap) { final loc = AppLocalizations.of(c); final d = snap.data ?? const _WorkoutData.empty(); + // The first open of the tab has no data yet, and the empty dataset is + // not "no data": drawn as fact it said "No sessions recorded yet" to + // someone with forty, asked for a weight already on file, and handed + // a null weight to any setup screen tapped open in that window. Only + // the first load gets here — a reload keeps the previous snapshot. + final List body; + if (snap.hasData) { + body = switch (tab) { + 0 => _forYou(c, d), + 1 => _activities(c, d), + _ => _history(c, d), + }; + } else if (snap.hasError) { + body = [ + StatusCard( + loc?.workoutLoadFailedTitle ?? 'Activity could not be read', + loc?.workoutLoadFailedBody ?? + 'Your sessions failed to load. Nothing was deleted — this ' + 'is a read that went wrong, not missing data.', + icon: LucideIcons.triangleAlert, + fix: loc?.homeTryAgain ?? 'Try again', + onFix: reload, + ), + ]; + } else { + body = const [ + SizedBox(height: S.x8), + Center(child: CircularProgressIndicator()), + ]; + } // THE LIST DROPS ITS SIDE PADDING and hands it to each child instead, // so the hero card can be the one child that does not get it and runs // edge to edge. Two earlier attempts had the CARD escape its parent — @@ -106,11 +137,7 @@ class _WorkoutScreenState extends State with RevisionReload { SubTabs(_tabs(loc), tab, (i) => setState(() => tab = i), color: C.domMove), const SizedBox(height: S.x5), - ...switch (tab) { - 0 => _forYou(c, d), - 1 => _activities(c, d), - _ => _history(c, d), - }, + ...body, ]) if (w is StartCard) w @@ -1068,7 +1095,7 @@ class _HistoryRow extends StatelessWidget { ]), ), if (w.strain != null) ...[ - Text(w.strain!.toStringAsFixed(1), + Text(displayFixed(w.strain!, 1), style: F.n17.copyWith(color: p.ink)), const SizedBox(width: S.x1), Padding( @@ -2028,8 +2055,10 @@ Future<_WorkoutData> _loadWorkoutData(AppState app) async { trimp = List.filled(7, null); } + // Not caught: a failed read of the session history has to reach the + // retry card, not draw as "No sessions recorded yet". final past = <_PastWorkout>[]; - try { + { final rows = (await repo.getWorkouts(range: 'month'))['workouts']; if (rows is List) { for (final r in rows) { @@ -2062,8 +2091,6 @@ Future<_WorkoutData> _loadWorkoutData(AppState app) async { )); } } - } catch (_) { - // leave `past` as-is } // Workouts another app recorded, on the SAME window the band's own list // uses. The store is read 90 days back (30 on Android) because that is the diff --git a/test/app_state_regressions_test.dart b/test/app_state_regressions_test.dart index be1f0bada..240be8bc8 100644 --- a/test/app_state_regressions_test.dart +++ b/test/app_state_regressions_test.dart @@ -600,6 +600,50 @@ void main() { expect(app.alarmConfirmed, isTrue); }); + // ── a changed step goal must reach Home, which re-reads on the revision ──── + group('updateProfile step goal', () { + test('a new step goal bumps the insights revision', () async { + final app = AppState.forTesting(); + addTearDown(app.dispose); + await app.updateProfile({'step_goal': 10000}); + final before = app.insightsRevision.value; + + await app.updateProfile({'step_goal': 8000}); + + expect(app.insightsRevision.value, greaterThan(before)); + expect(app.user!['step_goal'], 8000); + }); + + test('an unchanged goal or another field does not', () async { + final app = AppState.forTesting(); + addTearDown(app.dispose); + await app.updateProfile({'step_goal': 8000}); + final before = app.insightsRevision.value; + + await app.updateProfile({'step_goal': 8000}); + await app.updateProfile({'weight_kg': 70.0}); + + expect(app.insightsRevision.value, before); + }); + + test('overlapping updates that change then restore the goal both bump', + () async { + final app = AppState.forTesting(); + addTearDown(app.dispose); + await app.updateProfile({'step_goal': 8000}); + final before = app.insightsRevision.value; + + // Both start before either has merged. + await Future.wait([ + app.updateProfile({'step_goal': 10000}), + app.updateProfile({'step_goal': 8000}), + ]); + + expect(app.user!['step_goal'], 8000); + expect(app.insightsRevision.value, before + 2); + }); + }); + // ── 10. dispose must release EVERYTHING AppState owns ────────────────────── group('dispose', () { testWidgets('cancels every owned timer', (t) async { diff --git a/test/coach_setup_api_test.dart b/test/coach_setup_api_test.dart index 12fa2d5e5..5eda8ebe7 100644 --- a/test/coach_setup_api_test.dart +++ b/test/coach_setup_api_test.dart @@ -201,4 +201,60 @@ void main() { expect(tester.takeException(), isNull); }, ); + testWidgets('a model id typed over a saved model is the one saved', ( + tester, + ) async { + final config = CoachConfig(); + addTearDown(config.dispose); + await config.save(model: 'gpt-4o', apiKey: 'placeholder-key'); + await _mount(tester, config); + // The saved model is visible before anything is typed. + await tester.scrollUntilVisible( + find.text('Saving model: gpt-4o'), + 300, + scrollable: find.byType(Scrollable).first, + ); + expect(find.text('Saving model: gpt-4o'), findsOneWidget); + + await _enter(tester, 'Model', 'gpt-4.1'); + expect(find.text('Saving model: gpt-4o'), findsNothing); + await _save(tester); + expect(config.model, 'gpt-4.1'); + expect(tester.takeException(), isNull); + }); + + testWidgets('a hand-edited base URL saves the model typed for it', ( + tester, + ) async { + final config = CoachConfig(); + addTearDown(config.dispose); + await config.save(model: 'gpt-4o', apiKey: 'placeholder-key'); + await _mount(tester, config); + + await _enter(tester, 'Base URL', 'http://192.168.1.20:11434/v1'); + await _enter(tester, 'Model', 'llama3.1'); + await _save(tester); + expect(config.apiBase, 'http://192.168.1.20:11434/v1'); + expect(config.model, 'llama3.1'); + expect(tester.takeException(), isNull); + }); + + testWidgets('saving untouched keeps the saved model', (tester) async { + final config = CoachConfig(); + addTearDown(config.dispose); + await config.save(model: 'gpt-4o', apiKey: 'placeholder-key'); + await _mount(tester, config); + // No field was focused, so there is no keyboard for _save to hide, and + // nothing has scrolled the lazily built Save button into the tree yet. + await tester.scrollUntilVisible( + find.text('Save'), + 300, + scrollable: find.byType(Scrollable).first, + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + expect(config.model, 'gpt-4o'); + expect(tester.takeException(), isNull); + }); } diff --git a/test/cycle_lead_plural_test.dart b/test/cycle_lead_plural_test.dart new file mode 100644 index 000000000..7db84cc00 --- /dev/null +++ b/test/cycle_lead_plural_test.dart @@ -0,0 +1,46 @@ +// The cycle prediction lead clause is shown for "one day away" and "one day +// overdue" at every cycle boundary, so a plain "{days} days" placeholder read +// "in 1 days" / "1 days late" in most locales. +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/l10n/app_localizations.dart'; + +void main() { + AppLocalizations l(String code) => lookupAppLocalizations(Locale(code)); + + test('cycle lead clauses use the singular for one day', () { + final expected = >{ + 'en': ['in 1 day · ', '1 day late · ', '1 day past the end of it · '], + 'de': [ + 'in 1 Tag · ', + '1 Tag überfällig · ', + '1 Tag nach Ende des Zeitraums · ', + ], + 'fr': ['dans 1 jour · ', '1 jour de retard · ', '1 jour après sa fin · '], + 'es': [ + 'en 1 día · ', + '1 día de retraso · ', + '1 día después del final · ', + ], + }; + expected.forEach((code, want) { + final s = l(code); + expect( + [s.cycleLeadInDays(1), s.cycleLeadDaysLate(1), s.cyclePastEndOfIt(1)], + want, + reason: code, + ); + }); + expect(l('hi').cycleLeadInDays(1), '1 दिन में · '); + }); + + test('cycle lead clauses keep the plural for more than one day', () { + final en = l('en'); + expect(en.cycleLeadInDays(3), 'in 3 days · '); + expect(en.cycleLeadDaysLate(2), '2 days late · '); + expect(en.cyclePastEndOfIt(4), '4 days past the end of it · '); + expect(l('fr').cycleLeadInDays(2), 'dans 2 jours · '); + expect(l('de').cycleLeadInDays(2), 'in 2 Tagen · '); + expect(l('hi').cycleLeadInDays(2), '2 दिनों में · '); + }); +} diff --git a/test/day_strain_dst_grid_test.dart b/test/day_strain_dst_grid_test.dart new file mode 100644 index 000000000..f05269c00 --- /dev/null +++ b/test/day_strain_dst_grid_test.dart @@ -0,0 +1,90 @@ +// The day-strain curve sits under a fixed 00:00–24:00 axis. It used to slot +// each point by minutes ELAPSED since local midnight, so on a fall-back day +// (1500 minutes) everything after the repeated hour landed an hour late and +// 23:00–24:00 fell off the end of the grid, and on a spring-forward day +// (1380 minutes) everything after the gap landed an hour early. +// +// The DST cases only bite when the test machine's local zone observes DST; +// they search the year for such days and skip when there are none. + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:openstrap_edge/data/day_label.dart'; +import 'package:openstrap_edge/ui2/activity/day_strain.dart'; + +DateTime? _firstDayOfLength(int seconds) { + for (var d = DateTime(2026, 1, 1); + d.year == 2026; + d = DateTime(d.year, d.month, d.day + 1)) { + if (localDayLengthSec(dayLabelOf(d)) == seconds) return d; + } + return null; +} + +/// One point per minute of [day], value = the minute's sequence number, so a +/// cumulative curve whose last point is the largest. +List<(int, double)> _everyMinute(DateTime day) { + final start = day.millisecondsSinceEpoch ~/ 1000; + final len = localDayLengthSec(dayLabelOf(day))!; + return [ + for (var s = 0; s < len; s += 60) (start + s, (s ~/ 60).toDouble()), + ]; +} + +void main() { + test('an ordinary day maps every point to its clock minute', () { + final day = DateTime(2026, 6, 10); + final (got, grid) = DayStrainData.gridCurve(_everyMinute(day)); + expect(got, day); + expect(grid.length, 1440); + expect(grid.every((v) => v != null), isTrue); + expect(grid[23 * 60 + 30], 1410); + }); + + test('fall-back day keeps the last hour and draws it at its clock time', () { + final day = _firstDayOfLength(90000); + if (day == null) { + markTestSkipped('local zone has no fall-back day'); + return; + } + final pts = _everyMinute(day); + final (_, grid) = DayStrainData.gridCurve(pts); + expect(grid.length, 1440); + // The final point is the end of the cumulative curve — the headline. + expect(grid[1439], pts.last.$2); + // A late-evening sample lands under its own clock minute. + final t = DateTime(day.year, day.month, day.day, 23, 10); + final want = pts + .firstWhere((p) => p.$1 == t.millisecondsSinceEpoch ~/ 1000) + .$2; + expect(grid[23 * 60 + 10], want); + expect(grid.every((v) => v != null), isTrue); + }); + + test('spring-forward day draws points after the gap at their clock time', + () { + final day = _firstDayOfLength(82800); + if (day == null) { + markTestSkipped('local zone has no spring-forward day'); + return; + } + final pts = _everyMinute(day); + final (_, grid) = DayStrainData.gridCurve(pts); + expect(grid[1439], pts.last.$2); + final t = DateTime(day.year, day.month, day.day, 20, 0); + final want = pts + .firstWhere((p) => p.$1 == t.millisecondsSinceEpoch ~/ 1000) + .$2; + expect(grid[20 * 60], want); + // Exactly the skipped hour stays empty. + expect(grid.where((v) => v == null).length, 60); + }); + + test('points from another local day are ignored', () { + final day = DateTime(2026, 6, 10); + final next = DateTime(2026, 6, 11, 0, 5).millisecondsSinceEpoch ~/ 1000; + final (_, grid) = + DayStrainData.gridCurve([..._everyMinute(day), (next, 9999)]); + expect(grid.contains(9999), isFalse); + }); +} diff --git a/test/detected_activities_ui_test.dart b/test/detected_activities_ui_test.dart index b09492d6b..e6cb18212 100644 --- a/test/detected_activities_ui_test.dart +++ b/test/detected_activities_ui_test.dart @@ -30,6 +30,7 @@ class _Repo extends LocalRepository { proposal('workout', ActivityKind.workout), ]; bool fail = false; + Object? confirmError; String? savedType; @override Future> pendingActivities() async { @@ -57,6 +58,7 @@ class _Repo extends LocalRepository { int? endTs, String? workoutType, }) async { + if (confirmError case final e?) throw e; if (items.singleWhere((i) => i.id == s.id).revision != s.revision) { throw const ActivityReviewException( 'The proposal changed. Review the new times.', @@ -183,6 +185,30 @@ void main() { expect(repo.items.single.id, 'nap'); }); + testWidgets('a refused confirm shows the reason, not the exception class', ( + t, + ) async { + final repo = await pump(t); + repo.confirmError = const ManualWindowException( + ManualWindowError.overlapsExisting, + ); + await t.tap(find.text('Confirm').last); + await t.pumpAndSettle(); + expect( + find.text('That overlaps a workout already in your log.'), + findsOneWidget, + ); + expect(find.textContaining('ManualWindowException'), findsNothing); + expect(repo.items, hasLength(2)); + + repo.confirmError = StateError('database is locked'); + await t.tap(find.text('Confirm').last); + await t.pumpAndSettle(); + expect(find.text('Could not save that — try again.'), findsOneWidget); + expect(find.textContaining('StateError'), findsNothing); + expect(find.textContaining('database is locked'), findsNothing); + }); + testWidgets('home card stays up when the count fails to load', (t) async { await pump(t, fail: true, home: const Scaffold(body: DetectedActivitiesCard())); expect(find.text('Detected activities'), findsOneWidget); diff --git a/test/home_day_switch_decode_once_test.dart b/test/home_day_switch_decode_once_test.dart new file mode 100644 index 000000000..1b1d2a9a7 --- /dev/null +++ b/test/home_day_switch_decode_once_test.dart @@ -0,0 +1,79 @@ +// The Home day switcher reads three per-day getters (overview, strain, sleep) +// for the day it stepped onto. Each used to fetch and fully decode that day's +// stored bundle on its own, so every arrow tap decoded the same row three +// times on the UI isolate. + +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +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_impl.dart'; +import 'package:openstrap_edge/ui2/screens/home_screen.dart' show HomeData; + +void main() { + late LocalRepositoryImpl repo; + const day = '2026-03-10'; + + setUpAll(() async { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + LocalDb.dbName = 'openstrap_home_day_decode_once_test.db'; + await databaseFactory.deleteDatabase( + p.join(await databaseFactory.getDatabasesPath(), LocalDb.dbName), + ); + repo = LocalRepositoryImpl(getProfileMap: () => const {}); + await LocalDb.putDayResult( + dayId: day, + algoVersion: kAlgoVersion, + payloadJson: jsonEncode({ + 'date': day, + '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: '{}', + ); + }); + + tearDownAll(() async { + await LocalDb.close(); + await databaseFactory.deleteDatabase( + p.join(await databaseFactory.getDatabasesPath(), LocalDb.dbName), + ); + }); + + test('stepping onto a past day decodes its bundle once', () async { + final before = LocalRepositoryImpl.bundleDecodes; + final h = await HomeData.loadForDay(repo, day); + expect(LocalRepositoryImpl.bundleDecodes - before, 1); + + // Same figures the unshared getters serve. + final strain = await repo.getDayStrain(day); + final overview = await repo.getDayOverview(day); + final sleep = await repo.getDaySleepV2(day); + expect(h.strain.value, strain['strain']); + expect(h.steps.value, strain['steps']); + expect(h.rhr.value, overview['resting_hr']); + expect(h.sleepMin.value, sleep['duration_min']); + expect(h.sleepMin.value, isNotNull); + }); + + test('the shared decode does not outlive its scope', () async { + await repo.sharingDayReads(() => repo.getDayStrain(day)); + final before = LocalRepositoryImpl.bundleDecodes; + await repo.getDayStrain(day); + await repo.getDayStrain(day); + expect(LocalRepositoryImpl.bundleDecodes - before, 2); + }); +} diff --git a/test/journal_ai_entry_l10n_test.dart b/test/journal_ai_entry_l10n_test.dart new file mode 100644 index 000000000..64fb2881c --- /dev/null +++ b/test/journal_ai_entry_l10n_test.dart @@ -0,0 +1,43 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +/// The journal's "Talk it through with AI" link was a bare English literal, +/// so it stayed English above otherwise translated fields, and the sheet it +/// opens fell back to the English template title in most locales. +void main() { + const locales = ['de', 'es', 'fr', 'hi', 'ru', 'zh']; + const keys = [ + 'journalComposeTalkItThroughWithAi', + 'supplementTalkItThrough88a9d2', + ]; + + Map arb(String locale) => + jsonDecode(File('lib/l10n/app_$locale.arb').readAsStringSync()) + as Map; + + test('english template defines the keys', () { + final en = arb('en'); + for (final k in keys) { + expect(en[k], isA(), reason: k); + } + }); + + for (final locale in locales) { + test('$locale translates the journal AI entry and sheet title', () { + final en = arb('en'); + final l = arb(locale); + for (final k in keys) { + expect(l[k], isA(), reason: '$locale $k'); + expect(l[k], isNot(en[k]), reason: '$locale $k'); + } + }); + } + + test('the compose screen reads the link label from localizations', () { + final src = + File('lib/ui2/screens/journal_compose.dart').readAsStringSync(); + expect(src, contains('l?.journalComposeTalkItThroughWithAi')); + }); +} diff --git a/test/journal_ai_sheet_a11y_test.dart b/test/journal_ai_sheet_a11y_test.dart new file mode 100644 index 000000000..c8148c989 --- /dev/null +++ b/test/journal_ai_sheet_a11y_test.dart @@ -0,0 +1,69 @@ +// The journal AI sheet's send control is an arrow icon with no text, so +// without a label a screen reader announced it as a bare "button". Its +// primary "Add to journal" button was a raw English literal, so it stayed +// English under a translated chat. +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:openstrap_edge/coach/coach_config.dart'; +import 'package:openstrap_edge/l10n/app_localizations.dart'; +import 'package:openstrap_edge/state/app_state.dart'; +import 'package:openstrap_edge/ui2/screens/journal_compose.dart'; +import 'package:openstrap_edge/ui2/ui2.dart'; + +class _ReadyCoach extends CoachConfig { + @override + bool get configured => true; +} + +Future _openSheet(WidgetTester t, Locale locale) async { + // Wide enough that the test font's square glyphs don't overflow the + // compose screen's Russian row labels, which is not what this checks. + t.view.physicalSize = const Size(900 * 3, 2400 * 3); + t.view.devicePixelRatio = 3; + addTearDown(t.view.reset); + + await t.pumpWidget(MaterialApp( + theme: buildTheme(Brightness.light), + locale: locale, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: AppState.forTesting()), + ChangeNotifierProvider.value(value: _ReadyCoach()), + ], + child: const Scaffold(body: JournalCompose(date: '2026-09-01')), + ), + )); + await t.pumpAndSettle(); + // The entry link is localized, so tap it in the locale under test. + await t.tap(find.text( + lookupAppLocalizations(locale).journalComposeTalkItThroughWithAi)); + await t.pumpAndSettle(); +} + +void main() { + setUp(() => SharedPreferences.setMockInitialValues({})); + + testWidgets('send control has a screen-reader name', (t) async { + final handle = t.ensureSemantics(); + await _openSheet(t, const Locale('en')); + expect( + find.bySemanticsLabel('Send'), + findsOneWidget, + ); + handle.dispose(); + }); + + testWidgets('send label and Add to journal follow the locale', (t) async { + final handle = t.ensureSemantics(); + await _openSheet(t, const Locale('ru')); + expect(find.bySemanticsLabel('Отправить'), findsOneWidget); + expect(find.text('Add to journal'), findsNothing); + expect(find.text('Добавить в дневник'), findsOneWidget); + handle.dispose(); + }); +} diff --git a/test/journal_compose_error_test.dart b/test/journal_compose_error_test.dart new file mode 100644 index 000000000..2fadb331c --- /dev/null +++ b/test/journal_compose_error_test.dart @@ -0,0 +1,110 @@ +// A database error during the journal's load or save used to escape +// unhandled: the form stayed on its spinner, or Save stayed on "Saving" with +// no message, and backing out lost the note. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:openstrap_edge/data/journal_fields.dart'; +import 'package:openstrap_edge/data/local_repository.dart'; +import 'package:openstrap_edge/state/app_state.dart'; +import 'package:openstrap_edge/ui2/screens/journal_compose.dart'; +import 'package:openstrap_edge/ui2/ui2.dart'; + +class _Repo extends LocalRepository { + bool failLoad = false; + bool failJournalWrite = false; + Map metrics = {}; + List tags = []; + String note = ''; + int journalWrites = 0; + + @override + Future> getJournalFields() async { + if (failLoad) throw Exception('database is locked'); + return const []; + } + + @override + Future> getJournalMetrics(String date) async => + {...metrics}; + @override + Future postJournalMetrics( + String date, + Map values, + ) async => + metrics = {...values}; + @override + Future>> getJournal({String range = '30d'}) async => + [ + {'date': '2026-09-01', 'tags': [...tags], 'note': note}, + ]; + @override + Future postJournal(String date, List t, String n) async { + if (failJournalWrite) throw Exception('database is locked'); + journalWrites++; + tags = [...t]; + note = n; + } +} + +Future _pump(WidgetTester t, _Repo repo) async { + t.view.physicalSize = const Size(390 * 3, 2400 * 3); + t.view.devicePixelRatio = 3; + addTearDown(t.view.reset); + final app = AppState.forTesting(); + app.repo = repo; + await t.pumpWidget(MaterialApp( + theme: buildTheme(Brightness.light), + home: ChangeNotifierProvider.value( + value: app, + child: const Scaffold(body: JournalCompose(date: '2026-09-01')), + ), + )); + await t.pumpAndSettle(); +} + +void main() { + setUp(() => SharedPreferences.setMockInitialValues({})); + + testWidgets('a failed save says so, re-enables Save and keeps the note', + (t) async { + final repo = _Repo()..failJournalWrite = true; + await _pump(t, repo); + + await t.enterText(find.byType(TextField).last, 'long day'); + await t.tap(find.text('Save').first); + await t.pumpAndSettle(); + + expect(t.takeException(), isNull); + expect(find.text('Saving'), findsNothing); + expect(find.text('Save'), findsWidgets); + expect(find.byType(SnackBar), findsOneWidget); + expect(find.byType(JournalCompose), findsOneWidget); + expect(find.text('long day'), findsOneWidget); + + // The retry lands the note. + repo.failJournalWrite = false; + await t.tap(find.text('Save').first); + await t.pumpAndSettle(); + expect(repo.journalWrites, 1); + expect(repo.note, 'long day'); + }); + + testWidgets('a failed load offers a retry instead of spinning', (t) async { + final repo = _Repo()..failLoad = true; + await _pump(t, repo); + + expect(t.takeException(), isNull); + expect(find.byType(CircularProgressIndicator), findsNothing); + expect(find.text('Try again'), findsOneWidget); + + repo.failLoad = false; + await t.tap(find.text('Try again')); + await t.pumpAndSettle(); + expect(find.text('Try again'), findsNothing); + expect(find.text('Save'), findsWidgets); + }); +} diff --git a/test/live_hr_card_l10n_test.dart b/test/live_hr_card_l10n_test.dart new file mode 100644 index 000000000..21d1fddc0 --- /dev/null +++ b/test/live_hr_card_l10n_test.dart @@ -0,0 +1,74 @@ +// LiveHrCard's empty states and trace caption must follow the app locale. +// StatusCard only translates strings its lookup tables already know, so a +// raw English literal here showed an English card on a translated screen. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:openstrap_edge/l10n/app_localizations.dart'; +import 'package:openstrap_edge/state/app_state.dart'; +import 'package:openstrap_edge/ui2/ui2.dart'; + +Widget _page(AppState app, Locale locale, Widget child) => + ChangeNotifierProvider.value( + value: app, + child: MaterialApp( + locale: locale, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + theme: buildTheme(Brightness.light), + home: Scaffold(body: SingleChildScrollView(child: child)), + ), + ); + +void main() { + const english = [ + 'No live reading', + 'No band is paired.', + 'Pair one from Profile to read live beats.', + ]; + + for (final code in ['de', 'ru', 'zh']) { + testWidgets('unpaired empty state is translated ($code)', (t) async { + final app = AppState.forTesting(); + addTearDown(app.dispose); + final l = lookupAppLocalizations(Locale(code)); + + await t.pumpWidget(_page(app, Locale(code), const LiveHrCard())); + await t.pumpAndSettle(); + + for (final s in english) { + expect(find.textContaining(s), findsNothing, reason: s); + } + expect(find.textContaining(l.liveHrNoReadingTitle), findsWidgets); + expect(find.textContaining(l.liveHrNotPairedWhy), findsWidgets); + }); + + testWidgets('trace caption is translated ($code)', (t) async { + final app = AppState.forTesting(); + addTearDown(app.dispose); + final l = lookupAppLocalizations(Locale(code)); + + await t.pumpWidget(_page( + app, + Locale(code), + const LiveHrCard.preview(hr: 64, trace: [60, 62, 64, 66]), + )); + await t.pumpAndSettle(); + + expect(find.textContaining('Not stored'), findsNothing); + expect(find.text(l.liveHrTraceCaption(4, 60, 66)), findsOneWidget); + }); + } + + test('every locale carries the live HR strings', () { + for (final locale in AppLocalizations.supportedLocales) { + final l = lookupAppLocalizations(locale); + final en = lookupAppLocalizations(const Locale('en')); + if (locale.languageCode == 'en') continue; + expect(l.liveHrNoReadingTitle, isNot(en.liveHrNoReadingTitle), + reason: locale.toString()); + expect(l.liveHrNoRecentBeatWhy(30), contains('30')); + } + }); +} diff --git a/test/log_workout_test.dart b/test/log_workout_test.dart index 7fbbe1c5a..53a28ae44 100644 --- a/test/log_workout_test.dart +++ b/test/log_workout_test.dart @@ -15,10 +15,24 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:openstrap_edge/compute/manual_session.dart'; +import 'package:openstrap_edge/data/local_repository.dart'; +import 'package:openstrap_edge/state/app_state.dart'; import 'package:openstrap_edge/ui2/activity/catalogue.dart'; import 'package:openstrap_edge/ui2/screens/log_workout.dart'; import 'package:openstrap_edge/ui2/screens/detected_activities.dart'; import 'package:openstrap_edge/ui2/ui2.dart'; +import 'package:provider/provider.dart'; + +/// A save that the repository refuses: a retime over a pruned substrate, or +/// any write that throws. +class _RefusingRepo extends LocalRepository { + final Object error; + _RefusingRepo(this.error); + @override + Future> setWorkoutWindow(String id, + {required int startTs, required int endTs}) async => + throw error; +} /// A real phone, and tall enough that nothing under test is below the fold — /// the default 800x600 harness hides the very controls these tests are about. @@ -190,6 +204,46 @@ void main() { expect(find.text('Activity'), findsNothing); }); + for (final (name, error, body) in [ + ( + 'a retime the score cannot follow', + const ManualWindowException(ManualWindowError.scoreNotRecomputable), + ManualWindowError.scoreNotRecomputable.message, + ), + ('a write that throws', StateError('db'), 'Could not save that — try again.'), + ]) { + testWidgets('$name is shown as a refusal, never under a check', (t) async { + final app = AppState.forTesting(); + addTearDown(app.dispose); + app.repo = _RefusingRepo(error); + t.view.physicalSize = const Size(390 * 3, 2400 * 3); + t.view.devicePixelRatio = 3; + addTearDown(t.view.reset); + await t.pumpWidget(ChangeNotifierProvider.value( + value: app, + child: MaterialApp( + theme: buildTheme(Brightness.light), + home: LogWorkout( + sessionId: 'manual:123', + now: _now, + start: _start, + end: _end, + spans: const [], + activity: activityByName('running'), + title: 'Fix the times', + ), + ), + )); + await t.pumpAndSettle(); + await t.tap(find.text('Save the new times')); + await t.pumpAndSettle(); + expect(find.text(body), findsOneWidget); + expect(find.text('That window will not save'), findsOneWidget); + expect(find.text('Times updated'), findsNothing, + reason: 'nothing was saved'); + }); + } + testWidgets('picking an end time before the start rolls to the next day', ( t, ) async { diff --git a/test/nutrition_ux_559_test.dart b/test/nutrition_ux_559_test.dart index 9303c1aab..0f0ec815b 100644 --- a/test/nutrition_ux_559_test.dart +++ b/test/nutrition_ux_559_test.dart @@ -219,4 +219,38 @@ void main() { expect(find.text('45 g'), findsOneWidget); expect(find.text('75 g to go'), findsOneWidget); }); + + // A failed write used to be rethrown into a fire-and-forget onTap: it only + // reached the global error log, the sheet sat there unchanged, and a save + // that never happened looked like one that did. + testWidgets('a meal that fails to save says so and keeps the sheet open', + (t) async { + await _pump(t, const LogFoodSheet(meal: 'lunch')); + await t.runAsync(() => db.execute('DROP TABLE food_entry')); + + await t.tap(find.text('I ate lunch')); + await _settle(t); + + expect(find.text('Not saved — the meal is still here. Try again.'), + findsOneWidget); + expect(find.byType(LogFoodSheet), findsOneWidget); + }); + + testWidgets('a meal that fails to remove says so and keeps the sheet open', + (t) async { + final e = _e('f1', 'Chocolate bar', kcal: 120); + await t.runAsync(() => NutritionDb.put(db, e)); + await _pump(t, LogFoodSheet(entry: e)); + await t.runAsync(() => db.execute('DROP TABLE food_entry')); + + await t.ensureVisible(find.text('Remove this entry')); + await t.tap(find.text('Remove this entry')); + await t.pumpAndSettle(); + await t.tap(find.text('Remove')); + await _settle(t); + + expect(find.text('Not removed — the meal is still there. Try again.'), + findsOneWidget); + expect(find.byType(LogFoodSheet), findsOneWidget); + }); } diff --git a/test/past_day_detail_test.dart b/test/past_day_detail_test.dart new file mode 100644 index 000000000..b413311ea --- /dev/null +++ b/test/past_day_detail_test.dart @@ -0,0 +1,61 @@ +// Home's day switcher can step back to a past day, and the rings it draws are +// that day's. Tapping one has to open that day too: the recovery and strain +// detail screens used to read today unconditionally, so a past day's 41 +// opened a screen saying 78. +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/data/local_repository.dart'; +import 'package:openstrap_edge/ui2/activity/day_strain.dart'; +import 'package:openstrap_edge/ui2/screens/readiness_detail.dart'; + +class _Repo extends LocalRepository { + final asked = []; + + @override + Future> getToday() async => + throw StateError('a past day must not read today'); + + @override + Future> getInsights() async => + throw StateError('a past day must not read today\'s insights'); + + @override + Future> getDayOverview(String date) async { + asked.add('overview:$date'); + return { + 'readiness': {'value': date == '2026-05-19' ? 41 : 78, 'confidence': 0.8}, + }; + } + + @override + Future> getChart(String metric, + {int? from, int? to, Set signals = const {}}) async => + const {'points': []}; + + @override + Future> getDayStrain(String date) async { + asked.add('strain:$date'); + return {'curve': const [], 'strain': date == '2026-05-19' ? 15.2 : 3.0}; + } + + @override + Future> getDayWear(String date) async => const {}; +} + +void main() { + test('recovery for a past day is that day\'s stored score', () async { + final r = _Repo(); + final d = await ReadinessData.loadForDay(r, '2026-05-19'); + expect(d.readiness.value, 41); + expect(d.day, '2026-05-19'); + expect(d.breakdown, isEmpty, + reason: 'today\'s percentile ranking is not this day\'s'); + expect(r.asked, ['overview:2026-05-19']); + }); + + test('strain for a past day asks for that day', () async { + final r = _Repo(); + final d = await DayStrainData.load(r, forDay: '2026-05-19'); + expect(d.strain, 15.2); + expect(r.asked, contains('strain:2026-05-19')); + }); +} diff --git a/test/profile_decimal_keyboard_test.dart b/test/profile_decimal_keyboard_test.dart new file mode 100644 index 000000000..b68848ed4 --- /dev/null +++ b/test/profile_decimal_keyboard_test.dart @@ -0,0 +1,66 @@ +// Height and weight are decimals (a metric weight pre-fills as "72.5"), but a +// plain `TextInputType.number` is iOS's digits-only number pad: clearing the +// field to correct it left no way to type the separator, so 73.4 went in as +// 734 or 73. Age stays a whole number. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/ui2/onboarding/profile_setup.dart'; +import 'package:openstrap_edge/ui2/profile/settings.dart'; +import 'package:openstrap_edge/ui2/ui2.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +const _decimal = TextInputType.numberWithOptions(decimal: true); + +Future _pump(WidgetTester t, Widget child) async { + t.view.physicalSize = const Size(1200, 3600); + t.view.devicePixelRatio = 1; + addTearDown(t.view.reset); + await t.pumpWidget(MaterialApp( + theme: buildTheme(Brightness.light), + home: child, + )); + await t.pumpAndSettle(); +} + +TextInputType? _keyboardOf(WidgetTester t, String text) => + t.widget(find.widgetWithText(TextField, text)).keyboardType; + +void main() { + setUp(() => SharedPreferences.setMockInitialValues({})); + + testWidgets('edit profile: height and weight offer a decimal key', + (t) async { + await _pump( + t, + EditProfileView( + initial: const { + 'sex': 'm', + 'age': 34, + 'height_cm': 178.5, + 'weight_kg': 72.5, + }, + onSave: (_) async {}, + )); + expect(_keyboardOf(t, '178.5'), _decimal); + expect(_keyboardOf(t, '72.5'), _decimal); + expect(_keyboardOf(t, '34'), TextInputType.number); + }); + + testWidgets('onboarding: height and weight offer a decimal key', (t) async { + await _pump( + t, + ProfileSetupView( + initial: const { + 'sex': 'f', + 'age': 34, + 'height_cm': 165.5, + 'weight_kg': 61.5, + }, + onSave: (_) async {}, + )); + expect(_keyboardOf(t, '165.5'), _decimal); + expect(_keyboardOf(t, '61.5'), _decimal); + expect(_keyboardOf(t, '34'), TextInputType.number); + }); +} diff --git a/test/profile_imperial_roundtrip_test.dart b/test/profile_imperial_roundtrip_test.dart new file mode 100644 index 000000000..6d6fce184 --- /dev/null +++ b/test/profile_imperial_roundtrip_test.dart @@ -0,0 +1,70 @@ +// An imperial profile form pre-fills whole inches and pounds. Saving it +// without touching those fields converted the rounded numbers back to metric, +// so 72.5 kg became 72.57 kg and 178 cm became 177.8 cm on every save — a +// rename moved the stored body. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/state/units_controller.dart'; +import 'package:openstrap_edge/ui2/profile/settings.dart'; +import 'package:openstrap_edge/ui2/ui2.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +Future _pump(WidgetTester t, Widget child) async { + t.view.physicalSize = const Size(1200, 3600); + t.view.devicePixelRatio = 1; + addTearDown(t.view.reset); + await t.pumpWidget( + MaterialApp(theme: buildTheme(Brightness.light), home: child)); + await t.pumpAndSettle(); +} + +void main() { + setUp(() => SharedPreferences.setMockInitialValues({})); + + testWidgets('untouched imperial height and weight keep the stored metric', + (t) async { + Map? saved; + await _pump( + t, + EditProfileView( + initial: const { + 'name': 'A', + 'sex': 'f', + 'age': 34, + 'height_cm': 178.0, + 'weight_kg': 72.5, + }, + units: UnitsController.seed(UnitSystem.imperial), + onSave: (f) async => saved = f, + )); + + await t.enterText(find.widgetWithText(TextField, 'A'), 'B'); + await t.tap(find.text('Save')); + await t.pumpAndSettle(); + + expect(saved, isNotNull); + expect(saved!['name'], 'B'); + expect(saved!['height_cm'], 178.0); + expect(saved!['weight_kg'], 72.5); + }); + + testWidgets('an edited imperial weight is still converted', (t) async { + Map? saved; + await _pump( + t, + EditProfileView( + initial: const {'sex': 'f', 'age': 34, 'weight_kg': 72.5}, + units: UnitsController.seed(UnitSystem.imperial), + onSave: (f) async => saved = f, + )); + + // 72.5 kg pre-fills as 160 lb. + await t.enterText(find.widgetWithText(TextField, '160'), '150'); + await t.tap(find.text('Save')); + await t.pumpAndSettle(); + + expect(saved, isNotNull); + expect(saved!['weight_kg'] as double, closeTo(150 * 0.45359237, 0.01)); + }); +} diff --git a/test/profile_setup_copy_test.dart b/test/profile_setup_copy_test.dart new file mode 100644 index 000000000..aef0ca591 --- /dev/null +++ b/test/profile_setup_copy_test.dart @@ -0,0 +1,34 @@ +// The profile setup intro used to say "four numbers" could each be left +// blank, while Continue stays disabled until a sex is picked. Sex is not a +// number and is not optional, so the copy has to say it is required. + +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('profile setup intro names sex as required, not as a blankable number', + () { + final arb = jsonDecode(File('lib/l10n/app_en.arb').readAsStringSync()) + as Map; + final body = arb['profileSetupBody'] as String; + expect(body.toLowerCase(), isNot(contains('four numbers'))); + expect(body, contains('Sex')); + expect(body.toLowerCase(), contains('continue')); + + final src = + File('lib/ui2/onboarding/profile_setup.dart').readAsStringSync(); + expect(src, isNot(contains('Four numbers'))); + }); + + test('every locale carries the profile setup intro', () { + for (final loc in const ['de', 'en', 'es', 'fr', 'hi', 'ru', 'zh']) { + final arb = jsonDecode(File('lib/l10n/app_$loc.arb').readAsStringSync()) + as Map; + expect(arb['profileSetupBody'], isA(), reason: loc); + expect((arb['profileSetupBody'] as String).isNotEmpty, isTrue, + reason: loc); + } + }); +} diff --git a/test/readiness_detail_investigate_day_test.dart b/test/readiness_detail_investigate_day_test.dart new file mode 100644 index 000000000..59706e653 --- /dev/null +++ b/test/readiness_detail_investigate_day_test.dart @@ -0,0 +1,25 @@ +// Recovery opened for a past day (Home's day stepper, or the "See what was +// missing" card) showed that day's score, but Nerd stats opened Investigate +// with no day, so it read the newest derived day instead. Sleep and Beats +// already pass their day through; this pins Recovery to the same contract. +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('every detail screen hands its day to Investigate', () { + final src = { + 'readiness_detail': "Investigate('readiness', day: d.day)", + 'sleep_detail': "Investigate('sleep', day:", + 'beats': "Investigate('hrv', day:", + }; + for (final e in src.entries) { + final text = File('lib/ui2/screens/${e.key}.dart').readAsStringSync(); + expect(text, contains(e.value), + reason: '${e.key} opens Nerd stats on the newest day, not its own'); + } + expect( + File('lib/ui2/screens/readiness_detail.dart').readAsStringSync(), + isNot(contains("const Investigate('readiness')"))); + }); +} diff --git a/test/recovery_headline_ui_test.dart b/test/recovery_headline_ui_test.dart index 780b60ba4..6357b2623 100644 --- a/test/recovery_headline_ui_test.dart +++ b/test/recovery_headline_ui_test.dart @@ -142,6 +142,35 @@ void main() { expect(find.text('Finishing up'), findsNothing); }); + // A withheld rollup empties the breakdown while the headline still scores + // off day_result. "Takes about two weeks of nights" is then the wrong + // reason, under a number that proves the nights exist. + testWidgets('withheld rollup: the breakdown says why, not cold start', + (t) async { + await pumpDetail( + t, + const ReadinessData( + head: {'recovery_state': 'final', 'recovery': 62}, + readiness: Metric(value: 62, confidence: .8), + insightsStale: {'kind': 'algo_version'})); + await t.pumpAndSettle(); + expect(find.text('No breakdown to show'), findsOneWidget); + expect(find.textContaining('changed in the last update'), findsOneWidget); + expect(find.text('No breakdown yet'), findsNothing); + expect(find.textContaining('two weeks of nights'), findsNothing); + }); + + testWidgets('no withheld rollup: the cold-start card stays', (t) async { + await pumpDetail( + t, + const ReadinessData( + head: {'recovery_state': 'final', 'recovery': 62}, + readiness: Metric(value: 62, confidence: .8))); + await t.pumpAndSettle(); + expect(find.text('No breakdown yet'), findsOneWidget); + expect(find.text('No breakdown to show'), findsNothing); + }); + test('the shared line (Health reads it too) per state', () { expect(recoveryStateLine({'recovery_state': 'night_in_progress'}, null), 'Sleeping…'); diff --git a/test/rename_band_dialog_test.dart b/test/rename_band_dialog_test.dart new file mode 100644 index 000000000..163b2f517 --- /dev/null +++ b/test/rename_band_dialog_test.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/ui2/profile/devices.dart' show RenameBandDialog; + +// showDialog completes at pop time, before the exit transition finishes. The +// rename sheet used to dispose its controller and error notifier right after +// that await, while the field was still rebuilding, which threw on every Save +// and Cancel. Pumping the full transition is what surfaces it. +Future _open(WidgetTester t, void Function(String?) onResult) async { + await t.pumpWidget(MaterialApp( + home: Builder( + builder: (c) => TextButton( + onPressed: () async => onResult(await showDialog( + context: c, + builder: (_) => const RenameBandDialog(current: 'Old'), + )), + child: const Text('open'), + ), + ), + )); + await t.tap(find.text('open')); + await t.pumpAndSettle(); +} + +void main() { + testWidgets('save returns the trimmed name and survives the pop transition', + (t) async { + String? result; + await _open(t, (r) => result = r); + await t.enterText(find.byType(TextField), ' Left wrist '); + await t.tap(find.text('Save')); + await t.pumpAndSettle(); + expect(t.takeException(), isNull); + expect(find.byType(RenameBandDialog), findsNothing); + expect(result, 'Left wrist'); + }); + + testWidgets('cancel returns null and survives the pop transition', + (t) async { + var result = 'unset' as String?; + await _open(t, (r) => result = r); + await t.tap(find.text('Cancel')); + await t.pumpAndSettle(); + expect(t.takeException(), isNull); + expect(find.byType(RenameBandDialog), findsNothing); + expect(result, isNull); + }); + + testWidgets('an empty name is refused in the sheet', (t) async { + await _open(t, (_) {}); + await t.enterText(find.byType(TextField), ' '); + await t.tap(find.text('Save')); + await t.pumpAndSettle(); + expect(find.byType(RenameBandDialog), findsOneWidget); + expect(find.text('Give it a name'), findsOneWidget); + }); +} diff --git a/test/rough_night_test.dart b/test/rough_night_test.dart index f94ec0781..d4a819bc2 100644 --- a/test/rough_night_test.dart +++ b/test/rough_night_test.dart @@ -172,6 +172,29 @@ void main() { }); }); + group('where the attribution is written', () { + // The correlation engine pairs a behaviour tag dated D with the night + // ending D+1. A cause picked on the wake day for the night it ended must + // go on the evening before, or it explains the next night instead. + test('behaviour tags go on the evening before the night', () { + final days = roughNightTagDays('2026-10-10', ['alcohol', 'late meal']); + expect(days, { + '2026-10-09': ['alcohol', 'late meal'], + }); + }); + + test('retrospective tags stay on the night itself', () { + final days = roughNightTagDays('2026-10-10', ['alcohol', 'sick']); + expect(days['2026-10-09'], ['alcohol']); + expect(days['2026-10-10'], ['sick']); + }); + + test('crosses a month boundary', () { + expect(roughNightTagDays('2026-11-01', ['caffeine']).keys, + ['2026-10-31']); + }); + }); + group('what the card says', () { const night = RoughNight( day: '2026-08-15', diff --git a/test/settings_report_l10n_test.dart b/test/settings_report_l10n_test.dart new file mode 100644 index 000000000..4ea6a3e6f --- /dev/null +++ b/test/settings_report_l10n_test.dart @@ -0,0 +1,57 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +/// gen-l10n fills a key a locale lacks from the English template without a +/// warning, so a missing key shows up as English text inside an otherwise +/// translated screen. These are the Settings group headers, the whole +/// Report-an-issue screen and the onboarding strings that were English in +/// every locale except Russian. +void main() { + const locales = ['de', 'es', 'fr', 'hi', 'ru', 'zh']; + + Map arb(String locale) => + jsonDecode(File('lib/l10n/app_$locale.arb').readAsStringSync()) + as Map; + + bool covered(String k) => + k.startsWith('report') || + k.startsWith('settingsGroup') || + k.startsWith('settingsReportIssue') || + k.startsWith('settingsZoneAlert') || + k.startsWith('settingsAlarm') || + k == 'settingsCycleTrackingRowSubHealth' || + k == 'profileSetupCycleSub' || + k == 'supplementUnreadableField' || + k == 'supplementUnreadableFields' || + k == 'presentationHeightCm' || + k == 'presentationHeightIn' || + k == 'presentationWeightLb'; + + Set placeholders(String s) => + RegExp(r'\{(\w+)\}').allMatches(s).map((m) => m[1]!).toSet(); + + final en = arb('en'); + final keys = en.keys.where((k) => !k.startsWith('@') && covered(k)).toList(); + + test('the key set under test is not empty', () { + expect(keys, contains('reportNavTitle')); + expect(keys, contains('settingsGroupApp')); + }); + + for (final locale in locales) { + test('$locale translates settings, report and onboarding strings', () { + final l = arb(locale); + final missing = keys.where((k) => !l.containsKey(k)).toList(); + expect(missing, isEmpty); + for (final k in keys) { + expect( + placeholders(l[k] as String), + placeholders(en[k] as String), + reason: '$locale $k', + ); + } + }); + } +} diff --git a/test/share_elevation_caption_test.dart b/test/share_elevation_caption_test.dart new file mode 100644 index 000000000..889a27ac4 --- /dev/null +++ b/test/share_elevation_caption_test.dart @@ -0,0 +1,33 @@ +// The poster's elevation caption is built in the user's units, so an imperial +// "+394 ft elevation" has to be translated just like the metric one. +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:openstrap_edge/l10n/app_localizations.dart'; +import 'package:openstrap_edge/ui2/activity/catalogue.dart'; +import 'package:openstrap_edge/ui2/activity/share.dart' + show localizedExportCaption; +import 'package:openstrap_edge/ui2/activity/summary.dart'; + +void main() { + testWidgets('both elevation units are translated', (tester) async { + final r = ActivityResult( + allActivities.first, + start: DateTime(2026, 5, 20, 7, 15), + duration: const Duration(minutes: 45), + ); + late BuildContext ctx; + await tester.pumpWidget(MaterialApp( + locale: const Locale('ru'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Builder(builder: (c) { + ctx = c; + return const SizedBox(); + }), + )); + expect(localizedExportCaption(ctx, r, '+120 m elevation'), '+120 м высоты'); + expect(localizedExportCaption(ctx, r, '+394 ft elevation'), + '+394 фт высоты'); + }); +} diff --git a/test/sleep_detail_units_locale_test.dart b/test/sleep_detail_units_locale_test.dart new file mode 100644 index 000000000..aacea89b5 --- /dev/null +++ b/test/sleep_detail_units_locale_test.dart @@ -0,0 +1,45 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:openstrap_edge/l10n/app_localizations.dart'; +import 'package:openstrap_edge/state/locale_controller.dart'; +import 'package:openstrap_edge/ui2/screens/sleep_detail.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + // Metric detail prints these readings with displayFixed and a translated + // unit; the sleep screen has to print the same night the same way. + test('sleep readings use the display locale unit and separator', () { + SharedPreferences.setMockInitialValues({}); + final controller = LocaleController.seed('ru')..useForPresentation(); + addTearDown(controller.dispose); + final ru = lookupAppLocalizations(const Locale('ru')); + + expect(sleepReadingText(ru, 14.2, 1, 'br/min'), + '14,2 ${ru.supplementBrMin0041f9}'); + expect(sleepReadingText(ru, 52, 0, 'bpm'), '52 ${ru.activityLiveBpmUnit}'); + expect(sleepReadingText(ru, 48, 0, 'ms'), '48 ${ru.cycleUnitMs}'); + }); + + test('english readings are unchanged', () { + SharedPreferences.setMockInitialValues({}); + final controller = LocaleController.seed('en')..useForPresentation(); + addTearDown(controller.dispose); + final en = lookupAppLocalizations(const Locale('en')); + + expect(sleepReadingText(en, 14.2, 1, 'br/min'), '14.2 br/min'); + expect(sleepReadingText(en, 52, 0, 'bpm'), '52 bpm'); + }); + + // The readings sit inside a screen that needs a full app to pump; the + // regression is a raw literal slipping back in. + test('the sleep screen formats no reading with toStringAsFixed', () { + final src = File('lib/ui2/screens/sleep_detail.dart').readAsStringSync(); + expect(src.contains('toStringAsFixed'), isFalse); + expect(RegExp(r"\} (bpm|ms|br/min)'").hasMatch(src), isFalse); + }); +} diff --git a/test/sleep_efficiency_delta_l10n_test.dart b/test/sleep_efficiency_delta_l10n_test.dart new file mode 100644 index 000000000..60802b6aa --- /dev/null +++ b/test/sleep_efficiency_delta_l10n_test.dart @@ -0,0 +1,26 @@ +// The efficiency row of the sleep comparison put an English "N points" in +// front of the localized lower/higher phrase, so a German reader saw +// "12 points niedriger als üblich". The unit now comes from the locale. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:openstrap_edge/l10n/app_localizations.dart'; +import 'package:openstrap_edge/ui2/screens/screens.dart'; + +void main() { + test('english names percentage points', () { + final l = lookupAppLocalizations(const Locale('en')); + expect(sleepEfficiencyDelta(l, 11.6), '12 percentage points'); + expect(sleepEfficiencyDelta(null, 11.6), '12 percentage points'); + }); + + for (final code in ['de', 'es', 'ru', 'hi', 'zh']) { + test('$code verdict has no english unit', () { + final l = lookupAppLocalizations(Locale(code)); + final s = sleepEfficiencyDelta(l, 11.6); + expect(s, contains('12')); + expect(s, isNot(contains('points'))); + }); + } +} diff --git a/test/strain_decimal_separator_test.dart b/test/strain_decimal_separator_test.dart new file mode 100644 index 000000000..42f2d720e --- /dev/null +++ b/test/strain_decimal_separator_test.dart @@ -0,0 +1,73 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:openstrap_edge/l10n/app_localizations.dart'; +import 'package:openstrap_edge/l10n/decimal_text.dart'; +import 'package:openstrap_edge/state/locale_controller.dart'; +import 'package:openstrap_edge/ui2/activity/catalogue.dart'; +import 'package:openstrap_edge/ui2/activity/summary.dart'; +import 'package:openstrap_edge/ui2/profile/wearable_numbers.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + // The strain trend screen formats through displayFixed; every other place + // that prints the same strain number has to use the same separator, or a + // German user sees "12.3" on one screen and "12,3" on the next. + test('strain cells use the display locale decimal separator', () { + SharedPreferences.setMockInitialValues({}); + final controller = LocaleController.seed('de')..useForPresentation(); + addTearDown(controller.dispose); + final de = lookupAppLocalizations(const Locale('de')); + + expect(displayFixed(12.3, 1), '12,3'); + expect(formatCell(de, 'strain', 12.3).$1, displayFixed(12.3, 1)); + }); + + // The workout list and the live screen both open the session summary; the + // summary has to print the strain they printed, not a '.' version of it. + test('the session summary prints strain like the list that opens it', () { + SharedPreferences.setMockInitialValues({}); + final controller = LocaleController.seed('de')..useForPresentation(); + addTearDown(controller.dispose); + final r = ActivityResult( + allActivities.first, + start: DateTime(2026, 5, 20, 7, 15), + duration: const Duration(minutes: 45), + strain: 12.3, + ); + final strain = sessionStats(r, null).firstWhere((s) => s.$1 == 'Strain'); + expect(strain.$2, '12,3'); + }); + + // Every on-screen strain number. A source scan, because these sit inside + // widgets that need a full app around them to pump, and the regression is + // one `toStringAsFixed` slipping back in. + test('no screen formats a strain value with toStringAsFixed', () { + const files = [ + 'lib/ui2/screens/home_screen.dart', + 'lib/ui2/screens/workout_screen.dart', + 'lib/ui2/activity/day_strain.dart', + 'lib/ui2/activity/live.dart', + 'lib/ui2/activity/summary.dart', + ]; + final offenders = []; + for (final f in files) { + final lines = File(f).readAsLinesSync(); + for (var i = 0; i < lines.length; i++) { + final window = [ + if (i > 0) lines[i - 1], + lines[i], + ].join(' '); + if (lines[i].contains('toStringAsFixed') && + RegExp(r'strain|Strain|aim\b').hasMatch(window)) { + offenders.add('$f:${i + 1}: ${lines[i].trim()}'); + } + } + } + expect(offenders, isEmpty); + }); +} diff --git a/test/stress_level_label_test.dart b/test/stress_level_label_test.dart new file mode 100644 index 000000000..8151b0dd2 --- /dev/null +++ b/test/stress_level_label_test.dart @@ -0,0 +1,23 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:openstrap_edge/l10n/app_localizations.dart'; +import 'package:openstrap_edge/ui2/screens/wellness_screen.dart'; + +void main() { + test('stress level tokens map to the locale, not the analytics English', () { + final de = lookupAppLocalizations(const Locale('de')); + expect(stressLevelLabel(de, 'elevated'), de.wellnessStressLevelElevated); + expect(stressLevelLabel(de, 'elevated'), isNot('elevated')); + expect(stressLevelLabel(de, 'low'), de.wellnessStressLevelLow); + expect(stressLevelLabel(de, 'normal'), de.wellnessStressLevelNormal); + expect(stressLevelLabel(de, 'high'), de.wellnessStressLevelHigh); + }); + + test('unknown token and missing localizations fall back to the raw token', + () { + final de = lookupAppLocalizations(const Locale('de')); + expect(stressLevelLabel(de, 'weird'), 'weird'); + expect(stressLevelLabel(null, 'high'), 'high'); + }); +} diff --git a/test/ui2/grammar_test.dart b/test/ui2/grammar_test.dart index c9dde9ca6..fc13470ae 100644 --- a/test/ui2/grammar_test.dart +++ b/test/ui2/grammar_test.dart @@ -1,4 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/state/units_controller.dart'; import 'package:openstrap_edge/ui2/grammar.dart'; void main() { @@ -17,6 +18,30 @@ void main() { expect(t.value, 180.0); }); + test('reads a decimal comma that cannot be a thousands separator', () { + // The iOS decimal pad on a comma-locale phone has no '.' key. + expect(Typed.of('4,5').value, 4.5); + expect(Typed.of('72,25').value, 72.25); + expect(Typed.of('0,125').value, 0.125); + expect(Typed.of('1234,567').value, 1234.567); + expect(Typed.of('-0,5').value, -0.5); + expect(Typed.of(' 4,5 ').bad, isFalse); + }); + + test('a comma that could be grouping is still unreadable', () { + for (final s in ['1,200', '72,500', '1,2,3', '1,5.0', ',5', '5,']) { + expect(Typed.of(s).bad, isTrue, reason: s); + expect(Typed.of(s).value, isNull, reason: s); + } + }); + + test('profile weight and height take a decimal comma too', () { + final u = UnitsController.seed(UnitSystem.metric); + expect(u.weightToKg('72,5'), 72.5); + expect(u.heightToCm('180,5'), 180.5); + expect(u.weightToKg('1,200'), isNull); + }); + test('blank text is blank, not bad', () { final t = Typed.of(' '); expect(t.blank, isTrue); diff --git a/test/ui2_activity_test.dart b/test/ui2_activity_test.dart index 3e083adf2..c5e4ce5e1 100644 --- a/test/ui2_activity_test.dart +++ b/test/ui2_activity_test.dart @@ -428,6 +428,24 @@ void main() { expect(hero.$1, grouped(volumeLb)); }); + test('a climb follows the unit setting: feet next to miles, never a ' + 'bare m that reads as miles', () { + final r = ActivityResult(_first(Arch.journey), + start: _start, + duration: const Duration(hours: 1), + distanceKm: 5.02, + gainM: 120); + final imperial = UnitsController.seed(UnitSystem.imperial); + final metric = UnitsController.seed(UnitSystem.metric); + + expect(sessionStats(r, imperial), contains(('Elevation', '+394 ft'))); + expect(sessionStats(r, metric), contains(('Elevation', '+120 m'))); + expect(shareStats(r, imperial).firstWhere((s) => s.$1 == 'Elevation').$2, + '+394 ft'); + expect(shareHero(r, imperial).$3, '+394 ft elevation'); + expect(shareHero(r, metric).$3, '+120 m elevation'); + }); + test('a bodyweight-only session has no volume, not zero volume', () { final log = StrengthLog([ LoggedSet('pull_up', 8, at: _start), @@ -1011,6 +1029,35 @@ void main() { }); } + testWidgets('imperial: a hike reads its climb in feet under its miles', + (tester) async { + tester.view.physicalSize = const Size(390 * 3, 2200 * 3); + tester.view.devicePixelRatio = 3; + addTearDown(tester.view.reset); + + await tester.pumpWidget(_frame( + ChangeNotifierProvider.value( + value: UnitsController.seed(UnitSystem.imperial), + child: ActivitySummary(ActivityResult(_first(Arch.journey), + start: _start, + duration: const Duration(hours: 1), + distanceKm: 5.02, + gainM: 120, + lossM: 30, + elevationM: const [100, 160, 220, 190])), + ), + Brightness.light, + 1.0)); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + expect(find.text('+394 ft climbed'), findsOneWidget); + expect(find.textContaining('m climbed'), findsNothing); + expect(find.text('+394 ft'), findsWidgets); + expect(find.text('−98 ft'), findsOneWidget); + expect(find.text('722 ft'), findsOneWidget); + }); + // ── MT-08 · heat and cold ──────────────────────────────────────────── // // The card is built AROUND its no-signal state. Cold water closes the diff --git a/test/ui2_cycle_read_failed_test.dart b/test/ui2_cycle_read_failed_test.dart new file mode 100644 index 000000000..922bb02c1 --- /dev/null +++ b/test/ui2_cycle_read_failed_test.dart @@ -0,0 +1,64 @@ +// A cycle read that throws must not look like cycle tracking being off. +// +// The catch used to swap in `const CycleData()`, which is `enabled: false`, +// so a failed read rendered the "Cycle tracking is off" opt-in card: her logs +// and prediction looked gone and the screen offered to turn on a feature that +// was already on. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/ui2/screens/cycle_screen.dart'; +import 'package:openstrap_edge/ui2/ui2.dart'; + +Widget _frame(Widget child) => MaterialApp( + debugShowCheckedModeBanner: false, + theme: buildTheme(Brightness.light), + home: Scaffold(body: ListView(children: [child])), +); + +void main() { + testWidgets('a failed read shows a retry card, not the opt-in card', ( + tester, + ) async { + var calls = 0; + await tester.pumpWidget( + _frame( + CycleTab( + loader: () async { + calls++; + if (calls == 1) throw StateError('database closed'); + return const CycleData(enabled: true); + }, + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.textContaining('Cycle tracking is off'), findsNothing); + expect(find.textContaining('Turn on cycle tracking'), findsNothing); + expect(find.textContaining('Could not read your cycle data'), findsOneWidget); + + await tester.tap(find.textContaining('Could not read your cycle data')); + await tester.pumpAndSettle(); + + expect(calls, 2); + expect(find.textContaining('Could not read your cycle data'), findsNothing); + expect(find.textContaining('Cycle tracking is off'), findsNothing); + expect(find.textContaining('No period logged yet'), findsOneWidget); + }); + + testWidgets('a read that keeps failing stays on the retry card', ( + tester, + ) async { + await tester.pumpWidget( + _frame( + CycleTab( + loader: () async => throw StateError('database closed'), + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.textContaining('Cycle tracking is off'), findsNothing); + expect(find.textContaining('Could not read your cycle data'), findsOneWidget); + }); +} diff --git a/test/ui2_home_day_switcher_test.dart b/test/ui2_home_day_switcher_test.dart index 019f503fd..57604d8c6 100644 --- a/test/ui2_home_day_switcher_test.dart +++ b/test/ui2_home_day_switcher_test.dart @@ -12,6 +12,7 @@ import 'package:provider/provider.dart'; import 'package:openstrap_edge/data/day_label.dart'; import 'package:openstrap_edge/data/local_repository.dart'; +import 'package:openstrap_edge/l10n/app_localizations.dart'; import 'package:openstrap_edge/state/app_state.dart'; import 'package:openstrap_edge/theme/theme_controller.dart'; import 'package:openstrap_edge/ui2/screens/screens.dart'; @@ -137,6 +138,53 @@ void main() { _yesterday); }); + // The glance tiles show the switcher's day, so the screen behind a tap has + // to be that day too. They used to open the trend screen on today's window: + // a past day's "2,500" steps landed on today's count. + for (final (label, key) in [ + ('2,500', 'steps'), + ('60', 'resting_hr'), + ('300', 'calories'), + ]) { + testWidgets('on a past day the $key tile opens THAT day', (t) async { + final app = AppState.forTesting(); + addTearDown(app.dispose); + app.repo = _Repo(); + + await t.pumpWidget(MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: app), + ChangeNotifierProvider.value( + value: ThemeController.seed( + AppThemeChoice.light, Brightness.light)), + ], + child: MaterialApp( + theme: buildTheme(Brightness.light), + home: const Scaffold(body: HomeScreen(hour: 9)), + ), + )); + await _settle(t); + await t.tap(find.bySemanticsLabel('Previous day')); + await _settle(t); + + final tile = find.text(label); + await t.scrollUntilVisible(tile, 300, + scrollable: find.byType(Scrollable).first); + await t.tap(tile); + await _settle(t); + + expect(find.byType(MetricDetail), findsNothing); + if (key == 'steps') { + expect(t.widget(find.byType(DayStepsDetail)).day, + _yesterday); + } else { + final inv = t.widget(find.byType(Investigate)); + expect(inv.metricKey, key); + expect(inv.day, _yesterday); + } + }); + } + testWidgets('back on today the sleep ring opens the held-over night', (t) async { final app = AppState.forTesting(); @@ -168,6 +216,18 @@ void main() { expect(t.widget(find.byType(SleepDetail)).day, isNull); }); + test('a past day carries the learned sleep need, so its ring has a target', + () async { + final d = await HomeData.loadForDay(_NeedRepo(), _yesterday); + expect(d.sleepMin.value, 250); + expect(d.sleepNeedMin.value, 465); + }); + + test('a past day with no learned need stays without a target', () async { + final d = await HomeData.loadForDay(_Repo(), _yesterday); + expect(d.sleepNeedMin.value, isNull); + }); + testWidgets('a past day with nothing recorded says so, not "sync the band"', (t) async { final app = AppState.forTesting(); @@ -190,6 +250,54 @@ void main() { expect(find.text('Sync the band'), findsNothing); }); + testWidgets('the past-day empty card follows the app language', (t) async { + final app = AppState.forTesting(); + addTearDown(app.dispose); + app.repo = _EmptyPastRepo(); + + await t.pumpWidget(MaterialApp( + theme: buildTheme(Brightness.light), + locale: const Locale('de'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: ChangeNotifierProvider.value( + value: app, + child: const Scaffold(body: HomeScreen(hour: 9)), + ), + )); + await _settle(t); + + final l = AppLocalizations.of(t.element(find.byType(HomeScreen)))!; + await t.tap(find.bySemanticsLabel(l.metricDetailPreviousDay)); + await _settle(t); + + expect(find.text('Keine Daten für diesen Tag'), findsOneWidget); + expect(find.text('No data for this day'), findsNothing); + }); + + testWidgets('a past day with gaps does not call itself today', (t) async { + final app = AppState.forTesting(); + addTearDown(app.dispose); + app.repo = _PartialPastRepo(); + + await t.pumpWidget(MaterialApp( + theme: buildTheme(Brightness.light), + home: ChangeNotifierProvider.value( + value: app, + child: const Scaffold(body: HomeScreen(hour: 9)), + ), + )); + await _settle(t); + await t.tap(find.bySemanticsLabel('Previous day')); + await _settle(t); + + final title = find.text('Some of this day\'s data is missing'); + await t.scrollUntilVisible(title, 300, + scrollable: find.byType(Scrollable).first); + expect(title, findsOneWidget); + expect(find.text('Some of today\'s data is missing'), findsNothing); + }); + testWidgets('a failed read still shows the alarm door', (t) async { // The alarm lives on AppState, not HomeData, so the error card must not // take it down with it. @@ -209,6 +317,50 @@ void main() { expect(find.text('Today could not be read'), findsOneWidget); expect(find.text('Set an alarm'), findsOneWidget); }); + + // A day switch kept the old day on screen while it loaded, and for good if + // the read threw: the stepper named yesterday while the rings, tiles and + // greeting date were still today's, and nothing said the read had failed. + testWidgets('a past day that fails to load does not keep today on screen', + (t) async { + final app = AppState.forTesting(); + addTearDown(app.dispose); + app.repo = _PastDayThrowsRepo(); + + await t.pumpWidget(MaterialApp( + theme: buildTheme(Brightness.light), + home: ChangeNotifierProvider.value( + value: app, + child: const Scaffold(body: HomeScreen(hour: 9)), + ), + )); + await _settle(t); + expect(find.text('70'), findsOneWidget); + + await t.tap(find.bySemanticsLabel('Previous day')); + await _settle(t); + + expect(find.text('70'), findsNothing); + expect(find.textContaining('could not be read'), findsOneWidget); + expect(find.text('Today could not be read'), findsNothing); + + await t.ensureVisible(find.text('Back to today')); + await t.tap(find.text('Back to today')); + await _settle(t); + expect(find.text('70'), findsOneWidget); + expect(find.textContaining('could not be read'), findsNothing); + }); + + // Home is never disposed (the shell's IndexedStack), so "Back to today" + // storing today's literal date left the tab pinned to that date once the + // clock crossed midnight. + test('stepping onto today stores "today", not a date that goes stale', () { + final evening = DateTime(2026, 10, 16, 22); + expect(homeSwitcherDay('2026-10-16', now: evening), isNull); + expect(homeSwitcherDay('2026-10-15', now: evening), '2026-10-15'); + expect(homeSwitcherDay(_today), isNull); + expect(homeSwitcherDay(_yesterday), _yesterday); + }); } /// Today has a real reading; every past day is genuinely bare (no bundle at @@ -241,3 +393,29 @@ class _FailingRepo extends _EmptyPastRepo { @override Future> availableDays() async => throw StateError('read failed'); } + +/// Yesterday has a readiness and a strain but no resting HR and no +/// calories, so the glance has more than one absence to summarise. +class _PartialPastRepo extends _EmptyPastRepo { + @override + Future> getDayOverview(String date) async => + {'readiness': 42}; + @override + Future> getDayStrain(String date) async => + {'strain': 9.9}; +} + +/// `getDaySleepV2` as the live repo writes it once a need has been learned: +/// `need_min` is a plain minute count beside the night's duration. +class _NeedRepo extends _Repo { + @override + Future> getDaySleepV2(String date) async => + {'has_sleep': true, 'duration_min': 250, 'need_min': 465}; +} + +/// Today reads fine; any past day throws on its first per-day read. +class _PastDayThrowsRepo extends _Repo { + @override + Future> getDayOverview(String date) async => + throw StateError('read failed'); +} diff --git a/test/ui2_home_no_band_test.dart b/test/ui2_home_no_band_test.dart new file mode 100644 index 000000000..a5b8a2623 --- /dev/null +++ b/test/ui2_home_no_band_test.dart @@ -0,0 +1,59 @@ +// "Skip for now" at onboarding opens the app with no band paired. Home's +// first-run card still offered "Sync the band": the sync returned at once, +// the tap grace held "Connecting to your band" up for 20 s over nothing, and +// nothing on Home pointed at pairing. With no band, the card offers pairing. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; + +import 'package:openstrap_edge/state/app_state.dart'; +import 'package:openstrap_edge/sync/paired_device.dart' show PairedDevice; +import 'package:openstrap_edge/ui2/profile/devices.dart' show RePair; +import 'package:openstrap_edge/ui2/screens/screens.dart'; +import 'package:openstrap_edge/ui2/ui2.dart'; + +Widget _host(AppState? app) { + final probe = Builder( + builder: (c) => + Scaffold(body: noBandCard(c, null) ?? const Text('no card')), + ); + final home = MaterialApp(theme: buildTheme(Brightness.light), home: probe); + return app == null + ? home + : ChangeNotifierProvider.value(value: app, child: home); +} + +void main() { + testWidgets('no band paired: Home offers pairing, not a sync', (t) async { + final app = AppState.forTesting(); + addTearDown(app.dispose); + + await t.pumpWidget(_host(app)); + + expect(find.text('No band is paired'), findsOneWidget); + expect(find.text('Pair a band'), findsOneWidget); + expect(find.text('Sync the band'), findsNothing); + + await t.tap(find.text('Pair a band')); + await t.pump(); + await t.pump(const Duration(milliseconds: 400)); + expect(find.byType(RePair), findsOneWidget); + }); + + testWidgets('a paired band gets no pairing card', (t) async { + final app = AppState.forTesting(); + addTearDown(app.dispose); + app.paired = PairedDevice('AA:BB:CC:DD:EE:FF', 'SER1'); + + await t.pumpWidget(_host(app)); + + expect(find.text('No band is paired'), findsNothing); + expect(find.text('no card'), findsOneWidget); + }); + + testWidgets('no AppState above (goldens) gets no pairing card', (t) async { + await t.pumpWidget(_host(null)); + expect(find.text('no card'), findsOneWidget); + }); +} diff --git a/test/ui2_naps_load_failure_test.dart b/test/ui2_naps_load_failure_test.dart new file mode 100644 index 000000000..fb8506dca --- /dev/null +++ b/test/ui2_naps_load_failure_test.dart @@ -0,0 +1,88 @@ +// The Naps screen makes three reads (available days, the day's naps, the +// nap edits) and used to await them with no error handling. A read that +// threw — a busy or locked database is enough — left it on its spinner for +// good, with no message and no way to retry. It now ends in a retryable card. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +import 'package:openstrap_edge/data/local_repository.dart'; +import 'package:openstrap_edge/state/app_state.dart'; +import 'package:openstrap_edge/ui2/screens/screens.dart'; +import 'package:openstrap_edge/ui2/ui2.dart'; + +/// Throws on the first [failures] nap reads, then answers with a judged day +/// that had no naps. +class _FlakyRepo extends LocalRepository { + _FlakyRepo(this.failures); + int failures; + + @override + Future> availableDays() async => const ['2026-10-01']; + + @override + Future> getDayNaps(String date) async { + if (failures > 0) { + failures--; + throw StateError('database is locked'); + } + return const {'naps': [], 'nap_min': 0}; + } +} + +/// `_load` reaches sqflite on a real event loop, so real time has to pass +/// between pumps. Polls until the spinner is gone; false if it never goes. +Future _settle(WidgetTester t) async { + for (var i = 0; i < 40; i++) { + await t.runAsync( + () => Future.delayed(const Duration(milliseconds: 20))); + await t.pump(const Duration(milliseconds: 16)); + if (find.byType(CircularProgressIndicator).evaluate().isEmpty) return true; + } + return false; +} + +Future _pump(WidgetTester t, LocalRepository repo) async { + t.view.physicalSize = const Size(390 * 3, 844 * 3); + t.view.devicePixelRatio = 3; + addTearDown(t.view.reset); + final app = AppState.forTesting(); + addTearDown(app.dispose); + app.repo = repo; + await t.pumpWidget(MaterialApp( + theme: buildTheme(Brightness.light), + home: ChangeNotifierProvider.value( + value: app, + child: const NapsScreen(day: '2026-10-01'), + ), + )); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUpAll(() { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + }); + setUp(() => SharedPreferences.setMockInitialValues({})); + + testWidgets('a read that throws shows a retryable card, not a spinner', + (t) async { + final repo = _FlakyRepo(1); + await _pump(t, repo); + + expect(await _settle(t), isTrue, + reason: 'the Naps screen never left its spinner'); + expect(find.text('This could not be read'), findsOneWidget); + + await t.tap(find.text('Try again')); + await t.pump(); + expect(await _settle(t), isTrue); + expect(find.text('This could not be read'), findsNothing); + expect(find.text('No naps on this day'), findsOneWidget); + }, timeout: const Timeout(Duration(seconds: 60))); +} diff --git a/test/ui2_router_test.dart b/test/ui2_router_test.dart index 16b0f4c4a..07b5db88c 100644 --- a/test/ui2_router_test.dart +++ b/test/ui2_router_test.dart @@ -322,8 +322,9 @@ void main() { expect(saved, isNotNull); expect(saved!['sex'], 'f'); - // The three optional fields were left blank, so they are ABSENT — not - // zero, and not a default body. + // The three optional fields opened blank and stayed blank, so they are + // ABSENT — not zero, not a default body, and not a null over a value + // that arrived while setup was open. expect(saved!.containsKey('age'), isFalse); expect(saved!.containsKey('height_cm'), isFalse); expect(saved!.containsKey('weight_kg'), isFalse); @@ -349,6 +350,26 @@ void main() { expect(saved!.containsKey('height_cm'), isFalse); }); + testWidgets('clearing a stored value writes null over it', (tester) async { + _tallView(tester); + Map? saved; + await tester.pumpWidget(MaterialApp( + theme: buildTheme(Brightness.light), + home: ProfileSetupView( + initial: const {'sex': 'm', 'weight_kg': 78.4, 'age': 34}, + onSave: (f) async => saved = f), + )); + await tester.enterText(find.byType(TextField).at(2), ''); + await tester.pump(); + await tester.tap(find.text('Continue')); + await tester.pump(); + + // Merged into the stored profile, a missing key would keep 78.4. + expect(saved!.containsKey('weight_kg'), isTrue); + expect(saved!['weight_kg'], isNull); + expect(saved!['age'], 34); + }); + testWidgets('a number it cannot read stops the save instead of dropping it', (tester) async { _tallView(tester); @@ -374,9 +395,9 @@ void main() { expect(Typed.of(' ').blank, isTrue); expect(Typed.of(' ').bad, isFalse); expect(Typed.of('78.4').value, 78.4); - // A comma decimal is 1.5 to half of Europe and 15 to the other half, and - // a unit suffix is not a number at all. Neither is guessed at. - for (final s in ['78 kg', '1,5', '1,200', 'twelve']) { + // "1,200" is 1.2 to half of Europe and 1200 to the other half, and a + // unit suffix is not a number at all. Neither is guessed at. + for (final s in ['78 kg', '1,200', 'twelve']) { expect(Typed.of(s).bad, isTrue, reason: s); expect(Typed.of(s).value, isNull, reason: s); } diff --git a/test/ui2_sleep_detail_load_failure_test.dart b/test/ui2_sleep_detail_load_failure_test.dart new file mode 100644 index 000000000..3e8dea385 --- /dev/null +++ b/test/ui2_sleep_detail_load_failure_test.dart @@ -0,0 +1,195 @@ +// A read that throws on the Sleep tab used to look exactly like a day with no +// night: the first load fell through to "No night to show … wear the band +// overnight", and a failed day step left the previous night on screen under +// the new date, with its correction buttons still acting on that night. A +// failed read now ends in its own retryable card, and the same holds for the +// day timeline. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +import 'package:openstrap_edge/data/local_repository.dart'; +import 'package:openstrap_edge/state/app_state.dart'; +import 'package:openstrap_edge/ui2/screens/screens.dart'; +import 'package:openstrap_edge/ui2/ui2.dart'; + +/// Three days on disk, none with a night. Throws on insight and timeline +/// reads while [failures] is above zero. +class _FlakyRepo extends LocalRepository { + _FlakyRepo(this.failures); + int failures; + + void _maybeThrow() { + if (failures > 0) { + failures--; + throw const FormatException('database is locked'); + } + } + + @override + Future> getToday() async => { + 'status': {'today_day': '2026-05-22'}, + }; + + @override + Future> availableDays() async => + ['2026-05-22', '2026-05-20', '2026-05-19']; + + @override + Future> getDaySleepV2(String date) async => {}; + + @override + Future> getDayTimeline(String date) async { + _maybeThrow(); + return {}; + } + + @override + Future> getInsights() async { + _maybeThrow(); + return {}; + } + + @override + Future> getChart(String metric, + {int? from, int? to, Set signals = const {}}) async => + {}; + + @override + Future>> sleepWindows( + {int days = 60, String? before}) async => + []; +} + +Future _settle(WidgetTester t) async { + for (var i = 0; i < 40; i++) { + await t.runAsync( + () => Future.delayed(const Duration(milliseconds: 20))); + await t.pump(const Duration(milliseconds: 16)); + if (find.byType(CircularProgressIndicator).evaluate().isEmpty) return; + } +} + +Future _pump(WidgetTester t, LocalRepository repo, Widget screen) async { + t.view.physicalSize = const Size(390 * 3, 2400 * 3); + t.view.devicePixelRatio = 3; + addTearDown(t.view.reset); + final app = AppState.forTesting(); + addTearDown(app.dispose); + app.repo = repo; + await t.pumpWidget(MaterialApp( + theme: buildTheme(Brightness.light), + home: ChangeNotifierProvider.value(value: app, child: screen), + )); + await _settle(t); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUpAll(() { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + }); + setUp(() => SharedPreferences.setMockInitialValues({})); + + group('the Sleep screen', () { + testWidgets('a failed first read is not "no night", and can be retried', + (t) async { + final repo = _FlakyRepo(1); + await _pump(t, repo, const SleepDetail()); + + expect(find.textContaining('could not be read'), findsOneWidget); + expect(find.text('No night to show'), findsNothing); + + await t.tap(find.text('Try again')); + await t.pump(); + await _settle(t); + expect(find.textContaining('could not be read'), findsNothing); + expect(find.text('No night to show'), findsOneWidget); + }, timeout: const Timeout(Duration(seconds: 60))); + + testWidgets('a failed day step does not leave the old night on screen', + (t) async { + final repo = _FlakyRepo(0); + await _pump(t, repo, const SleepDetail()); + expect(find.text('No night to show'), findsOneWidget); + + repo.failures = 1; + t.widget(find.byType(DayNav)).onDay('2026-05-19'); + await t.pump(); + await _settle(t); + + expect(find.textContaining('could not be read'), findsOneWidget); + expect(find.text('No night to show'), findsNothing); + // The stepper stays so the day can be walked off. + expect(find.byType(DayNav), findsOneWidget); + }, timeout: const Timeout(Duration(seconds: 60))); + + testWidgets( + 'a failed step back to the latest night names that night, ' + 'with no plan under the error, and retries without the old night', + (t) async { + final repo = _FlakyRepo(0); + await _pump( + t, + repo, + const SleepDetail( + day: '2026-05-19', todayFooter: [Text('TONIGHT PLAN')])); + expect(t.widget(find.byType(DayNav)).day, '2026-05-19'); + expect(find.text('TONIGHT PLAN'), findsNothing); + + repo.failures = 1; + t.widget(find.byType(DayNav)).onDay('2026-05-22'); + await t.pump(); + await _settle(t); + + expect(find.textContaining('could not be read'), findsOneWidget); + expect(t.widget(find.byType(DayNav)).day, '2026-05-22'); + expect(find.text('TONIGHT PLAN'), findsNothing); + + // Hold the retry's read so the frame during it can be inspected. + repo.failures = 0; + await t.tap(find.text('Try again')); + await t.pump(); + expect(find.byType(CircularProgressIndicator), findsOneWidget); + await _settle(t); + expect(find.text('No night to show'), findsOneWidget); + }, timeout: const Timeout(Duration(seconds: 60))); + + testWidgets('a retry that fails again keeps the day stepper', (t) async { + final repo = _FlakyRepo(0); + await _pump(t, repo, const SleepDetail(day: '2026-05-19')); + + repo.failures = 1; + t.widget(find.byType(DayNav)).onDay('2026-05-20'); + await t.pump(); + await _settle(t); + expect(find.textContaining('could not be read'), findsOneWidget); + + repo.failures = 1; + await t.tap(find.text('Try again')); + await t.pump(); + await _settle(t); + expect(find.textContaining('could not be read'), findsOneWidget); + expect(find.byType(DayNav), findsOneWidget); + expect(t.widget(find.byType(DayNav)).day, '2026-05-20'); + }, timeout: const Timeout(Duration(seconds: 60))); + }); + + group('the day timeline', () { + // Only the failure is driven here: a successful timeline read also goes + // to the journal, meal and medication tables, which this stub has none of. + testWidgets('a failed read shows a retryable card, not an empty day', + (t) async { + final repo = _FlakyRepo(1); + await _pump(t, repo, const DayTimelineScreen()); + + expect(find.textContaining('could not be read'), findsOneWidget); + expect(find.text('Try again'), findsOneWidget); + }, timeout: const Timeout(Duration(seconds: 60))); + }); +} diff --git a/test/ui2_sleep_detail_test.dart b/test/ui2_sleep_detail_test.dart index d7d3abbfe..ea5c96c86 100644 --- a/test/ui2_sleep_detail_test.dart +++ b/test/ui2_sleep_detail_test.dart @@ -8,6 +8,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/data/day_label.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'; @@ -403,11 +405,9 @@ void main() { find.textContaining('Longest unbroken stretch 2h 42m'), findsOneWidget); }); - testWidgets('tonight is one takeaway, not six numbers', (t) async { - await _pump( - t, - SleepData( - day: '2026-05-20', + SleepData planned(String day, {String? latest}) => SleepData( + day: day, + latestDay: latest, night: _night(), need: const Metric( value: 462, unit: 'min', confidence: .7, tier: MetricTier.estimate), @@ -415,13 +415,147 @@ void main() { value: 22, unit: 'min', confidence: .7, tier: MetricTier.estimate), bedtime: const Metric(value: 1360, confidence: .7, tier: MetricTier.estimate), - ), - ); + ); + + // With one night on disk there is no stepper, so the subtitle dates the + // screen. It printed the raw day key ('2026-05-20') in every locale. + testWidgets('a lone night is dated by name, never by its raw key', + (t) async { + await _pump(t, SleepData(day: '2026-05-20', night: _night())); + expect(find.text('2026-05-20'), findsNothing); + expect(find.text(prettyDay('2026-05-20').toUpperCase()), findsOneWidget); + }); + + testWidgets('tonight is one takeaway, not six numbers', (t) async { + await _pump(t, planned(todayLabel())); expect(find.text('lights out'), findsOneWidget); expect(find.textContaining('Your need is 7h 42m'), findsOneWidget); expect(find.textContaining('22m down'), findsOneWidget); }); + // Need, debt and bedtime are today's rollup whichever night is selected. + testWidgets('a past night carries no plan for tonight', (t) async { + await _pump(t, planned('2026-05-20')); + expect(find.text('lights out'), findsNothing); + expect(find.textContaining('Your need is'), findsNothing); + }); + + // The landing view sits on a held-over night whenever one is served, which + // is a day or more behind the calendar. Tonight's plan still belongs there. + testWidgets('a held-over night the screen opened on keeps tonight', + (t) async { + await _pump(t, planned('2026-05-20', latest: '2026-05-20')); + expect(find.text('lights out'), findsOneWidget); + }); + + testWidgets('a night older than the held-over one carries no plan', + (t) async { + await _pump(t, planned('2026-05-18', latest: '2026-05-20')); + expect(find.text('lights out'), findsNothing); + }); + + group('SleepData.load', () { + test('the newest night on offer is the held-over one, not the calendar', + () async { + final repo = _HeldOverRepo(); + final opened = await SleepData.load(repo); + expect(opened.day, '2026-05-20'); + expect(opened.latestDay, '2026-05-20'); + expect(opened.isLatestNight, isTrue); + + final stepped = await SleepData.load(repo, want: '2026-05-19'); + expect(stepped.day, '2026-05-19'); + expect(stepped.latestDay, '2026-05-20'); + expect(stepped.isLatestNight, isFalse); + }); + }); + + group('sleepScrubTempDelta', () { + test('a relative sensor delta is signed and keeps its one decimal', () { + expect(sleepScrubTempDelta(-12.4), '−12.4'); + expect(sleepScrubTempDelta(3.1), '+3.1'); + }); + + test('zero carries no sign it does not have', () { + expect(sleepScrubTempDelta(0), '±0.0'); + expect(sleepScrubTempDelta(-0.04), '±0.0'); + }); + }); + + group('sleepStepperDay', () { + test('stepping onto the newest night follows it rather than pinning it', + () { + expect(sleepStepperDay('2026-10-17', '2026-10-17'), isNull); + }); + + test('an older night stays pinned', () { + expect(sleepStepperDay('2026-10-16', '2026-10-17'), '2026-10-16'); + }); + + test('without a known newest night the chosen date is kept', () { + expect(sleepStepperDay('2026-10-17', null), '2026-10-17'); + }); + }); + + group('sleepDayAfterOverride', () { + test('a correction to the newest night stops pinning it', () { + expect(sleepDayAfterOverride('2026-10-17', '2026-10-17', '2026-10-17'), + isNull); + }); + + test('an edited night that is no longer the newest stays pinned', () { + expect(sleepDayAfterOverride('2026-10-16', '2026-10-16', '2026-10-17'), + '2026-10-16'); + }); + + test('a night the user stepped to meanwhile is left alone', () { + expect(sleepDayAfterOverride('2026-10-15', '2026-10-17', '2026-10-17'), + '2026-10-15'); + expect(sleepDayAfterOverride(null, '2026-10-16', '2026-10-17'), isNull); + }); + }); + + group('the Sleep tab', () { + Future tab(WidgetTester t, String day, {String? latest}) async { + t.view.physicalSize = const Size(390 * 3, 5200 * 3); + t.view.devicePixelRatio = 3; + addTearDown(t.view.reset); + await t.pumpWidget(MaterialApp( + theme: buildTheme(Brightness.light), + home: Scaffold( + body: SleepDetail( + embedded: true, + data: planned(day, latest: latest), + todayFooter: const [Text('the sleep plan')], + footer: const [Text('alarm and body clock')], + ), + ), + )); + await t.pumpAndSettle(); + } + + testWidgets('leaves tonight to the plan below instead of saying it twice', + (t) async { + await tab(t, todayLabel()); + expect(find.text('lights out'), findsNothing); + expect(find.text('the sleep plan'), findsOneWidget); + expect(find.text('alarm and body clock'), findsOneWidget); + }); + + testWidgets('keeps the plan on a held-over night it opened on', + (t) async { + await tab(t, '2026-05-20', latest: '2026-05-20'); + expect(find.text('the sleep plan'), findsOneWidget); + }); + + testWidgets('drops the plan under a past night, keeps the links', + (t) async { + await tab(t, '2026-05-20'); + expect(find.text('the sleep plan'), findsNothing); + expect(find.text('alarm and body clock'), findsOneWidget); + }); + }); + group('correctedSleepWindow', () { test('a bedtime moved past midnight stays on the same night', () { final (on, off) = correctedSleepWindow( @@ -572,3 +706,38 @@ void main() { expect(find.text('I was asleep'), findsNothing); }); } + +/// `getToday` holding the 2026-05-20 night over, with older nights on disk. +class _HeldOverRepo extends LocalRepository { + @override + Future> getToday() async => { + 'status': { + 'today_day': '2026-05-22', + 'showing_prior_overnight': true, + 'overnight_day': '2026-05-20', + }, + }; + + @override + Future> availableDays() async => + ['2026-05-22', '2026-05-20', '2026-05-19']; + + @override + Future> getDaySleepV2(String date) async => {}; + + @override + Future> getDayTimeline(String date) async => {}; + + @override + Future> getInsights() async => {}; + + @override + Future> getChart(String metric, + {int? from, int? to, Set signals = const {}}) async => + {}; + + @override + Future>> sleepWindows( + {int days = 60, String? before}) async => + []; +} diff --git a/test/ui2_step_goal_unreadable_test.dart b/test/ui2_step_goal_unreadable_test.dart new file mode 100644 index 000000000..bb78e3d61 --- /dev/null +++ b/test/ui2_step_goal_unreadable_test.dart @@ -0,0 +1,78 @@ +// The step goal editor on the steps detail screen must not close as if it had +// saved when what was typed cannot be read as a number. +// +// Driven through the real `MetricDetail('steps', data: d)` widget, which skips +// the repo/AppState load path. There is no AppState Provider above it, so any +// path that reaches `updateProfile` would throw here too. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:openstrap_edge/ui2/screens/screens.dart'; +import 'package:openstrap_edge/ui2/ui2.dart'; + +Future _pumpSteps(WidgetTester t) async { + t.view.physicalSize = const Size(390 * 3, 900 * 3); + t.view.devicePixelRatio = 3; + addTearDown(t.view.reset); + final today = DateTime.now(); + final series = [ + for (var i = 6; i >= 0; i--) + ( + t: today.subtract(Duration(days: i)).millisecondsSinceEpoch ~/ 1000, + v: 6000.0, + ), + ]; + await t.pumpWidget(MaterialApp( + theme: buildTheme(Brightness.light), + home: Scaffold( + body: MetricDetail( + 'steps', + data: MetricData(daysAvailable: 10, series: series, stepGoal: 10000), + ), + ), + )); + await t.pumpAndSettle(); +} + +Future _openEditorAndType(WidgetTester t, String text) async { + // The edit and save buttons' labels merge into the gauge card's one + // semantics node ("60% | Edit daily step goal | Goal | ..."), and tapping + // that node lands on the ring, so the taps go to the buttons themselves. + final edit = find.text('10,000 steps'); + await t.ensureVisible(edit); + await t.tap(edit); + await t.pumpAndSettle(); + await t.enterText(find.byType(TextField), text); + await t.pump(); + await t.tap(find.byIcon(LucideIcons.check)); + await t.pumpAndSettle(); +} + +void main() { + for (final typed in ['12k', '12,000', '12.000 steps']) { + testWidgets('an unreadable goal "$typed" keeps the editor open and says so', + (t) async { + final handle = t.ensureSemantics(); + await _pumpSteps(t); + await _openEditorAndType(t, typed); + + expect(find.byType(SnackBar), findsOneWidget); + expect(find.textContaining('Nothing was saved'), findsOneWidget); + // The editor is still up, holding exactly what was typed. + expect(find.byType(TextField), findsOneWidget); + expect(find.text(typed), findsOneWidget); + handle.dispose(); + }); + } + + testWidgets('a blank goal still closes the editor quietly', (t) async { + final handle = t.ensureSemantics(); + await _pumpSteps(t); + await _openEditorAndType(t, ''); + + expect(find.byType(SnackBar), findsNothing); + expect(find.byType(TextField), findsNothing); + handle.dispose(); + }); +} diff --git a/test/ui2_subtabs_selected_semantics_test.dart b/test/ui2_subtabs_selected_semantics_test.dart new file mode 100644 index 000000000..8b3a094dd --- /dev/null +++ b/test/ui2_subtabs_selected_semantics_test.dart @@ -0,0 +1,63 @@ +// SubTabs drives the 7/30/90-day range on every metric detail and the +// sections of the Health, Wellness, Workouts and Devices screens. The active +// pill is told apart only by fill and font weight, so a screen reader has to +// be given the selected state explicitly or it reads every pill the same. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/ui2/ui2.dart'; + +Widget _harness(Widget child) => MaterialApp( + theme: buildTheme(Brightness.light), + home: Scaffold(body: Center(child: child)), + ); + +void main() { + const items = ['7 days', '30 days', '90 days']; + + testWidgets('only the active pill is announced as selected', (t) async { + final handle = t.ensureSemantics(); + await t.pumpWidget(_harness(SubTabs(items, 1, (_) {}))); + await t.pumpAndSettle(); + + expect( + t.getSemantics(find.text('30 days')), + isSemantics(isSelected: true, isButton: true), + ); + for (final other in const ['7 days', '90 days']) { + expect( + t.getSemantics(find.text(other)), + isSemantics(isSelected: false, isButton: true), + ); + } + handle.dispose(); + }); + + testWidgets('selection follows the index', (t) async { + final handle = t.ensureSemantics(); + await t.pumpWidget(_harness(SubTabs(items, 0, (_) {}))); + await t.pumpAndSettle(); + expect(t.getSemantics(find.text('7 days')), + isSemantics(isSelected: true)); + + await t.pumpWidget(_harness(SubTabs(items, 2, (_) {}))); + await t.pumpAndSettle(); + expect(t.getSemantics(find.text('7 days')), + isSemantics(isSelected: false)); + expect(t.getSemantics(find.text('90 days')), + isSemantics(isSelected: true)); + handle.dispose(); + }); + + testWidgets('a disabled pill is never announced as selected', (t) async { + final handle = t.ensureSemantics(); + await t.pumpWidget( + _harness(SubTabs(items, 1, (_) {}, disabled: const {1}))); + await t.pumpAndSettle(); + expect( + t.getSemantics(find.text('30 days')), + isSemantics(isSelected: false), + ); + handle.dispose(); + }); +} diff --git a/test/ui2_wellness_load_failure_test.dart b/test/ui2_wellness_load_failure_test.dart new file mode 100644 index 000000000..f56be53a7 --- /dev/null +++ b/test/ui2_wellness_load_failure_test.dart @@ -0,0 +1,111 @@ +// The sleep plan at the bottom of the Sleep tab is `WellnessScreen` in +// `sleepPlanOnly` mode, and its `_load` makes about ten reads (medication, +// breathing, insights, charts, journal). Any one of them throwing used to leave +// `_loading` set for good, so the section showed a spinner forever with no +// error and no way to retry. A read that throws now ends in a retryable card. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +import 'package:openstrap_edge/data/journal_fields.dart'; +import 'package:openstrap_edge/data/local_repository.dart'; +import 'package:openstrap_edge/state/app_state.dart'; +import 'package:openstrap_edge/ui2/screens/screens.dart'; +import 'package:openstrap_edge/ui2/ui2.dart'; + +/// Throws on the first [failures] insight reads, then answers with nothing. +class _FlakyRepo extends LocalRepository { + _FlakyRepo(this.failures); + int failures; + + @override + Future> getToday() async => const {}; + + @override + Future> getDayStress(String date) async => const {}; + + @override + Future> getInsights() async { + if (failures > 0) { + failures--; + throw const FormatException('corrupt insights row'); + } + return const {}; + } + + @override + Future> getDayHeart(String date) async => const {}; + + @override + Future> getChart(String metric, + {int? from, int? to, Set signals = const {}}) async => + const {'points': []}; + + @override + Future> getJournalMetrics( + String date) async => + {}; + + @override + Future> getJournalFields() async => const []; +} + +/// `_load` goes to sqflite on a real event loop, so real time has to pass +/// between pumps. Polls until the spinner is gone; false if it never goes. +Future _settle(WidgetTester t) async { + for (var i = 0; i < 40; i++) { + await t.runAsync( + () => Future.delayed(const Duration(milliseconds: 20))); + await t.pump(const Duration(milliseconds: 16)); + if (find.byType(CircularProgressIndicator).evaluate().isEmpty) return true; + } + return false; +} + +Future _pump(WidgetTester t, LocalRepository repo) async { + t.view.physicalSize = const Size(390 * 3, 844 * 3); + t.view.devicePixelRatio = 3; + addTearDown(t.view.reset); + final app = AppState.forTesting(); + addTearDown(app.dispose); + app.repo = repo; + await t.pumpWidget(MaterialApp( + theme: buildTheme(Brightness.light), + home: ChangeNotifierProvider.value( + value: app, + child: const Scaffold( + body: SingleChildScrollView( + child: WellnessScreen(sleepPlanOnly: true), + ), + ), + ), + )); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUpAll(() { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + }); + setUp(() => SharedPreferences.setMockInitialValues({})); + + testWidgets('a read that throws shows a retryable card, not a spinner', + (t) async { + final repo = _FlakyRepo(1); + await _pump(t, repo); + + expect(await _settle(t), isTrue, + reason: 'the sleep plan never left its spinner'); + expect(find.text('This could not be read'), findsOneWidget); + + await t.tap(find.text('Try again')); + await t.pump(); + expect(await _settle(t), isTrue); + expect(find.text('This could not be read'), findsNothing); + }, timeout: const Timeout(Duration(seconds: 60))); +} diff --git a/test/ui2_wellness_recovery_paint_test.dart b/test/ui2_wellness_recovery_paint_test.dart index 500695fca..7b170344e 100644 --- a/test/ui2_wellness_recovery_paint_test.dart +++ b/test/ui2_wellness_recovery_paint_test.dart @@ -292,6 +292,47 @@ void main() { }, timeout: const Timeout(Duration(seconds: 60))); } } + + // A WITHHELD rollup (`getInsights` -> `{'stale': reason}`) after an update + // that changed how days are computed. Home says the insights are being + // rebuilt; this tab used to say there were not enough nights yet and that + // nothing recorded why tonight has no need. + testWidgets('withheld rollup: says it is being rebuilt, not cold start', + (t) async { + await _openRecovery( + t, + dark: false, + scale: 1.0, + repo: _Repo(insights: const { + 'stale': {'kind': 'algo_version', 'algo_version': 1}, + }), + ); + + expect(find.text('No breakdown to show'), findsOneWidget); + expect(find.text('Your cross-day insights are being rebuilt'), + findsOneWidget); + expect(find.text('No recovery breakdown yet'), findsNothing); + expect(find.text('No sleep need yet'), findsNothing); + expect(find.textContaining('enough nights'), findsNothing); + _expectCardPainted(t); + }, timeout: const Timeout(Duration(seconds: 60))); + + // The plan is the Sleep tab's footer, so its debt link used to push a whole + // second SleepDetail over the one already on screen — same night, plus the + // Tonight section the tab leaves out on purpose. It scrolls back up instead. + testWidgets('the debt link stays on the Sleep tab instead of pushing it again', + (t) async { + await _openRecovery(t, dark: false, scale: 1.0, repo: _Repo()); + + final link = find.text('See what last night cost you'); + expect(link, findsOneWidget); + await t.tap(link); + await _frames(t); + + expect(find.byType(SleepDetail), findsNothing); + expect(find.text('See what last night cost you'), findsOneWidget); + expect(find.byType(BackButton), findsNothing); + }, timeout: const Timeout(Duration(seconds: 60))); } /// TWO framework-internal assertions this harness causes and the screen does diff --git a/test/ui2_workout_first_load_test.dart b/test/ui2_workout_first_load_test.dart new file mode 100644 index 000000000..d33996b23 --- /dev/null +++ b/test/ui2_workout_first_load_test.dart @@ -0,0 +1,99 @@ +// The Activity tab's first open, before its one long load has answered. +// +// The bug this pins: the builder drew the EMPTY dataset while the load was +// still running, so a user with sessions and a weight on file was told "No +// sessions recorded yet" on History and asked to add a weight on Activities, +// and a start tile tapped in that window opened setup with a null weight. + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:openstrap_edge/data/local_repository.dart'; +import 'package:openstrap_edge/state/app_state.dart'; +import 'package:openstrap_edge/state/locale_controller.dart'; +import 'package:openstrap_edge/ui2/screens/screens.dart'; +import 'package:openstrap_edge/ui2/screens/start_card.dart'; +import 'package:openstrap_edge/ui2/ui2.dart'; + +/// A repo whose first read never answers — the slow, sync-contended launch. +class _StalledRepo extends LocalRepository { + final profile = Completer>(); + @override + Future> getProfile() => profile.future; +} + +/// A repo whose session-history read fails. +class _BrokenHistoryRepo extends LocalRepository { + @override + Future> getProfile() async => {}; + @override + Future> getWorkouts({String range = 'month'}) async => + throw const FormatException('database is locked'); +} + +Widget _app(AppState app) => MaterialApp( + theme: buildTheme(Brightness.light), + home: MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: app), + ChangeNotifierProvider.value( + value: LocaleController.seed(null), + ), + ], + child: const Scaffold(body: WorkoutScreen()), + ), + ); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + setUp(() => SharedPreferences.setMockInitialValues({})); + + testWidgets('a load in flight is not drawn as an empty history', + (t) async { + t.view.physicalSize = const Size(390 * 3, 2400 * 3); + t.view.devicePixelRatio = 3; + addTearDown(t.view.reset); + + final app = AppState.forTesting(); + addTearDown(app.dispose); + app.repo = _StalledRepo(); + + await t.pumpWidget(_app(app)); + await t.pump(); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + // No start tiles to tap with a weight that has not been read yet. + expect(find.byType(StartCard), findsNothing); + + await t.tap(find.text('Activities')); + await t.pump(); + expect(find.text('Calorie estimates need your weight'), findsNothing); + + await t.tap(find.text('History')); + await t.pump(); + expect(find.text('No sessions recorded yet'), findsNothing); + expect(find.byType(CircularProgressIndicator), findsOneWidget); + }); + + testWidgets('a failed history read is a retry card, not an empty history', + (t) async { + t.view.physicalSize = const Size(390 * 3, 2400 * 3); + t.view.devicePixelRatio = 3; + addTearDown(t.view.reset); + + final app = AppState.forTesting(); + addTearDown(app.dispose); + app.repo = _BrokenHistoryRepo(); + + await t.pumpWidget(_app(app)); + await t.pumpAndSettle(); + await t.tap(find.text('History')); + await t.pumpAndSettle(); + expect(find.text('No sessions recorded yet'), findsNothing); + expect(find.text('Activity could not be read'), findsOneWidget); + }); +} diff --git a/test/vitals_day_failure_nav_test.dart b/test/vitals_day_failure_nav_test.dart new file mode 100644 index 000000000..357340337 --- /dev/null +++ b/test/vitals_day_failure_nav_test.dart @@ -0,0 +1,66 @@ +// Picking a Vitals day drops the old day's readings. A day whose read keeps +// failing must still leave the day stepper on screen, or there is no way off +// it. +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:openstrap_edge/data/local_repository.dart'; +import 'package:openstrap_edge/state/app_state.dart'; +import 'package:openstrap_edge/ui2/screens/screens.dart'; +import 'package:openstrap_edge/ui2/ui2.dart'; + +const _days = ['2026-05-22', '2026-05-20', '2026-05-19']; + +class _FailingDayRepo extends LocalRepository { + @override + Future> getToday() async => { + 'status': {'today_day': '2026-05-22'}, + }; + + @override + Future> availableDays() async => _days; + + @override + Future> getDayTimeline(String date) async => + throw const FormatException('database is locked'); +} + +void main() { + setUp(() => SharedPreferences.setMockInitialValues({})); + + testWidgets('a picked day that keeps failing keeps the day stepper', + (t) async { + t.view.physicalSize = const Size(390 * 3, 2400 * 3); + t.view.devicePixelRatio = 3; + addTearDown(t.view.reset); + final app = AppState.forTesting(); + addTearDown(app.dispose); + app.repo = _FailingDayRepo(); + + await t.pumpWidget(MaterialApp( + theme: buildTheme(Brightness.light), + home: ChangeNotifierProvider.value( + value: app, + child: const Scaffold( + body: HealthScreen( + data: HealthData(daysWithData: 3), + vitals: VitalsData(day: '2026-05-22', days: _days), + tab: 3, + ), + ), + ), + )); + await t.pump(); + expect(find.byType(DayNav), findsOneWidget); + + t.widget(find.byType(DayNav)).onDay('2026-05-19'); + await t.pump(); + await t.pump(const Duration(milliseconds: 50)); + + expect(find.textContaining('Could not read'), findsOneWidget); + expect(find.byType(DayNav), findsOneWidget); + expect(t.widget(find.byType(DayNav)).day, '2026-05-19'); + }); +} diff --git a/test/welcome_import_report_hold_test.dart b/test/welcome_import_report_hold_test.dart new file mode 100644 index 000000000..9848fa084 --- /dev/null +++ b/test/welcome_import_report_hold_test.dart @@ -0,0 +1,99 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:openstrap_edge/app.dart' show resolveRoute; +import 'package:openstrap_edge/state/app_state.dart'; +import 'package:openstrap_edge/ui2/onboarding/welcome.dart'; + +// The gate's welcome/pairing switch, on the real AppState.route and +// resolveRoute. Finishing onboarding swaps WelcomeView out in the same frame, +// which is how a lossy import's report used to vanish before it was drawn. +class _Gate extends StatelessWidget { + final ImportOutcome outcome; + final bool holding; + const _Gate(this.outcome, {required this.holding}); + @override + Widget build(BuildContext context) { + final app = context.read(); + final raw = context.select((a) => a.route); + final r = resolveRoute(raw, pairingSkipped: false, profileSeen: false); + return switch (r) { + AppRoute.welcome => WelcomeView( + onNew: () {}, + onImport: () {}, + outcome: outcome, + onContinue: holding ? app.completeImportOnboard : null), + _ => Scaffold(body: Text('PAIRING:${r.name}')), + }; + } +} + +Future _fresh() async { + SharedPreferences.setMockInitialValues({}); + return AppState()..initialized = true; +} + +void main() { + testWidgets('a lossy onboarding import stays on its report until Continue', + (tester) async { + final app = await _fresh(); + addTearDown(app.dispose); + const o = ImportOutcome( + source: 'Vendor CSV export', + days: 30, + rejectedRows: ['line 4: bad date'], + rollupError: 'boom'); + final hold = await settleOnboardImport(app, o); + expect(hold, isTrue); + await tester.pumpWidget(ChangeNotifierProvider.value( + value: app, child: MaterialApp(home: _Gate(o, holding: hold)))); + await tester.pump(); + expect(app.route, AppRoute.welcome); + expect(find.byType(ImportReport), findsOneWidget); + expect(find.textContaining('Re-analyze everything'), findsOneWidget); + // The only way on is Continue, below the report. + expect(find.text('Set up my band'), findsNothing); + + await tester.scrollUntilVisible(find.text('Continue'), 200); + await tester.tap(find.text('Continue')); + await tester.pumpAndSettle(); + expect(app.route, AppRoute.pairing); + expect(find.text('PAIRING:pairing'), findsOneWidget); + }); + + testWidgets('a clean onboarding import moves straight on to pairing', + (tester) async { + final app = await _fresh(); + addTearDown(app.dispose); + const o = ImportOutcome(source: 'OpenStrap backup', days: 30); + expect(await settleOnboardImport(app, o), isFalse); + expect(app.route, AppRoute.pairing); + }); + + testWidgets('an import that landed nothing leaves onboarding open', + (tester) async { + final app = await _fresh(); + addTearDown(app.dispose); + const o = ImportOutcome( + source: 'Vendor CSV export', rejectedRows: ['line 2: bad date']); + expect(await settleOnboardImport(app, o), isFalse); + expect(app.route, AppRoute.welcome); + }); + + test('every part of the report that says something was refused holds', () { + const base = ImportOutcome(source: 's', days: 1); + expect(base.needsReading, isFalse); + for (final o in const [ + ImportOutcome(source: 's', days: 1, rejectedRows: ['x']), + ImportOutcome(source: 's', days: 1, readError: 'x'), + ImportOutcome(source: 's', days: 1, rollupError: 'x'), + ImportOutcome(source: 's', days: 1, lateRows: 1), + ImportOutcome(source: 's', days: 1, strandedDays: 1), + ImportOutcome(source: 's', days: 1, corruptTables: {'t'}), + ImportOutcome(source: 's', days: 1, archiveBucketsSkipped: 1), + ]) { + expect(o.needsReading, isTrue); + } + }); +}