Add watchOS support
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
//
|
||||
// SalutManager.swift
|
||||
// meterios
|
||||
//
|
||||
// (c) 2025 Martin "maride" Dessauer
|
||||
//
|
||||
|
||||
class SalutManager {
|
||||
static let salutes: Array<String> = [
|
||||
"Prost!", "Guten!", "Kippis!", "Cheers!", "Chin-Chin!", "Zum Wohl!", "Salut!", "לחיים",
|
||||
"Jamas!", // thanks cocorilla
|
||||
"Budmo!", "за здоровье!", "Sláinte!", "乾杯", "Noroc!", // thanks ChaosAyumi
|
||||
]
|
||||
|
||||
static func getSalute() -> String {
|
||||
return salutes[Int.random(in: 0..<salutes.count)]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
//
|
||||
// WCManager.swift
|
||||
// meterios
|
||||
//
|
||||
// (c) 2025 Martin "maride" Dessauer
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import WatchConnectivity
|
||||
|
||||
enum WCMeteriosMessageTypes: String {
|
||||
case ConnectionInfo
|
||||
case DrinkList
|
||||
case BookDrink
|
||||
}
|
||||
|
||||
class WCManager: NSObject, WCSessionDelegate {
|
||||
@AppStorage("meteHostAddr") private var meteHostAddr = ""
|
||||
@AppStorage("meteUserID") private var meteUserID = -1
|
||||
|
||||
private let session: WCSession = WCSession.default
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
|
||||
if (WCSession.isSupported()) {
|
||||
session.delegate = self
|
||||
session.activate()
|
||||
log("Activated session")
|
||||
}
|
||||
}
|
||||
|
||||
// Functions required by WCSessionDelegate protocol, but not of interest for Meterios
|
||||
#if os(iOS)
|
||||
func sessionDidBecomeInactive(_ session: WCSession) {}
|
||||
func sessionDidDeactivate(_ session: WCSession) {}
|
||||
#endif // os(iOS)
|
||||
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.
|
||||
func session(_ session: WCSession, didReceiveMessage message: [String : Any], replyHandler: @escaping ([String : Any]) -> Void) {
|
||||
// Check for the different possible message types
|
||||
if message.contains(where: { $0.key == "request" && $0.value as! String == WCMeteriosMessageTypes.ConnectionInfo.rawValue }) {
|
||||
// Request for ConnectionInfo
|
||||
replyHandler([
|
||||
"userID": meteUserID,
|
||||
"hostAddr": meteHostAddr
|
||||
])
|
||||
} else if message.contains(where: { $0.key == "request" && $0.value as! String == WCMeteriosMessageTypes.DrinkList.rawValue }) {
|
||||
// Request for a list of available drinks
|
||||
Task {
|
||||
let drinks = try await BackendConnector().GetDrinks(baseAddr: meteHostAddr)
|
||||
let availableDrinks = drinks.filter({ $0.active })
|
||||
|
||||
// Create a plist-compatible variant of the Drink object containing the required information
|
||||
var drinkArray: Array<String> = []
|
||||
for d in availableDrinks {
|
||||
drinkArray.append(
|
||||
try! JSONEncoder().encode(d).base64EncodedString()
|
||||
)
|
||||
}
|
||||
|
||||
replyHandler([
|
||||
"drinks": drinkArray
|
||||
])
|
||||
}
|
||||
} else if message.contains(where: { $0.key == "request" && $0.value as! String == WCMeteriosMessageTypes.BookDrink.rawValue }) {
|
||||
// Book the given drink at the backend
|
||||
Task {
|
||||
do {
|
||||
// Book
|
||||
let drinkID = message["drinkID"] as! Int
|
||||
try await BackendConnector().BookDrink(baseAddr: meteHostAddr, userID: meteUserID, drinkID: drinkID)
|
||||
replyHandler([
|
||||
"salut": SalutManager.getSalute()
|
||||
])
|
||||
} catch {
|
||||
// Error booking against the backend
|
||||
replyHandler([
|
||||
"error": error,
|
||||
"salut": ""
|
||||
])
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log("Error: received unknown request. \(message)")
|
||||
}
|
||||
}
|
||||
|
||||
// requestConnectionInfo requests User ID and Host address from the paired device
|
||||
func requestConnectionInfo(replyHandler: @escaping (Int, String) -> Void, errorHandler: @escaping ((any Error)?) -> Void) {
|
||||
session.sendMessage([
|
||||
"request": WCMeteriosMessageTypes.ConnectionInfo.rawValue
|
||||
], replyHandler: { reply in
|
||||
replyHandler(reply["userID"] as! Int, reply["hostAddr"] as! String)
|
||||
}, errorHandler: { error in
|
||||
errorHandler(error)
|
||||
})
|
||||
}
|
||||
|
||||
// requestDrinksList requests the list of drinks available at the backend from the paired device
|
||||
func requestDrinksList(replyHandler: @escaping (Array<Drink>) -> Void, errorHandler: @escaping ((any Error)?) -> Void) {
|
||||
session.sendMessage([
|
||||
"request": WCMeteriosMessageTypes.DrinkList.rawValue
|
||||
], replyHandler: { reply in
|
||||
let drinks = reply["drinks"] as! Array<String>
|
||||
|
||||
// Create Drink objects from response
|
||||
var drinkArray: Array<Drink> = []
|
||||
for d in drinks {
|
||||
let jsonDecoded = Data(base64Encoded: d)
|
||||
let drink = try! JSONDecoder().decode(Drink.self, from: jsonDecoded!)
|
||||
drinkArray.append(drink)
|
||||
}
|
||||
|
||||
// enjoy your drinks, replyHandler!
|
||||
replyHandler(drinkArray)
|
||||
}, errorHandler: { error in
|
||||
errorHandler(error)
|
||||
})
|
||||
}
|
||||
|
||||
// bookDrink books the drink given by drinkID
|
||||
func bookDrink(drinkID: Int, replyHandler: @escaping (String) -> Void, errorHandler: @escaping ((any Error)?) -> Void) {
|
||||
session.sendMessage([
|
||||
"request": WCMeteriosMessageTypes.BookDrink.rawValue,
|
||||
"drinkID": drinkID,
|
||||
], replyHandler: { reply in
|
||||
let salut = reply["salut"] as! String
|
||||
replyHandler(salut)
|
||||
}, errorHandler: { error in
|
||||
errorHandler(error)
|
||||
})
|
||||
}
|
||||
|
||||
private func log(_ line: String) {
|
||||
#if os(iOS)
|
||||
let prefix = "[WCM@iOS]"
|
||||
#elseif os(watchOS)
|
||||
let prefix = "[WCM@wOS]"
|
||||
#else
|
||||
let prefix = "[WCM@?OS]"
|
||||
#endif // os(...)
|
||||
print("\(prefix) \(line)")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
//
|
||||
// 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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
//
|
||||
// User.swift
|
||||
// meterios
|
||||
//
|
||||
// (c) 2025 Martin "maride" Dessauer
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import CryptoKit
|
||||
|
||||
struct User: Decodable, Identifiable, Hashable {
|
||||
var id: UUID = UUID() // Careful: meterios-internal ID, not Mete/Backend user accoint ID!
|
||||
var meteID: Int
|
||||
var displayName: String
|
||||
var displayImageURL: String
|
||||
var balance: Float
|
||||
|
||||
private enum KeyMap : String, CodingKey { case id, name, email, balance }
|
||||
|
||||
// Init function to be used with JSONDecoder, converting from SpaceMarket API's strange types to proper types
|
||||
init(from decoder : Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: KeyMap.self)
|
||||
self.meteID = try container.decode(Int.self, forKey: .id)
|
||||
self.displayName = try container.decode(String.self, forKey: .name)
|
||||
self.displayImageURL = String(
|
||||
format: "https://secure.gravatar.com/avatar/%@",
|
||||
Insecure.MD5.hash(
|
||||
// e-mails may be empty as they are not required during the registration process in Mete
|
||||
// however, Gravatar requires them for the profile picture functionality.
|
||||
// Default to the profile picture of md5("0"), which is simply the Gravatar logo on blue background
|
||||
data: (try container.decode(String.self, forKey: .email).data(using: .utf8)) ?? "0".data(using: .utf8)!
|
||||
).compactMap {
|
||||
String(format: "%02x", $0)
|
||||
}.joined()
|
||||
)
|
||||
self.balance = Float(try container.decode(String.self, forKey: .balance)) ?? -9999.99
|
||||
}
|
||||
|
||||
// Init function, as straight-forward as it gets
|
||||
init(meteID: Int, displayName: String, displayImageURL: String, balance: Float) {
|
||||
self.meteID = meteID
|
||||
self.displayName = displayName
|
||||
self.displayImageURL = displayImageURL
|
||||
self.balance = balance
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user