diff options
Diffstat (limited to 'Hutch/Views/More')
| -rw-r--r-- | Hutch/Views/More/AboutView.swift | 146 | ||||
| -rw-r--r-- | Hutch/Views/More/MoreView.swift | 5 | ||||
| -rw-r--r-- | Hutch/Views/More/TipStoreViewModel.swift | 44 |
3 files changed, 195 insertions, 0 deletions
diff --git a/Hutch/Views/More/AboutView.swift b/Hutch/Views/More/AboutView.swift new file mode 100644 index 0000000..887bef1 --- /dev/null +++ b/Hutch/Views/More/AboutView.swift @@ -0,0 +1,146 @@ +import StoreKit +import SwiftUI + +struct AboutView: View { + @Environment(AppState.self) private var appState + private let appName = Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String + ?? Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String + ?? "Hutch" + private let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String + ?? "Unknown" + private let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String + ?? "Unknown" + @State private var developerRevealCount = 0 + @State private var storeViewModel = TipStoreViewModel() + + private var developerToolsVisible: Bool { + appState.isDebugModeEnabled || developerRevealCount >= 5 + } + + private var developerRevealFooterText: String { + developerRevealCount >= 5 + ? "Debug toggle unlocked. Scroll down to Developer to enable it." + : "Tap the build number 5 times to reveal the debug toggle." + } + + var body: some View { + Form { + Section { + VStack(alignment: .leading, spacing: 6) { + Text(appName) + .font(.title2.weight(.semibold)) + Text("A native SourceHut client for iOS.") + .font(.subheadline) + .foregroundStyle(.secondary) + } + .padding(.vertical, 4) + .themedRow() + + LabeledContent("Version", value: version) + .onTapGesture { + developerRevealCount = min(developerRevealCount + 1, 5) + } + .themedRow() + LabeledContent("Build", value: build) + .onTapGesture { + developerRevealCount = min(developerRevealCount + 1, 5) + } + .themedRow() + } footer: { + Text(developerRevealFooterText) + } + + Section("Links") { + Link(destination: URL(string: "https://sr.ht")!) { + SwiftUI.Label("SourceHut", systemImage: "link") + } + .themedRow() + Link(destination: URL(string: "https://man.sr.ht")!) { + SwiftUI.Label("SourceHut Manuals", systemImage: "book") + } + .themedRow() + Link(destination: URL(string: "https://sr.ht/~ccleberg/Hutch")!) { + SwiftUI.Label("Project Repository", systemImage: "folder") + } + .themedRow() + } + + Section("Support") { + Link(destination: URL(string: "mailto:[email protected]")!) { + SwiftUI.Label("Email Support", systemImage: "envelope") + } + .themedRow() + } + + Section("Privacy") { + Text("Hutch uses your SourceHut personal access token to make requests on your behalf. The token is stored locally in the iOS keychain.") + .font(.subheadline) + .foregroundStyle(.secondary) + .themedRow() + + Link(destination: URL(string: "https://zerolabs.sh/hutch/privacy-policy/")!) { + SwiftUI.Label("Privacy Policy", systemImage: "hand.raised") + } + .themedRow() + } + + Section("Acknowledgements") { + Text("Built for SourceHut users who want quick access to repositories, builds, and tickets on iOS.") + .font(.subheadline) + .foregroundStyle(.secondary) + .themedRow() + } + + Section { + if storeViewModel.isLoading { + ProgressView() + .themedRow() + } else if storeViewModel.products.isEmpty { + Text("Tips unavailable") + .foregroundStyle(.secondary) + .themedRow() + } else { + ForEach(storeViewModel.products, id: \.id) { product in + Button { + Task { + await storeViewModel.purchase(product) + } + } label: { + HStack { + Text(product.displayName) + Spacer() + Text(product.displayPrice) + .foregroundStyle(.secondary) + } + } + .themedRow() + } + } + } header: { + Text("Support Development") + } footer: { + Text("Tips help cover the costs of development and App Store distribution. They are one-time purchases and do not unlock any features.") + } + + if developerToolsVisible { + Section { + Toggle("Debug Mode", isOn: Binding( + get: { appState.isDebugModeEnabled }, + set: { appState.isDebugModeEnabled = $0 } + )) + .themedRow() + } header: { + Text("Developer") + } footer: { + Text("Shows raw API payloads and diagnostic details on builds and tickets screens. This stays hidden until explicitly enabled.") + } + } + } + .themedList() + .navigationTitle("About") + .navigationBarTitleDisplayMode(.inline) + .task { + await storeViewModel.loadProducts() + } + } +} diff --git a/Hutch/Views/More/MoreView.swift b/Hutch/Views/More/MoreView.swift index 4666e53..5c757ef 100644 --- a/Hutch/Views/More/MoreView.swift +++ b/Hutch/Views/More/MoreView.swift @@ -61,6 +61,11 @@ struct MoreView: View { Label("Settings", systemImage: "gear") } .themedRow() + + NavigationLink(value: MoreRoute.about) { + Label("About Hutch", systemImage: "info.circle") + } + .themedRow() } Section { diff --git a/Hutch/Views/More/TipStoreViewModel.swift b/Hutch/Views/More/TipStoreViewModel.swift new file mode 100644 index 0000000..54868ba --- /dev/null +++ b/Hutch/Views/More/TipStoreViewModel.swift @@ -0,0 +1,44 @@ +import StoreKit + +@Observable +final class TipStoreViewModel { + var products: [Product] = [] + var isLoading = false + var errorMessage: String? + + private let productIDs = [ + "net.cleberg.hutch.tip.small", + "net.cleberg.hutch.tip.medium", + "net.cleberg.hutch.tip.large" + ] + + func loadProducts() async { + isLoading = true + defer { isLoading = false } + do { + let fetched = try await Product.products(for: productIDs) + // Sort by price ascending to maintain small/medium/large order + products = fetched.sorted { $0.price < $1.price } + } catch { + errorMessage = error.localizedDescription + } + } + + func purchase(_ product: Product) async { + do { + let result = try await product.purchase() + switch result { + case .success(let verification): + if case .verified(let transaction) = verification { + await transaction.finish() + } + case .userCancelled, .pending: + break + @unknown default: + break + } + } catch { + errorMessage = error.localizedDescription + } + } +} |
