krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

v3.5.0: 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}