add notice update

This commit is contained in:
cheykrym 2025-12-18 02:41:11 +03:00
parent 84a8706b0a
commit dd61970357
5 changed files with 248 additions and 1 deletions

View File

@ -1766,6 +1766,9 @@
}
}
}
},
"Обновить приложение" : {
},
"Обратная связь" : {
"comment" : "feedback: navigation title",

View File

@ -0,0 +1,164 @@
import Foundation
import UIKit
struct AppUpdateNotice: Identifiable {
enum Kind {
case force
case soft
}
let id = UUID()
let kind: Kind
let title: String
let message: String
let appStoreURL: URL
}
final class AppUpdateChecker: ObservableObject {
@Published private(set) var softUpdateNotice: AppUpdateNotice?
@Published private(set) var forceUpdateNotice: AppUpdateNotice?
private let session: URLSession
private var didStartCheck = false
init(session: URLSession = .shared) {
self.session = session
}
func checkForUpdatesIfNeeded() {
guard !didStartCheck else { return }
didStartCheck = true
Task { await fetchRemoteConfig() }
}
func dismissSoftUpdateIfNeeded() {
softUpdateNotice = nil
}
func openAppStore() {
guard let url = forceUpdateNotice?.appStoreURL ?? softUpdateNotice?.appStoreURL else {
return
}
DispatchQueue.main.async {
UIApplication.shared.open(url, options: [:], completionHandler: nil)
}
}
private func fetchRemoteConfig() async {
guard
let buildType = AppConfig.APP_BUILD.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed),
let url = URL(string: "https://static.yobble.org/config/ios/dev2-\(buildType).json")
else {
log("Unable to build remote config URL")
return
}
do {
let (data, response) = try await session.data(from: url)
guard let httpResponse = response as? HTTPURLResponse, (200...299).contains(httpResponse.statusCode) else {
log("Unexpected response when fetching remote config")
return
}
let decoder = JSONDecoder()
let remoteConfig = try decoder.decode(RemoteBuildConfiguration.self, from: data)
await MainActor.run {
self.apply(remoteConfig)
}
} catch {
log("Failed to fetch remote config: \(error)")
}
}
@MainActor
private func apply(_ config: RemoteBuildConfiguration) {
guard let buildNumber = currentBuildNumber() else {
log("Unable to read current build number")
return
}
forceUpdateNotice = nil
softUpdateNotice = nil
guard let appStoreURL = config.appStoreURL else {
log("Config missing App Store URL")
return
}
print("buildNumber", buildNumber)
print("config", config.notSupportedBuild, config.minSupportedBuild, config.recommendedBuild)
let requiresDoUpdate = buildNumber <= config.notSupportedBuild
if requiresDoUpdate, let info = config.forceUpdate {
forceUpdateNotice = AppUpdateNotice(kind: .force, title: info.title, message: info.message, appStoreURL: appStoreURL)
return
}
let requiresForcedUpdate = buildNumber < config.minSupportedBuild
if requiresForcedUpdate, let info = config.forceUpdate {
forceUpdateNotice = AppUpdateNotice(kind: .force, title: info.title, message: info.message, appStoreURL: appStoreURL)
return
}
let needsSoftUpdate = buildNumber < config.recommendedBuild
if needsSoftUpdate, let info = config.softUpdate {
softUpdateNotice = AppUpdateNotice(kind: .soft, title: info.title, message: info.message, appStoreURL: appStoreURL)
return
}
}
private func currentBuildNumber() -> Int? {
guard let rawValue = Bundle.main.infoDictionary?["CFBundleVersion"] as? String else {
return nil
}
return Int(rawValue)
}
private func log(_ message: String) {
if AppConfig.DEBUG {
print("[AppUpdateChecker]", message)
}
}
}
private struct RemoteBuildConfiguration: Decodable {
struct UpdateInfo: Decodable {
let title: String
let message: String
}
let schemaVersion: Int
let notSupportedBuild: Int
let minSupportedBuild: Int
let recommendedBuild: Int
let forceUpdate: UpdateInfo?
let softUpdate: UpdateInfo?
let appStoreURL: URL?
enum CodingKeys: String, CodingKey {
case schemaVersion = "schema_version"
case notSupportedBuild = "not_supported_build"
case minSupportedBuild = "min_supported_build"
case recommendedBuild = "recommended_build"
case forceUpdate = "force_update"
case softUpdate = "soft_update"
case appStoreURL = "appstore_url"
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
schemaVersion = try container.decodeIfPresent(Int.self, forKey: .schemaVersion) ?? 1
notSupportedBuild = try container.decode(Int.self, forKey: .notSupportedBuild)
minSupportedBuild = try container.decode(Int.self, forKey: .minSupportedBuild)
recommendedBuild = try container.decode(Int.self, forKey: .recommendedBuild)
forceUpdate = try container.decodeIfPresent(UpdateInfo.self, forKey: .forceUpdate)
softUpdate = try container.decodeIfPresent(UpdateInfo.self, forKey: .softUpdate)
if let urlString = try container.decodeIfPresent(String.self, forKey: .appStoreURL) {
appStoreURL = URL(string: urlString)
} else {
appStoreURL = nil
}
}
}

View File

@ -0,0 +1,50 @@
import SwiftUI
struct ForceUpdateView: View {
let title: String
let message: String
let onUpdate: () -> Void
var body: some View {
ZStack {
Color.black.opacity(0.6)
.ignoresSafeArea()
VStack(spacing: 16) {
Text(title)
.font(.title3.weight(.semibold))
.multilineTextAlignment(.center)
.foregroundColor(.primary)
Text(message)
.font(.body)
.multilineTextAlignment(.center)
.foregroundColor(.primary)
Button(action: onUpdate) {
Text(NSLocalizedString("Обновить приложение", comment: ""))
.font(.headline)
.frame(maxWidth: .infinity)
.padding()
.background(Color.accentColor)
.foregroundColor(.white)
.cornerRadius(12)
}
}
.padding(24)
.background(.ultraThinMaterial)
.cornerRadius(20)
.padding(32)
}
.zIndex(2)
.accessibilityElement(children: .contain)
}
}
struct ForceUpdateView_Previews: PreviewProvider {
static var previews: some View {
ForceUpdateView(title: "Требуется обновление",
message: "Эта версия приложения устарела и больше не поддерживается.",
onUpdate: {})
}
}

View File

@ -1,7 +1,7 @@
import SwiftUI
struct AppConfig {
static var DEBUG: Bool = true
static var DEBUG: Bool = false
//static let SERVICE = Bundle.main.bundleIdentifier ?? "default.service"
static let PROTOCOL = "https"
static let API_SERVER = "\(PROTOCOL)://api.yobble.org"

View File

@ -16,6 +16,7 @@ struct yobbleApp: App {
@StateObject private var themeManager = ThemeManager()
@StateObject private var viewModel = LoginViewModel()
@StateObject private var messageCenter = IncomingMessageCenter()
@StateObject private var updateChecker = AppUpdateChecker()
private let persistenceController = PersistenceController.shared
var body: some Scene {
@ -64,11 +65,40 @@ struct yobbleApp: App {
.preferredColorScheme(themeManager.theme.colorScheme)
.environment(\.managedObjectContext, persistenceController.viewContext)
.onAppear {
updateChecker.checkForUpdatesIfNeeded()
messageCenter.currentUserId = viewModel.userId.isEmpty ? nil : viewModel.userId
}
.onChange(of: viewModel.userId) { newValue in
messageCenter.currentUserId = newValue.isEmpty ? nil : newValue
}
.alert(item: Binding(
get: { updateChecker.softUpdateNotice },
set: { newValue in
if newValue == nil {
updateChecker.dismissSoftUpdateIfNeeded()
}
}
)) { notice in
Alert(
title: Text(notice.title),
message: Text(notice.message),
primaryButton: .default(Text(NSLocalizedString("Обновить", comment: ""))) {
updateChecker.openAppStore()
},
secondaryButton: .cancel(Text(NSLocalizedString("Позже", comment: ""))) {
updateChecker.dismissSoftUpdateIfNeeded()
}
)
}
.overlay(alignment: .center) {
if let notice = updateChecker.forceUpdateNotice {
ForceUpdateView(
title: notice.title,
message: notice.message,
onUpdate: { updateChecker.openAppStore() }
)
}
}
}
}
}