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
}
}
-87
View File
@@ -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<Drink> = []
@State private var availableDrinks: Array<Drink> = []
@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)
}
}
}
+12 -6
View File
@@ -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)
}
}
+133
View File
@@ -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<MeteClientDrink>?
@State private var availableDrinks: Array<MeteClientDrink>?
@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)
}
}
}
+7 -28
View File
@@ -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;
};
-64
View File
@@ -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<Int>
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<Int> {
@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<String> = 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)
}
}
}
+6 -3
View File
@@ -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)
))
}
}
}
+20 -8
View File
@@ -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)
}
}
}
+57 -69
View File
@@ -9,31 +9,36 @@ import SwiftUI
import OSLog
struct MainView: View {
@State private var favouriteDrinks: Array<Drink> = []
@State private var availableDrinks: Array<Drink> = []
@State private var outOfOrderDrinks: Array<Drink> = []
@State private var user: User?
@State private var favouriteDrinks: Array<MeteClientDrink> = []
@State private var availableDrinks: Array<MeteClientDrink> = []
@State private var outOfOrderDrinks: Array<MeteClientDrink> = []
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<Drink>) -> some View {
func drinkSection(title: String, drinks: Array<MeteClientDrink>) -> 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()
})
}
+20 -22
View File
@@ -10,10 +10,10 @@ import OSLog
struct SetupView: View {
@State private var tmpAddress: String = "mete.chaosdorf.space"
@State private var tmpUsers: Array<User> = []
@State private var tmpUsers: Array<MeteClientUser> = []
@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)
+1 -1
View File
@@ -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
-71
View File
@@ -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)
}
}
}
}
}
+40
View File
@@ -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")
})
}
}
}
}