diff --git a/common/AccountManager.swift b/common/AccountManager.swift new file mode 100644 index 0000000..f33b9c6 --- /dev/null +++ b/common/AccountManager.swift @@ -0,0 +1,84 @@ +// +// AccountManager.swift +// meterios +// +// (c) 2025 Martin "maride" Dessauer +// + +import Foundation +import SwiftUI + +/// **AccountManager** stores connection details and the corresponding client +class AccountManager: ObservableObject { + // default contains a ready-to-use instance of AccountManager, read from AppStorage + static public var `default` = AccountManager() + + // accounts holds all saved accounts + @Published private(set) var Accounts: Array = [] + + // init creates a new AccountManager, reading saved account data from AppStorage + init() { + self.Accounts = AccountManager.readFromRaw() + } + + // readFromRaw reads the current accounts from the stored data and transforms it to a handy Account Array + static private func readFromRaw() -> Array { + @AppStorage("accounts") var rawAccs = Data() + do { + let simpleAccs = try JSONDecoder().decode([Account].self, from: rawAccs) + let richAccs = simpleAccs.map({ AccountManagerEntry($0) }) + return richAccs + } catch { + print("Error parsing stored accounts from JSON: \(error)") + return [] + } + } + + // writeToRaw writes the current account data to the AppStorage + private func writeToRaw() { + @AppStorage("accounts") var rawAccs = Data() + do { + let simpleAccs = Accounts.map({ $0.GetAccount() }) + rawAccs = try JSONEncoder().encode(simpleAccs) + } catch { + print("Error writing current accounts to JSON: \(error)") + } + } + + // CurrentAccount returns the currently selected account if there is one + func CurrentAccount() -> AccountManagerEntry? { + if self.Accounts.count > 0 { + return self.Accounts.first + } + return nil + } + + // AddAccount adds the given account if it doesn't exist yet + func AddAccount(_ account: Account) { + if IndexOfAccount(account) == nil { + Accounts.append(AccountManagerEntry(account)) + writeToRaw() + } + } + + // IndexOfAccount returns the index of the specified account identified by address and user ID + func IndexOfAccount(_ account: Account) -> Int? { + return Accounts.firstIndex(where: { + $0.GetAccount().GetAddress() == account.GetAddress() && + $0.GetAccount().GetUserID() == account.GetUserID() + }) + } + + // RemoveAccount removes the account specified by address and user ID of the given Account, if present + func RemoveAccount(_ account: Account) { + if IndexOfAccount(account) != nil { + Accounts.remove(at: IndexOfAccount(account)!) + writeToRaw() + } + } + + // RemoveAccount removes the account specified by address and user ID of the given AccountManagerEntry, if present + func RemoveAccount(_ account: AccountManagerEntry) { + RemoveAccount(account.GetAccount()) + } +} diff --git a/meterios/BackendConnector.swift b/common/MeteClient.swift similarity index 54% rename from meterios/BackendConnector.swift rename to common/MeteClient.swift index 1694eec..4559be5 100644 --- a/meterios/BackendConnector.swift +++ b/common/MeteClient.swift @@ -1,5 +1,5 @@ // -// BackendConnector.swift +// MeteClient.swift // meterios // // (c) 2025 Martin "maride" Dessauer @@ -7,61 +7,73 @@ import Foundation -enum BackendConnectorError: Error { +enum MeteClientError: Error { case StatusCodeLooksBad(explanation: String) } -class BackendConnector { +/// **MeteClient** is the client-side implementation of the Space Market API. Pure Swift, no platform-specific frameworks used. +class MeteClient { + private let address: String + private let userID: Int + + convenience init(account: Account) { + self.init(address: account.GetAddress(), userID: account.GetUserID()) + } + + init(address: String, userID: Int) { + self.address = address + self.userID = userID + } + // GetDrinks returns the list of drinks available at the backend - func GetDrinks(baseAddr: String) async throws -> Array { + func GetDrinks() async throws -> Array { // Send HTTP request to the drinks endpoint - let url = URL(string: "https://\(baseAddr)/api/v1/drinks") + let url = URL(string: "https://\(address)/api/v1/drinks") let (data, _) = try await URLSession.shared.data(from: url!) - + // Decode JSON to array of drinks let decoder = JSONDecoder() let drinks = try decoder.decode([Drink].self, from: data) - + return drinks } - - // GetUser returns the user object for the given ID< - func GetUser(baseAddr: String, userID: Int) async throws -> User { + + // GetUser returns the user object for the given ID + func GetUser() async throws -> User { // Send HTTP request to the user endpoint - let url = URL(string: "https://\(baseAddr)/api/v1/users/\(userID).json") + let url = URL(string: "https://\(address)/api/v1/users/\(userID).json") let (data, _) = try await URLSession.shared.data(from: url!) - + // Decode JSON to array of drinks let decoder = JSONDecoder() let user = try decoder.decode(User.self, from: data) - + return user } - + // GetUsers returns all users registered at the backend - func GetUsers(baseAddr: String) async throws -> Array { + func GetUsers() async throws -> Array { // Send HTTP request to the user endpoint - let url = URL(string: "https://\(baseAddr)/api/v1/users.json") + let url = URL(string: "https://\(address)/api/v1/users.json") let (data, _) = try await URLSession.shared.data(from: url!) - + // Decode JSON to array of drinks let decoder = JSONDecoder() let users = try decoder.decode([User].self, from: data) - + return users } - + // BookDrink subtracts the recommended price off the balance from the user given by ID - func BookDrink(baseAddr: String, userID: Int, drinkID: Int) async throws { + func BookDrink(_ drinkID: Int) async throws { // Send HTTP request to the user endpoint - let url = URL(string: "https://\(baseAddr)/api/v1/users/\(userID)/buy.json?drink=\(drinkID)") + let url = URL(string: "https://\(address)/api/v1/users/\(userID)/buy.json?drink=\(drinkID)") let (_, response) = try await URLSession.shared.data(from: url!) - + // Check status code let statusCode = (response as? HTTPURLResponse)?.statusCode if statusCode != 204 { - throw BackendConnectorError.StatusCodeLooksBad(explanation: "Failed to book drink \(drinkID) for user \(userID)@\(baseAddr): HTTP status code \(statusCode) received for \(url)") + throw MeteClientError.StatusCodeLooksBad(explanation: "Failed to book drink \(drinkID) for user \(userID)@\(address): HTTP status code \(statusCode) received for \(url)") } - } } diff --git a/common/WCManager.swift b/common/WCManager.swift index ea5a2e2..948d52f 100644 --- a/common/WCManager.swift +++ b/common/WCManager.swift @@ -13,13 +13,13 @@ enum WCMeteriosMessageTypes: String { case BookDrink } -class WCManager: NSObject, WCSessionDelegate { - @AppStorage("meteHostAddr") private var meteHostAddr = "" - @AppStorage("meteUserID") private var meteUserID = -1 - +/// **WCManager** managed the Meterios-specific communication between an iOS and an watchOS device +class WCManager: NSObject, WCSessionDelegate, ObservableObject { + private var accountManager: AccountManager private let session: WCSession = WCSession.default override init() { + self.accountManager = AccountManager.default super.init() if (WCSession.isSupported()) { @@ -39,12 +39,18 @@ class WCManager: NSObject, WCSessionDelegate { // As Meterios currently only needs messages from watchOS to iOS and responses back to the watch, this function currently only implements iOS-related responses. func session(_ session: WCSession, didReceiveMessage message: [String : Any], replyHandler: @escaping ([String : Any]) -> Void) { #if os(iOS) + // Prevent crashes when WCSession is received before AccountManager is ready + if accountManager.CurrentAccount() == nil { + print("Request encountered in WCSession, but AccountManager is not ready.") + return + } + // Check for the different possible message types switch message["request"] as! String { case WCMeteriosMessageTypes.DrinkList.rawValue: // Request for a list of available drinks Task { - let drinks = try await BackendConnector().GetDrinks(baseAddr: meteHostAddr) + let drinks = try await accountManager.CurrentAccount()!.GetClient().GetDrinks() let favouriteDrinks = drinks.filter({ FavouritesManager.default.IsFavourite($0.id) && $0.active }) let availableDrinks = drinks.filter({ !FavouritesManager.default.IsFavourite($0.id) && $0.active }) // note that drinks which are not active/available are not sent to the watch - the screen is too small to waste pixels. @@ -64,7 +70,7 @@ class WCManager: NSObject, WCSessionDelegate { do { // Book let drinkID = message["drinkID"] as! Int - try await BackendConnector().BookDrink(baseAddr: meteHostAddr, userID: meteUserID, drinkID: drinkID) + try await accountManager.CurrentAccount()!.GetClient().BookDrink(drinkID) replyHandler([ "salut": SalutManager.getSalute() ]) diff --git a/common/types/Account.swift b/common/types/Account.swift new file mode 100644 index 0000000..2279a77 --- /dev/null +++ b/common/types/Account.swift @@ -0,0 +1,24 @@ +// +// Account.swift +// meterios +// +// (c) 2025 Martin "maride" Dessauer +// + +struct Account: Encodable, Decodable { + private let address: String + private let userID: Int + + init(address: String, userID: Int) { + self.address = address + self.userID = userID + } + + func GetAddress() -> String { + return address + } + + func GetUserID() -> Int { + return userID + } +} diff --git a/common/types/AccountManagerEntry.swift b/common/types/AccountManagerEntry.swift new file mode 100644 index 0000000..7a0497e --- /dev/null +++ b/common/types/AccountManagerEntry.swift @@ -0,0 +1,24 @@ +// +// AccountManagerEntry.swift +// meterios +// +// (c) 2025 Martin "maride" Dessauer +// + +struct AccountManagerEntry { + private let account: Account + private let client: MeteClient + + init(_ account: Account) { + self.account = account + self.client = MeteClient(account: account) + } + + func GetAccount() -> Account { + return account + } + + func GetClient() -> MeteClient { + return client + } +} diff --git a/meterios-watch/meteriosWatchApp.swift b/meterios-watch/meteriosWatchApp.swift index 107a9af..0bb6d56 100644 --- a/meterios-watch/meteriosWatchApp.swift +++ b/meterios-watch/meteriosWatchApp.swift @@ -9,11 +9,11 @@ import SwiftUI @main struct meteriosWatchApp: App { - private var wcMgr = WCManager() - + @StateObject var wcMgr = WCManager() + var body: some Scene { WindowGroup { - ContentView(wcMgr) + ContentView().environmentObject(wcMgr) } } } diff --git a/meterios-watch/views/ContentView.swift b/meterios-watch/views/ContentView.swift index ada199d..0a0648a 100644 --- a/meterios-watch/views/ContentView.swift +++ b/meterios-watch/views/ContentView.swift @@ -8,7 +8,7 @@ import SwiftUI struct ContentView: View { - private var wcMgr: WCManager + @EnvironmentObject var wcMgr: WCManager @State private var favouriteDrinks: Array = [] @State private var availableDrinks: Array = [] @@ -17,10 +17,6 @@ struct ContentView: View { @State private var updateError: String = "" @State private var updateErrorCount: Int = 0 - init(_ wcMgr: WCManager) { - self.wcMgr = wcMgr - } - // update performs a full update from the paired device, covering connection info (user ID and backend address) and list of drinks func update() { if updateErrorCount >= 3 { @@ -55,14 +51,14 @@ struct ContentView: View { Section(String(localized: "FAVOURITES")) { ForEach(favouriteDrinks) { drink in NavigationLink(drink.name) { - DrinkDetailView(wcMgr: wcMgr, drink: drink) + DrinkDetailView(drink: drink) } } } Section(String(localized: "DRINKS")) { ForEach(availableDrinks) { drink in NavigationLink(drink.name) { - DrinkDetailView(wcMgr: wcMgr, drink: drink) + DrinkDetailView(drink: drink) } } } diff --git a/meterios-watch/views/DrinkDetailView.swift b/meterios-watch/views/DrinkDetailView.swift index 8d618e1..bc00121 100644 --- a/meterios-watch/views/DrinkDetailView.swift +++ b/meterios-watch/views/DrinkDetailView.swift @@ -8,15 +8,15 @@ import SwiftUI struct DrinkDetailView: View { - private var wcMgr: WCManager + @EnvironmentObject var wcMgr: WCManager + private var drink: Drink @State var shouldShowTransactionSheet = false @Environment(\.dismiss) private var dismiss - init(wcMgr: WCManager, drink: Drink) { - self.wcMgr = wcMgr + init(drink: Drink) { self.drink = drink } @@ -52,7 +52,7 @@ struct DrinkDetailView: View { // Sheet dismissed - dismiss detail perspective as well. dismiss() } content: { - TransactionView(wcMgr: wcMgr, drinkID: drink.id).interactiveDismissDisabled() + TransactionView(drinkID: drink.id).interactiveDismissDisabled() } } } diff --git a/meterios-watch/views/TransactionView.swift b/meterios-watch/views/TransactionView.swift index 5b363bf..19fa517 100644 --- a/meterios-watch/views/TransactionView.swift +++ b/meterios-watch/views/TransactionView.swift @@ -8,7 +8,8 @@ import SwiftUI struct TransactionView: View { - private var wcMgr: WCManager + @EnvironmentObject var wcMgr: WCManager + private var drinkID: Int @State var statusLine = String(localized: "BOOKING") @@ -22,8 +23,7 @@ struct TransactionView: View { @State var backendBookTimer: Timer? @State var backendTimerReady: Bool = false - init(wcMgr: WCManager, drinkID: Int) { - self.wcMgr = wcMgr + init(drinkID: Int) { self.drinkID = drinkID } diff --git a/meterios.xcodeproj/project.pbxproj b/meterios.xcodeproj/project.pbxproj index e66a5ab..8abdff1 100644 --- a/meterios.xcodeproj/project.pbxproj +++ b/meterios.xcodeproj/project.pbxproj @@ -40,19 +40,16 @@ /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ - A7FA4CBE2D9E90B0005ACDBB /* Exceptions for "meterios" folder in "meterios-watch" target */ = { - isa = PBXFileSystemSynchronizedBuildFileExceptionSet; - membershipExceptions = ( - BackendConnector.swift, - ); - target = A7FA4CA92D9E8FF1005ACDBB /* meterios-watch */; - }; A7FA4CCB2D9E9564005ACDBB /* Exceptions for "common" folder in "meterios-watch" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( + AccountManager.swift, AsyncImageWithPlaceholder.swift, Localizable.xcstrings, + MeteClient.swift, SalutManager.swift, + types/Account.swift, + types/AccountManagerEntry.swift, types/Drink.swift, types/User.swift, WCManager.swift, @@ -64,9 +61,6 @@ /* Begin PBXFileSystemSynchronizedRootGroup section */ A7F97A902D8DD542004EF4ED /* meterios */ = { isa = PBXFileSystemSynchronizedRootGroup; - exceptions = ( - A7FA4CBE2D9E90B0005ACDBB /* Exceptions for "meterios" folder in "meterios-watch" target */, - ); path = meterios; sourceTree = ""; }; diff --git a/meterios/meteriosApp.swift b/meterios/meteriosApp.swift index 7dafdad..74036ee 100644 --- a/meterios/meteriosApp.swift +++ b/meterios/meteriosApp.swift @@ -9,17 +9,14 @@ import SwiftUI @main struct meteriosApp: App { - @AppStorage("meteHostAddr") private var meteHostAddr = "" - @AppStorage("meteUserID") private var meteUserID = -1 - private var wcMgr = WCManager() var body: some Scene { WindowGroup { - if meteHostAddr == "" || meteUserID == -1 { - SetupView() - } else { + if AccountManager.default.CurrentAccount() != nil { MainView() + } else { + SetupView() } } } diff --git a/meterios/views/DrinkDetailView.swift b/meterios/views/DrinkDetailView.swift index dc844bd..526ae29 100644 --- a/meterios/views/DrinkDetailView.swift +++ b/meterios/views/DrinkDetailView.swift @@ -9,105 +9,104 @@ import SwiftUI import OSLog struct DrinkDetail: View { - @AppStorage("meteHostAddr") private var meteHostAddr = "" - @AppStorage("meteUserID") private var meteUserID = -1 - private var drink: Drink @State private var drinkAmount: Int = 1 @State var shouldShowTransactionSheet = false @State var overrideDeactivatedState: Bool = false - + @State var isFavourite: Bool = false - + @Environment(\.dismiss) private var dismiss - + init(drink: Drink) { self.drink = drink } - + var body: some View { - List() { - // Drink details - Section() { - HStack { - Spacer() - VStack() { - // Displays the drink image asynchronously - AsyncImageWithPlaceholder(url: URL(string: "https://\(meteHostAddr)/\(drink.logoURL)"), width: 300, height: 300).fixedSize() - - // Basic drink information - VStack(alignment: .center) { - Text("\(drink.name)").font(.largeTitle).fontWeight(.bold) - HStack { - VStack { - Text(String(format: "%img", drink.caffeine)).fontWeight(.bold) - Text(String(localized: "CAFFEINE")).fontWeight(Font.Weight.thin) - }.fixedSize() - Divider() - VStack { - Text(drink.GetPrice().formatted(.currency(code: "EUR"))).fontWeight(.bold) - Text(String(localized: "PRICE")).fontWeight(Font.Weight.thin) + if AccountManager.default.CurrentAccount() != nil { + List() { + // Drink details + Section() { + HStack { + Spacer() + VStack() { + // Displays the drink image asynchronously + AsyncImageWithPlaceholder(url: URL(string: "https://\(AccountManager.default.CurrentAccount()!.GetAccount().GetAddress())/\(drink.logoURL)"), width: 300, height: 300).fixedSize() + + // Basic drink information + VStack(alignment: .center) { + Text("\(drink.name)").font(.largeTitle).fontWeight(.bold) + HStack { + VStack { + Text(String(format: "%img", drink.caffeine)).fontWeight(.bold) + Text(String(localized: "CAFFEINE")).fontWeight(Font.Weight.thin) + }.fixedSize() + Divider() + VStack { + Text(drink.GetPrice().formatted(.currency(code: "EUR"))).fontWeight(.bold) + Text(String(localized: "PRICE")).fontWeight(Font.Weight.thin) + } } } + }.fixedSize() + Spacer() + } + } + + // Favourites + Section() { + Toggle(String(localized: "FAVOURITE"), isOn: $isFavourite).toggleStyle(SwitchToggleStyle(tint: .blue)) + .onAppear(perform: { + isFavourite = FavouritesManager.default.IsFavourite(drink.id) + }) + .onDisappear(perform: { + FavouritesManager.default.SetFavourite(drinkID: self.drink.id, shouldBeFavourite: isFavourite) + }) + } + + // Summary + Section { + Stepper(String(format: String(localized: "AMOUNT_FMT"), self.drinkAmount), onIncrement: { + if self.drinkAmount < 10 { + self.drinkAmount += 1 + } + }, onDecrement: { + if self.drinkAmount > 1 { + self.drinkAmount -= 1 } - }.fixedSize() - Spacer() - } - } - - // Favourites - Section() { - Toggle(String(localized: "FAVOURITE"), isOn: $isFavourite).toggleStyle(SwitchToggleStyle(tint: .blue)) - .onAppear(perform: { - isFavourite = FavouritesManager.default.IsFavourite(drink.id) }) - .onDisappear(perform: { - FavouritesManager.default.SetFavourite(drinkID: self.drink.id, shouldBeFavourite: isFavourite) - }) - } - - // Summary - Section { - Stepper(String(format: String(localized: "AMOUNT_FMT"), self.drinkAmount), onIncrement: { - if self.drinkAmount < 10 { - self.drinkAmount += 1 + HStack { + Text(String(localized: "TOTAL_SUM")) + Spacer() + Text((self.drink.GetPrice() * Float(self.drinkAmount)).formatted(.currency(code: "EUR"))).fontWeight(.bold) } - }, onDecrement: { - if self.drinkAmount > 1 { - self.drinkAmount -= 1 - } - }) - HStack { - Text(String(localized: "TOTAL_SUM")) - Spacer() - Text((self.drink.GetPrice() * Float(self.drinkAmount)).formatted(.currency(code: "EUR"))).fontWeight(.bold) } + + // Warning for inactive bewerages + if !drink.active { + Section( + header: Text(String(format: String(localized: "INACTIVE_FMT"), drink.name)), + content: { + Toggle(String(format: String(localized: "AVAILABLE_FMT"), drink.name), isOn: $overrideDeactivatedState).toggleStyle(SwitchToggleStyle(tint: .green)) + }) + } + + // Pick button + Button(action: { + // Open the transaction sheet + shouldShowTransactionSheet = true + }) { + Text(String(localized: "POISON_PICKED")).frame(maxWidth: .infinity) + }.tint(.green).disabled(!drink.active && !overrideDeactivatedState) } - // Warning for inactive bewerages - if !drink.active { - Section( - header: Text(String(format: String(localized: "INACTIVE_FMT"), drink.name)), - content: { - Toggle(String(format: String(localized: "AVAILABLE_FMT"), drink.name), isOn: $overrideDeactivatedState).toggleStyle(SwitchToggleStyle(tint: .green)) - }) + // Transaction sheet + .sheet(isPresented: $shouldShowTransactionSheet) { + // Sheet dismissed - dismiss detail perspective as well. + dismiss() + } content: { + TransactionView(drinkID: drink.id, drinkAmount: drinkAmount).interactiveDismissDisabled() } - - // Pick button - Button(action: { - // Open the transaction sheet - shouldShowTransactionSheet = true - }) { - Text(String(localized: "POISON_PICKED")).frame(maxWidth: .infinity) - }.tint(.green).disabled(!drink.active && !overrideDeactivatedState) - } - - // Transaction sheet - .sheet(isPresented: $shouldShowTransactionSheet) { - // Sheet dismissed - dismiss detail perspective as well. - dismiss() - } content: { - TransactionView(drinkID: drink.id, drinkAmount: drinkAmount).interactiveDismissDisabled() } } } diff --git a/meterios/views/MainView.swift b/meterios/views/MainView.swift index da81864..6229bae 100644 --- a/meterios/views/MainView.swift +++ b/meterios/views/MainView.swift @@ -9,9 +9,6 @@ import SwiftUI import OSLog struct MainView: View { - @AppStorage("meteHostAddr") private var meteHostAddr = "" - @AppStorage("meteUserID") private var meteUserID = -1 - @State private var favouriteDrinks: Array = [] @State private var availableDrinks: Array = [] @State private var outOfOrderDrinks: Array = [] @@ -24,14 +21,14 @@ struct MainView: View { Task { do { // Load user - user = try await BackendConnector().GetUser(baseAddr: meteHostAddr, userID: meteUserID) + user = try await AccountManager.default.CurrentAccount()!.GetClient().GetUser() // Load drinks - let drinks = try await BackendConnector().GetDrinks(baseAddr: meteHostAddr) + let drinks = try await AccountManager.default.CurrentAccount()!.GetClient().GetDrinks() favouriteDrinks = drinks.filter({ FavouritesManager.default.IsFavourite($0.id) && $0.active }) availableDrinks = drinks.filter({ !FavouritesManager.default.IsFavourite($0.id) && $0.active }) outOfOrderDrinks = drinks.filter({ !$0.active }) } catch { - let _ = Logger().error("Failed to update from \(meteHostAddr): \(error)") + let _ = Logger().error("Failed to update from \(AccountManager.default.CurrentAccount()!.GetAccount().GetAddress()): \(error)") } } } @@ -44,7 +41,7 @@ struct MainView: View { update() }) }, label: { - AsyncImageWithPlaceholder(url: URL(string: "https://\(meteHostAddr)/\(drink.logoURL)"), width: 32, height: 32) + AsyncImageWithPlaceholder(url: URL(string: "https://\(AccountManager.default.CurrentAccount()!.GetAccount().GetAddress())/\(drink.logoURL)"), width: 32, height: 32) Text(drink.name) }) } @@ -52,11 +49,11 @@ struct MainView: View { } var body: some View { - ScrollViewReader { scrollViewReader in + if AccountManager.default.CurrentAccount() != nil { NavigationView { - VStack(alignment: .leading) { - List () { - // Account + List () { + // Account + if user != nil { Section(header: Text(String(localized: "ACCOUNT")).id("topAnchor")) { NavigationLink(destination: UserDetailView(user: user!)) { Button(action: { @@ -70,45 +67,45 @@ struct MainView: View { // Account, Address & Balance VStack(alignment: .leading) { HStack { - Text(user.displayName).fontWeight(.bold) + Text(user!.displayName).fontWeight(.bold) Divider() - Text(user.balance.formatted(.currency(code: "EUR"))).foregroundStyle(user.balance > 0 ? .green : .red) + Text(user!.balance.formatted(.currency(code: "EUR"))).foregroundStyle(user!.balance > 0 ? .green : .red) }.fixedSize() - Text("\(meteHostAddr)").fontWeight(.thin) + Text("\(AccountManager.default.CurrentAccount()!.GetAccount().GetAddress())").fontWeight(.thin) }.fixedSize() }.fixedSize() } } } - - // Drinks list - if !favouriteDrinks.isEmpty { - drinkSection(title: String(localized: "FAVOURITES"), drinks: favouriteDrinks) - } - if !availableDrinks.isEmpty { - drinkSection(title: String(localized: "AVAILABLE_DRINKS"), drinks: availableDrinks) - } - if !outOfOrderDrinks.isEmpty { - drinkSection(title: String(localized: "OUT_OF_STOCK_DRINKS"), drinks: outOfOrderDrinks) - } - // Catch a backend without drinks - if favouriteDrinks.isEmpty && availableDrinks.isEmpty && outOfOrderDrinks.isEmpty { - Section(content: {}, footer: { - HStack(alignment: .center, content: { - Spacer() - VStack() { - Image(systemName: "bolt.horizontal.circle.fill").font(.title).foregroundStyle(.gray) - Spacer(minLength: 8) - Text(String(localized: "NO_DRINKS")).font(.title).foregroundStyle(.gray) - } - Spacer() - }) + } + + // Drinks list + if !favouriteDrinks.isEmpty { + drinkSection(title: String(localized: "FAVOURITES"), drinks: favouriteDrinks) + } + if !availableDrinks.isEmpty { + drinkSection(title: String(localized: "AVAILABLE_DRINKS"), drinks: availableDrinks) + } + if !outOfOrderDrinks.isEmpty { + drinkSection(title: String(localized: "OUT_OF_STOCK_DRINKS"), drinks: outOfOrderDrinks) + } + // Catch a backend without drinks + if favouriteDrinks.isEmpty && availableDrinks.isEmpty && outOfOrderDrinks.isEmpty { + Section(content: {}, footer: { + HStack(alignment: .center, content: { + Spacer() + VStack() { + Image(systemName: "bolt.horizontal.circle.fill").font(.title).foregroundStyle(.gray) + Spacer(minLength: 8) + Text(String(localized: "NO_DRINKS")).font(.title).foregroundStyle(.gray) + } + Spacer() }) - } - }.navigationTitle(String(localized: "METERIOS")).refreshable(action: { - update() - }) - } + }) + } + }.navigationTitle(String(localized: "METERIOS")).refreshable(action: { + update() + }) }.onAppear(perform: { update() }) diff --git a/meterios/views/SetupView.swift b/meterios/views/SetupView.swift index 094fbd6..f5f7a36 100644 --- a/meterios/views/SetupView.swift +++ b/meterios/views/SetupView.swift @@ -9,9 +9,6 @@ import SwiftUI import OSLog struct SetupView: View { - @AppStorage("meteHostAddr") private var meteHostAddr = "" - @AppStorage("meteUserID") private var meteUserID = -1 - @State private var tmpAddress: String = "mete.chaosdorf.space" @State private var tmpUsers: Array = [] @State private var selectedUserID: Int = 0 @@ -28,12 +25,6 @@ struct SetupView: View { @Environment(\.dismiss) private var dismiss - init() { - if meteHostAddr != "" { - self.tmpAddress = meteHostAddr - } - } - func tryConnect() { // If there was a previous connection attempt, the user list and save button might still be visible - hide them again userListVisible = false @@ -54,7 +45,8 @@ struct SetupView: View { // Create test connector do { // Try out the connection - tmpUsers = try await BackendConnector().GetUsers(baseAddr: tmpAddress) + let tmpConnector = MeteClient(address: tmpAddress, userID: -1) + tmpUsers = try await tmpConnector.GetUsers() connectionStatus = String(localized: "CONNECTION_SUCCESSFUL") connectionStatusIcon = "checkmark.icloud.fill" connectionStatusColor = .green @@ -101,7 +93,7 @@ struct SetupView: View { // Connection Section(content: { // Address field - TextField(text: $tmpAddress, label: { Text(meteHostAddr) }) + TextField(text: $tmpAddress, label: { Text(tmpAddress) }) .textInputAutocapitalization(.never) .disableAutocorrection(true) .focused($connectionFiledFocused) @@ -146,8 +138,7 @@ struct SetupView: View { // Save button Section { Button(action: { - meteHostAddr = tmpAddress - meteUserID = selectedUserID + AccountManager.default.AddAccount(Account(address: tmpAddress, userID: selectedUserID)) dismiss() }) { Text(String(localized: "SAVE_CONNECTION_SETTINGS")).frame(maxWidth: .infinity) diff --git a/meterios/views/TransactionView.swift b/meterios/views/TransactionView.swift index d3642d5..f482c1c 100644 --- a/meterios/views/TransactionView.swift +++ b/meterios/views/TransactionView.swift @@ -8,9 +8,6 @@ import SwiftUI struct TransactionView: View { - @AppStorage("meteHostAddr") private var meteHostAddr = "" - @AppStorage("meteUserID") private var meteUserID = -1 - var drinkID: Int var drinkAmount: Int @@ -43,7 +40,7 @@ struct TransactionView: View { do { // Try to book the drink(s) for _ in 1...drinkAmount { - try await BackendConnector().BookDrink(baseAddr: meteHostAddr, userID: meteUserID, drinkID: drinkID) + try await AccountManager.default.CurrentAccount()!.GetClient().BookDrink(drinkID) } statusLine = SalutManager.getSalute() backendTimerReady = true @@ -57,27 +54,29 @@ struct TransactionView: View { } var body: some View { - // Green checkmark or progress indicator - if backendTimerReady { - Image(systemName: "checkmark.circle.fill") + if AccountManager.default.CurrentAccount() != nil { + // Green checkmark or progress indicator + if backendTimerReady { + Image(systemName: "checkmark.circle.fill") + .font(.title) + .foregroundStyle(.green) + } else { + ProgressView() + } + + // Status line, on success with salute + Text(statusLine) .font(.title) - .foregroundStyle(.green) - } else { - ProgressView() + .onAppear(perform: { + closeTimer = Timer.scheduledTimer(withTimeInterval: 1.5, repeats: false) { timer in + closeTimerReady = true + maybeDismiss() + } + backendBookTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { timer in + book() + } + }) + .sensoryFeedback(.success, trigger: backendTimerReady) } - - // Status line, on success with salute - Text(statusLine) - .font(.title) - .onAppear(perform: { - closeTimer = Timer.scheduledTimer(withTimeInterval: 1.5, repeats: false) { timer in - closeTimerReady = true - maybeDismiss() - } - backendBookTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { timer in - book() - } - }) - .sensoryFeedback(.success, trigger: backendTimerReady) } } diff --git a/meterios/views/UserDetailView.swift b/meterios/views/UserDetailView.swift index 6cb057d..08d4424 100644 --- a/meterios/views/UserDetailView.swift +++ b/meterios/views/UserDetailView.swift @@ -8,64 +8,63 @@ import SwiftUI struct UserDetailView: View { - @AppStorage("meteHostAddr") private var meteHostAddr = "" - @AppStorage("meteUserID") private var meteUserID = -1 - var user: User @Environment(\.dismiss) private var dismiss var body: some View { - List() { - Section { - HStack(alignment: .center) { - Spacer() - VStack(alignment: .center) { - // User image - AsyncImageWithPlaceholder(url: URL(string: user.displayImageURL), width: 256, height: 256).clipShape(Circle()) - - // Basic info about user + if AccountManager.default.CurrentAccount() != nil { + List() { + Section { + HStack(alignment: .center) { + Spacer() VStack(alignment: .center) { - // User name - Text("\(user.displayName)").font(.largeTitle).fontWeight(.bold) - // Balance and Mete backend address - HStack { - VStack { - Text(user.balance.formatted(.currency(code: "EUR"))).fontWeight(.bold) - .foregroundStyle(user.balance > 0 ? .green : .red) - Text(String(localized: "BALANCE")).fontWeight(Font.Weight.thin) - }.fixedSize() - Divider() - VStack { - Text(String(meteHostAddr)) - Text(String(localized: "METE_SERVER")).fontWeight(Font.Weight.thin) + // User image + AsyncImageWithPlaceholder(url: URL(string: user.displayImageURL), width: 256, height: 256).clipShape(Circle()) + + // Basic info about user + VStack(alignment: .center) { + // User name + Text("\(user.displayName)").font(.largeTitle).fontWeight(.bold) + // Balance and Mete backend address + HStack { + VStack { + Text(user.balance.formatted(.currency(code: "EUR"))).fontWeight(.bold) + .foregroundStyle(user.balance > 0 ? .green : .red) + Text(String(localized: "BALANCE")).fontWeight(Font.Weight.thin) + }.fixedSize() + Divider() + VStack { + Text(String(AccountManager.default.CurrentAccount()!.GetAccount().GetAddress())) + Text(String(localized: "METE_SERVER")).fontWeight(Font.Weight.thin) + } } - } - }.fixedSize() + }.fixedSize() + } + Spacer() } - Spacer() } - } - - // Open profile in browser - Section() { - Button(action: { - UIApplication.shared.open(URL(string: "https://\(meteHostAddr)/users/\(meteUserID)")!) - }) { - Text(String(localized: "OPEN_PROFILE_IN_BROWSER")).frame(maxWidth: .infinity) + + // Open profile in browser + Section() { + Button(action: { + UIApplication.shared.open(URL(string: "https://\(AccountManager.default.CurrentAccount()!.GetAccount().GetAddress())/users/\(AccountManager.default.CurrentAccount()!.GetAccount().GetUserID())")!) + }) { + Text(String(localized: "OPEN_PROFILE_IN_BROWSER")).frame(maxWidth: .infinity) + } + } + + // Logout button + Section() { + Button(action: { + // Log out. Save the User ID as -1, which is forbidden by the backend. + // This forces Meterios to display the setup screen. + AccountManager.default.RemoveAccount(AccountManager.default.CurrentAccount()!) + dismiss() + }) { + Text(String(localized: "LOGOUT")).frame(maxWidth: .infinity) + }.tint(.red) } - } - - // Logout button - Section() { - Button(action: { - // Log out. Save the User ID as -1, which is forbidden by the backend. - // This forces Meterios to display the setup screen. - meteUserID = -1 - dismiss() - }) { - Text(String(localized: "LOGOUT")).frame(maxWidth: .infinity) - }.tint(.red) } } }