KasztelanKarta/Kasztelan/AppModel.swift
2026-10-16 23:37:49 +01:00

439 lines
16 KiB
Swift

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<Void, Never>?
private var bannerTask: Task<Void, Never>?
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)
}
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 }
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
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)
}
}