439 lines
16 KiB
Swift
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)
|
|
}
|
|
}
|