Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions App/Shared/Resources/de.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -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";
10 changes: 10 additions & 0 deletions App/Shared/Resources/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -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";
10 changes: 10 additions & 0 deletions App/Shared/Resources/es.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -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";
10 changes: 10 additions & 0 deletions App/Shared/Resources/fr.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -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";
10 changes: 10 additions & 0 deletions App/Shared/Resources/it.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -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";
10 changes: 10 additions & 0 deletions App/Shared/Resources/ja.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -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" = "また今度";
10 changes: 10 additions & 0 deletions App/Shared/Resources/ko.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -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" = "나중에";
10 changes: 10 additions & 0 deletions App/Shared/Resources/nl.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -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";
10 changes: 10 additions & 0 deletions App/Shared/Resources/pt-BR.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -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";
10 changes: 10 additions & 0 deletions App/Shared/Resources/zh-Hans.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -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" = "以后再说";
10 changes: 10 additions & 0 deletions App/Shared/Resources/zh-Hant.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -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" = "以後再說";
11 changes: 11 additions & 0 deletions App/Shared/Views/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,17 @@ struct SettingsView: View {
}
#endif

#if os(iOS)
Section("Support") {
NavigationLink {
TipJarView()
} label: {
Label("Support LogWeight", systemImage: "heart")
Comment on lines +84 to +88
}
.accessibilityIdentifier("settings.tipjar")
}
#endif

Section("Apple Health") {
Link(destination: URL(string: "x-apple-health://")!) {
Label("Open Apple Health", systemImage: "heart.text.square")
Expand Down
15 changes: 15 additions & 0 deletions App/iOS/LogWeightApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
75 changes: 75 additions & 0 deletions App/iOS/Support/TipJarStore.swift
Original file line number Diff line number Diff line change
@@ -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<Void, Never>?

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<Transaction>) async {
guard case .verified(let transaction) = result else { return }
await transaction.finish()
if let tip = TipJarProduct(rawValue: transaction.productID) {
purchaseState = .thankYou(tip)
}
}
}
18 changes: 18 additions & 0 deletions App/iOS/Views/EntryView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Void, Never>?
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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: {
Expand Down
72 changes: 72 additions & 0 deletions App/iOS/Views/TipJarView.swift
Original file line number Diff line number Diff line change
@@ -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")
Comment on lines +31 to +51
.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
Loading
Loading