import Foundation import UIKit import UserNotifications import Combine import Network @MainActor final class AppModel: ObservableObject { @Published var language = AppLanguage.initial { didSet { UserDefaults.standard.set(language.rawValue, forKey: "language") } } @Published var user: UserProfile? @Published var loyalty: Loyalty? @Published var messages: [KasztelanMessage] = [] @Published var isLoading = true @Published var isBusy = false @Published var errorMessage: String? @Published var selectedTab = 0 @Published var presentedMessage: KasztelanMessage? private let api = APIClient() private let messageStore = MessageStore() private let networkMonitor = NWPathMonitor() private var token = KeychainStore.load() private var refreshTask: Task? private var bannerTask: Task? private var accountRefreshInFlight = false private var messageRefreshInFlight = false var isAuthenticated: Bool { !token.isEmpty && user != nil } var cardNumber: String { let loyaltyNumber = digits(loyalty?.cardNumber?.value ?? "") if loyaltyNumber.count == 13 { return loyaltyNumber } let userNumber = digits(user?.cardNumber ?? "") return userNumber.count == 13 ? userNumber : "" } init() { messages = messageStore.messages if !token.isEmpty, let cached = OfflineAccountStore.load() { user = cached.user loyalty = cached.loyalty isLoading = false } else if token.isEmpty { isLoading = false } startNetworkMonitoring() } func text(_ polish: String, _ english: String) -> String { language == .pl ? polish : english } func launch() async { defer { isLoading = false } guard !token.isEmpty else { return } do { if let response = try await fetchAccount() { apply(response) } beginLiveRefresh() Task { await refreshMessages(showNotifications: true) } } catch { if case let APIError.server(status, code, _) = error, status == 401 || code == "invalid_token" { clearSession() } else if isAuthenticated { beginLiveRefresh() } } } func login(login: String, password: String) async { await perform { let response = try await self.api.login(login: login.trimmingCharacters(in: .whitespaces), password: password) try self.accept(response) } } func register(_ form: RegistrationForm) async { guard form.acceptsTerms else { errorMessage = text("Zaakceptuj regulamin.", "Please accept the terms.") return } guard validDate(form.dob) else { errorMessage = text("Nieprawidłowa data. Użyj formatu RRRR-MM-DD.", "Invalid date. Use YYYY-MM-DD.") return } await perform { try self.accept(await self.api.register(form)) } } func assignCard(_ number: String) async -> Bool { let number = digits(number) guard number.count == 13 else { errorMessage = text("Wpisz prawidłowy numer karty.", "Enter a valid card number.") return false } var succeeded = false await perform { self.apply(try await self.api.assignCard(number, token: self.token)) succeeded = true } return succeeded } func generateCard() async -> Bool { var succeeded = false await perform { self.apply(try await self.api.generateCard(token: self.token)) succeeded = true } return succeeded } func saveProfile(_ form: ProfileForm) async -> Bool { guard validDate(form.dob) else { errorMessage = text("Nieprawidłowa data. Użyj formatu RRRR-MM-DD.", "Invalid date. Use YYYY-MM-DD.") return false } var succeeded = false await perform { self.apply(try await self.api.updateProfile(form, token: self.token)) succeeded = true } return succeeded } func changePassword(current: String, new: String) async -> Bool { var succeeded = false await perform { try await self.api.changePassword(current: current, new: new, token: self.token) succeeded = true } return succeeded } func deleteAccount(password: String) async -> Bool { var succeeded = false await perform { try await self.api.deleteAccount(password: password, token: self.token) self.clearSession() succeeded = true } return succeeded } func logout() async { let currentToken = token if user?.id != nil { let wallet = DiscountCardStore() await wallet.sync(using: self) } await NotificationService.unregisterPush(session: currentToken) clearSession() try? await api.logout(token: currentToken) } func beginLiveRefresh() { UIApplication.shared.isIdleTimerDisabled = true guard isAuthenticated else { return } guard refreshTask == nil else { return } refreshTask = Task { [weak self] in await self?.refresh() while !Task.isCancelled { try? await Task.sleep(nanoseconds: 15_000_000_000) guard !Task.isCancelled, let self else { return } await self.refresh() } } } func stopLiveRefresh() { refreshTask?.cancel() refreshTask = nil UIApplication.shared.isIdleTimerDisabled = false } func refresh() async { guard isAuthenticated else { return } await NotificationService.syncPushRegistration() do { if let response = try await fetchAccount() { apply(response) } } catch { if case let APIError.server(status, code, _) = error, status == 401 || code == "invalid_token" { clearSession() return } } await refreshMessages(showNotifications: true) } func cloudWallet() async throws -> APIEnvelope { guard !token.isEmpty else { throw APIError.invalidResponse } return try await api.wallet(token: token) } func saveCloudWallet(_ payload: String) async throws { guard !token.isEmpty else { throw APIError.invalidResponse } _ = try await api.saveWallet(payload: payload, token: token) } func deleteMessage(id: Int) { messageStore.delete(id: id) messages = messageStore.messages } func openPresentedMessage() { selectedTab = 1 dismissPresentedMessage() } func dismissPresentedMessage() { bannerTask?.cancel() bannerTask = nil presentedMessage = nil } func profileForm() -> ProfileForm { ProfileForm( firstName: user?.firstName ?? "", lastName: user?.lastName ?? "", email: user?.email ?? "", mobile: user?.mobile ?? "", postcode: user?.postcode ?? "", dob: displayDOB(user?.dob) ) } func money(_ raw: String?) -> String { guard let number = decimal(raw) else { return "—" } let formatted = String(format: "%.2f", number) return (language == .pl ? formatted.replacingOccurrences(of: ".", with: ",") : formatted) + " £" } func pointsWithValue(_ raw: String?) -> String { guard let points = decimal(raw) else { return "—" } let count = points.rounded() == points ? String(Int(points)) : String(points) let localizedCount = language == .pl ? count.replacingOccurrences(of: ".", with: ",") : count return "\(localizedCount) = \(money(String(points / 100)))" } func friendlyError(_ error: Error) -> String { guard let apiError = error as? APIError else { return text("Brak połączenia z serwerem Kasztelan.", "Cannot connect to the Kasztelan server.") } switch apiError.code { case "invalid_credentials": return text("Nieprawidłowy login lub hasło.", "Incorrect username or password.") case "invalid_current_password": return text("Obecne hasło jest nieprawidłowe.", "The current password is incorrect.") case "invalid_card_number": return text("Wpisz prawidłowy numer karty.", "Enter a valid card number.") case "card_in_use": return text("Ta karta jest już przypisana do innego konta.", "This card is already assigned to another account.") case "card_already_assigned": return text("Do konta jest już przypisana karta i nie można jej zmienić.", "A card is already assigned and cannot be changed.") case "weak_password": return text("Hasło musi mieć co najmniej 8 znaków, wielką i małą literę, cyfrę oraz znak specjalny.", "Password must be at least 8 characters and include upper and lower case letters, a number and a special character.") case "email_exists": return text("Konto z tym adresem e-mail już istnieje.", "An account with this email already exists.") case "invalid_name": return text("Wpisz imię i nazwisko.", "Enter your first and last name.") case "invalid_email": return text("Wpisz prawidłowy adres e-mail.", "Enter a valid email address.") case "invalid_mobile": return text("Wpisz prawidłowy numer telefonu UK lub PL.", "Enter a valid UK or Polish phone number.") case "invalid_postcode": return text("Wpisz prawidłowy kod pocztowy UK.", "Enter a valid UK postcode.") case "invalid_dob": return text("Wpisz datę w formacie RRRR-MM-DD.", "Enter the date as YYYY-MM-DD.") case "too_many_attempts": return text("Zbyt wiele prób. Spróbuj ponownie za 15 minut.", "Too many attempts. Try again in 15 minutes.") default: return apiError.localizedDescription } } private func perform(_ action: () async throws -> Void) async { guard !isBusy else { return } isBusy = true defer { isBusy = false } do { try await action() } catch { errorMessage = friendlyError(error) } } private func accept(_ response: APIEnvelope) throws { guard let token = response.token, !token.isEmpty else { throw APIError.invalidResponse } try KeychainStore.save(token: token) self.token = token Task { await NotificationService.requestPermission() } apply(response) beginLiveRefresh() Task { await refreshMessages(showNotifications: true) } } private func apply(_ response: APIEnvelope) { if let user = response.user { self.user = user } if let loyalty = response.loyalty { self.loyalty = merged(loyalty, keeping: self.loyalty) } OfflineAccountStore.save(user: self.user, loyalty: self.loyalty) } private func fetchAccount() async throws -> APIEnvelope? { guard !accountRefreshInFlight else { return nil } accountRefreshInFlight = true defer { accountRefreshInFlight = false } return try await api.me(token: token) } private func merged(_ fresh: Loyalty, keeping cached: Loyalty?) -> Loyalty { Loyalty( points: nonEmpty(fresh.points) ?? cached?.points, transactionsValue: nonEmpty(fresh.transactionsValue) ?? cached?.transactionsValue, discountValue: nonEmpty(fresh.discountValue) ?? cached?.discountValue, cardNumber: nonEmpty(fresh.cardNumber) ?? cached?.cardNumber, savings: nonEmpty(fresh.savings) ?? cached?.savings, firstName: nonEmpty(fresh.firstName) ?? cached?.firstName ) } private func nonEmpty(_ value: FlexibleText?) -> FlexibleText? { guard let value, !value.value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return nil } return value } private func refreshMessages(showNotifications: Bool) async { guard !token.isEmpty, !messageRefreshInFlight else { return } messageRefreshInFlight = true defer { messageRefreshInFlight = false } var incoming: [KasztelanMessage] = [] if let response = try? await api.notifications(after: messageStore.lastServerID, token: token) { incoming = response.notifications ?? [] } if incoming.isEmpty, let response = try? await api.latestNotification(token: token), let latest = response.notification, latest.id > messageStore.lastServerID { incoming = [latest] } let added = messageStore.merge(incoming) messages = messageStore.messages guard showNotifications, !added.isEmpty else { return } for message in added { await NotificationService.show(message) } } private func showInAppBanner(_ message: KasztelanMessage) { bannerTask?.cancel() presentedMessage = message bannerTask = Task { [weak self] in try? await Task.sleep(nanoseconds: 6_000_000_000) guard !Task.isCancelled else { return } self?.presentedMessage = nil self?.bannerTask = nil } } private func clearSession() { DiscountCardStore.clearLocal(for: user?.id) KeychainStore.clear() OfflineAccountStore.clear() messageStore.clear() token = "" user = nil loyalty = nil messages = [] stopLiveRefresh() } private func startNetworkMonitoring() { networkMonitor.pathUpdateHandler = { [weak self] path in guard path.status == .satisfied else { return } Task { @MainActor [weak self] in guard let self, self.isAuthenticated else { return } await self.refresh() } } networkMonitor.start(queue: DispatchQueue(label: "uk.co.kasztelan.network-monitor")) } private func digits(_ value: String) -> String { value.filter(\.isNumber) } private func decimal(_ raw: String?) -> Double? { guard let raw else { return nil } let clean = raw.replacingOccurrences(of: "£", with: "") .replacingOccurrences(of: " ", with: "") .replacingOccurrences(of: ",", with: ".") .filter { $0.isNumber || $0 == "." || $0 == "-" } return Double(clean) } private func validDate(_ value: String) -> Bool { let formatter = DateFormatter() formatter.calendar = Calendar(identifier: .gregorian) formatter.locale = Locale(identifier: "en_US_POSIX") formatter.dateFormat = "yyyy-MM-dd" formatter.isLenient = false guard let date = formatter.date(from: value), let year = formatter.calendar.dateComponents([.year], from: date).year else { return false } return year >= 1900 && date <= Date() } private func displayDOB(_ value: String?) -> String { guard let value, !value.isEmpty else { return "" } if value.range(of: #"^\d{4}-\d{2}-\d{2}$"#, options: .regularExpression) != nil { return value } if let seconds = TimeInterval(value), value.count >= 9 { let formatter = DateFormatter() formatter.dateFormat = "yyyy-MM-dd" return formatter.string(from: Date(timeIntervalSince1970: seconds)) } return "" } } private struct CachedAccount: Codable { let user: UserProfile? let loyalty: Loyalty? let savedAt: Date } private enum OfflineAccountStore { private static var fileURL: URL? { guard let directory = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { return nil } return directory.appendingPathComponent("kasztelan-account-cache.json") } static func load() -> CachedAccount? { guard let fileURL, let data = try? Data(contentsOf: fileURL) else { return nil } return try? JSONDecoder().decode(CachedAccount.self, from: data) } static func save(user: UserProfile?, loyalty: Loyalty?) { guard user != nil || loyalty != nil, let fileURL, let data = try? JSONEncoder().encode(CachedAccount(user: user, loyalty: loyalty, savedAt: Date())) else { return } let directory = fileURL.deletingLastPathComponent() try? FileManager.default.createDirectory( at: directory, withIntermediateDirectories: true, attributes: [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication] ) try? data.write(to: fileURL, options: [.atomic, .completeFileProtectionUntilFirstUserAuthentication]) } static func clear() { guard let fileURL else { return } try? FileManager.default.removeItem(at: fileURL) } }