Transform to multi-account support

This commit is contained in:
2025-08-01 19:31:53 +02:00
parent 351f08e43e
commit 68239727fa
24 changed files with 635 additions and 503 deletions
+53 -25
View File
@@ -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()
}
}
}
+54
View File
@@ -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
View File
@@ -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)
+43
View File
@@ -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!)")
}
}
}
+43
View File
@@ -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
View File
@@ -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)
}
}
}
-24
View File
@@ -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
}
}
-52
View File
@@ -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
}
}