diff options
| author | Christian Cleberg <[email protected]> | 2026-07-25 11:35:46 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-07-25 11:49:23 -0500 |
| commit | 074052763638a36e274e18d00f5f5ab21846be28 (patch) | |
| tree | 9daf0a4c919fa8dc79c24b629157af45577ed4e4 /DomainDig | |
| parent | b824dae8ea16d20d75136687a7c59bc7b01afe27 (diff) | |
| download | domain-dig-074052763638a36e274e18d00f5f5ab21846be28.tar.gz domain-dig-074052763638a36e274e18d00f5f5ab21846be28.tar.bz2 domain-dig-074052763638a36e274e18d00f5f5ab21846be28.zip | |
feat: enrich Settings → App Info with metadata & resource links (#56)
Replaces the three-row App Info screen with a full About/Resources/Support/
Legal layout, driven by a declarative AppInfoRow model with all URLs centralized
in one AppLinks namespace.
- About: Version now shows "5.0.0 (build N)" (CFBundleShortVersionString +
CFBundleVersion), plus Storage, Backup Schema, and Minimum iOS (17.6).
- Resources: What's New (bundled ReleaseNotes.json sheet, no network),
Documentation & FAQ, Source Code, Privacy Policy, and Acknowledgements
("no third-party dependencies" + MIT license).
- Support: Report an Issue (a sheet that shows the locally-assembled
version/OS/device diagnostics before offering Email or GitHub — nothing is
collected silently) and Contact (mailto).
- Support the App: Rate (SwiftUI's @Environment(\.requestReview), which handles
the scene internally and respects Apple's throttling — the modern, safer
equivalent of the issue's SKStoreReviewController + connectedScenes path) and
Share (ShareLink to the App Store listing).
- Legal: copyright footer with the current year.
External rows use Link/openURL with an "opens outside the app" accessibility
hint; the screen is standard adaptive Form controls with semantic colors, so it
tracks Dynamic Type and light/dark automatically.
New files (AppLinks, AppInfo, ReleaseNotes[.swift/.json], AppInfoView) auto-
compile/bundle via the synchronized DomainDig/ group. AppInfoTests covers the
mailto builder, version format, diagnostics contents, and that the bundled notes
ship and parse. Unit suite green.
Placeholder pending the App Store listing: AppLinks.appStoreID (the review/share
URLs derive from it), flagged with a TODO.
Diffstat (limited to 'DomainDig')
| -rw-r--r-- | DomainDig/AppInfo.swift | 52 | ||||
| -rw-r--r-- | DomainDig/AppInfoView.swift | 254 | ||||
| -rw-r--r-- | DomainDig/AppLinks.swift | 34 | ||||
| -rw-r--r-- | DomainDig/ReleaseNotes.json | 9 | ||||
| -rw-r--r-- | DomainDig/ReleaseNotes.swift | 17 | ||||
| -rw-r--r-- | DomainDig/SettingsViews.swift | 24 |
6 files changed, 367 insertions, 23 deletions
diff --git a/DomainDig/AppInfo.swift b/DomainDig/AppInfo.swift new file mode 100644 index 0000000..28b0826 --- /dev/null +++ b/DomainDig/AppInfo.swift @@ -0,0 +1,52 @@ +import Foundation +import UIKit + +/// Runtime app metadata read from the bundle, plus the locally-assembled +/// diagnostics used to prefill an issue report. Nothing here touches the network +/// or collects any identifier beyond the app version, iOS version, and hardware +/// model string. +enum AppInfo { + /// CFBundleShortVersionString (marketing version), falling back to the + /// compiled-in `AppVersion.current` if the Info.plist key is missing. + static var marketingVersion: String { + Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? AppVersion.current + } + + /// CFBundleVersion (build number). + static var buildNumber: String { + Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "—" + } + + /// "5.0.0 (build 45)" — version alongside build, as the App Info row shows it. + static var versionDisplay: String { + "\(marketingVersion) (build \(buildNumber))" + } + + static let minimumOS = "17.6" + + static var systemVersion: String { + UIDevice.current.systemVersion + } + + /// Hardware model identifier (e.g. "iPhone15,2") — a model string, not a + /// per-device identifier. + static var deviceModel: String { + var systemInfo = utsname() + uname(&systemInfo) + let identifier = withUnsafeBytes(of: &systemInfo.machine) { raw -> String in + let bytes = raw.prefix { $0 != 0 } + return String(decoding: bytes, as: UTF8.self) + } + return identifier.isEmpty ? "Unknown" : identifier + } + + /// Diagnostics prefilled into an issue report. Shown to the user before any + /// send — never collected silently. + static var diagnosticsReport: String { + """ + DomainDig \(versionDisplay) + iOS \(systemVersion) + Device \(deviceModel) + """ + } +} diff --git a/DomainDig/AppInfoView.swift b/DomainDig/AppInfoView.swift new file mode 100644 index 0000000..749c066 --- /dev/null +++ b/DomainDig/AppInfoView.swift @@ -0,0 +1,254 @@ +import StoreKit +import SwiftUI + +/// Settings → App Info. Shows app/build metadata and links out to docs, source, +/// privacy, support, and the App Store. The actionable rows are driven by a +/// single declarative `AppInfoRow` model so titles, icons, and destinations live +/// in one place; only the Rate and Share rows are special-cased (a StoreKit +/// action and a `ShareLink` view, respectively). +struct AppInfoView: View { + @Environment(\.openURL) private var openURL + @Environment(\.requestReview) private var requestReview + @State private var cloudSyncService = CloudSyncService.shared + @State private var activeSheet: AppInfoSheet? + + var body: some View { + Form { + Section("About") { + LabeledContent("Version", value: AppInfo.versionDisplay) + LabeledContent("Storage", value: cloudSyncService.isEnabled ? "Local-first + iCloud" : "Local-only") + LabeledContent("Backup Schema", value: "v\(DomainDigBackup.currentSchemaVersion)") + LabeledContent("Minimum iOS", value: AppInfo.minimumOS) + } + + Section("Resources") { + ForEach(resourceRows) { row($0) } + } + + Section("Support") { + ForEach(supportRows) { row($0) } + } + + Section("Support the App") { + Button { + requestReview() + } label: { + Label("Rate DomainDig", systemImage: "star") + } + + ShareLink(item: AppLinks.appStoreListing) { + Label("Share DomainDig", systemImage: "square.and.arrow.up") + } + .accessibilityHint("Opens the share sheet") + } + + Section { + } footer: { + Text(AppLinks.copyright) + .frame(maxWidth: .infinity, alignment: .center) + } + } + .navigationTitle("App Info") + .task { + await cloudSyncService.refreshAvailability() + } + .sheet(item: $activeSheet) { sheet in + NavigationStack { + switch sheet { + case .whatsNew: + WhatsNewView() + case .acknowledgements: + AcknowledgementsView() + case .reportIssue: + ReportIssueView() + } + } + } + } + + private var resourceRows: [AppInfoRow] { + [ + AppInfoRow(title: "What's New", systemImage: "sparkles", action: .sheet(.whatsNew)), + AppInfoRow(title: "Documentation & FAQ", systemImage: "book", action: .openURL(AppLinks.documentation)), + AppInfoRow(title: "Source Code", systemImage: "chevron.left.forwardslash.chevron.right", action: .openURL(AppLinks.sourceCode)), + AppInfoRow(title: "Privacy Policy", systemImage: "hand.raised", action: .openURL(AppLinks.privacyPolicy)), + AppInfoRow(title: "Acknowledgements", systemImage: "checkmark.seal", action: .sheet(.acknowledgements)) + ] + } + + private var supportRows: [AppInfoRow] { + [ + AppInfoRow(title: "Report an Issue", systemImage: "ladybug", action: .sheet(.reportIssue)), + AppInfoRow( + title: "Contact", + systemImage: "envelope", + action: .mail(address: AppLinks.supportEmail, subject: "DomainDig", body: "") + ) + ] + } + + @ViewBuilder + private func row(_ item: AppInfoRow) -> some View { + switch item.action { + case .openURL(let url): + Link(destination: url) { + Label(item.title, systemImage: item.systemImage) + } + .accessibilityHint("Opens outside the app") + case .mail(let address, let subject, let body): + Button { + if let url = Self.mailURL(to: address, subject: subject, body: body) { + openURL(url) + } + } label: { + Label(item.title, systemImage: item.systemImage) + } + .accessibilityHint("Opens your mail app") + case .sheet(let sheet): + Button { + activeSheet = sheet + } label: { + Label(item.title, systemImage: item.systemImage) + } + } + } + + static func mailURL(to address: String, subject: String, body: String) -> URL? { + var components = URLComponents() + components.scheme = "mailto" + components.path = address + var query: [URLQueryItem] = [] + if !subject.isEmpty { query.append(URLQueryItem(name: "subject", value: subject)) } + if !body.isEmpty { query.append(URLQueryItem(name: "body", value: body)) } + components.queryItems = query.isEmpty ? nil : query + return components.url + } +} + +private struct AppInfoRow: Identifiable { + enum Action { + case openURL(URL) + case mail(address: String, subject: String, body: String) + case sheet(AppInfoSheet) + } + + let id = UUID() + let title: String + let systemImage: String + let action: Action +} + +private enum AppInfoSheet: String, Identifiable { + case whatsNew + case acknowledgements + case reportIssue + + var id: String { rawValue } +} + +// MARK: - Sheets + +private struct WhatsNewView: View { + @Environment(\.dismiss) private var dismiss + private let notes = ReleaseNotes.bundled() + + var body: some View { + Form { + if let notes { + Section { + ForEach(Array(notes.highlights.enumerated()), id: \.offset) { _, highlight in + Label(highlight, systemImage: "sparkle") + .labelStyle(.titleAndIcon) + } + } header: { + Text("Version \(notes.version)") + } + } else { + Section { + Text("Release notes are unavailable.") + .foregroundStyle(Color(.appTextSecondary)) + } + } + } + .navigationTitle("What's New") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { dismiss() } + } + } + } +} + +private struct AcknowledgementsView: View { + @Environment(\.dismiss) private var dismiss + + var body: some View { + Form { + Section("Dependencies") { + Text("DomainDig has no third-party dependencies. It is built entirely on Apple's frameworks.") + } + Section("License") { + Text("DomainDig is released under the MIT License.") + Text(AppLinks.copyright) + .foregroundStyle(Color(.appTextSecondary)) + } + } + .navigationTitle("Acknowledgements") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { dismiss() } + } + } + } +} + +private struct ReportIssueView: View { + @Environment(\.dismiss) private var dismiss + @Environment(\.openURL) private var openURL + + private let diagnostics = AppInfo.diagnosticsReport + + var body: some View { + Form { + Section { + Text("These details are included so issues can be reproduced. They are only added when you send a report — nothing is collected in the background.") + .foregroundStyle(Color(.appTextSecondary)) + } + + Section("Included Diagnostics") { + Text(diagnostics) + .font(.system(.footnote, design: .monospaced)) + .textSelection(.enabled) + } + + Section { + Button { + if let url = AppInfoView.mailURL( + to: AppLinks.supportEmail, + subject: "DomainDig Issue Report", + body: "\n\n---\n\(diagnostics)" + ) { + openURL(url) + } + } label: { + Label("Email Report", systemImage: "envelope") + } + .accessibilityHint("Opens your mail app with the diagnostics prefilled") + + Link(destination: AppLinks.sourceCode.appendingPathComponent("issues/new")) { + Label("Open on GitHub", systemImage: "chevron.left.forwardslash.chevron.right") + } + .accessibilityHint("Opens outside the app") + } + } + .navigationTitle("Report an Issue") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Done") { dismiss() } + } + } + } +} diff --git a/DomainDig/AppLinks.swift b/DomainDig/AppLinks.swift new file mode 100644 index 0000000..39c9036 --- /dev/null +++ b/DomainDig/AppLinks.swift @@ -0,0 +1,34 @@ +import Foundation + +/// Centralized external destinations, kept in one place so URLs and the App Store +/// identifier can be swapped without touching any view. +/// +/// TODO(release): `appStoreID` is a placeholder until the App Store listing is +/// live — the listing/review/share URLs derive from it. `documentation` points +/// at the repository README for now; swap in a dedicated docs site if one lands. +enum AppLinks { + /// App Store numeric identifier. Placeholder until the listing is published. + static let appStoreID = "0000000000" + + static let sourceCode = url("https://github.com/zerolabsco/domain-dig") + static let documentation = url("https://github.com/zerolabsco/domain-dig#readme") + static let privacyPolicy = url("https://zerolabs.sh/domaindig/privacy-policy/") + static let supportEmail = "[email protected]" + + static var appStoreListing: URL { url("https://apps.apple.com/app/id\(appStoreID)") } + static var writeReview: URL { url("https://apps.apple.com/app/id\(appStoreID)?action=write-review") } + + static var copyright: String { + let year = Calendar.current.component(.year, from: Date()) + return "© \(year) Christian Cleberg" + } + + /// Builds URLs from developer-controlled literals; a malformed literal is a + /// programming error, surfaced loudly in development rather than force-unwrapped. + private static func url(_ string: String) -> URL { + guard let url = URL(string: string) else { + preconditionFailure("Invalid AppLinks URL literal: \(string)") + } + return url + } +} diff --git a/DomainDig/ReleaseNotes.json b/DomainDig/ReleaseNotes.json new file mode 100644 index 0000000..99384f6 --- /dev/null +++ b/DomainDig/ReleaseNotes.json @@ -0,0 +1,9 @@ +{ + "version": "5.0.0", + "highlights": [ + "The Local API now has a documented, versioned \"v1\" response contract, so scripts and automation you build against it stay stable across updates.", + "A versioned migration system keeps your tracked domains, history, audits, and settings safe and correctly upgraded across app versions.", + "A large internal cleanup: the two biggest source files were decomposed into focused units behind a new unit-test safety net, for faster and safer future releases.", + "App Info now links to documentation, source, privacy, and support, and can prefill an issue report with your app and device versions." + ] +} diff --git a/DomainDig/ReleaseNotes.swift b/DomainDig/ReleaseNotes.swift new file mode 100644 index 0000000..2cac883 --- /dev/null +++ b/DomainDig/ReleaseNotes.swift @@ -0,0 +1,17 @@ +import Foundation + +/// Release notes shown in the "What's New" sheet, loaded from the bundled +/// `ReleaseNotes.json`. No network access — the content ships with the app. +struct ReleaseNotes: Decodable { + let version: String + let highlights: [String] + + static func bundled() -> ReleaseNotes? { + guard let url = Bundle.main.url(forResource: "ReleaseNotes", withExtension: "json"), + let data = try? Data(contentsOf: url), + let notes = try? JSONDecoder().decode(ReleaseNotes.self, from: data) else { + return nil + } + return notes + } +} diff --git a/DomainDig/SettingsViews.swift b/DomainDig/SettingsViews.swift index a39639d..d93f6f9 100644 --- a/DomainDig/SettingsViews.swift +++ b/DomainDig/SettingsViews.swift @@ -105,7 +105,7 @@ struct SettingsView: View { Section("About") { NavigationLink("App Info") { - AboutSettingsView() + AppInfoView() } } } @@ -1070,28 +1070,6 @@ private struct DataManagementSettingsView: View { } } -private struct AboutSettingsView: View { - @State private var cloudSyncService = CloudSyncService.shared - - private var appVersion: String { - AppVersion.current - } - - var body: some View { - Form { - Section("About") { - LabeledContent("Version", value: appVersion) - LabeledContent("Storage", value: cloudSyncService.isEnabled ? "Local-first + iCloud" : "Local-only") - LabeledContent("Backup Schema", value: "v\(DomainDigBackup.currentSchemaVersion)") - } - } - .navigationTitle("App Info") - .task { - await cloudSyncService.refreshAvailability() - } - } -} - private struct DataImportPreviewSheet: View { @Environment(\.dismiss) private var dismiss |
