diff --git a/JanitorialQC/ContentView.swift b/JanitorialQC/ContentView.swift index 6888f42..96dc3f3 100644 --- a/JanitorialQC/ContentView.swift +++ b/JanitorialQC/ContentView.swift @@ -1,10 +1,12 @@ // ContentView.swift import SwiftUI +import UIKit struct ContentView: View { @EnvironmentObject private var auth: AuthManager @EnvironmentObject private var sync: SyncManager + @StateObject private var updateChecker = UpdateChecker.shared var body: some View { Group { @@ -33,6 +35,28 @@ struct ContentView: View { if SyncManager.shared.isOnline && AuthManager.shared.isAuthenticated { await SyncManager.shared.triggerSync() } + // 4. Check the App Store for a newer version. Independent of auth — + // runs even on the login screen so unauthenticated devices still + // get prompted. Throttled internally to once per 24h; silent on + // any failure (offline, parse error, etc.) so it never disrupts + // normal app use. + await updateChecker.checkForUpdate() + } + .alert("Update Available", isPresented: $updateChecker.updateAvailable) { + Button("Update") { + if let url = updateChecker.appStoreURL { + UIApplication.shared.open(url) + } + } + Button("Later", role: .cancel) { + updateChecker.dismissForNow() + } + } message: { + if let version = updateChecker.latestVersion { + Text("Version \(version) is available on the App Store. Update now for the latest fixes and features.") + } else { + Text("A new version is available on the App Store.") + } } } } diff --git a/JanitorialQC/Utils/UpdateChecker.swift b/JanitorialQC/Utils/UpdateChecker.swift new file mode 100644 index 0000000..d71733f --- /dev/null +++ b/JanitorialQC/Utils/UpdateChecker.swift @@ -0,0 +1,125 @@ +// Utils/UpdateChecker.swift +// -------------------------- +// Checks the App Store for a newer published version of this app and +// surfaces an "Update Available" prompt when one exists. +// +// The app is distributed via App Store *unlisted* distribution — it is a +// real App Store listing (just excluded from public search/browse), so the +// standard public iTunes Lookup API works correctly here: +// https://itunes.apple.com/lookup?bundleId= +// No private server endpoint or App Store Connect API key is required. +// +// Usage: +// - Automatic: ContentView calls `await UpdateChecker.shared.checkForUpdate()` +// once per app launch (see .task in ContentView.swift). If a newer version +// is found, `updateAvailable` flips true and the root view presents an alert. +// - Manual: Settings → "Check for Updates" button calls checkForUpdate(force: true). +// +// Throttling: automatic launch checks are skipped if the last check was less +// than 24 hours ago, to avoid hitting Apple's endpoint on every cold start. +// The manual button always bypasses the throttle (force: true). + +import Foundation +import Combine + +@MainActor +final class UpdateChecker: ObservableObject { + static let shared = UpdateChecker() + + /// True once a newer version has been confirmed on the App Store. + /// The root view observes this to present the update alert. + @Published var updateAvailable = false + /// The newer version string, for display in the alert ("Version 1.3 is available"). + @Published var latestVersion: String? + /// The App Store page URL to open when the user taps "Update". + @Published var appStoreURL: URL? + /// True while a check is in flight — lets the Settings button show a spinner. + @Published var isChecking = false + + private static let lastCheckKey = "jqc.updateChecker.lastCheckedAt" + private static let throttleInterval: TimeInterval = 24 * 60 * 60 // 24 hours + + /// Must match PRODUCT_BUNDLE_IDENTIFIER in project.pbxproj. + private let bundleId = "com.ltservicesinc.JanitorialQC" + + private init() {} + + /// Compares the running app's version against the App Store's published + /// version. Sets `updateAvailable`/`latestVersion`/`appStoreURL` when a + /// newer version exists. Silent no-op on any network/parse failure — + /// an update check failing must never block or interrupt app usage. + /// + /// - Parameter force: bypass the 24-hour throttle (used by the manual + /// "Check for Updates" button in Settings). + func checkForUpdate(force: Bool = false) async { + guard force || shouldCheck() else { return } + + isChecking = true + defer { isChecking = false } + + guard let currentVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String, + let url = URL(string: "https://itunes.apple.com/lookup?bundleId=\(bundleId)") + else { return } + + do { + let (data, _) = try await URLSession.shared.data(from: url) + let lookup = try JSONDecoder().decode(LookupResponse.self, from: data) + + UserDefaults.standard.set(Date(), forKey: Self.lastCheckKey) + + guard let result = lookup.results.first else { return } + + if isVersion(result.version, newerThan: currentVersion) { + latestVersion = result.version + appStoreURL = URL(string: result.trackViewUrl) + updateAvailable = true + } else { + updateAvailable = false + } + } catch { + // Network failure, offline, or unexpected response shape — + // fail silently. The next launch (or manual check) will retry. + } + } + + /// Dismisses the current prompt without disabling future checks. + /// Called when the user taps "Later" on the update alert. + func dismissForNow() { + updateAvailable = false + } + + // ── Throttle ───────────────────────────────────────────────────────── + + private func shouldCheck() -> Bool { + guard let last = UserDefaults.standard.object(forKey: Self.lastCheckKey) as? Date + else { return true } + return Date().timeIntervalSince(last) >= Self.throttleInterval + } + + // ── Version comparison ────────────────────────────────────────────── + // Compares dotted version strings ("1.10" > "1.9") component-by-component + // as integers, rather than lexicographically (which would incorrectly + // rank "1.10" before "1.9"). Missing trailing components are treated as 0. + + private func isVersion(_ a: String, newerThan b: String) -> Bool { + let aParts = a.split(separator: ".").compactMap { Int($0) } + let bParts = b.split(separator: ".").compactMap { Int($0) } + let count = max(aParts.count, bParts.count) + for i in 0.. bv } + } + return false + } + + // ── iTunes Lookup response shape ──────────────────────────────────── + + private struct LookupResponse: Decodable { + let results: [LookupResult] + } + private struct LookupResult: Decodable { + let version: String + let trackViewUrl: String + } +} diff --git a/JanitorialQC/Views/Dashboard/DashboardView.swift b/JanitorialQC/Views/Dashboard/DashboardView.swift index a127680..6f8a1a2 100644 --- a/JanitorialQC/Views/Dashboard/DashboardView.swift +++ b/JanitorialQC/Views/Dashboard/DashboardView.swift @@ -1890,6 +1890,7 @@ struct SettingsView: View { @EnvironmentObject private var auth: AuthManager @EnvironmentObject private var sync: SyncManager @EnvironmentObject private var appearance: AppearanceManager + @StateObject private var updateChecker = UpdateChecker.shared @Environment(\.modelContext) private var context @State private var showClearCacheAlert = false @@ -1897,6 +1898,7 @@ struct SettingsView: View { @State private var settingsServer: ServerOption = ServerConfig.selectedOption @State private var pendingServer: ServerOption? = nil @State private var showServerSwitchAlert = false + @State private var hasCheckedOnce = false var body: some View { List { @@ -1992,6 +1994,29 @@ struct SettingsView: View { Section("App Info") { LabeledContent("Version", value: "\(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0") (\(Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "1"))") + + Button { + Task { + await updateChecker.checkForUpdate(force: true) + hasCheckedOnce = true + } + } label: { + if updateChecker.isChecking { + HStack { + ProgressView() + Text("Checking…") + } + } else { + Label("Check for Updates", systemImage: "arrow.triangle.2.circlepath") + } + } + .disabled(updateChecker.isChecking) + + if hasCheckedOnce, !updateChecker.isChecking, !updateChecker.updateAvailable { + Label("You're on the latest version.", systemImage: "checkmark.circle.fill") + .foregroundStyle(.green) + .font(.callout) + } } } .navigationTitle("Settings")