Transform to multi-account support
This commit is contained in:
+53
-25
@@ -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<AccountManagerEntry> = []
|
||||
private(set) var Accounts: Array<Account> = [] { 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<AccountManagerEntry> {
|
||||
static private func readFromRaw() -> Array<Account> {
|
||||
@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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" : {
|
||||
|
||||
+8
-10
@@ -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<Drink>, Array<Drink>) -> Void, errorHandler: @escaping ((any Error)?) -> Void) {
|
||||
func requestDrinksList(replyHandler: @escaping (Array<MeteClientDrink>, Array<MeteClientDrink>) -> 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<String>
|
||||
|
||||
// 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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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("?")
|
||||
@@ -5,6 +5,7 @@
|
||||
// (c) 2025 Martin "maride" Dessauer
|
||||
//
|
||||
|
||||
/// **SalutManager** provides random salutes from all over the world.
|
||||
class SalutManager {
|
||||
static let salutes: Array<String> = [
|
||||
"Prost!", "Guten!", "Kippis!", "Cheers!", "Chin-Chin!", "Zum Wohl!", "Salut!", "לחיים",
|
||||
@@ -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<Drink> {
|
||||
func GetDrinks() async throws -> Array<MeteClientDrink> {
|
||||
// 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<User> {
|
||||
func GetUsers() async throws -> Array<MeteClientUser> {
|
||||
// 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!)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
+104
-8
@@ -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<MeteClientDrink> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user