From 9984f2c943ae80db1b6c7d9cf39824104fe73e14 Mon Sep 17 00:00:00 2001 From: maride Date: Fri, 4 Apr 2025 19:45:10 +0200 Subject: [PATCH] Add Favourites feature --- common/WCManager.swift | 34 +++++++------- meterios-watch/views/ContentView.swift | 22 ++++++--- meterios/FavouritesManager.swift | 64 ++++++++++++++++++++++++++ meterios/views/DrinkDetailView.swift | 13 ++++++ meterios/views/MainView.swift | 18 +++++++- 5 files changed, 125 insertions(+), 26 deletions(-) create mode 100644 meterios/FavouritesManager.swift diff --git a/common/WCManager.swift b/common/WCManager.swift index 7ba35a4..6cb45e0 100644 --- a/common/WCManager.swift +++ b/common/WCManager.swift @@ -38,8 +38,9 @@ class WCManager: NSObject, WCSessionDelegate { func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: (any Error)?) {} // session(...) gets called for every incoming message through the WatchConnectivity channel - // As Meterios currently only needs messages from watch to device and responses back to the watch, this function currently only implements device-related responses. + // 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) // Check for the different possible message types if message.contains(where: { $0.key == "request" && $0.value as! String == WCMeteriosMessageTypes.ConnectionInfo.rawValue }) { // Request for ConnectionInfo @@ -51,18 +52,17 @@ class WCManager: NSObject, WCSessionDelegate { // Request for a list of available drinks Task { let drinks = try await BackendConnector().GetDrinks(baseAddr: meteHostAddr) - let availableDrinks = drinks.filter({ $0.active }) + 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. // Create a plist-compatible variant of the Drink object containing the required information - var drinkArray: Array = [] - for d in availableDrinks { - drinkArray.append( - try! JSONEncoder().encode(d).base64EncodedString() - ) - } + let favouriteDrinkArray = favouriteDrinks.map { drink in try! JSONEncoder().encode(drink).base64EncodedString() } + let availableDrinkArray = availableDrinks.map { drink in try! JSONEncoder().encode(drink).base64EncodedString() } replyHandler([ - "drinks": drinkArray + "favouriteDrinks": favouriteDrinkArray, + "availableDrinks": availableDrinkArray ]) } } else if message.contains(where: { $0.key == "request" && $0.value as! String == WCMeteriosMessageTypes.BookDrink.rawValue }) { @@ -86,6 +86,7 @@ class WCManager: NSObject, WCSessionDelegate { } else { log("Error: received unknown request. \(message)") } +#endif // os(iOS) } // requestConnectionInfo requests User ID and Host address from the paired device @@ -100,22 +101,19 @@ class WCManager: NSObject, WCSessionDelegate { } // requestDrinksList requests the list of drinks available at the backend from the paired device - func requestDrinksList(replyHandler: @escaping (Array) -> Void, errorHandler: @escaping ((any Error)?) -> Void) { + func requestDrinksList(replyHandler: @escaping (Array, Array) -> Void, errorHandler: @escaping ((any Error)?) -> Void) { session.sendMessage([ "request": WCMeteriosMessageTypes.DrinkList.rawValue ], replyHandler: { reply in - let drinks = reply["drinks"] as! Array + let favouriteDrinks = reply["favouriteDrinks"] as! Array + let availableDrinks = reply["availableDrinks"] as! Array // Create Drink objects from response - var drinkArray: Array = [] - for d in drinks { - let jsonDecoded = Data(base64Encoded: d) - let drink = try! JSONDecoder().decode(Drink.self, from: jsonDecoded!) - drinkArray.append(drink) - } + let favouriteDrinksArray = favouriteDrinks.map { drink in try! JSONDecoder().decode(Drink.self, from: Data(base64Encoded: drink)!) } + let availableDrinksArray = availableDrinks.map { drink in try! JSONDecoder().decode(Drink.self, from: Data(base64Encoded: drink)!) } // enjoy your drinks, replyHandler! - replyHandler(drinkArray) + replyHandler(favouriteDrinksArray, availableDrinksArray) }, errorHandler: { error in errorHandler(error) }) diff --git a/meterios-watch/views/ContentView.swift b/meterios-watch/views/ContentView.swift index fd00742..a77a29d 100644 --- a/meterios-watch/views/ContentView.swift +++ b/meterios-watch/views/ContentView.swift @@ -10,7 +10,8 @@ import SwiftUI struct ContentView: View { private var wcMgr: WCManager - @State private var drinks: Array = [] + @State private var favouriteDrinks: Array = [] + @State private var availableDrinks: Array = [] @State private var showUpdateError: Bool = false @State private var updateError: String = "" @@ -30,9 +31,10 @@ struct ContentView: View { // Update drinks list from paired device wcMgr.requestDrinksList( - replyHandler: { drinks in + replyHandler: { favDrinks, availDrinks in // Drinks received from backend - self.drinks = drinks + self.favouriteDrinks = favDrinks + self.availableDrinks = availDrinks showUpdateError = false }, errorHandler: { error in // Error receiving drinks @@ -46,12 +48,19 @@ struct ContentView: View { var body: some View { ZStack { - if drinks.count > 0 { + if favouriteDrinks.count + availableDrinks.count > 0 { NavigationView { List() { // Drinks + Section("Favourites") { + ForEach(favouriteDrinks) { drink in + NavigationLink(drink.name) { + DrinkDetailView(wcMgr: wcMgr, drink: drink) + } + } + } Section("Drinks") { - ForEach(drinks) { drink in + ForEach(availableDrinks) { drink in NavigationLink(drink.name) { DrinkDetailView(wcMgr: wcMgr, drink: drink) } @@ -61,7 +70,8 @@ struct ContentView: View { // Controls Section { Button("Refresh") { - drinks = [] + favouriteDrinks = [] + availableDrinks = [] update() } } diff --git a/meterios/FavouritesManager.swift b/meterios/FavouritesManager.swift new file mode 100644 index 0000000..7e42c8d --- /dev/null +++ b/meterios/FavouritesManager.swift @@ -0,0 +1,64 @@ +// +// 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 + + 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 { + @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 = 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) + } + } +} diff --git a/meterios/views/DrinkDetailView.swift b/meterios/views/DrinkDetailView.swift index 1a94c9d..96387c1 100644 --- a/meterios/views/DrinkDetailView.swift +++ b/meterios/views/DrinkDetailView.swift @@ -17,6 +17,8 @@ struct DrinkDetail: View { @State var shouldShowTransactionSheet = false @State var overrideDeactivatedState: Bool = false + @State var isFavourite: Bool = false + @Environment(\.dismiss) private var dismiss init(drink: Drink) { @@ -75,6 +77,17 @@ struct DrinkDetail: View { } } + // Favourites + Section() { + Toggle("Favourite", isOn: $isFavourite).toggleStyle(SwitchToggleStyle(tint: .blue)) + .onAppear(perform: { + isFavourite = FavouritesManager.default.IsFavourite(drink.id) + }) + .onDisappear(perform: { + FavouritesManager.default.SetFavourite(drinkID: self.drink.id, shouldBeFavourite: isFavourite) + }) + } + // Summary Section { Stepper("Amount: **\(self.drinkAmount)x**", onIncrement: { diff --git a/meterios/views/MainView.swift b/meterios/views/MainView.swift index 57eb70a..f02d1d9 100644 --- a/meterios/views/MainView.swift +++ b/meterios/views/MainView.swift @@ -12,6 +12,7 @@ struct MainView: View { @AppStorage("meteHostAddr") private var meteHostAddr = "" @AppStorage("meteUserID") private var meteUserID = -1 + @State private var favouriteDrinks: Array = [] @State private var availableDrinks: Array = [] @State private var outOfOrderDrinks: Array = [] @State private var user: User = User.init(meteID: 0, displayName: "", displayImageURL: "", balance: 0.00) @@ -24,8 +25,9 @@ struct MainView: View { do { user = try await BackendConnector().GetUser(baseAddr: meteHostAddr, userID: meteUserID) let drinks = try await BackendConnector().GetDrinks(baseAddr: meteHostAddr) - availableDrinks = drinks.filter({ $0.active }) - outOfOrderDrinks = drinks.filter({ !$0.active }) + favouriteDrinks = drinks.filter({ FavouritesManager.default.IsFavourite($0.id) && $0.active }) + availableDrinks = drinks.filter({ !FavouritesManager.default.IsFavourite($0.id) && $0.active }) + outOfOrderDrinks = drinks.filter({ !FavouritesManager.default.IsFavourite($0.id) && !$0.active }) } catch { let _ = Logger().error("Failed to update from \(meteHostAddr): \(error)") } @@ -73,6 +75,18 @@ struct MainView: View { } // Drinks list + if !favouriteDrinks.isEmpty { + Section(header: Text("Favourites")) { + ForEach(favouriteDrinks) { drink in + NavigationLink(drink.name) { + DrinkDetail(drink: drink).onDisappear(perform: { + update() + scrollViewReader.scrollTo("topAnchor", anchor: .top) + }) + } + } + } + } if !availableDrinks.isEmpty { Section(header: Text("Available")) { ForEach(availableDrinks) { drink in