From 282d894119469550d03160372c552d1ab73c0391 Mon Sep 17 00:00:00 2001 From: Matthias Buchhorn Date: Mon, 21 Sep 2026 00:05:40 +0200 Subject: [PATCH] fix(ios): a value typed into the profile stays typed, and says it was entered by you A weight typed by hand reverted to the one read from the reports a moment later, which is the opposite of what a field is for. The fields were seeded with `State(initialValue:)` inside the initialiser, and a sheet's content closure is rebuilt more than once. Each rebuild re-ran the initialiser and put the stored value back over the edit. The field accepted the keystrokes and showed them until the next redraw, which reads like a binding problem and is not one. They are seeded once now, in `onAppear`, guarded by a flag. The initialiser stores the `let`s and nothing else. And an edit means what it says. Typing a figure means it was measured now, so its date moves to today and the row stops claiming the value came from a report: it says "entered by you" instead. Typing the old figure back puts both claims back as they were, because that is no longer an edit. Saving already files each measurement under its own date, so a corrected weight now lands on today rather than on the day the old one was measured. Refs #186. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LFMtTkqc3btcaWNur2A93P --- .../ios/Sources/MeinBefund/ProfileView.swift | 154 ++++++++++++------ .../MeinBefund/de.lproj/Localizable.strings | 1 + .../ios/Tests/UITests/ProfileUITests.swift | 26 +++ docs/gotchas.md | 14 ++ 4 files changed, 146 insertions(+), 49 deletions(-) diff --git a/clients/ios/Sources/MeinBefund/ProfileView.swift b/clients/ios/Sources/MeinBefund/ProfileView.swift index 6577c65f..81e431cb 100644 --- a/clients/ios/Sources/MeinBefund/ProfileView.swift +++ b/clients/ios/Sources/MeinBefund/ProfileView.swift @@ -35,18 +35,49 @@ struct ProfileView: View { ) -> Void let onClose: () -> Void - @State private var sex: RangeSex - @State private var hasBirthDate: Bool - @State private var birthDate: Date - @State private var height: String - - @State private var waist: String - @State private var waistDate: Date - @State private var weight: String - @State private var weightDate: Date - @State private var visceralFat: String - @State private var visceralFatDate: Date - @State private var visceralFatUnit: BodyMeasurements.VisceralFatUnit + // Seeded once, when the screen appears, and never again. + // + // These used to be filled by `State(initialValue:)` in the initialiser. + // That runs every time the sheet's content is rebuilt, and SwiftUI then + // puts the stored value back: a weight typed by hand reverted to the one + // from the reports a moment later, which is the opposite of what a field + // is for. + @State private var seeded = false + @State private var sex: RangeSex = .any + @State private var hasBirthDate = false + @State private var birthDate = Date() + @State private var height = "" + + @State private var waist = Entry() + @State private var weight = Entry() + @State private var visceralFat = Entry() + @State private var visceralFatUnit: BodyMeasurements.VisceralFatUnit = .area + + /// One measurement being edited: what it says, when it was measured, and + /// whether the person changed it here. + struct Entry: Equatable { + var text = "" + var date = Date() + /// Entered on this screen rather than read from a report. A value changed + /// by hand was measured now, not when the old one was, so its date moves + /// to today and says who put it there. + var manual = false + /// What it was seeded with, to tell an edit from a redraw. + var seededText = "" + + mutating func edited(to newText: String) { + guard newText != text else { return } + text = newText + if newText == seededText { + manual = false + } else { + manual = true + date = ReportMetadata.calendarDay(Date()) + } + } + + var binding: Binding { .constant(text) } + } init( profile: Profile, @@ -63,26 +94,28 @@ struct ProfileView: View { self.onSave = onSave self.onMeasurements = onMeasurements self.onClose = onClose - _sex = State(initialValue: profile.sex) - _hasBirthDate = State(initialValue: profile.birthDate != nil) - _birthDate = State( - initialValue: profile.birthDate ?? ReportMetadataExtractor.day(1980, 1, 1) ?? Date()) - _height = State(initialValue: profile.heightCm.map { Measurement.text($0) } ?? "") - - // Prefilled from the most recent measurement of each, with the day it was - // taken, so the screen opens on what is true and every part is editable. - let waistFound = latest["waist-circumference"] - let weightFound = latest["body-weight"] - let fatFound = latest["visceral-fat"] - _waist = State(initialValue: waistFound.map { Measurement.text($0.value) } ?? "") - _waistDate = State(initialValue: waistFound?.date ?? Date()) - _weight = State(initialValue: weightFound.map { Measurement.text($0.value) } ?? "") - _weightDate = State(initialValue: weightFound?.date ?? Date()) - _visceralFat = State(initialValue: fatFound.map { Measurement.text($0.value) } ?? "") - _visceralFatDate = State(initialValue: fatFound?.date ?? Date()) - _visceralFatUnit = State( - initialValue: fatFound.flatMap { BodyMeasurements.VisceralFatUnit(rawValue: $0.ucum) } - ?? .area) + } + + /// Fills the screen from the profile and the most recent measurements. + private func seed() { + guard !seeded else { return } + seeded = true + sex = profile.sex + hasBirthDate = profile.birthDate != nil + birthDate = profile.birthDate ?? ReportMetadataExtractor.day(1980, 1, 1) ?? Date() + height = profile.heightCm.map { Measurement.text($0) } ?? "" + + func fill(_ key: String) -> Entry { + guard let found = latest[key] else { return Entry() } + let text = Measurement.text(found.value) + return Entry(text: text, date: found.date, manual: false, seededText: text) + } + waist = fill("waist-circumference") + weight = fill("body-weight") + visceralFat = fill("visceral-fat") + visceralFatUnit = + latest["visceral-fat"].flatMap { BodyMeasurements.VisceralFatUnit(rawValue: $0.ucum) } + ?? .area } private var edited: Profile { @@ -92,13 +125,13 @@ struct ProfileView: View { } private var waistReading: BodyMeasurements.Reading? { - number(waist).map { .init(value: $0, measuredOn: waistDate) } + number(waist.text).map { .init(value: $0, measuredOn: waist.date) } } private var weightReading: BodyMeasurements.Reading? { - number(weight).map { .init(value: $0, measuredOn: weightDate) } + number(weight.text).map { .init(value: $0, measuredOn: weight.date) } } private var visceralReading: BodyMeasurements.Reading? { - number(visceralFat).map { .init(value: $0, measuredOn: visceralFatDate) } + number(visceralFat.text).map { .init(value: $0, measuredOn: visceralFat.date) } } var body: some View { @@ -126,13 +159,16 @@ struct ProfileView: View { } Section { - measurementRow("Waist", text: $waist, unit: "cm", date: $waistDate, key: "waist-circumference") - measurementRow("Weight", text: $weight, unit: "kg", date: $weightDate, key: "body-weight") + measurementRow("Waist", entry: $waist, unit: "cm", key: "waist-circumference") + measurementRow("Weight", entry: $weight, unit: "kg", key: "body-weight") visceralFatRow - if let waistValue = number(waist), let ratio = edited.waistToHeight(waistCm: waistValue) { + if let waistValue = number(waist.text), + let ratio = edited.waistToHeight(waistCm: waistValue) + { LabeledContent("Waist to height", value: String(format: "%.2f", ratio)) } - if let weightValue = number(weight), let bmi = edited.bodyMassIndex(weightKg: weightValue) + if let weightValue = number(weight.text), + let bmi = edited.bodyMassIndex(weightKg: weightValue) { LabeledContent("BMI", value: String(format: "%.1f", bmi)) } @@ -151,6 +187,7 @@ struct ProfileView: View { } } } + .onAppear(perform: seed) .navigationTitle("Profile") .navigationBarTitleDisplayMode(.inline) .toolbar { @@ -175,7 +212,7 @@ struct ProfileView: View { HStack { Text("Visceral fat") Spacer(minLength: 12) - TextField(text: $visceralFat, prompt: nil) { Text("Visceral fat") } + TextField(text: text(of: $visceralFat), prompt: nil) { Text("Visceral fat") } .keyboardType(.decimalPad) .multilineTextAlignment(.trailing) .labelsHidden() @@ -187,7 +224,7 @@ struct ProfileView: View { .pickerStyle(.menu) .labelsHidden() } - dateRow($visceralFatDate, key: "visceral-fat") + dateRow($visceralFat) } } @@ -197,30 +234,49 @@ struct ProfileView: View { /// its content as a value to read, and a text field inside one is easy to /// miss as something you can tap. private func measurementRow( - _ title: String, text: Binding, unit: String, date: Binding, key: String + _ title: String, entry: Binding, unit: String, key: String ) -> some View { VStack(alignment: .leading, spacing: 6) { HStack { Text(title) Spacer(minLength: 12) - TextField(text: text, prompt: nil) { Text(title) } + TextField(text: text(of: entry), prompt: nil) { Text(title) } .keyboardType(.decimalPad) .multilineTextAlignment(.trailing) .labelsHidden() .accessibilityIdentifier("profile-\(key)") Text(unit).foregroundStyle(.secondary) } - dateRow(date, key: key) + dateRow(entry) } } + /// A text binding that records an edit as an edit. + /// + /// Typing a figure here means it was measured now, so its date moves to + /// today and stops claiming to have come from a report. Typing the old + /// figure back puts both claims back as they were. + private func text(of entry: Binding) -> Binding { + Binding( + get: { entry.wrappedValue.text }, + set: { entry.wrappedValue.edited(to: $0) }) + } + /// The day one measurement was taken, and where that day came from. - private func dateRow(_ date: Binding, key: String) -> some View { + private func dateRow(_ entry: Binding) -> some View { HStack { - DatePicker("Measured", selection: date, in: ...Date(), displayedComponents: .date) - .datePickerStyle(.compact) - .font(.caption) - if let found = latest[key], ReportMetadata.calendarDay(found.date) == ReportMetadata.calendarDay(date.wrappedValue) { + DatePicker( + "Measured", + selection: Binding( + get: { entry.wrappedValue.date }, + set: { entry.wrappedValue.date = $0 }), + in: ...Date(), displayedComponents: .date + ) + .datePickerStyle(.compact) + .font(.caption) + if entry.wrappedValue.manual { + Text("entered by you").font(.caption2).foregroundStyle(.secondary) + } else if !entry.wrappedValue.text.isEmpty { Text("from your reports").font(.caption2).foregroundStyle(.secondary) } } diff --git a/clients/ios/Sources/MeinBefund/de.lproj/Localizable.strings b/clients/ios/Sources/MeinBefund/de.lproj/Localizable.strings index c0e1b268..10bcc054 100644 --- a/clients/ios/Sources/MeinBefund/de.lproj/Localizable.strings +++ b/clients/ios/Sources/MeinBefund/de.lproj/Localizable.strings @@ -250,3 +250,4 @@ "Most measured" = "Am häufigsten gemessen"; "By name" = "Nach Name"; "Show" = "Anzeigen"; +"entered by you" = "von Ihnen eingetragen"; diff --git a/clients/ios/Tests/UITests/ProfileUITests.swift b/clients/ios/Tests/UITests/ProfileUITests.swift index b75ad606..db1da113 100644 --- a/clients/ios/Tests/UITests/ProfileUITests.swift +++ b/clients/ios/Tests/UITests/ProfileUITests.swift @@ -15,6 +15,32 @@ final class ProfileUITests: XCTestCase { return app } + func testAnEditedValueKeepsWhatWasTypedAndMovesItsDate() { + // Typing a figure means it was measured now. It used to revert to the one + // from the reports a moment later, because the fields were seeded in the + // initialiser and the sheet's content is rebuilt more than once. + let app = openProfile() + let weight = AppDriver.require(app.textFields["profile-body-weight"], "the weight field") + weight.tap() + // Clear whatever was prefilled, then type. + weight.press(forDuration: 1.0) + if app.menuItems["Select All"].waitForExistence(timeout: 2) { + app.menuItems["Select All"].tap() + } + weight.typeText("70,4") + XCTAssertEqual(weight.value as? String, "70,4", "the field holds what was typed") + + // The date says it was entered here rather than read from a report. + XCTAssertTrue( + AppDriver.visibleText(app).contains("entered by you"), + "an edited value stops claiming to have come from a report") + + // And it is still there after the screen has been rebuilt. + app.swipeUp() + app.swipeDown() + XCTAssertEqual(weight.value as? String, "70,4", "and it survives a redraw") + } + func testTheWaistCanBeTypedAndIsKept() { // It could be typed, and then the obvious Save button at the top of the // sheet threw it away, because a second button further down was the one diff --git a/docs/gotchas.md b/docs/gotchas.md index be3e8adf..8d5a8c98 100644 --- a/docs/gotchas.md +++ b/docs/gotchas.md @@ -606,3 +606,17 @@ already extracted. Worth remembering when a line looks perfectly ordinary in a diagnostics dump and still refuses to parse: print the scalars, not the string. + +## 2026-09-21 — `State(initialValue:)` in an initialiser undoes what a person typed + +A weight typed into the profile reverted to the one read from the reports a +moment later. The fields were seeded with `State(initialValue:)` inside +`ProfileView.init`, and a sheet's content closure is rebuilt more than once: +each rebuild re-ran the initialiser and put the stored value back over the +edit. + +Seed once, in `.onAppear`, guarded by a flag. The initialiser stores the +`let`s and nothing else. + +The symptom is easy to misread as a binding problem, because the field accepts +the keystrokes and shows them until the next redraw.