krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v3.8.1: Hutch/Views/More/AboutView.swift · raw
1import StoreKit
2import SwiftUI
3
4struct AboutView: View {
5 @Environment(AppState.self) private var appState
6 private let appName = Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String
7 ?? Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String
8 ?? "Hutch"
9 private let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String
10 ?? "Unknown"
11 private let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String
12 ?? "Unknown"
13 @State private var developerRevealCount = 0
14 @State private var storeViewModel = TipStoreViewModel()
15
16 private var developerToolsVisible: Bool {
17 appState.isDebugModeEnabled || developerRevealCount >= 5
18 }
19
20 private var developerRevealFooterText: String {
21 developerRevealCount >= 5
22 ? "Debug toggle unlocked. Scroll down to Developer to enable it."
23 : "Tap the build number 5 times to reveal the debug toggle."
24 }
25
26 var body: some View {
27 Form {
28 Section {
29 VStack(alignment: .leading, spacing: 6) {
30 Text(appName)
31 .font(.title2.weight(.semibold))
32 Text("A native SourceHut client for iOS.")
33 .font(.subheadline)
34 .foregroundStyle(.secondary)
35 }
36 .padding(.vertical, 4)
37 .themedRow()
38
39 LabeledContent("Version", value: version)
40 .onTapGesture {
41 developerRevealCount = min(developerRevealCount + 1, 5)
42 }
43 .themedRow()
44 LabeledContent("Build", value: build)
45 .onTapGesture {
46 developerRevealCount = min(developerRevealCount + 1, 5)
47 }
48 .themedRow()
49 } footer: {
50 Text(developerRevealFooterText)
51 }
52
53 Section("Links") {
54 Link(destination: URL(string: "https://sr.ht")!) {
55 SwiftUI.Label("SourceHut", systemImage: "link")
56 }
57 .themedRow()
58 Link(destination: URL(string: "https://man.sr.ht")!) {
59 SwiftUI.Label("SourceHut Manuals", systemImage: "book")
60 }
61 .themedRow()
62 Link(destination: URL(string: "https://sr.ht/~ccleberg/Hutch")!) {
63 SwiftUI.Label("Project Repository", systemImage: "folder")
64 }
65 .themedRow()
66 }
67
68 Section("Support") {
69 Link(destination: URL(string: "mailto:hello@cleberg.net")!) {
70 SwiftUI.Label("Email Support", systemImage: "envelope")
71 }
72 .themedRow()
73 }
74
75 Section("Privacy") {
76 Text("Hutch uses your SourceHut personal access token to make requests on your behalf. The token is stored locally in the iOS keychain.")
77 .font(.subheadline)
78 .foregroundStyle(.secondary)
79 .themedRow()
80
81 Link(destination: URL(string: "https://zerolabs.sh/hutch/privacy-policy/")!) {
82 SwiftUI.Label("Privacy Policy", systemImage: "hand.raised")
83 }
84 .themedRow()
85 }
86
87 Section("Acknowledgements") {
88 Text("Built for SourceHut users who want quick access to repositories, builds, and tickets on iOS.")
89 .font(.subheadline)
90 .foregroundStyle(.secondary)
91 .themedRow()
92 }
93
94 Section {
95 if storeViewModel.isLoading {
96 ProgressView("Loading tip options…")
97 .themedRow()
98 } else if storeViewModel.products.isEmpty {
99 VStack(alignment: .leading, spacing: 8) {
100 Text("Tips unavailable")
101 .font(.headline)
102 Text(storeViewModel.errorMessage ?? "Hutch couldn't load tip products from the App Store yet.")
103 .font(.subheadline)
104 .foregroundStyle(.secondary)
105 }
106 .padding(.vertical, 4)
107 .themedRow()
108 } else {
109 ForEach(storeViewModel.products, id: \.id) { product in
110 Button {
111 Task {
112 await storeViewModel.purchase(product)
113 }
114 } label: {
115 HStack {
116 VStack(alignment: .leading, spacing: 2) {
117 Text(product.displayName)
118 Text(product.id)
119 .font(.caption)
120 .foregroundStyle(.secondary)
121 }
122 Spacer()
123 if storeViewModel.isPurchasing(product) {
124 ProgressView()
125 .controlSize(.small)
126 } else {
127 Text(product.displayPrice)
128 .foregroundStyle(.secondary)
129 }
130 }
131 }
132 .disabled(storeViewModel.purchasingProductID != nil || storeViewModel.isRestoringPurchases)
133 .themedRow()
134 }
135 }
136
137 if let errorMessage = storeViewModel.errorMessage {
138 Text(errorMessage)
139 .font(.footnote)
140 .foregroundStyle(.secondary)
141 .themedRow()
142 }
143
144 Button("Retry Loading Tips") {
145 Task {
146 await storeViewModel.loadProducts()
147 }
148 }
149 .disabled(storeViewModel.isLoading || storeViewModel.purchasingProductID != nil)
150 .themedRow()
151
152 Button(storeViewModel.isRestoringPurchases ? "Syncing Purchases…" : "Restore / Sync Purchases") {
153 Task {
154 await storeViewModel.restorePurchases()
155 }
156 }
157 .disabled(storeViewModel.isLoading || storeViewModel.purchasingProductID != nil || storeViewModel.isRestoringPurchases)
158 .themedRow()
159 } header: {
160 Text("Support Development")
161 } footer: {
162 Text("Tips help cover the costs of development and App Store distribution. They are one-time purchases and do not unlock any features.")
163 }
164
165 if developerToolsVisible {
166 Section {
167 Toggle("Debug Mode", isOn: Binding(
168 get: { appState.isDebugModeEnabled },
169 set: { appState.isDebugModeEnabled = $0 }
170 ))
171 .themedRow()
172 } header: {
173 Text("Developer")
174 } footer: {
175 Text("Shows raw API payloads and diagnostic details on builds and tickets screens. This stays hidden until explicitly enabled.")
176 }
177 }
178 }
179 .themedList()
180 .navigationTitle("About")
181 .navigationBarTitleDisplayMode(.inline)
182 .alert(
183 "Store Message",
184 isPresented: Binding(
185 get: { storeViewModel.statusMessage != nil },
186 set: { isPresented in
187 if !isPresented {
188 storeViewModel.clearStatusMessage()
189 }
190 }
191 )
192 ) {
193 Button("OK") {
194 storeViewModel.clearStatusMessage()
195 }
196 } message: {
197 Text(storeViewModel.statusMessage ?? "")
198 }
199 .task {
200 await storeViewModel.loadProducts()
201 }
202 }
203}