06/22 Add Check for Update button in Settings

This commit is contained in:
Nguyen Ngo
2026-06-22 10:36:07 -04:00
parent a57d6f02ba
commit 6c48a38743
3 changed files with 174 additions and 0 deletions
+24
View File
@@ -1,10 +1,12 @@
// ContentView.swift // ContentView.swift
import SwiftUI import SwiftUI
import UIKit
struct ContentView: View { struct ContentView: View {
@EnvironmentObject private var auth: AuthManager @EnvironmentObject private var auth: AuthManager
@EnvironmentObject private var sync: SyncManager @EnvironmentObject private var sync: SyncManager
@StateObject private var updateChecker = UpdateChecker.shared
var body: some View { var body: some View {
Group { Group {
@@ -33,6 +35,28 @@ struct ContentView: View {
if SyncManager.shared.isOnline && AuthManager.shared.isAuthenticated { if SyncManager.shared.isOnline && AuthManager.shared.isAuthenticated {
await SyncManager.shared.triggerSync() 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.")
}
} }
} }
} }
+125
View File
@@ -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 auth: AuthManager
@EnvironmentObject private var sync: SyncManager @EnvironmentObject private var sync: SyncManager
@EnvironmentObject private var appearance: AppearanceManager @EnvironmentObject private var appearance: AppearanceManager
@StateObject private var updateChecker = UpdateChecker.shared
@Environment(\.modelContext) private var context @Environment(\.modelContext) private var context
@State private var showClearCacheAlert = false @State private var showClearCacheAlert = false
@@ -1897,6 +1898,7 @@ struct SettingsView: View {
@State private var settingsServer: ServerOption = ServerConfig.selectedOption @State private var settingsServer: ServerOption = ServerConfig.selectedOption
@State private var pendingServer: ServerOption? = nil @State private var pendingServer: ServerOption? = nil
@State private var showServerSwitchAlert = false @State private var showServerSwitchAlert = false
@State private var hasCheckedOnce = false
var body: some View { var body: some View {
List { List {
@@ -1992,6 +1994,29 @@ struct SettingsView: View {
Section("App Info") { Section("App Info") {
LabeledContent("Version", value: "\(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0") (\(Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "1"))") 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") .navigationTitle("Settings")