Transform to multi-account support
This commit is contained in:
+53
-25
@@ -7,29 +7,34 @@
|
|||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import OSLog
|
||||||
|
|
||||||
/// **AccountManager** stores connection details and the corresponding client
|
/// **AccountManager** holds multiple accounts and takes care of storing them on the device.
|
||||||
class AccountManager: ObservableObject {
|
@Observable class AccountManager {
|
||||||
// default contains a ready-to-use instance of AccountManager, read from AppStorage
|
// default contains a ready-to-use instance of AccountManager, read from AppStorage
|
||||||
static public var `default` = AccountManager()
|
static public var `default` = AccountManager()
|
||||||
|
|
||||||
// accounts holds all saved accounts
|
// 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 creates a new AccountManager, reading saved account data from AppStorage
|
||||||
init() {
|
init() {
|
||||||
self.Accounts = AccountManager.readFromRaw()
|
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
|
// 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()
|
@AppStorage("accounts") var rawAccs = Data()
|
||||||
do {
|
do {
|
||||||
let simpleAccs = try JSONDecoder().decode([Account].self, from: rawAccs)
|
return try JSONDecoder().decode([Account].self, from: rawAccs)
|
||||||
let richAccs = simpleAccs.map({ AccountManagerEntry($0) })
|
|
||||||
return richAccs
|
|
||||||
} catch {
|
} catch {
|
||||||
print("Error parsing stored accounts from JSON: \(error)")
|
let _ = Logger().error("Error parsing stored accounts from JSON: \(error)")
|
||||||
|
rawAccs = Data()
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -38,47 +43,70 @@ class AccountManager: ObservableObject {
|
|||||||
private func writeToRaw() {
|
private func writeToRaw() {
|
||||||
@AppStorage("accounts") var rawAccs = Data()
|
@AppStorage("accounts") var rawAccs = Data()
|
||||||
do {
|
do {
|
||||||
let simpleAccs = Accounts.map({ $0.GetAccount() })
|
rawAccs = try JSONEncoder().encode(Accounts)
|
||||||
rawAccs = try JSONEncoder().encode(simpleAccs)
|
|
||||||
} catch {
|
} 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
|
// CurrentAccount returns the currently selected account if there is one
|
||||||
func CurrentAccount() -> AccountManagerEntry? {
|
func CurrentAccount() -> Account? {
|
||||||
if self.Accounts.count > 0 {
|
if selectedAccount == nil {
|
||||||
return self.Accounts.first
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if Accounts.count > selectedAccount! {
|
||||||
|
return Accounts[selectedAccount!]
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
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
|
// AddAccount adds the given account if it doesn't exist yet
|
||||||
func AddAccount(_ account: Account) {
|
func AddAccount(_ account: Account) {
|
||||||
if IndexOfAccount(account) == nil {
|
if IndexOfAccount(account) == nil {
|
||||||
Accounts.append(AccountManagerEntry(account))
|
Accounts.append(account)
|
||||||
writeToRaw()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// IndexOfAccount returns the index of the specified account identified by address and user ID
|
// IndexOfAccount returns the index of the specified account identified by address and user ID
|
||||||
func IndexOfAccount(_ account: Account) -> Int? {
|
func IndexOfAccount(_ account: Account) -> Int? {
|
||||||
return Accounts.firstIndex(where: {
|
return Accounts.firstIndex(where: {
|
||||||
$0.GetAccount().GetAddress() == account.GetAddress() &&
|
$0.address == account.address &&
|
||||||
$0.GetAccount().GetUserID() == account.GetUserID()
|
$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) {
|
func RemoveAccount(_ account: Account) {
|
||||||
if IndexOfAccount(account) != nil {
|
if IndexOfAccount(account) != nil {
|
||||||
Accounts.remove(at: IndexOfAccount(account)!)
|
RemoveAccount(IndexOfAccount(account)!)
|
||||||
writeToRaw()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// RemoveAccount removes the account specified by address and user ID of the given AccountManagerEntry, if present
|
// RemoveAccount removes the account at the specified index
|
||||||
func RemoveAccount(_ account: AccountManagerEntry) {
|
func RemoveAccount(_ index: Int) {
|
||||||
RemoveAccount(account.GetAccount())
|
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
|
||||||
},
|
},
|
||||||
|
"? €" : {
|
||||||
|
"shouldTranslate" : false
|
||||||
|
},
|
||||||
|
"? mg" : {
|
||||||
|
"shouldTranslate" : false
|
||||||
|
},
|
||||||
"%@" : {
|
"%@" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
@@ -51,6 +57,22 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"ACCOUNTS" : {
|
||||||
|
"localizations" : {
|
||||||
|
"de" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Accounts"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Accounts"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"AMOUNT_FMT" : {
|
"AMOUNT_FMT" : {
|
||||||
"extractionState" : "manual",
|
"extractionState" : "manual",
|
||||||
"localizations" : {
|
"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" : {
|
"OPEN_PROFILE_IN_BROWSER" : {
|
||||||
"extractionState" : "manual",
|
"extractionState" : "manual",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -543,6 +581,22 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"SWITCH_ACCOUNT" : {
|
||||||
|
"localizations" : {
|
||||||
|
"de" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Account wechseln"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Switch Account"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"TOTAL_SUM" : {
|
"TOTAL_SUM" : {
|
||||||
"extractionState" : "manual",
|
"extractionState" : "manual",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
|
|||||||
+8
-10
@@ -15,11 +15,9 @@ enum WCMeteriosMessageTypes: String {
|
|||||||
|
|
||||||
/// **WCManager** managed the Meterios-specific communication between an iOS and an watchOS device
|
/// **WCManager** managed the Meterios-specific communication between an iOS and an watchOS device
|
||||||
class WCManager: NSObject, WCSessionDelegate, ObservableObject {
|
class WCManager: NSObject, WCSessionDelegate, ObservableObject {
|
||||||
private var accountManager: AccountManager
|
|
||||||
private let session: WCSession = WCSession.default
|
private let session: WCSession = WCSession.default
|
||||||
|
|
||||||
override init() {
|
override init() {
|
||||||
self.accountManager = AccountManager.default
|
|
||||||
super.init()
|
super.init()
|
||||||
|
|
||||||
if (WCSession.isSupported()) {
|
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) {
|
func session(_ session: WCSession, didReceiveMessage message: [String : Any], replyHandler: @escaping ([String : Any]) -> Void) {
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
// Prevent crashes when WCSession is received before AccountManager is ready
|
// 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.")
|
print("Request encountered in WCSession, but AccountManager is not ready.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -50,9 +48,9 @@ class WCManager: NSObject, WCSessionDelegate, ObservableObject {
|
|||||||
case WCMeteriosMessageTypes.DrinkList.rawValue:
|
case WCMeteriosMessageTypes.DrinkList.rawValue:
|
||||||
// Request for a list of available drinks
|
// Request for a list of available drinks
|
||||||
Task {
|
Task {
|
||||||
let drinks = try await accountManager.CurrentAccount()!.GetClient().GetDrinks()
|
let drinks = try await AccountManager.default.CurrentAccount()!.GetDrinks()
|
||||||
let favouriteDrinks = drinks.filter({ FavouritesManager.default.IsFavourite($0.id) && $0.active })
|
let favouriteDrinks = drinks.filter({ AccountManager.default.CurrentAccount()!.IsFavourite($0.id) && $0.active })
|
||||||
let availableDrinks = drinks.filter({ !FavouritesManager.default.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.
|
// 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
|
// Create a plist-compatible variant of the Drink object containing the required information
|
||||||
@@ -70,7 +68,7 @@ class WCManager: NSObject, WCSessionDelegate, ObservableObject {
|
|||||||
do {
|
do {
|
||||||
// Book
|
// Book
|
||||||
let drinkID = message["drinkID"] as! Int
|
let drinkID = message["drinkID"] as! Int
|
||||||
try await accountManager.CurrentAccount()!.GetClient().BookDrink(drinkID)
|
try await AccountManager.default.CurrentAccount()!.BookDrink(drinkID)
|
||||||
replyHandler([
|
replyHandler([
|
||||||
"salut": SalutManager.getSalute()
|
"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
|
// 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([
|
session.sendMessage([
|
||||||
"request": WCMeteriosMessageTypes.DrinkList.rawValue
|
"request": WCMeteriosMessageTypes.DrinkList.rawValue
|
||||||
], replyHandler: { reply in
|
], replyHandler: { reply in
|
||||||
@@ -100,8 +98,8 @@ class WCManager: NSObject, WCSessionDelegate, ObservableObject {
|
|||||||
let availableDrinks = reply["availableDrinks"] as! Array<String>
|
let availableDrinks = reply["availableDrinks"] as! Array<String>
|
||||||
|
|
||||||
// Create Drink objects from response
|
// Create Drink objects from response
|
||||||
let favouriteDrinksArray = favouriteDrinks.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(Drink.self, from: Data(base64Encoded: drink)!) }
|
let availableDrinksArray = availableDrinks.map { drink in try! JSONDecoder().decode(MeteClientDrink.self, from: Data(base64Encoded: drink)!) }
|
||||||
|
|
||||||
// enjoy your drinks, replyHandler!
|
// enjoy your drinks, replyHandler!
|
||||||
replyHandler(favouriteDrinksArray, availableDrinksArray)
|
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 Foundation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import OSLog
|
||||||
|
|
||||||
/// **AsyncImageWithPlaceholder** wraps AsyncImage, showing a Spinner while loading and a question mark in case of errors.
|
/// **AsyncImageWithPlaceholder** wraps AsyncImage, showing a Spinner while loading and a question mark in case of errors.
|
||||||
struct AsyncImageWithPlaceholder : View {
|
struct AsyncImageWithPlaceholder : View {
|
||||||
@@ -25,7 +26,7 @@ struct AsyncImageWithPlaceholder : View {
|
|||||||
.frame(width: width, height: height)
|
.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) {
|
init(url: URL? = nil, width: CGFloat, height: CGFloat) {
|
||||||
self.url = url
|
self.url = url
|
||||||
self.width = width
|
self.width = width
|
||||||
@@ -42,7 +43,8 @@ struct AsyncImageWithPlaceholder : View {
|
|||||||
.frame(width: self.width, height: self.height)
|
.frame(width: self.width, height: self.height)
|
||||||
.clipShape(baseShape)
|
.clipShape(baseShape)
|
||||||
} else if phase.error != nil {
|
} 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 {
|
ZStack {
|
||||||
baseShapeView
|
baseShapeView
|
||||||
Text("?")
|
Text("?")
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
// (c) 2025 Martin "maride" Dessauer
|
// (c) 2025 Martin "maride" Dessauer
|
||||||
//
|
//
|
||||||
|
|
||||||
|
/// **SalutManager** provides random salutes from all over the world.
|
||||||
class SalutManager {
|
class SalutManager {
|
||||||
static let salutes: Array<String> = [
|
static let salutes: Array<String> = [
|
||||||
"Prost!", "Guten!", "Kippis!", "Cheers!", "Chin-Chin!", "Zum Wohl!", "Salut!", "לחיים",
|
"Prost!", "Guten!", "Kippis!", "Cheers!", "Chin-Chin!", "Zum Wohl!", "Salut!", "לחיים",
|
||||||
@@ -16,50 +16,55 @@ class MeteClient {
|
|||||||
private let address: String
|
private let address: String
|
||||||
private let userID: Int
|
private let userID: Int
|
||||||
|
|
||||||
convenience init(account: Account) {
|
|
||||||
self.init(address: account.GetAddress(), userID: account.GetUserID())
|
|
||||||
}
|
|
||||||
|
|
||||||
init(address: String, userID: Int) {
|
init(address: String, userID: Int) {
|
||||||
self.address = address
|
self.address = address
|
||||||
self.userID = userID
|
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
|
// 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
|
// Send HTTP request to the drinks endpoint
|
||||||
let url = URL(string: "https://\(address)/api/v1/drinks")
|
let url = URL(string: "https://\(address)/api/v1/drinks")
|
||||||
let (data, _) = try await URLSession.shared.data(from: url!)
|
let (data, _) = try await URLSession.shared.data(from: url!)
|
||||||
|
|
||||||
// Decode JSON to array of drinks
|
// Decode JSON to array of drinks
|
||||||
let decoder = JSONDecoder()
|
let decoder = JSONDecoder()
|
||||||
let drinks = try decoder.decode([Drink].self, from: data)
|
let drinks = try decoder.decode([MeteClientDrink].self, from: data)
|
||||||
|
|
||||||
return drinks
|
return drinks
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetUser returns the user object for the given ID
|
// 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
|
// Send HTTP request to the user endpoint
|
||||||
let url = URL(string: "https://\(address)/api/v1/users/\(userID).json")
|
let url = URL(string: "https://\(address)/api/v1/users/\(userID).json")
|
||||||
let (data, _) = try await URLSession.shared.data(from: url!)
|
let (data, _) = try await URLSession.shared.data(from: url!)
|
||||||
|
|
||||||
// Decode JSON to array of drinks
|
// Decode JSON to array of drinks
|
||||||
let decoder = JSONDecoder()
|
let decoder = JSONDecoder()
|
||||||
let user = try decoder.decode(User.self, from: data)
|
let user = try decoder.decode(MeteClientUser.self, from: data)
|
||||||
|
|
||||||
return user
|
return user
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetUsers returns all users registered at the backend
|
// 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
|
// Send HTTP request to the user endpoint
|
||||||
let url = URL(string: "https://\(address)/api/v1/users.json")
|
let url = URL(string: "https://\(address)/api/v1/users.json")
|
||||||
let (data, _) = try await URLSession.shared.data(from: url!)
|
let (data, _) = try await URLSession.shared.data(from: url!)
|
||||||
|
|
||||||
// Decode JSON to array of drinks
|
// Decode JSON to array of drinks
|
||||||
let decoder = JSONDecoder()
|
let decoder = JSONDecoder()
|
||||||
let users = try decoder.decode([User].self, from: data)
|
let users = try decoder.decode([MeteClientUser].self, from: data)
|
||||||
|
|
||||||
return users
|
return users
|
||||||
}
|
}
|
||||||
@@ -73,7 +78,7 @@ class MeteClient {
|
|||||||
// Check status code
|
// Check status code
|
||||||
let statusCode = (response as? HTTPURLResponse)?.statusCode
|
let statusCode = (response as? HTTPURLResponse)?.statusCode
|
||||||
if statusCode != 204 {
|
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
|
// meterios
|
||||||
//
|
//
|
||||||
// (c) 2025 Martin "maride" Dessauer
|
// (c) 2025 Martin "maride" Dessauer
|
||||||
//
|
//
|
||||||
|
|
||||||
import SwiftUI
|
import Foundation
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
|
|
||||||
struct User: Decodable, Identifiable, Hashable {
|
/// **MeteClientUser** resembles a user retrieved from Space Market API, Version 1. Pure Swift, no platform-specific frameworks used.
|
||||||
var id: UUID = UUID() // Careful: meterios-internal ID, not Mete/Backend user accoint ID!
|
struct MeteClientUser: Decodable, Identifiable, Hashable {
|
||||||
var meteID: Int
|
let id: UUID = UUID() // Careful: meterios-internal ID, not Mete/Backend user accoint ID!
|
||||||
var displayName: String
|
let meteID: Int
|
||||||
var displayImageURL: String
|
let displayName: String
|
||||||
var balance: Float
|
let displayImageURL: URL?
|
||||||
|
let balance: Float?
|
||||||
|
|
||||||
private enum KeyMap : String, CodingKey { case id, name, email, balance }
|
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)
|
let container = try decoder.container(keyedBy: KeyMap.self)
|
||||||
self.meteID = try container.decode(Int.self, forKey: .id)
|
self.meteID = try container.decode(Int.self, forKey: .id)
|
||||||
self.displayName = try container.decode(String.self, forKey: .name)
|
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",
|
format: "https://secure.gravatar.com/avatar/%@?s=300",
|
||||||
Insecure.MD5.hash(
|
Insecure.MD5.hash(
|
||||||
// e-mails may be empty as they are not required during the registration process in Mete
|
// 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 {
|
).compactMap {
|
||||||
String(format: "%02x", $0)
|
String(format: "%02x", $0)
|
||||||
}.joined()
|
}.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 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.meteID = meteID
|
||||||
self.displayName = displayName
|
self.displayName = displayName
|
||||||
self.displayImageURL = displayImageURL
|
self.displayImageURL = displayImageURL
|
||||||
+104
-8
@@ -5,20 +5,116 @@
|
|||||||
// (c) 2025 Martin "maride" Dessauer
|
// (c) 2025 Martin "maride" Dessauer
|
||||||
//
|
//
|
||||||
|
|
||||||
struct Account: Encodable, Decodable {
|
import Foundation
|
||||||
private let address: String
|
import OSLog
|
||||||
private let userID: Int
|
|
||||||
|
|
||||||
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.address = address
|
||||||
self.userID = userID
|
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 {
|
func encode(to encoder: Encoder) throws {
|
||||||
return address
|
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 {
|
// Ping updates the state of the account by trying to connect to the backend
|
||||||
return userID
|
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
|
import SwiftUI
|
||||||
|
|
||||||
struct DrinkDetailView: View {
|
struct DrinkDetailView: View {
|
||||||
@EnvironmentObject var wcMgr: WCManager
|
private var drink: MeteClientDrink
|
||||||
|
|
||||||
private var drink: Drink
|
|
||||||
|
|
||||||
@State var shouldShowTransactionSheet = false
|
@State var shouldShowTransactionSheet = false
|
||||||
|
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
init(drink: Drink) {
|
init(drink: MeteClientDrink) {
|
||||||
self.drink = drink
|
self.drink = drink
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,12 +26,20 @@ struct DrinkDetailView: View {
|
|||||||
// Basic info
|
// Basic info
|
||||||
HStack {
|
HStack {
|
||||||
VStack {
|
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)
|
Text(String(localized: "CAFFEINE")).fontWeight(Font.Weight.thin)
|
||||||
}
|
}
|
||||||
Divider()
|
Divider()
|
||||||
VStack {
|
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)
|
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, ); }; };
|
A7FA4CB72D9E8FF5005ACDBB /* meterios-watch.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = A7FA4CAA2D9E8FF1005ACDBB /* meterios-watch.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||||
/* End PBXBuildFile section */
|
/* 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 */
|
/* Begin PBXCopyFilesBuildPhase section */
|
||||||
A7C3C98C2D9D67EB00731E27 /* Embed Watch Content */ = {
|
A7C3C98C2D9D67EB00731E27 /* Embed Watch Content */ = {
|
||||||
isa = PBXCopyFilesBuildPhase;
|
isa = PBXCopyFilesBuildPhase;
|
||||||
@@ -43,15 +33,13 @@
|
|||||||
A7FA4CCB2D9E9564005ACDBB /* Exceptions for "common" folder in "meterios-watch" target */ = {
|
A7FA4CCB2D9E9564005ACDBB /* Exceptions for "common" folder in "meterios-watch" target */ = {
|
||||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||||
membershipExceptions = (
|
membershipExceptions = (
|
||||||
AccountManager.swift,
|
aux/AsyncImageWithPlaceholder.swift,
|
||||||
AsyncImageWithPlaceholder.swift,
|
aux/SalutManager.swift,
|
||||||
|
client/MeteClient.swift,
|
||||||
|
client/MeteClientDrink.swift,
|
||||||
|
client/MeteClientUser.swift,
|
||||||
Localizable.xcstrings,
|
Localizable.xcstrings,
|
||||||
MeteClient.swift,
|
|
||||||
SalutManager.swift,
|
|
||||||
types/Account.swift,
|
types/Account.swift,
|
||||||
types/AccountManagerEntry.swift,
|
|
||||||
types/Drink.swift,
|
|
||||||
types/User.swift,
|
|
||||||
WCManager.swift,
|
WCManager.swift,
|
||||||
);
|
);
|
||||||
target = A7FA4CA92D9E8FF1005ACDBB /* meterios-watch */;
|
target = A7FA4CA92D9E8FF1005ACDBB /* meterios-watch */;
|
||||||
@@ -131,7 +119,6 @@
|
|||||||
buildRules = (
|
buildRules = (
|
||||||
);
|
);
|
||||||
dependencies = (
|
dependencies = (
|
||||||
A7FA4CB62D9E8FF5005ACDBB /* PBXTargetDependency */,
|
|
||||||
);
|
);
|
||||||
fileSystemSynchronizedGroups = (
|
fileSystemSynchronizedGroups = (
|
||||||
A7F97A902D8DD542004EF4ED /* meterios */,
|
A7F97A902D8DD542004EF4ED /* meterios */,
|
||||||
@@ -239,14 +226,6 @@
|
|||||||
};
|
};
|
||||||
/* End PBXSourcesBuildPhase section */
|
/* End PBXSourcesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXTargetDependency section */
|
|
||||||
A7FA4CB62D9E8FF5005ACDBB /* PBXTargetDependency */ = {
|
|
||||||
isa = PBXTargetDependency;
|
|
||||||
target = A7FA4CA92D9E8FF1005ACDBB /* meterios-watch */;
|
|
||||||
targetProxy = A7FA4CB52D9E8FF5005ACDBB /* PBXContainerItemProxy */;
|
|
||||||
};
|
|
||||||
/* End PBXTargetDependency section */
|
|
||||||
|
|
||||||
/* Begin XCBuildConfiguration section */
|
/* Begin XCBuildConfiguration section */
|
||||||
A7F97A9A2D8DD54B004EF4ED /* Debug */ = {
|
A7F97A9A2D8DD54B004EF4ED /* Debug */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
@@ -472,7 +451,7 @@
|
|||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = 4;
|
TARGETED_DEVICE_FAMILY = 4;
|
||||||
WATCHOS_DEPLOYMENT_TARGET = 8.7;
|
WATCHOS_DEPLOYMENT_TARGET = 10.6;
|
||||||
};
|
};
|
||||||
name = Debug;
|
name = Debug;
|
||||||
};
|
};
|
||||||
@@ -501,7 +480,7 @@
|
|||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = 4;
|
TARGETED_DEVICE_FAMILY = 4;
|
||||||
WATCHOS_DEPLOYMENT_TARGET = 8.7;
|
WATCHOS_DEPLOYMENT_TARGET = 10.6;
|
||||||
};
|
};
|
||||||
name = Release;
|
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 {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
if AccountManager.default.CurrentAccount() != nil {
|
if AccountManager.default.CurrentAccount() == nil {
|
||||||
MainView()
|
UserListView()
|
||||||
} else {
|
} else {
|
||||||
SetupView()
|
MainView().transition(AnyTransition.asymmetric(
|
||||||
|
insertion: .move(edge: .leading),
|
||||||
|
removal: .move(edge: .trailing)
|
||||||
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import SwiftUI
|
|||||||
import OSLog
|
import OSLog
|
||||||
|
|
||||||
struct DrinkDetail: View {
|
struct DrinkDetail: View {
|
||||||
private var drink: Drink
|
private var drink: MeteClientDrink
|
||||||
@State private var drinkAmount: Int = 1
|
@State private var drinkAmount: Int = 1
|
||||||
@State var shouldShowTransactionSheet = false
|
@State var shouldShowTransactionSheet = false
|
||||||
@State var overrideDeactivatedState: Bool = false
|
@State var overrideDeactivatedState: Bool = false
|
||||||
@@ -18,7 +18,7 @@ struct DrinkDetail: View {
|
|||||||
|
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
init(drink: Drink) {
|
init(drink: MeteClientDrink) {
|
||||||
self.drink = drink
|
self.drink = drink
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,19 +31,27 @@ struct DrinkDetail: View {
|
|||||||
Spacer()
|
Spacer()
|
||||||
VStack() {
|
VStack() {
|
||||||
// Displays the drink image asynchronously
|
// 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
|
// Basic drink information
|
||||||
VStack(alignment: .center) {
|
VStack(alignment: .center) {
|
||||||
Text("\(drink.name)").font(.largeTitle).fontWeight(.bold)
|
Text("\(drink.name)").font(.largeTitle).fontWeight(.bold)
|
||||||
HStack {
|
HStack {
|
||||||
VStack {
|
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)
|
Text(String(localized: "CAFFEINE")).fontWeight(Font.Weight.thin)
|
||||||
}.fixedSize()
|
}.fixedSize()
|
||||||
Divider()
|
Divider()
|
||||||
VStack {
|
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)
|
Text(String(localized: "PRICE")).fontWeight(Font.Weight.thin)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -57,10 +65,10 @@ struct DrinkDetail: View {
|
|||||||
Section() {
|
Section() {
|
||||||
Toggle(String(localized: "FAVOURITE"), isOn: $isFavourite).toggleStyle(SwitchToggleStyle(tint: .blue))
|
Toggle(String(localized: "FAVOURITE"), isOn: $isFavourite).toggleStyle(SwitchToggleStyle(tint: .blue))
|
||||||
.onAppear(perform: {
|
.onAppear(perform: {
|
||||||
isFavourite = FavouritesManager.default.IsFavourite(drink.id)
|
isFavourite = AccountManager.default.CurrentAccount()!.IsFavourite(drink.id)
|
||||||
})
|
})
|
||||||
.onDisappear(perform: {
|
.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 {
|
HStack {
|
||||||
Text(String(localized: "TOTAL_SUM"))
|
Text(String(localized: "TOTAL_SUM"))
|
||||||
Spacer()
|
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
|
import OSLog
|
||||||
|
|
||||||
struct MainView: View {
|
struct MainView: View {
|
||||||
@State private var favouriteDrinks: Array<Drink> = []
|
@State private var favouriteDrinks: Array<MeteClientDrink> = []
|
||||||
@State private var availableDrinks: Array<Drink> = []
|
@State private var availableDrinks: Array<MeteClientDrink> = []
|
||||||
@State private var outOfOrderDrinks: Array<Drink> = []
|
@State private var outOfOrderDrinks: Array<MeteClientDrink> = []
|
||||||
@State private var user: User?
|
|
||||||
|
private var account: Account = AccountManager.default.CurrentAccount()!
|
||||||
|
|
||||||
@Environment(\.dismiss) var dismiss
|
@Environment(\.dismiss) var dismiss
|
||||||
|
|
||||||
// Update pulls drinks off the backend and updates the user info (balance, name, profile pic)
|
// Update pulls drinks off the backend and updates the user info (balance, name, profile pic)
|
||||||
func update() {
|
func update() {
|
||||||
|
if AccountManager.default.CurrentAccount() == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
Task {
|
Task {
|
||||||
do {
|
do {
|
||||||
// Load user
|
// Load user
|
||||||
user = try await AccountManager.default.CurrentAccount()!.GetClient().GetUser()
|
await account.Refresh()
|
||||||
// Load drinks
|
// Load drinks
|
||||||
let drinks = try await AccountManager.default.CurrentAccount()!.GetClient().GetDrinks()
|
let drinks = try await account.GetDrinks()
|
||||||
favouriteDrinks = drinks.filter({ FavouritesManager.default.IsFavourite($0.id) && $0.active })
|
favouriteDrinks = drinks.filter({ AccountManager.default.CurrentAccount()!.IsFavourite($0.id) && $0.active })
|
||||||
availableDrinks = drinks.filter({ !FavouritesManager.default.IsFavourite($0.id) && $0.active })
|
availableDrinks = drinks.filter({ !AccountManager.default.CurrentAccount()!.IsFavourite($0.id) && $0.active })
|
||||||
outOfOrderDrinks = drinks.filter({ !$0.active })
|
outOfOrderDrinks = drinks.filter({ !$0.active })
|
||||||
} catch {
|
} 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)) {
|
Section(header: Text(title)) {
|
||||||
ForEach(drinks) { drink in
|
ForEach(drinks) { drink in
|
||||||
NavigationLink(destination: {
|
NavigationLink(destination: {
|
||||||
@@ -41,72 +46,55 @@ struct MainView: View {
|
|||||||
update()
|
update()
|
||||||
})
|
})
|
||||||
}, label: {
|
}, 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)
|
Text(drink.name)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
if AccountManager.default.CurrentAccount() != nil {
|
NavigationView {
|
||||||
NavigationView {
|
List {
|
||||||
List () {
|
// Account
|
||||||
// Account
|
Section(header: Text(String(localized: "ACCOUNT"))) {
|
||||||
if user != nil {
|
AccountButton(account: account, action: {
|
||||||
Section(header: Text(String(localized: "ACCOUNT")).id("topAnchor")) {
|
UIApplication.shared.open(URL(string: "https://\(account.address)/users/\(account.userID)")!)
|
||||||
NavigationLink(destination: UserDetailView(user: user!)) {
|
})
|
||||||
Button(action: {
|
Button(action: {
|
||||||
dismiss()
|
AccountManager.default.SelectAccount(nil)
|
||||||
}) {
|
}) {
|
||||||
HStack {
|
Text(String(localized: "SWITCH_ACCOUNT")).frame(maxWidth: .infinity)
|
||||||
// Profile Image
|
}
|
||||||
AsyncImageWithPlaceholder(url: URL(string: user!.displayImageURL), width: 64, height: 64).clipShape(Circle()).fixedSize()
|
}
|
||||||
Spacer(minLength: 16.0)
|
|
||||||
|
// Drinks list
|
||||||
// Account, Address & Balance
|
if !favouriteDrinks.isEmpty {
|
||||||
VStack(alignment: .leading) {
|
drinkSection(title: String(localized: "FAVOURITES"), drinks: favouriteDrinks)
|
||||||
HStack {
|
}
|
||||||
Text(user!.displayName).fontWeight(.bold)
|
if !availableDrinks.isEmpty {
|
||||||
Divider()
|
drinkSection(title: String(localized: "AVAILABLE_DRINKS"), drinks: availableDrinks)
|
||||||
Text(user!.balance.formatted(.currency(code: "EUR"))).foregroundStyle(user!.balance > 0 ? .green : .red)
|
}
|
||||||
}.fixedSize()
|
if !outOfOrderDrinks.isEmpty {
|
||||||
Text("\(AccountManager.default.CurrentAccount()!.GetAccount().GetAddress())").fontWeight(.thin)
|
drinkSection(title: String(localized: "OUT_OF_STOCK_DRINKS"), drinks: outOfOrderDrinks)
|
||||||
}.fixedSize()
|
}
|
||||||
}.fixedSize()
|
// 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()
|
||||||
}
|
|
||||||
|
|
||||||
// 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()
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
}
|
})
|
||||||
}.navigationTitle(String(localized: "METERIOS")).refreshable(action: {
|
}
|
||||||
update()
|
}.navigationTitle(String(localized: "METERIOS")).refreshable(action: {
|
||||||
})
|
update()
|
||||||
}.onAppear(perform: {
|
}).onAppear(perform: {
|
||||||
update()
|
update()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,10 +10,10 @@ import OSLog
|
|||||||
|
|
||||||
struct SetupView: View {
|
struct SetupView: View {
|
||||||
@State private var tmpAddress: String = "mete.chaosdorf.space"
|
@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
|
@State private var selectedUserID: Int = 0
|
||||||
|
|
||||||
@FocusState private var connectionFiledFocused: Bool
|
@FocusState private var connectionFieldFocused: Bool
|
||||||
@State private var connectionStatus: String?
|
@State private var connectionStatus: String?
|
||||||
@State private var connectionStatusColor: Color?
|
@State private var connectionStatusColor: Color?
|
||||||
@State private var connectionStatusIcon: String?
|
@State private var connectionStatusIcon: String?
|
||||||
@@ -31,39 +31,35 @@ struct SetupView: View {
|
|||||||
saveButtonEnabled = false
|
saveButtonEnabled = false
|
||||||
|
|
||||||
// Update status
|
// Update status
|
||||||
connectionStatus = String(format: String(localized: "CONNECTING_TO_FMT"), tmpAddress)
|
updateConnStatus(String(format: String(localized: "CONNECTING_TO_FMT"), tmpAddress), statusIcon: "network", progressVisible: true)
|
||||||
connectionStatusIcon = "network"
|
|
||||||
connectionStatusColor = .blue
|
|
||||||
connectionStatusProgressVisible = true
|
|
||||||
connectionStatusVisible = true
|
|
||||||
|
|
||||||
// Connect to the server asynchronously
|
// Connect to the server asynchronously
|
||||||
Task {
|
Task {
|
||||||
// Un-Focus the address field to hide keyboard
|
// Un-Focus the address field to hide keyboard
|
||||||
connectionFiledFocused = false
|
connectionFieldFocused = false
|
||||||
|
|
||||||
// Create test connector
|
// Create test connector
|
||||||
do {
|
do {
|
||||||
// Try out the connection
|
// Try out the connection
|
||||||
let tmpConnector = MeteClient(address: tmpAddress, userID: -1)
|
let tmpConnector = MeteClient(address: tmpAddress, userID: -1)
|
||||||
tmpUsers = try await tmpConnector.GetUsers()
|
tmpUsers = try await tmpConnector.GetUsers()
|
||||||
connectionStatus = String(localized: "CONNECTION_SUCCESSFUL")
|
updateConnStatus(String(localized: "CONNECTION_SUCCESSFUL"), statusIcon: "checkmark.icloud.fill", color: .green, progressVisible: false)
|
||||||
connectionStatusIcon = "checkmark.icloud.fill"
|
|
||||||
connectionStatusColor = .green
|
|
||||||
connectionStatusProgressVisible = false
|
|
||||||
connectionStatusVisible = true
|
|
||||||
userListVisible = true
|
userListVisible = true
|
||||||
} catch {
|
} catch {
|
||||||
connectionStatus = error.localizedDescription
|
updateConnStatus(error.localizedDescription, statusIcon: "bolt.horizontal.circle.fill", color: .red, progressVisible: false)
|
||||||
connectionStatusIcon = "bolt.horizontal.circle.fill"
|
|
||||||
connectionStatusColor = .red
|
|
||||||
connectionStatusProgressVisible = false
|
|
||||||
connectionStatusVisible = true
|
|
||||||
let _ = Logger().error("Failed to connect to new backend \(tmpAddress): \(error)")
|
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 {
|
var body: some View {
|
||||||
// Meterios banner
|
// Meterios banner
|
||||||
ZStack {
|
ZStack {
|
||||||
@@ -96,7 +92,7 @@ struct SetupView: View {
|
|||||||
TextField(text: $tmpAddress, label: { Text(tmpAddress) })
|
TextField(text: $tmpAddress, label: { Text(tmpAddress) })
|
||||||
.textInputAutocapitalization(.never)
|
.textInputAutocapitalization(.never)
|
||||||
.disableAutocorrection(true)
|
.disableAutocorrection(true)
|
||||||
.focused($connectionFiledFocused)
|
.focused($connectionFieldFocused)
|
||||||
.onSubmit {
|
.onSubmit {
|
||||||
tryConnect()
|
tryConnect()
|
||||||
}
|
}
|
||||||
@@ -126,7 +122,7 @@ struct SetupView: View {
|
|||||||
// User selection
|
// User selection
|
||||||
if userListVisible {
|
if userListVisible {
|
||||||
Picker(String(localized: "ACCOUNT"), selection: $selectedUserID, content: {
|
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)
|
Text(user.displayName).tag(user.displayName)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -138,7 +134,9 @@ struct SetupView: View {
|
|||||||
// Save button
|
// Save button
|
||||||
Section {
|
Section {
|
||||||
Button(action: {
|
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()
|
dismiss()
|
||||||
}) {
|
}) {
|
||||||
Text(String(localized: "SAVE_CONNECTION_SETTINGS")).frame(maxWidth: .infinity)
|
Text(String(localized: "SAVE_CONNECTION_SETTINGS")).frame(maxWidth: .infinity)
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ struct TransactionView: View {
|
|||||||
do {
|
do {
|
||||||
// Try to book the drink(s)
|
// Try to book the drink(s)
|
||||||
for _ in 1...drinkAmount {
|
for _ in 1...drinkAmount {
|
||||||
try await AccountManager.default.CurrentAccount()!.GetClient().BookDrink(drinkID)
|
try await AccountManager.default.CurrentAccount()!.BookDrink(drinkID)
|
||||||
}
|
}
|
||||||
statusLine = SalutManager.getSalute()
|
statusLine = SalutManager.getSalute()
|
||||||
backendTimerReady = true
|
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