diff --git a/App/Shared/Resources/de.lproj/Localizable.strings b/App/Shared/Resources/de.lproj/Localizable.strings index 09e089e..b37acca 100644 --- a/App/Shared/Resources/de.lproj/Localizable.strings +++ b/App/Shared/Resources/de.lproj/Localizable.strings @@ -111,3 +111,13 @@ "No data" = "Keine Daten"; "No weight yet" = "Noch kein Gewicht"; "No weight" = "Kein Gewicht"; + +"Support" = "Support"; +"Support LogWeight" = "LogWeight unterstützen"; +"LogWeight is free, with no ads and no tracking. If it's useful to you, a tip helps keep it that way." = "LogWeight ist kostenlos, ohne Werbung und ohne Tracking. Wenn es dir nützlich ist, hilft ein Trinkgeld, das so zu halten."; +"Thank you for supporting LogWeight!" = "Danke, dass du LogWeight unterstützt!"; +"The purchase didn't go through. Please try again." = "Der Kauf ist fehlgeschlagen. Bitte versuche es erneut."; +"Enjoying LogWeight?" = "Gefällt dir LogWeight?"; +"It's free, with no ads and no tracking. If it's been useful, a small tip helps keep it that way." = "Es ist kostenlos, ohne Werbung und ohne Tracking. Wenn es dir nützlich war, hilft ein kleines Trinkgeld, das so zu halten."; +"Leave a tip" = "Trinkgeld geben"; +"Maybe later" = "Vielleicht später"; diff --git a/App/Shared/Resources/en.lproj/Localizable.strings b/App/Shared/Resources/en.lproj/Localizable.strings index bcb6c39..7ff21fc 100644 --- a/App/Shared/Resources/en.lproj/Localizable.strings +++ b/App/Shared/Resources/en.lproj/Localizable.strings @@ -111,3 +111,13 @@ "No data" = "No data"; "No weight yet" = "No weight yet"; "No weight" = "No weight"; + +"Support" = "Support"; +"Support LogWeight" = "Support LogWeight"; +"LogWeight is free, with no ads and no tracking. If it's useful to you, a tip helps keep it that way." = "LogWeight is free, with no ads and no tracking. If it's useful to you, a tip helps keep it that way."; +"Thank you for supporting LogWeight!" = "Thank you for supporting LogWeight!"; +"The purchase didn't go through. Please try again." = "The purchase didn't go through. Please try again."; +"Enjoying LogWeight?" = "Enjoying LogWeight?"; +"It's free, with no ads and no tracking. If it's been useful, a small tip helps keep it that way." = "It's free, with no ads and no tracking. If it's been useful, a small tip helps keep it that way."; +"Leave a tip" = "Leave a tip"; +"Maybe later" = "Maybe later"; diff --git a/App/Shared/Resources/es.lproj/Localizable.strings b/App/Shared/Resources/es.lproj/Localizable.strings index 6d59b52..3dc39f2 100644 --- a/App/Shared/Resources/es.lproj/Localizable.strings +++ b/App/Shared/Resources/es.lproj/Localizable.strings @@ -111,3 +111,13 @@ "No data" = "Sin datos"; "No weight yet" = "Aún no hay peso"; "No weight" = "Sin peso"; + +"Support" = "Apoyo"; +"Support LogWeight" = "Apoyar a LogWeight"; +"LogWeight is free, with no ads and no tracking. If it's useful to you, a tip helps keep it that way." = "LogWeight es gratis, sin anuncios ni seguimiento. Si te resulta útil, una propina ayuda a que siga siendo así."; +"Thank you for supporting LogWeight!" = "¡Gracias por apoyar a LogWeight!"; +"The purchase didn't go through. Please try again." = "La compra no se pudo completar. Inténtalo de nuevo."; +"Enjoying LogWeight?" = "¿Te gusta LogWeight?"; +"It's free, with no ads and no tracking. If it's been useful, a small tip helps keep it that way." = "Es gratis, sin anuncios ni seguimiento. Si te ha resultado útil, una pequeña propina ayuda a que siga siendo así."; +"Leave a tip" = "Dejar una propina"; +"Maybe later" = "Quizá más tarde"; diff --git a/App/Shared/Resources/fr.lproj/Localizable.strings b/App/Shared/Resources/fr.lproj/Localizable.strings index f8af59e..156a861 100644 --- a/App/Shared/Resources/fr.lproj/Localizable.strings +++ b/App/Shared/Resources/fr.lproj/Localizable.strings @@ -111,3 +111,13 @@ "No data" = "Aucune donnée"; "No weight yet" = "Aucun poids pour le moment"; "No weight" = "Aucun poids"; + +"Support" = "Soutien"; +"Support LogWeight" = "Soutenir LogWeight"; +"LogWeight is free, with no ads and no tracking. If it's useful to you, a tip helps keep it that way." = "LogWeight est gratuit, sans publicité ni suivi. Si l’application vous est utile, un pourboire aide à le rester."; +"Thank you for supporting LogWeight!" = "Merci de soutenir LogWeight !"; +"The purchase didn't go through. Please try again." = "L’achat n’a pas abouti. Veuillez réessayer."; +"Enjoying LogWeight?" = "LogWeight vous plaît ?"; +"It's free, with no ads and no tracking. If it's been useful, a small tip helps keep it that way." = "L’application est gratuite, sans publicité ni suivi. Si elle vous a été utile, un petit pourboire aide à le rester."; +"Leave a tip" = "Laisser un pourboire"; +"Maybe later" = "Plus tard"; diff --git a/App/Shared/Resources/it.lproj/Localizable.strings b/App/Shared/Resources/it.lproj/Localizable.strings index 71bbd4c..efd48bd 100644 --- a/App/Shared/Resources/it.lproj/Localizable.strings +++ b/App/Shared/Resources/it.lproj/Localizable.strings @@ -111,3 +111,13 @@ "No data" = "Nessun dato"; "No weight yet" = "Nessun peso ancora"; "No weight" = "Nessun peso"; + +"Support" = "Supporto"; +"Support LogWeight" = "Sostieni LogWeight"; +"LogWeight is free, with no ads and no tracking. If it's useful to you, a tip helps keep it that way." = "LogWeight è gratuita, senza pubblicità né tracciamento. Se ti è utile, una mancia aiuta a mantenerla così."; +"Thank you for supporting LogWeight!" = "Grazie per aver sostenuto LogWeight!"; +"The purchase didn't go through. Please try again." = "L’acquisto non è andato a buon fine. Riprova."; +"Enjoying LogWeight?" = "Ti piace LogWeight?"; +"It's free, with no ads and no tracking. If it's been useful, a small tip helps keep it that way." = "È gratuita, senza pubblicità né tracciamento. Se ti è stata utile, una piccola mancia aiuta a mantenerla così."; +"Leave a tip" = "Lascia una mancia"; +"Maybe later" = "Forse più tardi"; diff --git a/App/Shared/Resources/ja.lproj/Localizable.strings b/App/Shared/Resources/ja.lproj/Localizable.strings index 3ed692f..04d2191 100644 --- a/App/Shared/Resources/ja.lproj/Localizable.strings +++ b/App/Shared/Resources/ja.lproj/Localizable.strings @@ -111,3 +111,13 @@ "No data" = "データなし"; "No weight yet" = "まだ体重がありません"; "No weight" = "体重なし"; + +"Support" = "サポート"; +"Support LogWeight" = "LogWeightをサポート"; +"LogWeight is free, with no ads and no tracking. If it's useful to you, a tip helps keep it that way." = "LogWeightは無料で、広告もトラッキングもありません。役立っていると感じたら、チップがその継続を支えます。"; +"Thank you for supporting LogWeight!" = "LogWeightを応援していただきありがとうございます!"; +"The purchase didn't go through. Please try again." = "購入を完了できませんでした。もう一度お試しください。"; +"Enjoying LogWeight?" = "LogWeightは気に入っていますか?"; +"It's free, with no ads and no tracking. If it's been useful, a small tip helps keep it that way." = "無料で、広告もトラッキングもありません。役立っていたら、少額のチップがその継続を支えます。"; +"Leave a tip" = "チップを送る"; +"Maybe later" = "また今度"; diff --git a/App/Shared/Resources/ko.lproj/Localizable.strings b/App/Shared/Resources/ko.lproj/Localizable.strings index 2ee380c..5b8a274 100644 --- a/App/Shared/Resources/ko.lproj/Localizable.strings +++ b/App/Shared/Resources/ko.lproj/Localizable.strings @@ -111,3 +111,13 @@ "No data" = "데이터 없음"; "No weight yet" = "아직 체중 없음"; "No weight" = "체중 없음"; + +"Support" = "지원"; +"Support LogWeight" = "LogWeight 후원"; +"LogWeight is free, with no ads and no tracking. If it's useful to you, a tip helps keep it that way." = "LogWeight는 광고와 추적 없이 무료로 제공됩니다. 유용하셨다면 팁이 이를 유지하는 데 도움이 됩니다."; +"Thank you for supporting LogWeight!" = "LogWeight를 후원해 주셔서 감사합니다!"; +"The purchase didn't go through. Please try again." = "구매가 완료되지 않았습니다. 다시 시도해 주세요."; +"Enjoying LogWeight?" = "LogWeight를 잘 사용하고 계신가요?"; +"It's free, with no ads and no tracking. If it's been useful, a small tip helps keep it that way." = "광고와 추적 없이 무료로 제공됩니다. 유용하셨다면 소액의 팁이 이를 유지하는 데 도움이 됩니다."; +"Leave a tip" = "팁 남기기"; +"Maybe later" = "나중에"; diff --git a/App/Shared/Resources/nl.lproj/Localizable.strings b/App/Shared/Resources/nl.lproj/Localizable.strings index 3acd855..0735a21 100644 --- a/App/Shared/Resources/nl.lproj/Localizable.strings +++ b/App/Shared/Resources/nl.lproj/Localizable.strings @@ -111,3 +111,13 @@ "No data" = "Geen gegevens"; "No weight yet" = "Nog geen gewicht"; "No weight" = "Geen gewicht"; + +"Support" = "Ondersteuning"; +"Support LogWeight" = "LogWeight ondersteunen"; +"LogWeight is free, with no ads and no tracking. If it's useful to you, a tip helps keep it that way." = "LogWeight is gratis, zonder advertenties en zonder tracking. Als het nuttig voor je is, helpt een fooi om dat zo te houden."; +"Thank you for supporting LogWeight!" = "Bedankt voor je steun aan LogWeight!"; +"The purchase didn't go through. Please try again." = "De aankoop is niet gelukt. Probeer het opnieuw."; +"Enjoying LogWeight?" = "Bevalt LogWeight je?"; +"It's free, with no ads and no tracking. If it's been useful, a small tip helps keep it that way." = "Het is gratis, zonder advertenties en zonder tracking. Als het nuttig was, helpt een kleine fooi om dat zo te houden."; +"Leave a tip" = "Een fooi geven"; +"Maybe later" = "Misschien later"; diff --git a/App/Shared/Resources/pt-BR.lproj/Localizable.strings b/App/Shared/Resources/pt-BR.lproj/Localizable.strings index 0bde1cd..9319abf 100644 --- a/App/Shared/Resources/pt-BR.lproj/Localizable.strings +++ b/App/Shared/Resources/pt-BR.lproj/Localizable.strings @@ -111,3 +111,13 @@ "No data" = "Sem dados"; "No weight yet" = "Ainda sem peso"; "No weight" = "Sem peso"; + +"Support" = "Apoio"; +"Support LogWeight" = "Apoiar o LogWeight"; +"LogWeight is free, with no ads and no tracking. If it's useful to you, a tip helps keep it that way." = "O LogWeight é gratuito, sem anúncios e sem rastreamento. Se ele é útil para você, uma gorjeta ajuda a manter assim."; +"Thank you for supporting LogWeight!" = "Obrigado por apoiar o LogWeight!"; +"The purchase didn't go through. Please try again." = "A compra não foi concluída. Tente novamente."; +"Enjoying LogWeight?" = "Está gostando do LogWeight?"; +"It's free, with no ads and no tracking. If it's been useful, a small tip helps keep it that way." = "É gratuito, sem anúncios e sem rastreamento. Se tem sido útil, uma pequena gorjeta ajuda a manter assim."; +"Leave a tip" = "Deixar uma gorjeta"; +"Maybe later" = "Talvez depois"; diff --git a/App/Shared/Resources/zh-Hans.lproj/Localizable.strings b/App/Shared/Resources/zh-Hans.lproj/Localizable.strings index bad5b32..6a27d6a 100644 --- a/App/Shared/Resources/zh-Hans.lproj/Localizable.strings +++ b/App/Shared/Resources/zh-Hans.lproj/Localizable.strings @@ -111,3 +111,13 @@ "No data" = "无数据"; "No weight yet" = "尚无体重"; "No weight" = "无体重"; + +"Support" = "支持"; +"Support LogWeight" = "支持 LogWeight"; +"LogWeight is free, with no ads and no tracking. If it's useful to you, a tip helps keep it that way." = "LogWeight 免费提供,没有广告也没有追踪。如果它对你有帮助,赞赏有助于保持这样。"; +"Thank you for supporting LogWeight!" = "感谢你支持 LogWeight!"; +"The purchase didn't go through. Please try again." = "购买未完成,请重试。"; +"Enjoying LogWeight?" = "喜欢 LogWeight 吗?"; +"It's free, with no ads and no tracking. If it's been useful, a small tip helps keep it that way." = "它是免费的,没有广告也没有追踪。如果对你有帮助,一点小赞赏有助于保持这样。"; +"Leave a tip" = "赞赏一下"; +"Maybe later" = "以后再说"; diff --git a/App/Shared/Resources/zh-Hant.lproj/Localizable.strings b/App/Shared/Resources/zh-Hant.lproj/Localizable.strings index 7a21f3d..c95f4c4 100644 --- a/App/Shared/Resources/zh-Hant.lproj/Localizable.strings +++ b/App/Shared/Resources/zh-Hant.lproj/Localizable.strings @@ -111,3 +111,13 @@ "No data" = "沒有資料"; "No weight yet" = "尚無體重"; "No weight" = "沒有體重"; + +"Support" = "支援"; +"Support LogWeight" = "支援 LogWeight"; +"LogWeight is free, with no ads and no tracking. If it's useful to you, a tip helps keep it that way." = "LogWeight 免費提供,沒有廣告也沒有追蹤。如果它對你有幫助,贊賞有助於保持這樣。"; +"Thank you for supporting LogWeight!" = "感謝你支援 LogWeight!"; +"The purchase didn't go through. Please try again." = "購買未完成,請再試一次。"; +"Enjoying LogWeight?" = "喜歡 LogWeight 嗎?"; +"It's free, with no ads and no tracking. If it's been useful, a small tip helps keep it that way." = "它是免費的,沒有廣告也沒有追蹤。如果對你有幫助,一點小贊賞有助於保持這樣。"; +"Leave a tip" = "贊賞一下"; +"Maybe later" = "以後再說"; diff --git a/App/Shared/Views/SettingsView.swift b/App/Shared/Views/SettingsView.swift index a909e9f..d7e99d7 100644 --- a/App/Shared/Views/SettingsView.swift +++ b/App/Shared/Views/SettingsView.swift @@ -80,6 +80,17 @@ struct SettingsView: View { } #endif +#if os(iOS) + Section("Support") { + NavigationLink { + TipJarView() + } label: { + Label("Support LogWeight", systemImage: "heart") + } + .accessibilityIdentifier("settings.tipjar") + } +#endif + Section("Apple Health") { Link(destination: URL(string: "x-apple-health://")!) { Label("Open Apple Health", systemImage: "heart.text.square") diff --git a/App/iOS/LogWeightApp.swift b/App/iOS/LogWeightApp.swift index e62bb15..cc9d975 100644 --- a/App/iOS/LogWeightApp.swift +++ b/App/iOS/LogWeightApp.swift @@ -20,6 +20,7 @@ struct LogWeightApp: App { private static let preparingHintDelay: Duration = .seconds(1.4) init() { + Self.resetUserDefaultsForUITestIfNeeded() SettingsMigrator.migrateIfNeeded() let store = Self.makeStore() self.healthKitStore = store @@ -90,6 +91,20 @@ struct LogWeightApp: App { return InMemoryHealthKitStore(samples: fixtureSamples) } + /// UI tests run in the same simulator app container across launches (and, on a + /// persistent CI runner, across separate CI jobs). `--use-in-memory-store` already + /// isolates HealthKit; without this, `logweight_*` UserDefaults keys — notably the + /// tip-jar `successfulEntryCount` counter — keep accumulating and can cross + /// `TipPromptCoordinator.promptThreshold`, popping the tip sheet mid-test and + /// breaking assertions that don't expect it. + private static func resetUserDefaultsForUITestIfNeeded() { + guard CommandLine.arguments.contains("--use-in-memory-store") else { return } + let defaults = UserDefaults.standard + for (key, _) in defaults.dictionaryRepresentation() where key.hasPrefix("logweight_") { + defaults.removeObject(forKey: key) + } + } + private static func parseSeedFixture(from arguments: [String]) -> ScreenshotFixture? { let prefix = "--seed=" guard let raw = arguments.first(where: { $0.hasPrefix(prefix) })?.dropFirst(prefix.count) else { diff --git a/App/iOS/Support/TipJarStore.swift b/App/iOS/Support/TipJarStore.swift new file mode 100644 index 0000000..75bf073 --- /dev/null +++ b/App/iOS/Support/TipJarStore.swift @@ -0,0 +1,75 @@ +import Observation +import StoreKit +import LogWeightCore + +/// Loads tip-jar products and drives purchases via StoreKit 2. +/// +/// Tips are consumables: a successful purchase has nothing to unlock or +/// restore, so there is no entitlement to track beyond `purchaseState` for +/// the confirmation UI. No server round-trip, no receipt validation needed. +@Observable +@MainActor +final class TipJarStore { + + enum PurchaseState: Equatable { + case idle + case purchasing(TipJarProduct) + case thankYou(TipJarProduct) + case failed + } + + private(set) var products: [Product] = [] + private(set) var purchaseState: PurchaseState = .idle + @ObservationIgnored + private nonisolated(unsafe) var transactionListener: Task? + + init() { + transactionListener = Task { [weak self] in + for await result in Transaction.updates { + await self?.handle(result) + } + } + } + + deinit { + transactionListener?.cancel() + } + + func loadProducts() async { + do { + let identifiers = TipJarProduct.allCases.map(\.rawValue) + products = try await Product.products(for: identifiers) + .sorted { $0.price < $1.price } + } catch { + products = [] + } + } + + func purchase(_ product: Product) async { + guard let tip = TipJarProduct(rawValue: product.id) else { return } + purchaseState = .purchasing(tip) + do { + switch try await product.purchase() { + case .success(.verified(let transaction)): + await transaction.finish() + purchaseState = .thankYou(tip) + case .success(.unverified): + purchaseState = .failed + case .userCancelled, .pending: + purchaseState = .idle + @unknown default: + purchaseState = .idle + } + } catch { + purchaseState = .failed + } + } + + private func handle(_ result: VerificationResult) async { + guard case .verified(let transaction) = result else { return } + await transaction.finish() + if let tip = TipJarProduct(rawValue: transaction.productID) { + purchaseState = .thankYou(tip) + } + } +} diff --git a/App/iOS/Views/EntryView.swift b/App/iOS/Views/EntryView.swift index 92e86b9..5c5eff8 100644 --- a/App/iOS/Views/EntryView.swift +++ b/App/iOS/Views/EntryView.swift @@ -20,6 +20,9 @@ struct EntryView: View { @AppStorage(SettingsKey.hapticsEnabled) private var hapticsEnabled: Bool = true @State private var showSettings = false + @State private var showTipPrompt = false + @State private var showTipJar = false + @State private var pendingOpenTipJar = false @State private var isEditingValue = false @State private var typedValue: String = "" @State private var clearSavedStatusTask: Task? @@ -110,6 +113,17 @@ struct EntryView: View { .sheet(isPresented: $showSettings) { SettingsView() } + .sheet(isPresented: $showTipPrompt, onDismiss: { + if pendingOpenTipJar { + pendingOpenTipJar = false + showTipJar = true + } + }) { + TipPromptSheet(onOpenTipJar: { pendingOpenTipJar = true }) + } + .sheet(isPresented: $showTipJar) { + NavigationStack { TipJarView() } + } .sensoryFeedback(.success, trigger: state.saveStatus) { _, new in if hapticsEnabled, case .savedAt = new { return true } return false @@ -264,6 +278,10 @@ struct EntryView: View { await state.commit(store: store) if case .savedAt = state.saveStatus { syncWidgetAfterSuccessfulSave() + if TipPromptCoordinator.recordSuccessfulEntry() { + TipPromptCoordinator.markPresented() + showTipPrompt = true + } } } } label: { diff --git a/App/iOS/Views/TipJarView.swift b/App/iOS/Views/TipJarView.swift new file mode 100644 index 0000000..ba7c4e8 --- /dev/null +++ b/App/iOS/Views/TipJarView.swift @@ -0,0 +1,72 @@ +import SwiftUI +import StoreKit +import LogWeightCore + +/// "Support LogWeight" screen: lists consumable tip tiers. +/// +/// No paywall, no nagging — reachable only from Settings, or from the +/// one-time `TipPromptSheet` shown after `TipPromptCoordinator.promptThreshold` +/// successful entries. +struct TipJarView: View { + @State private var store = TipJarStore() + + var body: some View { + List { + Section { + ForEach(store.products, id: \.id) { product in + Button { + Task { await store.purchase(product) } + } label: { + HStack { + Label(product.displayName, systemImage: symbol(for: product)) + Spacer() + Text(product.displayPrice) + .foregroundStyle(.secondary) + } + } + .disabled(isPurchasing) + .accessibilityIdentifier("tipjar.product.\(product.id)") + } + + Text("LogWeight is free, with no ads and no tracking. If it's useful to you, a tip helps keep it that way.") + .font(.footnote) + .foregroundStyle(.secondary) + } + + if case .thankYou = store.purchaseState { + Section { + Label("Thank you for supporting LogWeight!", systemImage: "heart.fill") + .foregroundStyle(.pink) + .accessibilityIdentifier("tipjar.thankyou") + } + } + + if case .failed = store.purchaseState { + Section { + Text("The purchase didn't go through. Please try again.") + .foregroundStyle(.red) + } + } + } + .navigationTitle("Support LogWeight") + .navigationBarTitleDisplayMode(.inline) + .task { + await store.loadProducts() + } + } + + private var isPurchasing: Bool { + if case .purchasing = store.purchaseState { return true } + return false + } + + private func symbol(for product: Product) -> String { + TipJarProduct(rawValue: product.id)?.sfSymbol ?? "heart" + } +} + +#if DEBUG +#Preview { + NavigationStack { TipJarView() } +} +#endif diff --git a/App/iOS/Views/TipPromptSheet.swift b/App/iOS/Views/TipPromptSheet.swift new file mode 100644 index 0000000..55d0b2d --- /dev/null +++ b/App/iOS/Views/TipPromptSheet.swift @@ -0,0 +1,45 @@ +import SwiftUI + +/// One-time, subtle nudge shown after `TipPromptCoordinator.promptThreshold` +/// successful entries. Never shown again after this, regardless of which +/// button the user taps — `EntryView` marks it presented before showing it. +struct TipPromptSheet: View { + let onOpenTipJar: () -> Void + + @Environment(\.dismiss) private var dismiss + + var body: some View { + VStack(spacing: 20) { + Image(systemName: "heart") + .font(.largeTitle) + .foregroundStyle(.pink) + Text("Enjoying LogWeight?") + .font(.title3.weight(.semibold)) + Text("It's free, with no ads and no tracking. If it's been useful, a small tip helps keep it that way.") + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + Button { + onOpenTipJar() + dismiss() + } label: { + Text("Leave a tip") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .accessibilityIdentifier("tipprompt.leaveTip") + Button("Maybe later") { dismiss() } + .foregroundStyle(.secondary) + .accessibilityIdentifier("tipprompt.later") + } + .padding(24) + .presentationDetents([.medium]) + } +} + +#if DEBUG +#Preview { + TipPromptSheet(onOpenTipJar: {}) +} +#endif diff --git a/App/iOSScreenshots/EntryScreenshots.swift b/App/iOSScreenshots/EntryScreenshots.swift index 89d8f97..a26cb8e 100644 --- a/App/iOSScreenshots/EntryScreenshots.swift +++ b/App/iOSScreenshots/EntryScreenshots.swift @@ -39,15 +39,4 @@ final class EntryScreenshots: ScreenshotTestCase { Thread.sleep(forTimeInterval: 0.2) attachScreenshot(named: "entry-keyboard-up") } - - /// Entry surface at XXXL Dynamic Type — accessibility regression sanity check. - func test_entry_xxxl_dynamic_type() throws { - launchApp(extraArguments: [ - "-UIPreferredContentSizeCategoryName", - "UICTContentSizeCategoryAccessibilityXXXL" - ]) - waitForElement(app.buttons["entry.stepper.plus"], named: "entry.stepper.plus") - Thread.sleep(forTimeInterval: 0.3) - attachScreenshot(named: "entry-xxxl-dynamic-type") - } } diff --git a/App/iOSScreenshots/HistoryScreenshots.swift b/App/iOSScreenshots/HistoryScreenshots.swift index c3c5e47..ecf2001 100644 --- a/App/iOSScreenshots/HistoryScreenshots.swift +++ b/App/iOSScreenshots/HistoryScreenshots.swift @@ -69,18 +69,6 @@ final class HistoryScreenshots: ScreenshotTestCase { attachScreenshot(named: "history-list-small-scroll") } - /// History tab with crosshair active (long-press on chart) showing tooltip. - func test_history_chart_crosshair() throws { - launchApp(seed: "linearTrend30Days") - openHistoryTab() - let chart = app.descendants(matching: .any)["history.chart"] - waitForElement(chart, named: "history.chart") - // Long-press the chart to engage the crosshair gesture. - chart.press(forDuration: 0.6) - Thread.sleep(forTimeInterval: 0.4) - attachScreenshot(named: "history-chart-crosshair") - } - private func openHistoryTab() { app.openHistoryTab() } diff --git a/App/iOSScreenshots/Resources/LogWeight.storekit b/App/iOSScreenshots/Resources/LogWeight.storekit new file mode 100644 index 0000000..8ea556d --- /dev/null +++ b/App/iOSScreenshots/Resources/LogWeight.storekit @@ -0,0 +1,65 @@ +{ + "identifier" : "5A0F1A54-9C3B-4B7B-8B1C-6F1B2E7C1A11", + "nonRenewingSubscriptions" : [], + "products" : [ + { + "displayPrice" : "0.99", + "familyShareable" : false, + "internalID" : "B89A08B9-F353-4094-A469-290ADF7A7679", + "localizations" : [ + { + "description" : "A small tip to support LogWeight's development.", + "displayName" : "Small Tip", + "locale" : "en_US" + } + ], + "productID" : "dev.logweight.tip.small", + "referenceName" : "Small Tip", + "type" : "Consumable" + }, + { + "displayPrice" : "2.99", + "familyShareable" : false, + "internalID" : "33FDA2EF-B828-452D-849A-99129C889541", + "localizations" : [ + { + "description" : "A medium tip to support LogWeight's development.", + "displayName" : "Medium Tip", + "locale" : "en_US" + } + ], + "productID" : "dev.logweight.tip.medium", + "referenceName" : "Medium Tip", + "type" : "Consumable" + }, + { + "displayPrice" : "4.99", + "familyShareable" : false, + "internalID" : "EB79018E-AC54-48A9-869B-C0E95BEFCE2E", + "localizations" : [ + { + "description" : "A large tip to support LogWeight's development.", + "displayName" : "Large Tip", + "locale" : "en_US" + } + ], + "productID" : "dev.logweight.tip.large", + "referenceName" : "Large Tip", + "type" : "Consumable" + } + ], + "settings" : { + "_askToBuyEnabled" : false, + "_billingIssuesEnabled" : false, + "_disableDialogs" : false, + "_failTransactionsEnabled" : false, + "_locale" : "en_US", + "_storefront" : "USA", + "_storeKitErrors" : [] + }, + "subscriptionGroups" : [], + "version" : { + "major" : 3, + "minor" : 0 + } +} diff --git a/App/iOSScreenshots/ScreenshotTestCase.swift b/App/iOSScreenshots/ScreenshotTestCase.swift index cd419d4..7c5944f 100644 --- a/App/iOSScreenshots/ScreenshotTestCase.swift +++ b/App/iOSScreenshots/ScreenshotTestCase.swift @@ -1,4 +1,5 @@ import XCTest +import StoreKitTest /// Base class for AI-driven screenshot scenes. /// @@ -16,12 +17,40 @@ import XCTest class ScreenshotTestCase: XCTestCase { var app: XCUIApplication! + private var storeKitSession: SKTestSession? override func setUpWithError() throws { continueAfterFailure = false app = XCUIApplication() } + override func tearDownWithError() throws { + storeKitSession = nil + } + + /// Starts a local StoreKit testing session from `Resources/LogWeight.storekit` + /// so IAP-driven scenes (the tip jar) load real `Product`s without a + /// network call. Scheme-level StoreKit configuration only applies to the + /// Run action, not Test, so scenes needing IAP call this explicitly + /// before `launchApp()`. + /// + /// Reads the file straight from the checked-out source tree via + /// `#filePath` rather than as a bundled resource: XcodeGen 2.46 silently + /// drops `.storekit` files from a target's `resources:` list (confirmed + /// against an identically-placed `.txt`, which copies fine), so bundling + /// isn't an option here. + @discardableResult + func startStoreKitTestSession() throws -> SKTestSession { + let url = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .appendingPathComponent("Resources/LogWeight.storekit") + let session = try SKTestSession(contentsOf: url) + session.disableDialogs = true + session.clearTransactions() + storeKitSession = session + return session + } + /// Launches the app with the standard screenshot launch arguments. /// Pass `seed` to preload the in-memory store with a `ScreenshotFixture`. /// `extraArguments` is appended verbatim (e.g. for Dynamic Type overrides). diff --git a/App/iOSScreenshots/SettingsScreenshots.swift b/App/iOSScreenshots/SettingsScreenshots.swift index 0895773..bcab44c 100644 --- a/App/iOSScreenshots/SettingsScreenshots.swift +++ b/App/iOSScreenshots/SettingsScreenshots.swift @@ -30,4 +30,20 @@ final class SettingsScreenshots: ScreenshotTestCase { Thread.sleep(forTimeInterval: 0.3) attachScreenshot(named: "settings-lbs-unit") } + + /// Support LogWeight tip-jar screen — used as the IAP review screenshot in App Store Connect. + func test_settings_tipjar() throws { + try startStoreKitTestSession() + launchApp() + let settings = app.buttons["entry.settings"] + waitForElement(settings, named: "entry.settings") + settings.tap() + let tipJarLink = app.buttons["settings.tipjar"] + waitForElement(tipJarLink, named: "settings.tipjar") + tipJarLink.tap() + let smallTip = app.buttons["tipjar.product.dev.logweight.tip.small"] + waitForElement(smallTip, named: "tipjar.product.dev.logweight.tip.small", timeout: 10) + Thread.sleep(forTimeInterval: 0.3) + attachScreenshot(named: "settings-tipjar") + } } diff --git a/App/iOSWidget/LogWeightChartConfigIntent.swift b/App/iOSWidget/LogWeightChartConfigIntent.swift index a604049..33e4e8f 100644 --- a/App/iOSWidget/LogWeightChartConfigIntent.swift +++ b/App/iOSWidget/LogWeightChartConfigIntent.swift @@ -44,7 +44,7 @@ struct LogWeightChartConfigIntent: WidgetConfigurationIntent { static let description = IntentDescription("Shows your weight trend from Apple Health.") @Parameter(title: "Range") - var range: ChartWidgetRange + var range: ChartWidgetRange? init() { range = .oneWeek diff --git a/App/iOSWidget/LogWeightChartWidget.swift b/App/iOSWidget/LogWeightChartWidget.swift index 41c1c10..28d6449 100644 --- a/App/iOSWidget/LogWeightChartWidget.swift +++ b/App/iOSWidget/LogWeightChartWidget.swift @@ -72,7 +72,7 @@ struct LogWeightChartProvider: AppIntentTimelineProvider { private static func resolvedChartRange( configuration: LogWeightChartConfigIntent ) async -> ChartTimeRange { - let range = configuration.range.chartTimeRange + let range = (configuration.range ?? .oneWeek).chartTimeRange WidgetChartRangeStore.save(range) return range } diff --git a/Packages/LogWeightCore/Sources/LogWeightCore/Settings/SettingsKeys.swift b/Packages/LogWeightCore/Sources/LogWeightCore/Settings/SettingsKeys.swift index 02fdd71..6c94abc 100644 --- a/Packages/LogWeightCore/Sources/LogWeightCore/Settings/SettingsKeys.swift +++ b/Packages/LogWeightCore/Sources/LogWeightCore/Settings/SettingsKeys.swift @@ -16,6 +16,8 @@ public enum SettingsKey { public static let reminderEnabled = "logweight_reminder_enabled" public static let reminderHour = "logweight_reminder_hour" public static let reminderMinute = "logweight_reminder_minute" + public static let successfulEntryCount = "logweight_successful_entry_count" + public static let tipPromptShown = "logweight_tip_prompt_shown" public static let schemaVersion = "logweight_settings_schema_version" } diff --git a/Packages/LogWeightCore/Sources/LogWeightCore/Support/TipJarProduct.swift b/Packages/LogWeightCore/Sources/LogWeightCore/Support/TipJarProduct.swift new file mode 100644 index 0000000..29512e7 --- /dev/null +++ b/Packages/LogWeightCore/Sources/LogWeightCore/Support/TipJarProduct.swift @@ -0,0 +1,21 @@ +import Foundation + +/// Consumable tip-jar product identifiers. +/// +/// Kept in Core (Foundation-only, no StoreKit import) so both the App-layer +/// StoreKit wrapper and `TipPromptCoordinator`'s tests share one source of +/// truth for the identifiers registered in App Store Connect and the local +/// `.storekit` configuration file. +public enum TipJarProduct: String, CaseIterable, Sendable { + case small = "dev.logweight.tip.small" + case medium = "dev.logweight.tip.medium" + case large = "dev.logweight.tip.large" + + public var sfSymbol: String { + switch self { + case .small: return "cup.and.saucer" + case .medium: return "heart" + case .large: return "star" + } + } +} diff --git a/Packages/LogWeightCore/Sources/LogWeightCore/Support/TipPromptCoordinator.swift b/Packages/LogWeightCore/Sources/LogWeightCore/Support/TipPromptCoordinator.swift new file mode 100644 index 0000000..2efbf8b --- /dev/null +++ b/Packages/LogWeightCore/Sources/LogWeightCore/Support/TipPromptCoordinator.swift @@ -0,0 +1,31 @@ +import Foundation + +/// Decides when to show the one-time "support LogWeight" tip prompt. +/// +/// Shown at most once, after the user has logged `promptThreshold` successful +/// weight entries. Pure `UserDefaults` logic with no StoreKit/UIKit +/// dependency, so it is exercised without any purchase infrastructure — the +/// App layer only calls `recordSuccessfulEntry(defaults:)` once per +/// successful `EntryState.commit(store:)` and presents the prompt when it +/// returns `true`. +public enum TipPromptCoordinator { + public static let promptThreshold = 10 + + /// Increments the successful-entry count and reports whether the prompt + /// should be shown now. Returns `true` at most once per install. + @discardableResult + public static func recordSuccessfulEntry(defaults: UserDefaults = .standard) -> Bool { + let count = defaults.integer(forKey: SettingsKey.successfulEntryCount) + 1 + defaults.set(count, forKey: SettingsKey.successfulEntryCount) + guard count >= promptThreshold, !defaults.bool(forKey: SettingsKey.tipPromptShown) else { + return false + } + return true + } + + /// Marks the prompt as shown so it never appears again, regardless of + /// whether the user opened the tip jar or dismissed it. + public static func markPresented(defaults: UserDefaults = .standard) { + defaults.set(true, forKey: SettingsKey.tipPromptShown) + } +} diff --git a/Packages/LogWeightCore/Tests/LogWeightCoreTests/TipPromptCoordinatorTests.swift b/Packages/LogWeightCore/Tests/LogWeightCoreTests/TipPromptCoordinatorTests.swift new file mode 100644 index 0000000..e578386 --- /dev/null +++ b/Packages/LogWeightCore/Tests/LogWeightCoreTests/TipPromptCoordinatorTests.swift @@ -0,0 +1,51 @@ +import XCTest +@testable import LogWeightCore + +final class TipPromptCoordinatorTests: XCTestCase { + + private func freshDefaults(_ name: String) -> UserDefaults { + let defaults = UserDefaults(suiteName: name)! + defaults.removePersistentDomain(forName: name) + return defaults + } + + func testDoesNotPromptBeforeThreshold() { + let defaults = freshDefaults("TipPromptCoordinatorTests.before") + for _ in 1..&2; exit 1 ;; esac done +if [[ -n "$APPEARANCE" && "$APPEARANCE" != "light" && "$APPEARANCE" != "dark" ]]; then + echo "Invalid --appearance: $APPEARANCE (must be 'light' or 'dark')" >&2 + exit 1 +fi + if [[ "$RUN_ALL" == false && -z "$SCENE" ]]; then echo "Usage: Tools/CaptureScene.sh --scene | --all [--device ]" >&2 echo "" >&2 @@ -93,7 +103,7 @@ run_tests() { -testLanguage en \ -testRegion US \ CODE_SIGNING_ALLOWED=NO \ - "${only_testing_flag[@]}" \ + ${only_testing_flag[@]+"${only_testing_flag[@]}"} \ 2>&1 | grep -E "(Test|error:|warning:|Build)" | grep -v "^$" || true # xcodebuild exits non-zero when tests fail; we tolerate that because some @@ -181,5 +191,10 @@ PYEOF echo "Booting simulator: $DEVICE" UDID="$(boot_if_needed "$DEVICE")" +if [[ -n "$APPEARANCE" ]]; then + echo "Setting appearance: $APPEARANCE" + xcrun simctl ui "$UDID" appearance "$APPEARANCE" +fi + run_tests "$UDID" extract_attachments diff --git a/Tools/CaptureStoreScreenshots.sh b/Tools/CaptureStoreScreenshots.sh index 7af1c8b..2be8002 100755 --- a/Tools/CaptureStoreScreenshots.sh +++ b/Tools/CaptureStoreScreenshots.sh @@ -15,9 +15,13 @@ # (A SCREENSHOT_LOCALE env var doesn't work here: env vars set by the calling # shell don't cross into the simulator-hosted XCTest process.) # -# Note: Apple currently caps uploads at 10 screenshots per device size — -# trim Docs/store-screenshots/// down to the best 10 -# before uploading if a folder ends up with more. +# Note: Apple caps uploads at 10 screenshots per device size. ALL_SCENES in +# screenshot-scenes.sh is kept at exactly 10 for this reason — if it grows +# past that, trim Docs/store-screenshots/// before upload. +# +# Always captured in light mode (forced per-device below) — that's the +# storefront's chosen presentation, independent of whatever appearance a +# given simulator happens to be left in. set -euo pipefail @@ -176,6 +180,10 @@ for l in "${!LOCALE_LANGUAGES[@]}"; do echo "" echo "=== $device ($key) [$language-$region] ===" udid="$(boot_if_needed "$device")" + # App Store screenshots are always captured in light mode — set explicitly + # rather than relying on whatever appearance the simulator happened to be + # left in (that state is per-simulator and doesn't travel with this repo). + xcrun simctl ui "$udid" appearance light run_all_scenes "$udid" "$result_bundle" "$language" "$region" extract_attachments "$result_bundle" "$attach_tmp" "$out_dir" done diff --git a/Tools/screenshot-scenes.sh b/Tools/screenshot-scenes.sh index cacbf93..ccfb11e 100644 --- a/Tools/screenshot-scenes.sh +++ b/Tools/screenshot-scenes.sh @@ -16,15 +16,16 @@ scene_to_test() { entry-default) echo "LogWeightScreenshots/EntryScreenshots/test_entry_default" ;; entry-after-plus-ten) echo "LogWeightScreenshots/EntryScreenshots/test_entry_after_plus_ten" ;; entry-keyboard-up) echo "LogWeightScreenshots/EntryScreenshots/test_entry_keyboard_up" ;; - entry-xxxl-dynamic-type) echo "LogWeightScreenshots/EntryScreenshots/test_entry_xxxl_dynamic_type" ;; history-empty) echo "LogWeightScreenshots/HistoryScreenshots/test_history_empty" ;; history-with-chart-30d) echo "LogWeightScreenshots/HistoryScreenshots/test_history_with_chart_30d" ;; history-90d-plateau) echo "LogWeightScreenshots/HistoryScreenshots/test_history_90d_plateau" ;; - history-chart-crosshair) echo "LogWeightScreenshots/HistoryScreenshots/test_history_chart_crosshair" ;; history-list-scrolled) echo "LogWeightScreenshots/HistoryScreenshots/test_history_list_scrolled" ;; history-list-small-scroll) echo "LogWeightScreenshots/HistoryScreenshots/test_history_list_small_scroll" ;; settings-default) echo "LogWeightScreenshots/SettingsScreenshots/test_settings_default" ;; settings-lbs-unit) echo "LogWeightScreenshots/SettingsScreenshots/test_settings_lbs_unit" ;; + # Not in ALL_SCENES: an App Store Connect IAP-review screenshot, not a + # marketing scene — must stay out of the App Store screenshot set. + settings-tipjar) echo "LogWeightScreenshots/SettingsScreenshots/test_settings_tipjar" ;; *) echo "Unknown scene: $1" >&2 echo "Run Tools/CaptureScene.sh with no arguments to see available scenes." >&2 @@ -33,16 +34,16 @@ scene_to_test() { esac } -# All scene IDs — used by --all to print the usage list. +# All scene IDs — used by --all, and by Tools/CaptureStoreScreenshots.sh to +# build the App Store screenshot set (Apple caps uploads at 10 per device +# size, so keep this list at exactly 10). ALL_SCENES=( entry-default entry-after-plus-ten entry-keyboard-up - entry-xxxl-dynamic-type history-empty history-with-chart-30d history-90d-plateau - history-chart-crosshair history-list-scrolled history-list-small-scroll settings-default diff --git a/docs/AIScreenshotWorkflow.md b/docs/AIScreenshotWorkflow.md index 030e156..2b8e3f7 100644 --- a/docs/AIScreenshotWorkflow.md +++ b/docs/AIScreenshotWorkflow.md @@ -27,19 +27,30 @@ Output PNGs land in `Docs/ai-screenshots/` (gitignored). The script prints the a ## Scene catalog +All 10 scenes below are in `ALL_SCENES` and make up the App Store screenshot +set (`Tools/CaptureStoreScreenshots.sh`) — capped there at exactly 10 per +device size per Apple's upload limit. + | Scene ID | Class | What it shows | |---|---|---| | `entry-default` | `EntryScreenshots` | Entry surface, splash dismissed, empty store | | `entry-after-plus-ten` | `EntryScreenshots` | Entry surface after 10 `+` taps — Save enabled | | `entry-keyboard-up` | `EntryScreenshots` | Entry surface with decimal-pad keyboard open | -| `entry-xxxl-dynamic-type` | `EntryScreenshots` | Entry surface at XXXL accessibility text size | | `history-empty` | `HistoryScreenshots` | History sheet, no entries | | `history-with-chart-30d` | `HistoryScreenshots` | History sheet, 30-day linear trend chart | | `history-90d-plateau` | `HistoryScreenshots` | History sheet, 90-day plateau-then-drop chart | -| `history-chart-crosshair` | `HistoryScreenshots` | History chart with crosshair/tooltip engaged | +| `history-list-scrolled` | `HistoryScreenshots` | History list scrolled, chart follows | +| `history-list-small-scroll` | `HistoryScreenshots` | History list nudged one row, chart in sync | | `settings-default` | `SettingsScreenshots` | Settings sheet, default state (kg) | | `settings-lbs-unit` | `SettingsScreenshots` | Settings sheet after switching to lbs | +Not in `ALL_SCENES` — capture with `--scene` directly, excluded from the App +Store set on purpose: + +| Scene ID | Class | What it shows | +|---|---|---| +| `settings-tipjar` | `SettingsScreenshots` | Support LogWeight (tip jar) screen — App Store Connect IAP review screenshot, not a marketing scene | + --- ## AI iteration loop diff --git a/project.yml b/project.yml index 175f1c4..41eac57 100644 --- a/project.yml +++ b/project.yml @@ -207,6 +207,8 @@ targets: deploymentTarget: "17.0" sources: - path: App/iOSScreenshots + excludes: + - "Resources/LogWeight.storekit" - path: App/iOSUITests/UITestNavigation.swift dependencies: - target: LogWeight @@ -238,6 +240,7 @@ schemes: LogWeightUITests: [test] run: config: Debug + storeKitConfiguration: App/iOSScreenshots/Resources/LogWeight.storekit test: config: Debug targets: @@ -272,6 +275,7 @@ schemes: LogWeightScreenshots: [test] run: config: Debug + storeKitConfiguration: App/iOSScreenshots/Resources/LogWeight.storekit test: config: Debug targets: