06/22 Add Check for Update button in Settings
This commit is contained in:
@@ -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.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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=<bundle id>
|
||||
// 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..<count {
|
||||
let av = i < aParts.count ? aParts[i] : 0
|
||||
let bv = i < bParts.count ? bParts[i] : 0
|
||||
if av != bv { return av > bv }
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ── iTunes Lookup response shape ────────────────────────────────────
|
||||
|
||||
private struct LookupResponse: Decodable {
|
||||
let results: [LookupResult]
|
||||
}
|
||||
private struct LookupResult: Decodable {
|
||||
let version: String
|
||||
let trackViewUrl: String
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user