Transform to multi-account support
This commit is contained in:
+52
-24
@@ -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)
|
||||
}
|
||||
|
||||
func GetAddress() -> String {
|
||||
return address
|
||||
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 GetUserID() -> Int {
|
||||
return userID
|
||||
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)
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,7 +46,7 @@ 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)
|
||||
})
|
||||
}
|
||||
@@ -49,64 +54,47 @@ struct MainView: View {
|
||||
}
|
||||
|
||||
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)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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()
|
||||
}
|
||||
// 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()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user