field_ios

In-the-field app for Radroots on iOS
git clone https://radroots.dev/git/field_ios.git
Log | Files | Refs | README | LICENSE

commit 43121e80950668f9f942dd90483e749a2838053b
parent d9aded4d7059b7457a734b135d2717012592d579
Author: triesap <tyson@radroots.org>
Date:   Fri,  2 Oct 2026 13:20:49 +0000

ios: localize typed values without wire drift

- Preserve every admitted decimal digit during native presentation
- Share localized price and quantity across visible and accessible cards
- Check authored dates before native conversion and display
- Qualify locale precision canonical continuity and accessible rendering

Diffstat:
MPackage.swift | 2++
MTera.xcodeproj/project.pbxproj | 4++++
MTera/Runtime/TeraCalendarPresentation.swift | 26++++++++++++++++++++++----
ATera/Runtime/TeraExactDecimalPresentation.swift | 46++++++++++++++++++++++++++++++++++++++++++++++
ATera/Runtime/TeraFoodPresentation.swift | 17+++++++++++++++++
MTera/Views/TeraTodayCardPresentation.swift | 6+++++-
MTera/Views/TeraTodayView.swift | 15+++++++++------
ATeraTests/TeraExactDecimalPresentationTests.swift | 90+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mproject.yml | 1+
Mrelease/provenance.json | 2+-
10 files changed, 197 insertions(+), 12 deletions(-)

diff --git a/Package.swift b/Package.swift @@ -112,6 +112,8 @@ let package = Package( "Runtime/TeraCalendarEditing.swift", "Runtime/TeraCivilDateInput.swift", "Runtime/TeraFoodDecimalEntry.swift", + "Runtime/TeraExactDecimalPresentation.swift", + "Runtime/TeraFoodPresentation.swift", "Runtime/TeraCivilDateFormat.swift", "Runtime/TeraCheckedTime.swift", "Runtime/TeraComposerModels.swift", diff --git a/Tera.xcodeproj/project.pbxproj b/Tera.xcodeproj/project.pbxproj @@ -51,6 +51,7 @@ 4DDC1E0EC1194DD15B2B19A3 /* TeraSubmissionReservationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4721B7A69E2FB989732A64D /* TeraSubmissionReservationTests.swift */; }; 53651EBAF42E8698717CE936 /* TeraScopedStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF28A9D711806A07A4A65EA8 /* TeraScopedStoreTests.swift */; }; 542968135071D02F02BD4614 /* TeraAccessibilityUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D159B8D5E2417578E1C88FC1 /* TeraAccessibilityUITests.swift */; }; + 5813921C9074F00625F87E06 /* TeraExactDecimalPresentationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F617B18A76B833F3575487F /* TeraExactDecimalPresentationTests.swift */; }; 5CA871667D410B9553BF2BF6 /* TeraCapacitySettingsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF84D1BEAAAF218A58EE58D6 /* TeraCapacitySettingsTests.swift */; }; 5DFD8053E97DED72B42BF528 /* TeraRecoveryInventoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77F0FDC0D59BFCB5D7F928D5 /* TeraRecoveryInventoryTests.swift */; }; 5F252ABA8CA48D5E3A7A0157 /* TeraMediaVisibilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27E1E87F9A11E85700F38635 /* TeraMediaVisibilityTests.swift */; }; @@ -228,6 +229,7 @@ 6B8677670E203FF68EC88983 /* TeraRootShellTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraRootShellTests.swift; sourceTree = "<group>"; }; 6D2BE93A0818202625CAF1F5 /* TeraTodayCalendarScopeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraTodayCalendarScopeTests.swift; sourceTree = "<group>"; }; 6D536D6625083ACB3DA4A674 /* TeraComposerDurabilityBoundaryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraComposerDurabilityBoundaryTests.swift; sourceTree = "<group>"; }; + 6F617B18A76B833F3575487F /* TeraExactDecimalPresentationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraExactDecimalPresentationTests.swift; sourceTree = "<group>"; }; 70A358431863E54EDDAA24F4 /* TeraLocalBackupTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraLocalBackupTests.swift; sourceTree = "<group>"; }; 714B4DD70B881D29A89812C9 /* TeraTodayReconciliationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraTodayReconciliationTests.swift; sourceTree = "<group>"; }; 7283D1C8F6E90A236BC84AF6 /* TeraTodayRenderingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraTodayRenderingTests.swift; sourceTree = "<group>"; }; @@ -411,6 +413,7 @@ B310FD8CCA9F9C2DA19BE8CF /* TeraEditingReplacementFFITests.swift */, 0F97ECE43BB7028B5A497720 /* TeraEditingReplacementTests.swift */, 93A3358AFC23C567DB9CB02F /* TeraErrorRecoveryTests.swift */, + 6F617B18A76B833F3575487F /* TeraExactDecimalPresentationTests.swift */, 547AD45DC8C58FC4AA0D073D /* TeraFoodDecimalEntryTests.swift */, 99681A076A08E385942344F7 /* TeraForegroundSubmissionTests.swift */, F666094C5E385A2820F0FE82 /* TeraIdentityCancellationTests.swift */, @@ -743,6 +746,7 @@ 888B178679E012BB053FE31B /* TeraEditingReplacementFFITests.swift in Sources */, 7B8C7C130DC15AF0266F2544 /* TeraEditingReplacementTests.swift in Sources */, D918A664D9A4B9859A1A6503 /* TeraErrorRecoveryTests.swift in Sources */, + 5813921C9074F00625F87E06 /* TeraExactDecimalPresentationTests.swift in Sources */, 116EF45DA06343BA3FA697AD /* TeraFoodDecimalEntryTests.swift in Sources */, 9A7E808CB591126300D3A118 /* TeraForegroundSubmissionTests.swift in Sources */, 41BD76CAA07F6071411F7AC1 /* TeraIdentityCancellationTests.swift in Sources */, diff --git a/Tera/Runtime/TeraCalendarPresentation.swift b/Tera/Runtime/TeraCalendarPresentation.swift @@ -29,16 +29,30 @@ struct TeraCalendarPresentation { } } + func authoredSummary(_ seconds: UInt64) -> String { + instant(seconds, zone: timeZone, label: "Posted") + } + + func authoredDate(_ seconds: UInt64) -> Date? { + displayDate(seconds, zone: timeZone) + } + + private func displayDate(_ seconds: UInt64, zone: TimeZone) -> Date? { + guard let date = TeraCalendarTiming.presentationInstant(seconds) else { return nil } + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = zone + return (1 ... 9999).contains(calendar.component(.year, from: date)) ? date : nil + } + private func sourceInstant(_ seconds: UInt64, zoneID: String, label: String) -> String { guard zoneID.utf8.count <= 255, let zone = TimeZone(identifier: zoneID) else { return "Date unavailable" } return "\(instant(seconds, zone: zone, label: label)) (\(zoneID))" } private func instant(_ seconds: UInt64, zone: TimeZone, label: String) -> String { - guard let date = TeraCalendarTiming.presentationInstant(seconds) else { return "Date unavailable" } + guard let date = displayDate(seconds, zone: zone) else { return "Date unavailable" } var calendar = Calendar(identifier: .gregorian) calendar.timeZone = zone - guard (1 ... 9999).contains(calendar.component(.year, from: date)) else { return "Date unavailable" } let format = DateFormatter() format.locale = locale format.calendar = calendar @@ -65,8 +79,12 @@ extension TeraTodayCard { if let calendarTiming { parts.append(TeraCalendarPresentation(locale: locale, timeZone: timeZone).summary(calendarTiming)) } - if let priceSummary { - parts.append(priceSummary) + let food = TeraFoodPresentation(locale: locale) + if let price = food.price(self) { + parts.append(price) + } + if let quantity = food.quantity(self) { + parts.append(quantity) } if lifecycle != .active { parts.append(lifecycle.rawValue) diff --git a/Tera/Runtime/TeraExactDecimalPresentation.swift b/Tera/Runtime/TeraExactDecimalPresentation.swift @@ -0,0 +1,46 @@ +import Foundation + +/// Display admitted wire decimals as strings. NumberFormatter supplies locale +/// symbols and grouping only; it never receives the full monetary value. +struct TeraExactDecimalPresentation { + var locale: Locale = .current + + func string(_ canonical: String) -> String? { + // The public food contract admits at most 28 digits plus one decimal dot. + // This is a bounded presentation guard, not a second publication validator. + guard canonical.utf8.count <= 29 else { return nil } + let parts = canonical.split(separator: ".", omittingEmptySubsequences: false) + guard (1 ... 2).contains(parts.count), parts.allSatisfy({ !$0.isEmpty }), + parts.allSatisfy({ $0.utf8.allSatisfy { (48 ... 57).contains($0) } }), + parts.reduce(0, { $0 + $1.utf8.count }) <= 28 else { return nil } + let format = NumberFormatter() + format.locale = locale + format.numberStyle = .decimal + let glyphs = (0 ... 9).map { format.string(from: NSNumber(value: $0)) ?? String($0) } + let whole = grouped(parts[0], format: format) + let localized = whole.utf8.map { byte -> String in + (48 ... 57).contains(byte) ? glyphs[Int(byte - 48)] : String(UnicodeScalar(byte)) + }.joined() + // Translate digits before introducing possibly multibyte grouping symbols. + let integer = localized.replacingOccurrences(of: "|", with: format.groupingSeparator ?? "") + guard parts.count == 2 else { return integer } + let fraction = parts[1].utf8.map { glyphs[Int($0 - 48)] }.joined() + return integer + (format.decimalSeparator ?? ".") + fraction + } + + private func grouped(_ whole: Substring, format: NumberFormatter) -> String { + let primary = format.groupingSize + guard format.usesGroupingSeparator, primary > 0, whole.count > primary else { return String(whole) } + let secondary = format.secondaryGroupingSize > 0 ? format.secondaryGroupingSize : primary + var end = whole.endIndex + var width = primary + var groups: [Substring] = [] + while end > whole.startIndex { + let start = whole.index(end, offsetBy: -width, limitedBy: whole.startIndex) ?? whole.startIndex + groups.append(whole[start ..< end]) + end = start + width = secondary + } + return groups.reversed().joined(separator: "|") + } +} diff --git a/Tera/Runtime/TeraFoodPresentation.swift b/Tera/Runtime/TeraFoodPresentation.swift @@ -0,0 +1,17 @@ +import Foundation + +struct TeraFoodPresentation { + var locale: Locale = .current + + func price(_ card: TeraTodayCard) -> String? { + guard let amount = card.priceAmount, let currency = card.priceCurrency, let unit = card.priceUnit else { return nil } + guard let display = TeraExactDecimalPresentation(locale: locale).string(amount) else { return "Amount unavailable" } + return "\(display) \(currency)/\(unit)" + } + + func quantity(_ card: TeraTodayCard) -> String? { + guard let quantity = card.quantity, let unit = card.priceUnit else { return nil } + guard let display = TeraExactDecimalPresentation(locale: locale).string(quantity) else { return "Quantity unavailable" } + return "\(display) \(unit) available" + } +} diff --git a/Tera/Views/TeraTodayCardPresentation.swift b/Tera/Views/TeraTodayCardPresentation.swift @@ -34,9 +34,13 @@ enum TeraTodayCardPresentation { if let timing = card.calendarTiming { parts.append(TeraCalendarPresentation(locale: locale, timeZone: timeZone).summary(timing)) } - if let price = card.priceSummary { + let food = TeraFoodPresentation(locale: locale) + if let price = food.price(card) { parts.append(label(price)) } + if let quantity = food.quantity(card) { + parts.append(label(quantity)) + } if card.lifecycle != .active { parts.append(card.lifecycle.rawValue) } diff --git a/Tera/Views/TeraTodayView.swift b/Tera/Views/TeraTodayView.swift @@ -233,9 +233,11 @@ struct TeraTodayCardView: View { } HStack(spacing: 12) { - Text( - Date(timeIntervalSince1970: TimeInterval(card.authoredAtUnixSeconds)), style: .relative - ) + if let date = TeraCalendarPresentation(locale: locale, timeZone: timeZone).authoredDate(card.authoredAtUnixSeconds) { + Text(date, style: .relative) + } else { + Text("Date unavailable") + } if card.lifecycle != .active { Label(card.lifecycle.rawValue.capitalized, systemImage: "clock") } @@ -268,11 +270,12 @@ struct TeraTodayCardView: View { @ViewBuilder private var foodMetadataLabels: some View { - if let price = card.priceSummary { + let food = TeraFoodPresentation(locale: locale) + if let price = food.price(card) { Label(presentation.label(price), systemImage: "tag") } - if let quantity = card.quantity, let unit = card.priceUnit { - Label(presentation.label("\(quantity) \(unit) available"), systemImage: "basket") + if let quantity = food.quantity(card) { + Label(presentation.label(quantity), systemImage: "basket") } if let location = card.location { Label(presentation.label(location), systemImage: "mappin.and.ellipse") diff --git a/TeraTests/TeraExactDecimalPresentationTests.swift b/TeraTests/TeraExactDecimalPresentationTests.swift @@ -0,0 +1,90 @@ +import Foundation +import SwiftUI +@testable import TeraApp +import XCTest + +final class TeraExactDecimalPresentationTests: XCTestCase { + func testAllAdmittedDigitsSurviveLocaleGroupingWithoutRounding() { + let raw = "12345678901234567890.12345678" + for (locale, expected) in [ + ("en_US", "12,345,678,901,234,567,890.12345678"), + ("de_DE", "12.345.678.901.234.567.890,12345678"), + ("fr_FR", "12\u{202F}345\u{202F}678\u{202F}901\u{202F}234\u{202F}567\u{202F}890,12345678"), + ("ar_EG", "١٢٬٣٤٥٬٦٧٨٬٩٠١٬٢٣٤٬٥٦٧٬٨٩٠٫١٢٣٤٥٦٧٨"), + ("en_IN", "1,23,45,67,89,01,23,45,67,890.12345678"), + ] { + XCTAssertEqual(TeraExactDecimalPresentation(locale: Locale(identifier: locale)).string(raw), expected, locale) + } + XCTAssertEqual(raw, "12345678901234567890.12345678") + } + + func testTinyFractionsAndLargestIntegersRetainEveryDigit() { + let format = TeraExactDecimalPresentation(locale: Locale(identifier: "de_DE")) + XCTAssertEqual(format.string("0.000000000000000000000000001"), "0,000000000000000000000000001") + XCTAssertEqual(format.string("9999999999999999999999999999"), "9.999.999.999.999.999.999.999.999.999") + XCTAssertEqual(format.string("0"), "0") + for raw in ["", "1.", ".1", "1e3", "1,5", "-1", "١", "1.2.3", String(repeating: "9", count: 29)] { + XCTAssertNil(format.string(raw), raw) + } + } + + func testVisibleAndAccessibleFoodValuesUseTheSameExactLocalePresentation() { + let card = foodCard() + let original = card + let locale = Locale(identifier: "de_DE") + let format = TeraFoodPresentation(locale: locale) + XCTAssertEqual(format.price(card), "12.345,67890123456789 ZZZ/bag") + XCTAssertEqual(format.quantity(card), "0,000000000000000000000000001 bag available") + for mode: TeraTodayCardPresentation in [.feed, .detail] { + let summary = mode.accessibility(card, locale: locale, timeZone: .gmt) + XCTAssertTrue(summary.contains("12.345,67890123456789 ZZZ/bag")) + XCTAssertTrue(summary.contains("0,000000000000000000000000001 bag available")) + } + XCTAssertEqual(card, original) + XCTAssertEqual(card.priceAmount, "12345.67890123456789") + XCTAssertEqual(card.priceCurrency, "ZZZ") + XCTAssertEqual(card.priceUnit, "bag") + } + + func testAuthoredInstantChecksTheNativeDomainAndLocalYearBeforeConversion() throws { + let format = TeraCalendarPresentation(locale: Locale(identifier: "en_US"), timeZone: .gmt) + XCTAssertTrue(format.authoredSummary(0).hasPrefix("Posted: Jan 1, 1970")) + XCTAssertEqual(format.authoredDate(0), Date(timeIntervalSince1970: 0)) + for seconds: UInt64 in [253_402_300_800, (1 << 53) + 1, .max] { + XCTAssertEqual(format.authoredSummary(seconds), "Date unavailable") + } + let east = try XCTUnwrap(TimeZone(identifier: "Pacific/Kiritimati")) + XCTAssertEqual(TeraCalendarPresentation(timeZone: east).authoredSummary(253_402_300_799), "Date unavailable") + XCTAssertNil(TeraCalendarPresentation(timeZone: east).authoredDate(253_402_300_799)) + } + + @MainActor + func testProductionFoodCardRendersLocalizedValuesAtAccessibleSize() async throws { + let client = try await TeraScopeFixtures.client(TeraScopeBackend()) + let store = TeraMediaStore(runtimeClient: client) + let view = TeraTodayCardView(card: foodCard(), context: nil, mediaStore: store, presentation: .detail) + .environment(\.locale, Locale(identifier: "de_DE")) + .environment(\.dynamicTypeSize, .accessibility5).padding(16).frame(width: 390) + let renderer = ImageRenderer(content: view) + let image = try XCTUnwrap(renderer.uiImage) + XCTAssertGreaterThan(image.size.height, 100) + let attachment = XCTAttachment(image: image) + attachment.name = "c122-exact-food-de-DE-accessibility5" + attachment.lifetime = .keepAlways + add(attachment) + _ = try await client.stop() + } + + private func foodCard() -> TeraTodayCard { + TeraTodayCard( + id: "food", type: .foodAvailability, sourceEventID: "event", sourceAddress: nil, + authorPublicKey: String(repeating: "a", count: 64), contractID: "test.food", + title: nil, content: "Seasonal food", authoredAtUnixSeconds: 0, effectiveAtUnixSeconds: 0, + calendarTiming: nil, location: nil, priceAmount: "12345.67890123456789", priceCurrency: "ZZZ", + priceUnit: "bag", quantity: "0.000000000000000000000000001", foodSummary: nil, + foodPublishedAtUnixSeconds: nil, foodStatus: nil, contextRank: 1, inclusionReason: "local", + media: [], lifecycle: .active, rankDigest: nil, authorProfile: nil, thread: [], + localOperationID: nil, localOperationState: nil + ) + } +} diff --git a/project.yml b/project.yml @@ -75,6 +75,7 @@ targets: - path: TeraTests/TeraCoordinateAdmissionTests.swift - path: TeraTests/TeraRevisionPreparationTests.swift - path: TeraTests/TeraFoodDecimalEntryTests.swift + - path: TeraTests/TeraExactDecimalPresentationTests.swift - path: TeraTests/TeraRevisionDetailTests.swift - path: TeraTests/TeraRevisionTestBackend.swift - path: TeraTests/TeraStoppedUploadTests.swift diff --git a/release/provenance.json b/release/provenance.json @@ -6,7 +6,7 @@ "info_plist_sha256": "15ef08b1cdd1096cfb9eeaf5be5bf8f814807a7ca9350bbbb47860fa72ec13ef", "privacy_manifest_sha256": "a331d51864743ebe4e00dd22360b4a538b6b3ac26a6b3eb54094e60a36959a12", "sbom_sha256": "9e243176e3eaec256f404207d284314e8c96d3c6861077acb8e760640047ee2f", - "xcode_project_sha256": "e95526f119f2e373f894ea32e63c353661bfceeae96d25d0bd5b797ca9caba09" + "xcode_project_sha256": "e5346577077bbb48a52caf97c7088d2cbebddca5a76b70855afd60c7aa99cc68" }, "disposition": "unsigned", "platforms": [