Replace lazy AppStorage calls with AccountManager

This commit is contained in:
2025-04-25 14:26:05 +02:00
parent d3143915ea
commit 351f08e43e
16 changed files with 392 additions and 270 deletions
+84
View File
@@ -0,0 +1,84 @@
//
// AccountManager.swift
// meterios
//
// (c) 2025 Martin "maride" Dessauer
//
import Foundation
import SwiftUI
/// **AccountManager** stores connection details and the corresponding client
class AccountManager: ObservableObject {
// 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> = []
// init creates a new AccountManager, reading saved account data from AppStorage
init() {
self.Accounts = AccountManager.readFromRaw()
}
// readFromRaw reads the current accounts from the stored data and transforms it to a handy Account Array
static private func readFromRaw() -> Array<AccountManagerEntry> {
@AppStorage("accounts") var rawAccs = Data()
do {
let simpleAccs = try JSONDecoder().decode([Account].self, from: rawAccs)
let richAccs = simpleAccs.map({ AccountManagerEntry($0) })
return richAccs
} catch {
print("Error parsing stored accounts from JSON: \(error)")
return []
}
}
// writeToRaw writes the current account data to the AppStorage
private func writeToRaw() {
@AppStorage("accounts") var rawAccs = Data()
do {
let simpleAccs = Accounts.map({ $0.GetAccount() })
rawAccs = try JSONEncoder().encode(simpleAccs)
} catch {
print("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
}
return nil
}
// AddAccount adds the given account if it doesn't exist yet
func AddAccount(_ account: Account) {
if IndexOfAccount(account) == nil {
Accounts.append(AccountManagerEntry(account))
writeToRaw()
}
}
// 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()
})
}
// RemoveAccount removes the account specified by address and user ID of the given Account, if present
func RemoveAccount(_ account: Account) {
if IndexOfAccount(account) != nil {
Accounts.remove(at: IndexOfAccount(account)!)
writeToRaw()
}
}
// RemoveAccount removes the account specified by address and user ID of the given AccountManagerEntry, if present
func RemoveAccount(_ account: AccountManagerEntry) {
RemoveAccount(account.GetAccount())
}
}
@@ -1,5 +1,5 @@
//
// BackendConnector.swift
// MeteClient.swift
// meterios
//
// (c) 2025 Martin "maride" Dessauer
@@ -7,15 +7,28 @@
import Foundation
enum BackendConnectorError: Error {
enum MeteClientError: Error {
case StatusCodeLooksBad(explanation: String)
}
class BackendConnector {
/// **MeteClient** is the client-side implementation of the Space Market API. Pure Swift, no platform-specific frameworks used.
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
}
// GetDrinks returns the list of drinks available at the backend
func GetDrinks(baseAddr: String) async throws -> Array<Drink> {
func GetDrinks() async throws -> Array<Drink> {
// Send HTTP request to the drinks endpoint
let url = URL(string: "https://\(baseAddr)/api/v1/drinks")
let url = URL(string: "https://\(address)/api/v1/drinks")
let (data, _) = try await URLSession.shared.data(from: url!)
// Decode JSON to array of drinks
@@ -25,10 +38,10 @@ class BackendConnector {
return drinks
}
// GetUser returns the user object for the given ID<
func GetUser(baseAddr: String, userID: Int) async throws -> User {
// GetUser returns the user object for the given ID
func GetUser() async throws -> User {
// Send HTTP request to the user endpoint
let url = URL(string: "https://\(baseAddr)/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!)
// Decode JSON to array of drinks
@@ -39,9 +52,9 @@ class BackendConnector {
}
// GetUsers returns all users registered at the backend
func GetUsers(baseAddr: String) async throws -> Array<User> {
func GetUsers() async throws -> Array<User> {
// Send HTTP request to the user endpoint
let url = URL(string: "https://\(baseAddr)/api/v1/users.json")
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
@@ -52,16 +65,15 @@ class BackendConnector {
}
// BookDrink subtracts the recommended price off the balance from the user given by ID
func BookDrink(baseAddr: String, userID: Int, drinkID: Int) async throws {
func BookDrink(_ drinkID: Int) async throws {
// Send HTTP request to the user endpoint
let url = URL(string: "https://\(baseAddr)/api/v1/users/\(userID)/buy.json?drink=\(drinkID)")
let url = URL(string: "https://\(address)/api/v1/users/\(userID)/buy.json?drink=\(drinkID)")
let (_, response) = try await URLSession.shared.data(from: url!)
// Check status code
let statusCode = (response as? HTTPURLResponse)?.statusCode
if statusCode != 204 {
throw BackendConnectorError.StatusCodeLooksBad(explanation: "Failed to book drink \(drinkID) for user \(userID)@\(baseAddr): HTTP status code \(statusCode) received for \(url)")
throw MeteClientError.StatusCodeLooksBad(explanation: "Failed to book drink \(drinkID) for user \(userID)@\(address): HTTP status code \(statusCode) received for \(url)")
}
}
}
+12 -6
View File
@@ -13,13 +13,13 @@ enum WCMeteriosMessageTypes: String {
case BookDrink
}
class WCManager: NSObject, WCSessionDelegate {
@AppStorage("meteHostAddr") private var meteHostAddr = ""
@AppStorage("meteUserID") private var meteUserID = -1
/// **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()) {
@@ -39,12 +39,18 @@ class WCManager: NSObject, WCSessionDelegate {
// As Meterios currently only needs messages from watchOS to iOS and responses back to the watch, this function currently only implements iOS-related responses.
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 {
print("Request encountered in WCSession, but AccountManager is not ready.")
return
}
// Check for the different possible message types
switch message["request"] as! String {
case WCMeteriosMessageTypes.DrinkList.rawValue:
// Request for a list of available drinks
Task {
let drinks = try await BackendConnector().GetDrinks(baseAddr: meteHostAddr)
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 })
// note that drinks which are not active/available are not sent to the watch - the screen is too small to waste pixels.
@@ -64,7 +70,7 @@ class WCManager: NSObject, WCSessionDelegate {
do {
// Book
let drinkID = message["drinkID"] as! Int
try await BackendConnector().BookDrink(baseAddr: meteHostAddr, userID: meteUserID, drinkID: drinkID)
try await accountManager.CurrentAccount()!.GetClient().BookDrink(drinkID)
replyHandler([
"salut": SalutManager.getSalute()
])
+24
View File
@@ -0,0 +1,24 @@
//
// Account.swift
// meterios
//
// (c) 2025 Martin "maride" Dessauer
//
struct Account: Encodable, Decodable {
private let address: String
private let userID: Int
init(address: String, userID: Int) {
self.address = address
self.userID = userID
}
func GetAddress() -> String {
return address
}
func GetUserID() -> Int {
return userID
}
}
+24
View File
@@ -0,0 +1,24 @@
//
// 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
}
}
+2 -2
View File
@@ -9,11 +9,11 @@ import SwiftUI
@main
struct meteriosWatchApp: App {
private var wcMgr = WCManager()
@StateObject var wcMgr = WCManager()
var body: some Scene {
WindowGroup {
ContentView(wcMgr)
ContentView().environmentObject(wcMgr)
}
}
}
+3 -7
View File
@@ -8,7 +8,7 @@
import SwiftUI
struct ContentView: View {
private var wcMgr: WCManager
@EnvironmentObject var wcMgr: WCManager
@State private var favouriteDrinks: Array<Drink> = []
@State private var availableDrinks: Array<Drink> = []
@@ -17,10 +17,6 @@ struct ContentView: View {
@State private var updateError: String = ""
@State private var updateErrorCount: Int = 0
init(_ wcMgr: WCManager) {
self.wcMgr = wcMgr
}
// 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 {
@@ -55,14 +51,14 @@ struct ContentView: View {
Section(String(localized: "FAVOURITES")) {
ForEach(favouriteDrinks) { drink in
NavigationLink(drink.name) {
DrinkDetailView(wcMgr: wcMgr, drink: drink)
DrinkDetailView(drink: drink)
}
}
}
Section(String(localized: "DRINKS")) {
ForEach(availableDrinks) { drink in
NavigationLink(drink.name) {
DrinkDetailView(wcMgr: wcMgr, drink: drink)
DrinkDetailView(drink: drink)
}
}
}
+4 -4
View File
@@ -8,15 +8,15 @@
import SwiftUI
struct DrinkDetailView: View {
private var wcMgr: WCManager
@EnvironmentObject var wcMgr: WCManager
private var drink: Drink
@State var shouldShowTransactionSheet = false
@Environment(\.dismiss) private var dismiss
init(wcMgr: WCManager, drink: Drink) {
self.wcMgr = wcMgr
init(drink: Drink) {
self.drink = drink
}
@@ -52,7 +52,7 @@ struct DrinkDetailView: View {
// Sheet dismissed - dismiss detail perspective as well.
dismiss()
} content: {
TransactionView(wcMgr: wcMgr, drinkID: drink.id).interactiveDismissDisabled()
TransactionView(drinkID: drink.id).interactiveDismissDisabled()
}
}
}
+3 -3
View File
@@ -8,7 +8,8 @@
import SwiftUI
struct TransactionView: View {
private var wcMgr: WCManager
@EnvironmentObject var wcMgr: WCManager
private var drinkID: Int
@State var statusLine = String(localized: "BOOKING")
@@ -22,8 +23,7 @@ struct TransactionView: View {
@State var backendBookTimer: Timer?
@State var backendTimerReady: Bool = false
init(wcMgr: WCManager, drinkID: Int) {
self.wcMgr = wcMgr
init(drinkID: Int) {
self.drinkID = drinkID
}
+4 -10
View File
@@ -40,19 +40,16 @@
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
A7FA4CBE2D9E90B0005ACDBB /* Exceptions for "meterios" folder in "meterios-watch" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
BackendConnector.swift,
);
target = A7FA4CA92D9E8FF1005ACDBB /* meterios-watch */;
};
A7FA4CCB2D9E9564005ACDBB /* Exceptions for "common" folder in "meterios-watch" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
AccountManager.swift,
AsyncImageWithPlaceholder.swift,
Localizable.xcstrings,
MeteClient.swift,
SalutManager.swift,
types/Account.swift,
types/AccountManagerEntry.swift,
types/Drink.swift,
types/User.swift,
WCManager.swift,
@@ -64,9 +61,6 @@
/* Begin PBXFileSystemSynchronizedRootGroup section */
A7F97A902D8DD542004EF4ED /* meterios */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
A7FA4CBE2D9E90B0005ACDBB /* Exceptions for "meterios" folder in "meterios-watch" target */,
);
path = meterios;
sourceTree = "<group>";
};
+3 -6
View File
@@ -9,17 +9,14 @@ import SwiftUI
@main
struct meteriosApp: App {
@AppStorage("meteHostAddr") private var meteHostAddr = ""
@AppStorage("meteUserID") private var meteUserID = -1
private var wcMgr = WCManager()
var body: some Scene {
WindowGroup {
if meteHostAddr == "" || meteUserID == -1 {
SetupView()
} else {
if AccountManager.default.CurrentAccount() != nil {
MainView()
} else {
SetupView()
}
}
}
+3 -4
View File
@@ -9,9 +9,6 @@ import SwiftUI
import OSLog
struct DrinkDetail: View {
@AppStorage("meteHostAddr") private var meteHostAddr = ""
@AppStorage("meteUserID") private var meteUserID = -1
private var drink: Drink
@State private var drinkAmount: Int = 1
@State var shouldShowTransactionSheet = false
@@ -26,6 +23,7 @@ struct DrinkDetail: View {
}
var body: some View {
if AccountManager.default.CurrentAccount() != nil {
List() {
// Drink details
Section() {
@@ -33,7 +31,7 @@ struct DrinkDetail: View {
Spacer()
VStack() {
// Displays the drink image asynchronously
AsyncImageWithPlaceholder(url: URL(string: "https://\(meteHostAddr)/\(drink.logoURL)"), width: 300, height: 300).fixedSize()
AsyncImageWithPlaceholder(url: URL(string: "https://\(AccountManager.default.CurrentAccount()!.GetAccount().GetAddress())/\(drink.logoURL)"), width: 300, height: 300).fixedSize()
// Basic drink information
VStack(alignment: .center) {
@@ -110,4 +108,5 @@ struct DrinkDetail: View {
TransactionView(drinkID: drink.id, drinkAmount: drinkAmount).interactiveDismissDisabled()
}
}
}
}
+10 -13
View File
@@ -9,9 +9,6 @@ import SwiftUI
import OSLog
struct MainView: View {
@AppStorage("meteHostAddr") private var meteHostAddr = ""
@AppStorage("meteUserID") private var meteUserID = -1
@State private var favouriteDrinks: Array<Drink> = []
@State private var availableDrinks: Array<Drink> = []
@State private var outOfOrderDrinks: Array<Drink> = []
@@ -24,14 +21,14 @@ struct MainView: View {
Task {
do {
// Load user
user = try await BackendConnector().GetUser(baseAddr: meteHostAddr, userID: meteUserID)
user = try await AccountManager.default.CurrentAccount()!.GetClient().GetUser()
// Load drinks
let drinks = try await BackendConnector().GetDrinks(baseAddr: meteHostAddr)
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 })
outOfOrderDrinks = drinks.filter({ !$0.active })
} catch {
let _ = Logger().error("Failed to update from \(meteHostAddr): \(error)")
let _ = Logger().error("Failed to update from \(AccountManager.default.CurrentAccount()!.GetAccount().GetAddress()): \(error)")
}
}
}
@@ -44,7 +41,7 @@ struct MainView: View {
update()
})
}, label: {
AsyncImageWithPlaceholder(url: URL(string: "https://\(meteHostAddr)/\(drink.logoURL)"), width: 32, height: 32)
AsyncImageWithPlaceholder(url: URL(string: "https://\(AccountManager.default.CurrentAccount()!.GetAccount().GetAddress())/\(drink.logoURL)"), width: 32, height: 32)
Text(drink.name)
})
}
@@ -52,11 +49,11 @@ struct MainView: View {
}
var body: some View {
ScrollViewReader { scrollViewReader in
if AccountManager.default.CurrentAccount() != nil {
NavigationView {
VStack(alignment: .leading) {
List () {
// Account
if user != nil {
Section(header: Text(String(localized: "ACCOUNT")).id("topAnchor")) {
NavigationLink(destination: UserDetailView(user: user!)) {
Button(action: {
@@ -70,16 +67,17 @@ struct MainView: View {
// Account, Address & Balance
VStack(alignment: .leading) {
HStack {
Text(user.displayName).fontWeight(.bold)
Text(user!.displayName).fontWeight(.bold)
Divider()
Text(user.balance.formatted(.currency(code: "EUR"))).foregroundStyle(user.balance > 0 ? .green : .red)
Text(user!.balance.formatted(.currency(code: "EUR"))).foregroundStyle(user!.balance > 0 ? .green : .red)
}.fixedSize()
Text("\(meteHostAddr)").fontWeight(.thin)
Text("\(AccountManager.default.CurrentAccount()!.GetAccount().GetAddress())").fontWeight(.thin)
}.fixedSize()
}.fixedSize()
}
}
}
}
// Drinks list
if !favouriteDrinks.isEmpty {
@@ -108,7 +106,6 @@ struct MainView: View {
}.navigationTitle(String(localized: "METERIOS")).refreshable(action: {
update()
})
}
}.onAppear(perform: {
update()
})
+4 -13
View File
@@ -9,9 +9,6 @@ import SwiftUI
import OSLog
struct SetupView: View {
@AppStorage("meteHostAddr") private var meteHostAddr = ""
@AppStorage("meteUserID") private var meteUserID = -1
@State private var tmpAddress: String = "mete.chaosdorf.space"
@State private var tmpUsers: Array<User> = []
@State private var selectedUserID: Int = 0
@@ -28,12 +25,6 @@ struct SetupView: View {
@Environment(\.dismiss) private var dismiss
init() {
if meteHostAddr != "" {
self.tmpAddress = meteHostAddr
}
}
func tryConnect() {
// If there was a previous connection attempt, the user list and save button might still be visible - hide them again
userListVisible = false
@@ -54,7 +45,8 @@ struct SetupView: View {
// Create test connector
do {
// Try out the connection
tmpUsers = try await BackendConnector().GetUsers(baseAddr: tmpAddress)
let tmpConnector = MeteClient(address: tmpAddress, userID: -1)
tmpUsers = try await tmpConnector.GetUsers()
connectionStatus = String(localized: "CONNECTION_SUCCESSFUL")
connectionStatusIcon = "checkmark.icloud.fill"
connectionStatusColor = .green
@@ -101,7 +93,7 @@ struct SetupView: View {
// Connection
Section(content: {
// Address field
TextField(text: $tmpAddress, label: { Text(meteHostAddr) })
TextField(text: $tmpAddress, label: { Text(tmpAddress) })
.textInputAutocapitalization(.never)
.disableAutocorrection(true)
.focused($connectionFiledFocused)
@@ -146,8 +138,7 @@ struct SetupView: View {
// Save button
Section {
Button(action: {
meteHostAddr = tmpAddress
meteUserID = selectedUserID
AccountManager.default.AddAccount(Account(address: tmpAddress, userID: selectedUserID))
dismiss()
}) {
Text(String(localized: "SAVE_CONNECTION_SETTINGS")).frame(maxWidth: .infinity)
+3 -4
View File
@@ -8,9 +8,6 @@
import SwiftUI
struct TransactionView: View {
@AppStorage("meteHostAddr") private var meteHostAddr = ""
@AppStorage("meteUserID") private var meteUserID = -1
var drinkID: Int
var drinkAmount: Int
@@ -43,7 +40,7 @@ struct TransactionView: View {
do {
// Try to book the drink(s)
for _ in 1...drinkAmount {
try await BackendConnector().BookDrink(baseAddr: meteHostAddr, userID: meteUserID, drinkID: drinkID)
try await AccountManager.default.CurrentAccount()!.GetClient().BookDrink(drinkID)
}
statusLine = SalutManager.getSalute()
backendTimerReady = true
@@ -57,6 +54,7 @@ struct TransactionView: View {
}
var body: some View {
if AccountManager.default.CurrentAccount() != nil {
// Green checkmark or progress indicator
if backendTimerReady {
Image(systemName: "checkmark.circle.fill")
@@ -80,4 +78,5 @@ struct TransactionView: View {
})
.sensoryFeedback(.success, trigger: backendTimerReady)
}
}
}
+5 -6
View File
@@ -8,14 +8,12 @@
import SwiftUI
struct UserDetailView: View {
@AppStorage("meteHostAddr") private var meteHostAddr = ""
@AppStorage("meteUserID") private var meteUserID = -1
var user: User
@Environment(\.dismiss) private var dismiss
var body: some View {
if AccountManager.default.CurrentAccount() != nil {
List() {
Section {
HStack(alignment: .center) {
@@ -37,7 +35,7 @@ struct UserDetailView: View {
}.fixedSize()
Divider()
VStack {
Text(String(meteHostAddr))
Text(String(AccountManager.default.CurrentAccount()!.GetAccount().GetAddress()))
Text(String(localized: "METE_SERVER")).fontWeight(Font.Weight.thin)
}
}
@@ -50,7 +48,7 @@ struct UserDetailView: View {
// Open profile in browser
Section() {
Button(action: {
UIApplication.shared.open(URL(string: "https://\(meteHostAddr)/users/\(meteUserID)")!)
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)
}
@@ -61,7 +59,7 @@ struct UserDetailView: View {
Button(action: {
// Log out. Save the User ID as -1, which is forbidden by the backend.
// This forces Meterios to display the setup screen.
meteUserID = -1
AccountManager.default.RemoveAccount(AccountManager.default.CurrentAccount()!)
dismiss()
}) {
Text(String(localized: "LOGOUT")).frame(maxWidth: .infinity)
@@ -69,4 +67,5 @@ struct UserDetailView: View {
}
}
}
}
}