From 68239727fac371c3051ea9011fa9aa69d871c31a Mon Sep 17 00:00:00 2001 From: maride Date: Wed, 11 Jun 2025 03:41:21 +0200 Subject: [PATCH] Transform to multi-account support --- common/AccountManager.swift | 78 ++++++---- common/Localizable.xcstrings | 54 +++++++ common/WCManager.swift | 18 ++- common/aux/AccountButton.swift | 43 ++++++ .../{ => aux}/AsyncImageWithPlaceholder.swift | 6 +- common/{ => aux}/SalutManager.swift | 1 + common/{ => client}/MeteClient.swift | 27 ++-- common/client/MeteClientDrink.swift | 43 ++++++ .../MeteClientUser.swift} | 25 ++-- common/types/Account.swift | 112 +++++++++++++-- common/types/AccountManagerEntry.swift | 24 ---- common/types/Drink.swift | 52 ------- meterios-watch/views/ContentView.swift | 87 ------------ meterios-watch/views/DrinkDetailView.swift | 18 ++- meterios-watch/views/MainView.swift | 133 ++++++++++++++++++ meterios.xcodeproj/project.pbxproj | 35 +---- meterios/FavouritesManager.swift | 64 --------- meterios/meteriosApp.swift | 9 +- meterios/views/DrinkDetailView.swift | 28 ++-- meterios/views/MainView.swift | 126 ++++++++--------- meterios/views/SetupView.swift | 42 +++--- meterios/views/TransactionView.swift | 2 +- meterios/views/UserDetailView.swift | 71 ---------- meterios/views/UserListView.swift | 40 ++++++ 24 files changed, 635 insertions(+), 503 deletions(-) create mode 100644 common/aux/AccountButton.swift rename common/{ => aux}/AsyncImageWithPlaceholder.swift (86%) rename common/{ => aux}/SalutManager.swift (87%) rename common/{ => client}/MeteClient.swift (73%) create mode 100644 common/client/MeteClientDrink.swift rename common/{types/User.swift => client/MeteClientUser.swift} (71%) delete mode 100644 common/types/AccountManagerEntry.swift delete mode 100644 common/types/Drink.swift delete mode 100644 meterios-watch/views/ContentView.swift create mode 100644 meterios-watch/views/MainView.swift delete mode 100644 meterios/FavouritesManager.swift delete mode 100644 meterios/views/UserDetailView.swift create mode 100644 meterios/views/UserListView.swift diff --git a/common/AccountManager.swift b/common/AccountManager.swift index f33b9c6..fa96a66 100644 --- a/common/AccountManager.swift +++ b/common/AccountManager.swift @@ -7,29 +7,34 @@ import Foundation import SwiftUI +import OSLog -/// **AccountManager** stores connection details and the corresponding client -class AccountManager: ObservableObject { +/// **AccountManager** holds multiple accounts and takes care of storing them on the device. +@Observable class AccountManager { // 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 = [] + private(set) var Accounts: Array = [] { didSet { writeToRaw() } } + private var selectedAccount: Int? { didSet { writeToRaw() } } // init creates a new AccountManager, reading saved account data from AppStorage init() { self.Accounts = AccountManager.readFromRaw() + + // Read account selection + @AppStorage("selectedAccount") var selectedAccount: Int? + self.selectedAccount = selectedAccount } // readFromRaw reads the current accounts from the stored data and transforms it to a handy Account Array - static private func readFromRaw() -> 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 + return try JSONDecoder().decode([Account].self, from: rawAccs) } catch { - print("Error parsing stored accounts from JSON: \(error)") + let _ = Logger().error("Error parsing stored accounts from JSON: \(error)") + rawAccs = Data() return [] } } @@ -38,47 +43,70 @@ class AccountManager: ObservableObject { private func writeToRaw() { @AppStorage("accounts") var rawAccs = Data() do { - let simpleAccs = Accounts.map({ $0.GetAccount() }) - rawAccs = try JSONEncoder().encode(simpleAccs) + rawAccs = try JSONEncoder().encode(Accounts) } catch { - print("Error writing current accounts to JSON: \(error)") + let _ = Logger().error("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 + func CurrentAccount() -> Account? { + if selectedAccount == nil { + return nil } + + if Accounts.count > selectedAccount! { + return Accounts[selectedAccount!] + } + return nil } + + // SelectAccount sets the current main account as returned by CurrentAccount + func SelectAccount(_ account: Account) { + SelectAccount(IndexOfAccount(account)) + } + + // SelectAccount sets the current main account as returned by CurrentAccount + func SelectAccount(_ accountID: Int?) { + self.selectedAccount = accountID + + // Store account selection + @AppStorage("selectedAccount") var selectedAccount: Int? + selectedAccount = self.selectedAccount + } // AddAccount adds the given account if it doesn't exist yet func AddAccount(_ account: Account) { if IndexOfAccount(account) == nil { - Accounts.append(AccountManagerEntry(account)) - writeToRaw() + Accounts.append(account) } } // 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() + $0.address == account.address && + $0.userID == account.userID }) } - // RemoveAccount removes the account specified by address and user ID of the given Account, if present + // RemoveAccount removes the specified account func RemoveAccount(_ account: Account) { if IndexOfAccount(account) != nil { - Accounts.remove(at: IndexOfAccount(account)!) - writeToRaw() + RemoveAccount(IndexOfAccount(account)!) } } - - // RemoveAccount removes the account specified by address and user ID of the given AccountManagerEntry, if present - func RemoveAccount(_ account: AccountManagerEntry) { - RemoveAccount(account.GetAccount()) + + // RemoveAccount removes the account at the specified index + func RemoveAccount(_ index: Int) { + Accounts.remove(at: index) + } + + // RefreshAll refreshes all accounts held by this manager instance + func RefreshAll() async { + for acc in Accounts { + await acc.Refresh() + } } } diff --git a/common/Localizable.xcstrings b/common/Localizable.xcstrings index 4cad4b4..b4e7c5b 100644 --- a/common/Localizable.xcstrings +++ b/common/Localizable.xcstrings @@ -23,6 +23,12 @@ }, "shouldTranslate" : false }, + "? €" : { + "shouldTranslate" : false + }, + "? mg" : { + "shouldTranslate" : false + }, "%@" : { "localizations" : { "de" : { @@ -51,6 +57,22 @@ } } }, + "ACCOUNTS" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Accounts" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Accounts" + } + } + } + }, "AMOUNT_FMT" : { "extractionState" : "manual", "localizations" : { @@ -424,6 +446,22 @@ } } }, + "NO_PRICE" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kein Preis" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No price" + } + } + } + }, "OPEN_PROFILE_IN_BROWSER" : { "extractionState" : "manual", "localizations" : { @@ -543,6 +581,22 @@ } } }, + "SWITCH_ACCOUNT" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Account wechseln" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Switch Account" + } + } + } + }, "TOTAL_SUM" : { "extractionState" : "manual", "localizations" : { diff --git a/common/WCManager.swift b/common/WCManager.swift index 948d52f..1393d40 100644 --- a/common/WCManager.swift +++ b/common/WCManager.swift @@ -15,11 +15,9 @@ enum WCMeteriosMessageTypes: String { /// **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()) { @@ -40,7 +38,7 @@ class WCManager: NSObject, WCSessionDelegate, ObservableObject { 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 { + if AccountManager.default.CurrentAccount() == nil { print("Request encountered in WCSession, but AccountManager is not ready.") return } @@ -50,9 +48,9 @@ class WCManager: NSObject, WCSessionDelegate, ObservableObject { case WCMeteriosMessageTypes.DrinkList.rawValue: // Request for a list of available drinks Task { - 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 }) + let drinks = try await AccountManager.default.CurrentAccount()!.GetDrinks() + let favouriteDrinks = drinks.filter({ AccountManager.default.CurrentAccount()!.IsFavourite($0.id) && $0.active }) + let availableDrinks = drinks.filter({ !AccountManager.default.CurrentAccount()!.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. // Create a plist-compatible variant of the Drink object containing the required information @@ -70,7 +68,7 @@ class WCManager: NSObject, WCSessionDelegate, ObservableObject { do { // Book let drinkID = message["drinkID"] as! Int - try await accountManager.CurrentAccount()!.GetClient().BookDrink(drinkID) + try await AccountManager.default.CurrentAccount()!.BookDrink(drinkID) replyHandler([ "salut": SalutManager.getSalute() ]) @@ -92,7 +90,7 @@ class WCManager: NSObject, WCSessionDelegate, ObservableObject { } // requestDrinksList requests the list of drinks available at the backend from the paired device - func requestDrinksList(replyHandler: @escaping (Array, Array) -> Void, errorHandler: @escaping ((any Error)?) -> Void) { + func requestDrinksList(replyHandler: @escaping (Array, Array) -> Void, errorHandler: @escaping ((any Error)?) -> Void) { session.sendMessage([ "request": WCMeteriosMessageTypes.DrinkList.rawValue ], replyHandler: { reply in @@ -100,8 +98,8 @@ class WCManager: NSObject, WCSessionDelegate, ObservableObject { let availableDrinks = reply["availableDrinks"] as! Array // Create Drink objects from response - let favouriteDrinksArray = favouriteDrinks.map { drink in try! JSONDecoder().decode(Drink.self, from: Data(base64Encoded: drink)!) } - let availableDrinksArray = availableDrinks.map { drink in try! JSONDecoder().decode(Drink.self, from: Data(base64Encoded: drink)!) } + let favouriteDrinksArray = favouriteDrinks.map { drink in try! JSONDecoder().decode(MeteClientDrink.self, from: Data(base64Encoded: drink)!) } + let availableDrinksArray = availableDrinks.map { drink in try! JSONDecoder().decode(MeteClientDrink.self, from: Data(base64Encoded: drink)!) } // enjoy your drinks, replyHandler! replyHandler(favouriteDrinksArray, availableDrinksArray) diff --git a/common/aux/AccountButton.swift b/common/aux/AccountButton.swift new file mode 100644 index 0000000..8299063 --- /dev/null +++ b/common/aux/AccountButton.swift @@ -0,0 +1,43 @@ +// +// AccountButton.swift +// meterios +// +// (c) 2025 Martin "maride" Dessauer +// + +import SwiftUI + +/// **AccountButton** creates a button showing all relevant information of a Mete user account: profile picture, name, and balance +struct AccountButton : View { + @Bindable private var account: Account + private let action: () -> Void + + init(account: Account, action: @escaping () -> Void = {}) { + self.account = account + self.action = action + } + + var body: some View { + Button(action: action) { + HStack { + // Profile Image + AsyncImageWithPlaceholder(url: account.displayImageURL, width: 64, height: 64).clipShape(Circle()).fixedSize() + Spacer(minLength: 16.0) + + // Account, Address & Balance + VStack(alignment: .leading) { + HStack { + if account.displayName != nil { + Text(account.displayName!).fontWeight(.bold) + } + Divider() + if account.balance != nil { + Text(account.balance!.formatted(.currency(code: "EUR"))).foregroundStyle(account.balance! > 0 ? .green : .red) + } + }.fixedSize() + Text("\(account.address)").fontWeight(.thin) + }.fixedSize() + }.fixedSize() + } + } +} diff --git a/common/AsyncImageWithPlaceholder.swift b/common/aux/AsyncImageWithPlaceholder.swift similarity index 86% rename from common/AsyncImageWithPlaceholder.swift rename to common/aux/AsyncImageWithPlaceholder.swift index 3446078..ac3fe2f 100644 --- a/common/AsyncImageWithPlaceholder.swift +++ b/common/aux/AsyncImageWithPlaceholder.swift @@ -7,6 +7,7 @@ import Foundation import SwiftUI +import OSLog /// **AsyncImageWithPlaceholder** wraps AsyncImage, showing a Spinner while loading and a question mark in case of errors. struct AsyncImageWithPlaceholder : View { @@ -25,7 +26,7 @@ struct AsyncImageWithPlaceholder : View { .frame(width: width, height: height) } - /// Creates a new instance of AsyncImageWithPlaceholder, loading the image pointed to by url, with exact measures of width by height + // Creates a new instance of AsyncImageWithPlaceholder, loading the image pointed to by url, with exact measures of width by height init(url: URL? = nil, width: CGFloat, height: CGFloat) { self.url = url self.width = width @@ -42,7 +43,8 @@ struct AsyncImageWithPlaceholder : View { .frame(width: self.width, height: self.height) .clipShape(baseShape) } else if phase.error != nil { - // Error, display a dummy image (question mark) + // Error, log and display a dummy image (question mark) + let _ = Logger().error("Error loading image: \(phase.error)") ZStack { baseShapeView Text("?") diff --git a/common/SalutManager.swift b/common/aux/SalutManager.swift similarity index 87% rename from common/SalutManager.swift rename to common/aux/SalutManager.swift index 4a7ffa4..aecffea 100644 --- a/common/SalutManager.swift +++ b/common/aux/SalutManager.swift @@ -5,6 +5,7 @@ // (c) 2025 Martin "maride" Dessauer // +/// **SalutManager** provides random salutes from all over the world. class SalutManager { static let salutes: Array = [ "Prost!", "Guten!", "Kippis!", "Cheers!", "Chin-Chin!", "Zum Wohl!", "Salut!", "לחיים", diff --git a/common/MeteClient.swift b/common/client/MeteClient.swift similarity index 73% rename from common/MeteClient.swift rename to common/client/MeteClient.swift index 4559be5..2b17fce 100644 --- a/common/MeteClient.swift +++ b/common/client/MeteClient.swift @@ -16,50 +16,55 @@ 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 } + + func CanConnect() async throws -> Bool { + // Send HTTP request to the drinks endpoint + let url = URL(string: "https://\(address)/api/v1/users/stats.json") + let (_, response) = try await URLSession.shared.data(from: url!) + + let statusCode = (response as? HTTPURLResponse)?.statusCode + return statusCode == 200 + } // GetDrinks returns the list of drinks available at the backend - func GetDrinks() async throws -> Array { + func GetDrinks() async throws -> Array { // Send HTTP request to the drinks endpoint 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) + let drinks = try decoder.decode([MeteClientDrink].self, from: data) return drinks } // GetUser returns the user object for the given ID - func GetUser() async throws -> User { + func GetUser() async throws -> MeteClientUser { // Send HTTP request to the user endpoint 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) + let user = try decoder.decode(MeteClientUser.self, from: data) return user } // GetUsers returns all users registered at the backend - func GetUsers() async throws -> Array { + func GetUsers() async throws -> Array { // Send HTTP request to the user endpoint 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) + let users = try decoder.decode([MeteClientUser].self, from: data) return users } @@ -73,7 +78,7 @@ class MeteClient { // Check status code let statusCode = (response as? HTTPURLResponse)?.statusCode if statusCode != 204 { - throw MeteClientError.StatusCodeLooksBad(explanation: "Failed to book drink \(drinkID) for user \(userID)@\(address): HTTP status code \(statusCode) received for \(url)") + throw MeteClientError.StatusCodeLooksBad(explanation: "Failed to book drink \(drinkID) for user \(userID)@\(address): HTTP status code \(statusCode ?? 0) received for \(url!)") } } } diff --git a/common/client/MeteClientDrink.swift b/common/client/MeteClientDrink.swift new file mode 100644 index 0000000..7ad4d3f --- /dev/null +++ b/common/client/MeteClientDrink.swift @@ -0,0 +1,43 @@ +// +// MeteClientDrink.swift +// meterios +// +// (c) 2025 Martin "maride" Dessauer +// + +/// **MeteClientDrink** resembles a drink retrieved from Space Market API, Version 1. Pure Swift, no platform-specific frameworks used. +struct MeteClientDrink: Identifiable, Codable { + let id: Int + let name: String + let logoURL: String + let price: Float? + let bottle_size: Float? + let caffeine: Int? + let active: Bool + + // KeyMap is an enum covering the field names as used by SpaceMarket API + private enum CodingKeys : String, CodingKey { case id, name, bottle_size, caffeine, price, active, logoURL = "logo_url" } + + // Init function to be used with JSONDecoder using SpaceMarket API's strange types + init(from decoder : Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.id = try container.decode(Int.self, forKey: .id) + self.name = try container.decode(String.self, forKey: .name) + self.bottle_size = Float((try? container.decode(String.self, forKey: .bottle_size)) ?? "") + self.caffeine = (try? container.decode(Int.self, forKey: .caffeine)) + self.price = Float((try? container.decode(String.self, forKey: .price)) ?? "") + self.active = (try? container.decode(Bool.self, forKey: .active)) ?? false + self.logoURL = (try? container.decode(String.self, forKey: .logoURL)) ?? "" + } + + // Init function, as straight-forward as it gets + init(id: Int, name: String, logoURL: String = "", price: Float = 0, bottle_size: Float = 0, caffeine: Int = 0, active: Bool = true) { + self.id = id + self.name = name + self.logoURL = logoURL + self.price = price + self.bottle_size = bottle_size + self.caffeine = caffeine + self.active = active + } +} diff --git a/common/types/User.swift b/common/client/MeteClientUser.swift similarity index 71% rename from common/types/User.swift rename to common/client/MeteClientUser.swift index b2f5bc1..2b6481e 100644 --- a/common/types/User.swift +++ b/common/client/MeteClientUser.swift @@ -1,19 +1,20 @@ // -// User.swift +// MeteClientUser.swift // meterios // // (c) 2025 Martin "maride" Dessauer // -import SwiftUI +import Foundation import CryptoKit -struct User: Decodable, Identifiable, Hashable { - var id: UUID = UUID() // Careful: meterios-internal ID, not Mete/Backend user accoint ID! - var meteID: Int - var displayName: String - var displayImageURL: String - var balance: Float +/// **MeteClientUser** resembles a user retrieved from Space Market API, Version 1. Pure Swift, no platform-specific frameworks used. +struct MeteClientUser: Decodable, Identifiable, Hashable { + let id: UUID = UUID() // Careful: meterios-internal ID, not Mete/Backend user accoint ID! + let meteID: Int + let displayName: String + let displayImageURL: URL? + let balance: Float? private enum KeyMap : String, CodingKey { case id, name, email, balance } @@ -22,7 +23,7 @@ struct User: Decodable, Identifiable, Hashable { let container = try decoder.container(keyedBy: KeyMap.self) self.meteID = try container.decode(Int.self, forKey: .id) self.displayName = try container.decode(String.self, forKey: .name) - self.displayImageURL = String( + self.displayImageURL = URL(string: String( format: "https://secure.gravatar.com/avatar/%@?s=300", Insecure.MD5.hash( // e-mails may be empty as they are not required during the registration process in Mete @@ -32,12 +33,12 @@ struct User: Decodable, Identifiable, Hashable { ).compactMap { String(format: "%02x", $0) }.joined() - ) - self.balance = Float(try container.decode(String.self, forKey: .balance)) ?? -9999.99 + )) + self.balance = Float(try container.decode(String.self, forKey: .balance)) } // Init function, as straight-forward as it gets - init(meteID: Int, displayName: String, displayImageURL: String, balance: Float) { + init(meteID: Int, displayName: String, displayImageURL: URL?, balance: Float) { self.meteID = meteID self.displayName = displayName self.displayImageURL = displayImageURL diff --git a/common/types/Account.swift b/common/types/Account.swift index 2279a77..63e3f43 100644 --- a/common/types/Account.swift +++ b/common/types/Account.swift @@ -5,20 +5,116 @@ // (c) 2025 Martin "maride" Dessauer // -struct Account: Encodable, Decodable { - private let address: String - private let userID: Int +import Foundation +import OSLog - init(address: String, userID: Int) { +enum AccountState: Int { + case Unknown // No connection attempt has been made yet; basically the startup state + case Disconnected // Not connected as per user request + case Failure // Last connection failed + case Connected // Last connection succeeded +} + +/// **Account** resembles an user account in Mete and provides relevant functions to Meterios, like encoding and decoding, the set-up client for this very account and favourites management +@Observable class Account: Identifiable, Codable { + public let id: UUID = UUID() + + private(set) var state: AccountState = .Unknown + + private let client: MeteClient + public let address: String + public let userID: Int + + private(set) var displayName: String? + private(set) var displayImageURL: URL? + private(set) var balance: Float? + + private var favourites: [Int] + + enum CodingKeys: String, CodingKey { case address, userID, favourites } + + init(address: String, userID: Int, favourites: [Int] = []) { self.address = address self.userID = userID + self.favourites = favourites + self.client = MeteClient(address: address, userID: userID) + } + + required convenience init(from decoder : Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let address = try container.decode(String.self, forKey: .address) + let userID = try container.decode(Int.self, forKey: .userID) + let favourites = try container.decode([Int].self, forKey: .favourites) + self.init(address: address, userID: userID, favourites: favourites) } - func GetAddress() -> String { - return address + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(address, forKey: .address) + try container.encode(userID, forKey: .userID) + try container.encode(favourites, forKey: .favourites) } - func GetUserID() -> Int { - return userID + // Ping updates the state of the account by trying to connect to the backend + func Ping() async { + do { + state = try await client.CanConnect() ? .Connected : .Disconnected + } catch { + state = .Failure + } + } + + // Refresh updates the account metadata; failure to do so changes the account state + func Refresh() async { + do { + let user = try await client.GetUser() + displayName = user.displayName + displayImageURL = user.displayImageURL + balance = user.balance + state = .Connected + } catch { + let _ = Logger().error("Unable to refresh user account: \(error)") + state = .Failure + } + } + + // GetDrinks returns all drinks for this account and backend + func GetDrinks() async throws -> Array { + return try await client.GetDrinks() + } + + // BookDrink books the specified drink using this account + func BookDrink(_ drinkID: Int) async throws { + try await client.BookDrink(drinkID) + await Refresh() + } + + // IsFavourite returns true if the given drink ID is a favourite + func IsFavourite(_ drinkID: Int) -> Bool { + return favourites.contains(drinkID) + } + + // AddFavourite adds the given drink ID to the list of favourites + func AddFavourite(_ drinkID: Int) { + if !IsFavourite(drinkID) { + favourites.append(drinkID) + } + } + + // RemoveFavourite removes the given drink ID from the favourites list + func RemoveFavourite(_ drinkID: Int) { + let index = favourites.firstIndex(where: { $0 == drinkID }) + if index != nil { + favourites.remove(at: index!) + } + } + + // SetFavourite sets if the given drink ID is a favourite + func SetFavourite(drinkID: Int, shouldBeFavourite: Bool) { + if IsFavourite(drinkID) && !shouldBeFavourite { + RemoveFavourite(drinkID) + } else if !IsFavourite(drinkID) && shouldBeFavourite { + AddFavourite(drinkID) + } } } diff --git a/common/types/AccountManagerEntry.swift b/common/types/AccountManagerEntry.swift deleted file mode 100644 index 7a0497e..0000000 --- a/common/types/AccountManagerEntry.swift +++ /dev/null @@ -1,24 +0,0 @@ -// -// 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/common/types/Drink.swift b/common/types/Drink.swift deleted file mode 100644 index 3ce4ef4..0000000 --- a/common/types/Drink.swift +++ /dev/null @@ -1,52 +0,0 @@ -// -// Drink.swift -// meterios -// -// (c) 2025 Martin "maride" Dessauer -// - -import SwiftUI - -struct Drink: Identifiable, Encodable, Decodable { - public var id: Int - public var name: String - public var logoURL: String - private var price: String - private var bottle_size: String - public var caffeine: Int - public var active: Bool - - // KeyMap is an enum covering the field names as used by SpaceMarket API - private enum KeyMap : String, CodingKey { case id, name, bottle_size, caffeine, price, active, logo_url } - - // Init function to be used with JSONDecoder using SpaceMarket API's strange types - init(from decoder : Decoder) throws { - let container = try decoder.container(keyedBy: KeyMap.self) - self.id = try container.decode(Int.self, forKey: .id) - self.name = try container.decode(String.self, forKey: .name) - self.bottle_size = (try? container.decode(String.self, forKey: .bottle_size)) ?? "" - self.caffeine = (try? container.decode(Int.self, forKey: .caffeine)) ?? 0 - self.price = (try? container.decode(String.self, forKey: .price)) ?? "" - self.active = (try? container.decode(Bool.self, forKey: .active)) ?? false - self.logoURL = (try? container.decode(String.self, forKey: .logo_url)) ?? "" - } - - // Init function, as straight-forward as it gets - init(id: Int, name: String, logoURL: String = "", price: String = "", bottle_size: String = "", caffeine: Int = 0, active: Bool = true) { - self.id = id - self.name = name - self.logoURL = logoURL - self.price = price - self.bottle_size = bottle_size - self.caffeine = caffeine - self.active = active - } - - func GetPrice() -> Float { - return Float(self.price) ?? 9999.99 - } - - func GetBottleSize() -> Float { - return Float(self.bottle_size) ?? 9999.99 - } -} diff --git a/meterios-watch/views/ContentView.swift b/meterios-watch/views/ContentView.swift deleted file mode 100644 index 0a0648a..0000000 --- a/meterios-watch/views/ContentView.swift +++ /dev/null @@ -1,87 +0,0 @@ -// -// ContentView.swift -// meterios-watch -// -// (c) 2025 Martin "maride" Dessauer -// - -import SwiftUI - -struct ContentView: View { - @EnvironmentObject var wcMgr: WCManager - - @State private var favouriteDrinks: Array = [] - @State private var availableDrinks: Array = [] - - @State private var showUpdateError: Bool = false - @State private var updateError: String = "" - @State private var updateErrorCount: Int = 0 - - // 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 { - showUpdateError = true - updateErrorCount = 0 - return - } - - // Update drinks list from paired device - wcMgr.requestDrinksList( - replyHandler: { favDrinks, availDrinks in - // Drinks received from backend - self.favouriteDrinks = favDrinks - self.availableDrinks = availDrinks - showUpdateError = false - }, errorHandler: { error in - // Error receiving drinks - print("Error receiving drinks: \(error)") - updateError = error?.localizedDescription ?? "Unknown error" - updateErrorCount += 1 - update() - } - ) - } - - var body: some View { - ZStack { - if favouriteDrinks.count + availableDrinks.count > 0 { - NavigationView { - List() { - // Drinks - Section(String(localized: "FAVOURITES")) { - ForEach(favouriteDrinks) { drink in - NavigationLink(drink.name) { - DrinkDetailView(drink: drink) - } - } - } - Section(String(localized: "DRINKS")) { - ForEach(availableDrinks) { drink in - NavigationLink(drink.name) { - DrinkDetailView(drink: drink) - } - } - } - - // Controls - Section { - Button(String(localized: "REFRESH")) { - favouriteDrinks = [] - availableDrinks = [] - update() - } - } - } - } - } else { - ProgressView() - } - } - .onAppear(perform: update) - // Alert to communicate an error in communication with the paired device - .alert(String(localized: "CONNECTION_ERROR"), isPresented: $showUpdateError) { - Text(updateError).tint(.red).font(.footnote) - Button(String(localized: "RETRY"), systemImage: "arrow.clockwise", action: update) - } - } -} diff --git a/meterios-watch/views/DrinkDetailView.swift b/meterios-watch/views/DrinkDetailView.swift index bc00121..903e5c8 100644 --- a/meterios-watch/views/DrinkDetailView.swift +++ b/meterios-watch/views/DrinkDetailView.swift @@ -8,15 +8,13 @@ import SwiftUI struct DrinkDetailView: View { - @EnvironmentObject var wcMgr: WCManager - - private var drink: Drink + private var drink: MeteClientDrink @State var shouldShowTransactionSheet = false @Environment(\.dismiss) private var dismiss - init(drink: Drink) { + init(drink: MeteClientDrink) { self.drink = drink } @@ -28,12 +26,20 @@ struct DrinkDetailView: View { // Basic info HStack { VStack { - Text(String(format: "%img", drink.caffeine)).fontWeight(.bold) + if drink.caffeine != nil { + Text(String(format: "%img", drink.caffeine!)).fontWeight(.bold) + } else { + Text("? mg").fontWeight(.bold) + } Text(String(localized: "CAFFEINE")).fontWeight(Font.Weight.thin) } Divider() VStack { - Text(String(format: "%.2f€", drink.GetPrice())).fontWeight(.bold) + if drink.price != nil { + Text(drink.price!.formatted(.currency(code: "EUR"))).fontWeight(.bold) + } else { + Text("? €").fontWeight(.bold) + } Text(String(localized: "PRICE")).fontWeight(Font.Weight.thin) } } diff --git a/meterios-watch/views/MainView.swift b/meterios-watch/views/MainView.swift new file mode 100644 index 0000000..18d714d --- /dev/null +++ b/meterios-watch/views/MainView.swift @@ -0,0 +1,133 @@ +// +// ContentView.swift +// meterios-watch +// +// (c) 2025 Martin "maride" Dessauer +// + +import SwiftUI + +struct ContentView: View { + @EnvironmentObject var wcMgr: WCManager + + @State private var favouriteDrinks: Array? + @State private var availableDrinks: Array? + + @State private var userInfo: WCUser? + + @State private var showUpdateError: Bool = false + @State private var updateError: String = "" + @State private var updateErrorCount: Int = 0 + + // 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 { + showUpdateError = true + updateErrorCount = 0 + return + } + + // Update user info + wcMgr.requestUserInfo(replyHandler: { userInfo in + self.userInfo = userInfo + }, errorHandler: { error in + // Error receiving user info + print("Error receiving user info: \(error)") + }) + + // Update drinks list from paired device + wcMgr.requestDrinksList( + replyHandler: { favDrinks, availDrinks in + // Drinks received from backend + self.favouriteDrinks = favDrinks + self.availableDrinks = availDrinks + showUpdateError = false + }, errorHandler: { error in + // Error receiving drinks + print("Error receiving drinks: \(error)") + updateError = error?.localizedDescription ?? "Unknown error" + updateErrorCount += 1 + update() + } + ) + } + + var body: some View { + ZStack { + if favouriteDrinks != nil && availableDrinks != nil { + NavigationView { + List() { + // Account + if userInfo != nil { + Button(action: {}, label: { + HStack { + // Profile Image + AsyncImageWithPlaceholder(url: userInfo?.displayImageURL, width: 64, height: 64).clipShape(Circle()).fixedSize() + Spacer(minLength: 16.0) + + // Account, Address & Balance + VStack(alignment: .leading) { + Text(userInfo!.displayName) + Text((userInfo!.balance!.formatted(.currency(code: "EUR")))).foregroundStyle((userInfo?.balance)! > 0 ? .green : .red) + }.fixedSize() + }.fixedSize() + }) + } + + // Drinks + if !favouriteDrinks!.isEmpty { + Section(String(localized: "FAVOURITES")) { + ForEach(favouriteDrinks!) { drink in + NavigationLink(destination: { + DrinkDetailView(drink: drink) + }, label: { + AsyncImageWithPlaceholder(url: URL(string: "https://\(account.address)/\(drink.logoURL)"), width: 32, height: 32) + Text(drink.name) + }) + } + } + } + if !availableDrinks!.isEmpty { + Section(String(localized: "DRINKS")) { + ForEach(availableDrinks!) { drink in + NavigationLink(drink.name) { + DrinkDetailView(drink: drink) + } + } + } + } else { + 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() + }) + }) + } + + // Controls + Section { + Button(String(localized: "REFRESH")) { + favouriteDrinks = [] + availableDrinks = [] + update() + } + } + } + } + } else { + ProgressView() + } + } + .onAppear(perform: update) + // Alert to communicate an error in communication with the paired device + .alert(String(localized: "CONNECTION_ERROR"), isPresented: $showUpdateError) { + Text(updateError).tint(.red).font(.footnote) + Button(String(localized: "RETRY"), systemImage: "arrow.clockwise", action: update) + } + } +} diff --git a/meterios.xcodeproj/project.pbxproj b/meterios.xcodeproj/project.pbxproj index 8abdff1..e9e380c 100644 --- a/meterios.xcodeproj/project.pbxproj +++ b/meterios.xcodeproj/project.pbxproj @@ -10,16 +10,6 @@ A7FA4CB72D9E8FF5005ACDBB /* meterios-watch.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = A7FA4CAA2D9E8FF1005ACDBB /* meterios-watch.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; /* End PBXBuildFile section */ -/* Begin PBXContainerItemProxy section */ - A7FA4CB52D9E8FF5005ACDBB /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = A7F97A862D8DD542004EF4ED /* Project object */; - proxyType = 1; - remoteGlobalIDString = A7FA4CA92D9E8FF1005ACDBB; - remoteInfo = "meterios-watch Watch App"; - }; -/* End PBXContainerItemProxy section */ - /* Begin PBXCopyFilesBuildPhase section */ A7C3C98C2D9D67EB00731E27 /* Embed Watch Content */ = { isa = PBXCopyFilesBuildPhase; @@ -43,15 +33,13 @@ A7FA4CCB2D9E9564005ACDBB /* Exceptions for "common" folder in "meterios-watch" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( - AccountManager.swift, - AsyncImageWithPlaceholder.swift, + aux/AsyncImageWithPlaceholder.swift, + aux/SalutManager.swift, + client/MeteClient.swift, + client/MeteClientDrink.swift, + client/MeteClientUser.swift, Localizable.xcstrings, - MeteClient.swift, - SalutManager.swift, types/Account.swift, - types/AccountManagerEntry.swift, - types/Drink.swift, - types/User.swift, WCManager.swift, ); target = A7FA4CA92D9E8FF1005ACDBB /* meterios-watch */; @@ -131,7 +119,6 @@ buildRules = ( ); dependencies = ( - A7FA4CB62D9E8FF5005ACDBB /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( A7F97A902D8DD542004EF4ED /* meterios */, @@ -239,14 +226,6 @@ }; /* End PBXSourcesBuildPhase section */ -/* Begin PBXTargetDependency section */ - A7FA4CB62D9E8FF5005ACDBB /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = A7FA4CA92D9E8FF1005ACDBB /* meterios-watch */; - targetProxy = A7FA4CB52D9E8FF5005ACDBB /* PBXContainerItemProxy */; - }; -/* End PBXTargetDependency section */ - /* Begin XCBuildConfiguration section */ A7F97A9A2D8DD54B004EF4ED /* Debug */ = { isa = XCBuildConfiguration; @@ -472,7 +451,7 @@ SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 4; - WATCHOS_DEPLOYMENT_TARGET = 8.7; + WATCHOS_DEPLOYMENT_TARGET = 10.6; }; name = Debug; }; @@ -501,7 +480,7 @@ SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 4; - WATCHOS_DEPLOYMENT_TARGET = 8.7; + WATCHOS_DEPLOYMENT_TARGET = 10.6; }; name = Release; }; diff --git a/meterios/FavouritesManager.swift b/meterios/FavouritesManager.swift deleted file mode 100644 index 7e42c8d..0000000 --- a/meterios/FavouritesManager.swift +++ /dev/null @@ -1,64 +0,0 @@ -// -// FavouritesManager.swift -// meterios -// -// (c) 2025 Martin "maride" Dessauer -// - -import Foundation -import SwiftUI - -class FavouritesManager { - // default contains a ready-to-use instance of FavouritesManager. - // Not that it would make sense to have multiple instances anyway. - static public var `default` = FavouritesManager() - - // favourites contain the list of drink IDs marked as favourites - private var favourites: Array - - init() { - self.favourites = FavouritesManager.readFromRaw() - } - - // readFromRaw reads the current favourites from the stored string and transforms it to a handy Int Array - static private func readFromRaw() -> Array { - @AppStorage("favourites") var favs = "" - return favs.split(separator: ";").map({ Int($0)! }) - } - - // writeToRaw writes the current favourites to the AppStorage as String - private func writeToRaw() { - @AppStorage("favourites") var favs = "" - let strFavs: Array = self.favourites.map({ String($0) }) - favs = strFavs.joined(separator: ";") - } - - // IsFavourite returns true if the given drink ID is a favourite - func IsFavourite(_ drinkID: Int) -> Bool { - return favourites.contains(drinkID) - } - - // AddFavourite adds the given drink ID to the list of favourites - func AddFavourite(_ drinkID: Int) { - favourites.append(drinkID) - writeToRaw() - } - - // RemoveFavourite removes the given drink ID from the favourites list - func RemoveFavourite(_ drinkID: Int) { - let index = favourites.firstIndex(where: { $0 == drinkID }) - if index != nil { - favourites.remove(at: index!) - } - writeToRaw() - } - - // SetFavourite sets if the given drink ID is a favourite - func SetFavourite(drinkID: Int, shouldBeFavourite: Bool) { - if IsFavourite(drinkID) && !shouldBeFavourite { - RemoveFavourite(drinkID) - } else if !IsFavourite(drinkID) && shouldBeFavourite { - AddFavourite(drinkID) - } - } -} diff --git a/meterios/meteriosApp.swift b/meterios/meteriosApp.swift index 74036ee..629288c 100644 --- a/meterios/meteriosApp.swift +++ b/meterios/meteriosApp.swift @@ -13,10 +13,13 @@ struct meteriosApp: App { var body: some Scene { WindowGroup { - if AccountManager.default.CurrentAccount() != nil { - MainView() + if AccountManager.default.CurrentAccount() == nil { + UserListView() } else { - SetupView() + MainView().transition(AnyTransition.asymmetric( + insertion: .move(edge: .leading), + removal: .move(edge: .trailing) + )) } } } diff --git a/meterios/views/DrinkDetailView.swift b/meterios/views/DrinkDetailView.swift index 526ae29..3484c84 100644 --- a/meterios/views/DrinkDetailView.swift +++ b/meterios/views/DrinkDetailView.swift @@ -9,7 +9,7 @@ import SwiftUI import OSLog struct DrinkDetail: View { - private var drink: Drink + private var drink: MeteClientDrink @State private var drinkAmount: Int = 1 @State var shouldShowTransactionSheet = false @State var overrideDeactivatedState: Bool = false @@ -18,7 +18,7 @@ struct DrinkDetail: View { @Environment(\.dismiss) private var dismiss - init(drink: Drink) { + init(drink: MeteClientDrink) { self.drink = drink } @@ -31,19 +31,27 @@ struct DrinkDetail: View { Spacer() VStack() { // Displays the drink image asynchronously - AsyncImageWithPlaceholder(url: URL(string: "https://\(AccountManager.default.CurrentAccount()!.GetAccount().GetAddress())/\(drink.logoURL)"), width: 300, height: 300).fixedSize() + AsyncImageWithPlaceholder(url: URL(string: "https://\(AccountManager.default.CurrentAccount()!.address)/\(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) + if drink.caffeine != nil { + Text(String(format: "%img", drink.caffeine!)).fontWeight(.bold) + } else { + Text("? mg").fontWeight(.bold) + } Text(String(localized: "CAFFEINE")).fontWeight(Font.Weight.thin) }.fixedSize() Divider() VStack { - Text(drink.GetPrice().formatted(.currency(code: "EUR"))).fontWeight(.bold) + if drink.price != nil { + Text(drink.price!.formatted(.currency(code: "EUR"))).fontWeight(.bold) + } else { + Text("? €").fontWeight(.bold) + } Text(String(localized: "PRICE")).fontWeight(Font.Weight.thin) } } @@ -57,10 +65,10 @@ struct DrinkDetail: View { Section() { Toggle(String(localized: "FAVOURITE"), isOn: $isFavourite).toggleStyle(SwitchToggleStyle(tint: .blue)) .onAppear(perform: { - isFavourite = FavouritesManager.default.IsFavourite(drink.id) + isFavourite = AccountManager.default.CurrentAccount()!.IsFavourite(drink.id) }) .onDisappear(perform: { - FavouritesManager.default.SetFavourite(drinkID: self.drink.id, shouldBeFavourite: isFavourite) + AccountManager.default.CurrentAccount()!.SetFavourite(drinkID: self.drink.id, shouldBeFavourite: isFavourite) }) } @@ -78,7 +86,11 @@ struct DrinkDetail: View { HStack { Text(String(localized: "TOTAL_SUM")) Spacer() - Text((self.drink.GetPrice() * Float(self.drinkAmount)).formatted(.currency(code: "EUR"))).fontWeight(.bold) + if self.drink.price != nil { + Text((self.drink.price! * Float(self.drinkAmount)).formatted(.currency(code: "EUR"))).fontWeight(.bold) + } else { + Text(String(localized: "NO_PRICE")).fontWeight(.bold) + } } } diff --git a/meterios/views/MainView.swift b/meterios/views/MainView.swift index 6229bae..03568f2 100644 --- a/meterios/views/MainView.swift +++ b/meterios/views/MainView.swift @@ -9,31 +9,36 @@ import SwiftUI import OSLog struct MainView: View { - @State private var favouriteDrinks: Array = [] - @State private var availableDrinks: Array = [] - @State private var outOfOrderDrinks: Array = [] - @State private var user: User? + @State private var favouriteDrinks: Array = [] + @State private var availableDrinks: Array = [] + @State private var outOfOrderDrinks: Array = [] + + private var account: Account = AccountManager.default.CurrentAccount()! @Environment(\.dismiss) var dismiss // Update pulls drinks off the backend and updates the user info (balance, name, profile pic) func update() { + if AccountManager.default.CurrentAccount() == nil { + return + } + Task { do { // Load user - user = try await AccountManager.default.CurrentAccount()!.GetClient().GetUser() + await account.Refresh() // Load drinks - 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 }) + let drinks = try await account.GetDrinks() + favouriteDrinks = drinks.filter({ AccountManager.default.CurrentAccount()!.IsFavourite($0.id) && $0.active }) + availableDrinks = drinks.filter({ !AccountManager.default.CurrentAccount()!.IsFavourite($0.id) && $0.active }) outOfOrderDrinks = drinks.filter({ !$0.active }) } catch { - let _ = Logger().error("Failed to update from \(AccountManager.default.CurrentAccount()!.GetAccount().GetAddress()): \(error)") + // TODO: Show user-facing error } } } - - func drinkSection(title: String, drinks: Array) -> some View { + + func drinkSection(title: String, drinks: Array) -> some View { Section(header: Text(title)) { ForEach(drinks) { drink in NavigationLink(destination: { @@ -41,72 +46,55 @@ struct MainView: View { update() }) }, label: { - AsyncImageWithPlaceholder(url: URL(string: "https://\(AccountManager.default.CurrentAccount()!.GetAccount().GetAddress())/\(drink.logoURL)"), width: 32, height: 32) + AsyncImageWithPlaceholder(url: URL(string: "https://\(account.address)/\(drink.logoURL)"), width: 32, height: 32) Text(drink.name) }) } } } - + var body: some View { - if AccountManager.default.CurrentAccount() != nil { - NavigationView { - List () { - // Account - if user != nil { - Section(header: Text(String(localized: "ACCOUNT")).id("topAnchor")) { - NavigationLink(destination: UserDetailView(user: user!)) { - Button(action: { - dismiss() - }) { - HStack { - // Profile Image - AsyncImageWithPlaceholder(url: URL(string: user!.displayImageURL), width: 64, height: 64).clipShape(Circle()).fixedSize() - Spacer(minLength: 16.0) - - // Account, Address & Balance - VStack(alignment: .leading) { - HStack { - Text(user!.displayName).fontWeight(.bold) - Divider() - Text(user!.balance.formatted(.currency(code: "EUR"))).foregroundStyle(user!.balance > 0 ? .green : .red) - }.fixedSize() - Text("\(AccountManager.default.CurrentAccount()!.GetAccount().GetAddress())").fontWeight(.thin) - }.fixedSize() - }.fixedSize() - } + NavigationView { + List { + // Account + Section(header: Text(String(localized: "ACCOUNT"))) { + AccountButton(account: account, action: { + UIApplication.shared.open(URL(string: "https://\(account.address)/users/\(account.userID)")!) + }) + Button(action: { + AccountManager.default.SelectAccount(nil) + }) { + Text(String(localized: "SWITCH_ACCOUNT")).frame(maxWidth: .infinity) + } + } + + // 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) } - } - } - - // 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() - }) + Spacer() }) - } - }.navigationTitle(String(localized: "METERIOS")).refreshable(action: { - update() - }) - }.onAppear(perform: { + }) + } + }.navigationTitle(String(localized: "METERIOS")).refreshable(action: { + update() + }).onAppear(perform: { update() }) } diff --git a/meterios/views/SetupView.swift b/meterios/views/SetupView.swift index f5f7a36..9b3702b 100644 --- a/meterios/views/SetupView.swift +++ b/meterios/views/SetupView.swift @@ -10,10 +10,10 @@ import OSLog struct SetupView: View { @State private var tmpAddress: String = "mete.chaosdorf.space" - @State private var tmpUsers: Array = [] + @State private var tmpUsers: Array = [] @State private var selectedUserID: Int = 0 - @FocusState private var connectionFiledFocused: Bool + @FocusState private var connectionFieldFocused: Bool @State private var connectionStatus: String? @State private var connectionStatusColor: Color? @State private var connectionStatusIcon: String? @@ -31,39 +31,35 @@ struct SetupView: View { saveButtonEnabled = false // Update status - connectionStatus = String(format: String(localized: "CONNECTING_TO_FMT"), tmpAddress) - connectionStatusIcon = "network" - connectionStatusColor = .blue - connectionStatusProgressVisible = true - connectionStatusVisible = true + updateConnStatus(String(format: String(localized: "CONNECTING_TO_FMT"), tmpAddress), statusIcon: "network", progressVisible: true) // Connect to the server asynchronously Task { // Un-Focus the address field to hide keyboard - connectionFiledFocused = false + connectionFieldFocused = false // Create test connector do { // Try out the connection let tmpConnector = MeteClient(address: tmpAddress, userID: -1) tmpUsers = try await tmpConnector.GetUsers() - connectionStatus = String(localized: "CONNECTION_SUCCESSFUL") - connectionStatusIcon = "checkmark.icloud.fill" - connectionStatusColor = .green - connectionStatusProgressVisible = false - connectionStatusVisible = true + updateConnStatus(String(localized: "CONNECTION_SUCCESSFUL"), statusIcon: "checkmark.icloud.fill", color: .green, progressVisible: false) userListVisible = true } catch { - connectionStatus = error.localizedDescription - connectionStatusIcon = "bolt.horizontal.circle.fill" - connectionStatusColor = .red - connectionStatusProgressVisible = false - connectionStatusVisible = true + updateConnStatus(error.localizedDescription, statusIcon: "bolt.horizontal.circle.fill", color: .red, progressVisible: false) let _ = Logger().error("Failed to connect to new backend \(tmpAddress): \(error)") } } } - + + func updateConnStatus(_ statusText: String, statusIcon: String = "", color: Color = .blue, progressVisible: Bool = true) { + connectionStatus = statusText + connectionStatusIcon = statusIcon + connectionStatusColor = color + connectionStatusProgressVisible = progressVisible + connectionStatusVisible = statusText != "" + } + var body: some View { // Meterios banner ZStack { @@ -96,7 +92,7 @@ struct SetupView: View { TextField(text: $tmpAddress, label: { Text(tmpAddress) }) .textInputAutocapitalization(.never) .disableAutocorrection(true) - .focused($connectionFiledFocused) + .focused($connectionFieldFocused) .onSubmit { tryConnect() } @@ -126,7 +122,7 @@ struct SetupView: View { // User selection if userListVisible { Picker(String(localized: "ACCOUNT"), selection: $selectedUserID, content: { - ForEach(tmpUsers, id: \.self.meteID) { (user: User) in + ForEach(tmpUsers, id: \.self.meteID) { (user: MeteClientUser) in Text(user.displayName).tag(user.displayName) } }) @@ -138,7 +134,9 @@ struct SetupView: View { // Save button Section { Button(action: { - AccountManager.default.AddAccount(Account(address: tmpAddress, userID: selectedUserID)) + let newAcc = Account(address: tmpAddress, userID: selectedUserID) + AccountManager.default.AddAccount(newAcc) + AccountManager.default.SelectAccount(newAcc) dismiss() }) { Text(String(localized: "SAVE_CONNECTION_SETTINGS")).frame(maxWidth: .infinity) diff --git a/meterios/views/TransactionView.swift b/meterios/views/TransactionView.swift index f482c1c..39a74bd 100644 --- a/meterios/views/TransactionView.swift +++ b/meterios/views/TransactionView.swift @@ -40,7 +40,7 @@ struct TransactionView: View { do { // Try to book the drink(s) for _ in 1...drinkAmount { - try await AccountManager.default.CurrentAccount()!.GetClient().BookDrink(drinkID) + try await AccountManager.default.CurrentAccount()!.BookDrink(drinkID) } statusLine = SalutManager.getSalute() backendTimerReady = true diff --git a/meterios/views/UserDetailView.swift b/meterios/views/UserDetailView.swift deleted file mode 100644 index 08d4424..0000000 --- a/meterios/views/UserDetailView.swift +++ /dev/null @@ -1,71 +0,0 @@ -// -// UserDetailView.swift -// meterios -// -// (c) 2025 Martin "maride" Dessauer -// - -import SwiftUI - -struct UserDetailView: View { - var user: User - - @Environment(\.dismiss) private var dismiss - - var body: some View { - if AccountManager.default.CurrentAccount() != nil { - 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 - 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() - } - Spacer() - } - } - - // 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) - } - } - } - } -} diff --git a/meterios/views/UserListView.swift b/meterios/views/UserListView.swift new file mode 100644 index 0000000..c53aa3d --- /dev/null +++ b/meterios/views/UserListView.swift @@ -0,0 +1,40 @@ +// +// UserListView.swift +// meterios +// +// (c) 2025 Martin "maride" Dessauer +// + +import SwiftUI + +struct UserListView: View { + var body: some View { + NavigationView { + VStack { + List { + ForEach(AccountManager.default.Accounts) { acc in + AccountButton(account: acc, action: { + AccountManager.default.SelectAccount(acc) + }) + } + .onDelete { indexSet in + AccountManager.default.RemoveAccount(indexSet.first!) + } + }.onAppear(perform: { + Task { + await AccountManager.default.RefreshAll() + } + }) + } + .navigationTitle("ACCOUNTS") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + NavigationLink(destination: { + SetupView() + }, label: { + Image(systemName: "plus") + }) + } + } + } +}