This commit is contained in:
2025-04-02 14:39:51 +02:00
commit 96b0466deb
20 changed files with 1150 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
xcuserdata/
.DS_Store
+7
View File
@@ -0,0 +1,7 @@
![meterios icon](meterios/Assets.xcassets/AppIcon.appiconset/mete-new.jpg)
# meterios
*Pick your poison*, now on iOS.
+349
View File
@@ -0,0 +1,349 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 77;
objects = {
/* Begin PBXFileReference section */
A7F97A8E2D8DD542004EF4ED /* meterios.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = meterios.app; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
A7F97A902D8DD542004EF4ED /* meterios */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = meterios;
sourceTree = "<group>";
};
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
A7F97A8B2D8DD542004EF4ED /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
A7F97A852D8DD542004EF4ED = {
isa = PBXGroup;
children = (
A7F97A902D8DD542004EF4ED /* meterios */,
A7F97A8F2D8DD542004EF4ED /* Products */,
);
sourceTree = "<group>";
};
A7F97A8F2D8DD542004EF4ED /* Products */ = {
isa = PBXGroup;
children = (
A7F97A8E2D8DD542004EF4ED /* meterios.app */,
);
name = Products;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
A7F97A8D2D8DD542004EF4ED /* meterios */ = {
isa = PBXNativeTarget;
buildConfigurationList = A7F97A9C2D8DD54B004EF4ED /* Build configuration list for PBXNativeTarget "meterios" */;
buildPhases = (
A7F97A8A2D8DD542004EF4ED /* Sources */,
A7F97A8B2D8DD542004EF4ED /* Frameworks */,
A7F97A8C2D8DD542004EF4ED /* Resources */,
);
buildRules = (
);
dependencies = (
);
fileSystemSynchronizedGroups = (
A7F97A902D8DD542004EF4ED /* meterios */,
);
name = meterios;
packageProductDependencies = (
);
productName = meterios;
productReference = A7F97A8E2D8DD542004EF4ED /* meterios.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
A7F97A862D8DD542004EF4ED /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 1620;
LastUpgradeCheck = 1620;
TargetAttributes = {
A7F97A8D2D8DD542004EF4ED = {
CreatedOnToolsVersion = 16.2;
};
};
};
buildConfigurationList = A7F97A892D8DD542004EF4ED /* Build configuration list for PBXProject "meterios" */;
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = A7F97A852D8DD542004EF4ED;
minimizedProjectReferenceProxies = 1;
preferredProjectObjectVersion = 77;
productRefGroup = A7F97A8F2D8DD542004EF4ED /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
A7F97A8D2D8DD542004EF4ED /* meterios */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
A7F97A8C2D8DD542004EF4ED /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
A7F97A8A2D8DD542004EF4ED /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin XCBuildConfiguration section */
A7F97A9A2D8DD54B004EF4ED /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 18.2;
ITSAppUsesNonExemptEncryption = NO;
"ITSAppUsesNonExemptEncryption[arch=*]" = NO;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
name = Debug;
};
A7F97A9B2D8DD54B004EF4ED /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 18.2;
ITSAppUsesNonExemptEncryption = NO;
"ITSAppUsesNonExemptEncryption[arch=*]" = NO;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SDKROOT = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
VALIDATE_PRODUCT = YES;
};
name = Release;
};
A7F97A9D2D8DD54B004EF4ED /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 3RNVF52786;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = Meterios;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.food-and-drink";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UIRequiresFullScreen = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
ITSAppUsesNonExemptEncryption = NO;
"ITSAppUsesNonExemptEncryption[arch=*]" = NO;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = space.chaosdorf.meterios;
PRODUCT_NAME = "$(TARGET_NAME)";
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = NO;
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 1;
};
name = Debug;
};
A7F97A9E2D8DD54B004EF4ED /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 3RNVF52786;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = Meterios;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.food-and-drink";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UIRequiresFullScreen = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
ITSAppUsesNonExemptEncryption = NO;
"ITSAppUsesNonExemptEncryption[arch=*]" = NO;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = space.chaosdorf.meterios;
PRODUCT_NAME = "$(TARGET_NAME)";
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = NO;
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 1;
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
A7F97A892D8DD542004EF4ED /* Build configuration list for PBXProject "meterios" */ = {
isa = XCConfigurationList;
buildConfigurations = (
A7F97A9A2D8DD54B004EF4ED /* Debug */,
A7F97A9B2D8DD54B004EF4ED /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
A7F97A9C2D8DD54B004EF4ED /* Build configuration list for PBXNativeTarget "meterios" */ = {
isa = XCConfigurationList;
buildConfigurations = (
A7F97A9D2D8DD54B004EF4ED /* Debug */,
A7F97A9E2D8DD54B004EF4ED /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = A7F97A862D8DD542004EF4ED /* Project object */;
}
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>
@@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,38 @@
{
"images" : [
{
"filename" : "mete-new.jpg",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"filename" : "mete-new-dark.jpg",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "tinted"
}
],
"filename" : "mete-new-tinted.jpg",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

+6
View File
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
+67
View File
@@ -0,0 +1,67 @@
//
// BackendConnector.swift
// meterios
//
// (c) 2025 Martin "maride" Dessauer
//
import Foundation
enum BackendConnectorError: Error {
case StatusCodeLooksBad(explanation: String)
}
class BackendConnector {
// GetDrinks returns the list of drinks available at the backend
func GetDrinks(baseAddr: String) async throws -> Array<Drink> {
// Send HTTP request to the drinks endpoint
let url = URL(string: "https://\(baseAddr)/api/v1/drinks")
let (data, _) = try await URLSession.shared.data(from: url!)
// Decode JSON to array of drinks
let decoder = JSONDecoder()
let drinks = try decoder.decode([Drink].self, from: data)
return drinks
}
// GetUser returns the user object for the given ID<
func GetUser(baseAddr: String, userID: Int) async throws -> User {
// Send HTTP request to the user endpoint
let url = URL(string: "https://\(baseAddr)/api/v1/users/\(userID).json")
let (data, _) = try await URLSession.shared.data(from: url!)
// Decode JSON to array of drinks
let decoder = JSONDecoder()
let user = try decoder.decode(User.self, from: data)
return user
}
// GetUsers returns all users registered at the backend
func GetUsers(baseAddr: String) async throws -> Array<User> {
// Send HTTP request to the user endpoint
let url = URL(string: "https://\(baseAddr)/api/v1/users.json")
let (data, _) = try await URLSession.shared.data(from: url!)
// Decode JSON to array of drinks
let decoder = JSONDecoder()
let users = try decoder.decode([User].self, from: data)
return users
}
// BookDrink subtracts the recommended price off the balance from the user given by ID
func BookDrink(baseAddr: String, userID: Int, 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 (_, 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)")
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

+24
View File
@@ -0,0 +1,24 @@
//
// meteriosApp.swift
// meterios
//
// (c) 2025 Martin "maride" Dessauer
//
import SwiftUI
@main
struct meteriosApp: App {
@AppStorage("meteHostAddr") private var meteHostAddr = ""
@AppStorage("meteUserID") private var meteUserID = -1
var body: some Scene {
WindowGroup {
if meteHostAddr == "" || meteUserID == -1 {
SetupView()
} else {
MainView()
}
}
}
}
+33
View File
@@ -0,0 +1,33 @@
//
// Drink.swift
// meterios
//
// (c) 2025 Martin "maride" Dessauer
//
import SwiftUI
struct Drink: Identifiable, Decodable {
var id: Int
var name: String
var logoURL: String = ""
var price: Float = 0.00
var bottle_size: Float = 0.00
var caffeine: Int = 0
var active: Bool = true
// 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, converting from SpaceMarket API's strange types to proper 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 = Float(try container.decode(String.self, forKey: .bottle_size)) ?? 0.00
self.caffeine = (try? container.decode(Int.self, forKey: .caffeine)) ?? 0
self.price = Float(try container.decode(String.self, forKey: .price)) ?? 9999.99
self.active = (try? container.decode(Bool.self, forKey: .active)) ?? false
self.logoURL = (try? container.decode(String.self, forKey: .logo_url)) ?? ""
}
}
+46
View File
@@ -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
}
}
+122
View File
@@ -0,0 +1,122 @@
//
// DrinkDetailView.swift
// meterios
//
// (c) 2025 Martin "maride" Dessauer
//
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
@State var overrideDeactivatedState: Bool = false
@Environment(\.dismiss) private var dismiss
init(drink: Drink) {
self.drink = drink
}
var body: some View {
List() {
// Drink details
Section() {
HStack {
Spacer()
VStack() {
// Displays the drink image asynchronously
AsyncImage(url: URL(string: "https://\(meteHostAddr)/\(drink.logoURL)")) { phase in
if let image = phase.image {
// Image loaded
image
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 300, height: 300)
.clipShape(RoundedRectangle(cornerRadius: 8.0))
} else if phase.error != nil {
// Error, display a dummy image (question mark)
let _ = Logger().error("Failed to load drink image for \(drink.name): \(phase.error)")
ZStack {
RoundedRectangle(cornerRadius: 8.0).foregroundStyle(.white).tint(.gray).frame(width: 300, height: 300)
Text("?").fontWeight(.bold)
}
} else {
// Loading Placeholder
ZStack {
RoundedRectangle(cornerRadius: 8.0).foregroundStyle(.white).tint(.gray).frame(width: 300, height: 300)
ProgressView()
}
}
}.fixedSize()
// Basic drink information
VStack(alignment: .center) {
Text("\(drink.name)").font(.largeTitle).fontWeight(.bold)
HStack {
VStack {
Text(String(format: "%img", drink.caffeine)).fontWeight(.bold)
Text("Caffeine").fontWeight(Font.Weight.thin)
}.fixedSize()
Divider()
VStack {
Text(String(format: "%.2f€", drink.price)).fontWeight(.bold)
Text("Price").fontWeight(Font.Weight.thin)
}
}
}
}.fixedSize()
Spacer()
}
}
// Summary
Section {
Stepper("Amount: **\(self.drinkAmount)x**", onIncrement: {
if self.drinkAmount < 10 {
self.drinkAmount += 1
}
}, onDecrement: {
if self.drinkAmount > 1 {
self.drinkAmount -= 1
}
})
HStack {
Text("Total sum:")
Spacer()
Text(String(format: "%.2f€", self.drink.price * Float(self.drinkAmount))).fontWeight(.bold)
}
}
// Warning for inactive bewerages
if !drink.active {
Section(
header: Text("\(drink.name) is marked inactive."),
content: {
Toggle("\(drink.name) is available", isOn: $overrideDeactivatedState).toggleStyle(SwitchToggleStyle(tint: .green))
})
}
// Pick button
Button(action: {
// Open the transaction sheet
shouldShowTransactionSheet = true
}) {
Text("Poison picked!").frame(maxWidth: .infinity)
}.tint(.green).disabled(!drink.active && !overrideDeactivatedState)
}
// Transaction sheet
.sheet(isPresented: $shouldShowTransactionSheet) {
// Sheet dismissed - dismiss detail perspective as well.
dismiss()
} content: {
TransactionView(drinkID: drink.id, drinkAmount: drinkAmount).interactiveDismissDisabled()
}
}
}
+106
View File
@@ -0,0 +1,106 @@
//
// MainView.swift
// meterios
//
// (c) 2025 Martin "maride" Dessauer
//
import SwiftUI
import OSLog
struct MainView: View {
@AppStorage("meteHostAddr") private var meteHostAddr = ""
@AppStorage("meteUserID") private var meteUserID = -1
@State private var availableDrinks: Array<Drink> = []
@State private var outOfOrderDrinks: Array<Drink> = []
@State private var user: User = User.init(meteID: 0, displayName: "", displayImageURL: "", balance: 0.00)
@Environment(\.dismiss) var dismiss
// Update pulls drinks off the backend and updates the user info (balance, name, profile pic)
func update() {
Task {
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 })
} catch {
let _ = Logger().error("Failed to update from \(meteHostAddr): \(error)")
}
}
}
var body: some View {
ScrollViewReader { scrollViewReader in
NavigationView {
VStack(alignment: .leading) {
List () {
// Account
Section(header: Text("Account").id("topAnchor")) {
NavigationLink(destination: UserDetailView(user: user)) {
Button(action: {
dismiss()
}) {
HStack {
// Profile Image
AsyncImage(
url: URL(string: user.displayImageURL),
content: { image in image.resizable().aspectRatio(contentMode: .fill).frame(width: 64, height: 64).clipShape(Circle()) },
placeholder: {
ZStack {
Circle().fill(.gray).frame(width: 64, height: 64)
ProgressView()
}
}
).fixedSize()
Spacer(minLength: 16.0)
// Account, Address & Balance
VStack(alignment: .leading) {
HStack {
Text(user.displayName).fontWeight(.bold)
Divider()
Text(String(format: "%.2f€", user.balance)).foregroundStyle(user.balance > 0 ? .green : .red)
}.fixedSize()
Text("\(meteHostAddr)").fontWeight(.thin)
}.fixedSize()
}.fixedSize()
}
}
}
// Drinks list
if !availableDrinks.isEmpty {
Section(header: Text("Available")) {
ForEach(availableDrinks) { drink in
NavigationLink(drink.name) {
DrinkDetail(drink: drink).onDisappear(perform: {
update()
scrollViewReader.scrollTo("topAnchor", anchor: .top)
})
}
}
}
}
if !outOfOrderDrinks.isEmpty {
Section(header: Text("Out of stock")) {
ForEach(outOfOrderDrinks) { drink in
NavigationLink(drink.name) {
DrinkDetail(drink: drink)
}
}
}
}
}.navigationTitle("Meterios").refreshable(action: {
update()
})
}
}.onAppear(perform: {
update()
})
}
}
}
+159
View File
@@ -0,0 +1,159 @@
//
// SetupView.swift
// meterios
//
// (c) 2025 Martin "maride" Dessauer
//
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
@FocusState private var connectionFiledFocused: Bool
@State private var connectionStatus: String?
@State private var connectionStatusColor: Color?
@State private var connectionStatusIcon: String?
@State private var connectionStatusVisible: Bool = false
@State private var connectionStatusProgressVisible: Bool = false
@State private var userListVisible: Bool = false
@State private var saveButtonEnabled: Bool = false
@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
saveButtonEnabled = false
// Update status
connectionStatus = "Connecting to \(tmpAddress)"
connectionStatusIcon = "network"
connectionStatusColor = .blue
connectionStatusProgressVisible = true
connectionStatusVisible = true
// Connect to the server asynchronously
Task {
// Un-Focus the address field to hide keyboard
connectionFiledFocused = false
// Create test connector
do {
// Try out the connection
tmpUsers = try await BackendConnector().GetUsers(baseAddr: tmpAddress)
connectionStatus = "Connection successful"
connectionStatusIcon = "checkmark.icloud.fill"
connectionStatusColor = .green
connectionStatusProgressVisible = false
connectionStatusVisible = true
userListVisible = true
} catch {
connectionStatus = error.localizedDescription
connectionStatusIcon = "bolt.horizontal.circle.fill"
connectionStatusColor = .red
connectionStatusProgressVisible = false
connectionStatusVisible = true
let _ = Logger().error("Failed to connect to new backend \(tmpAddress): \(error)")
}
}
}
var body: some View {
// Meterios banner
ZStack {
// Background color
Color(.sRGB, red: 0, green: 57/255, blue: 132/255)
.ignoresSafeArea()
.frame(maxHeight: 200)
// Logo and text
HStack {
Image(uiImage: UIImage(named: "mete-transparent")!)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(maxWidth: 100, maxHeight: 100)
.contentMargins(32.0)
.fixedSize()
Spacer()
VStack(alignment: .leading) {
Text("Meterios").font(.title).fontWeight(.heavy).foregroundStyle(.white)
Text("Pick your poison.").font(.title2).foregroundStyle(.white)
}.fixedSize()
}.fixedSize()
}
// Input fields
Form {
// Connection
Section(content: {
// Address field
TextField(text: $tmpAddress, label: { Text(meteHostAddr) })
.textInputAutocapitalization(.never)
.disableAutocorrection(true)
.focused($connectionFiledFocused)
.onSubmit {
tryConnect()
}
Button("Connect", action: {
tryConnect()
})
}, header: {
Text("Connection")
}, footer: {
// Status bar
if connectionStatusVisible {
HStack {
// Status text
Label(
connectionStatus ?? "",
systemImage: connectionStatusIcon ?? "questionmark.diamond.fill"
).foregroundStyle(connectionStatusColor ?? .red)
// Progress indicator
if connectionStatusProgressVisible {
ProgressView().frame(width: 8, height: 8)
}
}
}
})
// User selection
if userListVisible {
Picker("Account", selection: $selectedUserID, content: {
ForEach(tmpUsers, id: \.self.meteID) { (user: User) in
Text(user.displayName).tag(user.displayName)
}
})
.pickerStyle(.inline)
.onChange(of: selectedUserID, {
saveButtonEnabled = true
})
}
// Save button
Section {
Button(action: {
meteHostAddr = tmpAddress
meteUserID = selectedUserID
dismiss()
}) {
Text("Save connection settings").frame(maxWidth: .infinity)
}.tint(.green).disabled(!saveButtonEnabled)
}
}
}
}
+92
View File
@@ -0,0 +1,92 @@
//
// TransactionView.swift
// meterios
//
// (c) 2025 Martin "maride" Dessauer
//
import SwiftUI
struct TransactionView: View {
@AppStorage("meteHostAddr") private var meteHostAddr = ""
@AppStorage("meteUserID") private var meteUserID = -1
var drinkID: Int
var drinkAmount: Int
@State var statusLine = "Contacting Mete backend..."
@Environment(\.dismiss) private var dismiss
// If the backend reacts too fast (we hate that, huh?), the user is confused because of the short life of this view
// closeTimer takes care of that confusion and gives the user the good old 56k modem feeling.
@State var closeTimer: Timer?
@State var closeTimerReady: Bool = false
@State var backendBookTimer: Timer?
@State var backendTimerReady: Bool = false
let salutes: Array<String> = [
"Prost!", "Guten!", "Kippis!", "Cheers!", "Chin-Chin!", "Zum Wohl!", "Salut!", "לחיים",
"Jamas!", // thanks cocorilla
"Budmo!", "за здоровье!", "Sláinte!", "乾杯", "Noroc!", // thanks ChaosAyumi
]
func getSalute() -> String {
return salutes[Int.random(in: 0..<salutes.count)]
}
init(drinkID: Int, drinkAmount: Int) {
self.drinkID = drinkID
self.drinkAmount = drinkAmount
}
// maybeDismiss dismisses this view after the backend call(s) finished AND the timer(s) ran out
func maybeDismiss() {
if closeTimerReady && backendTimerReady {
dismiss()
}
}
// Books the drinks handed over to this view
func book() {
Task {
do {
// Try to book the drink(s)
for _ in 1...drinkAmount {
try await BackendConnector().BookDrink(baseAddr: meteHostAddr, userID: meteUserID, drinkID: drinkID)
}
statusLine = getSalute()
backendTimerReady = true
maybeDismiss()
} catch {
// Error occurred
statusLine = "Error in communication"
print(error)
}
}
}
var body: some View {
// Green checkmark or progress indicator
if backendTimerReady {
Image(systemName: "checkmark.circle.fill")
.font(.title)
.foregroundStyle(.green)
} else {
ProgressView()
}
// Status line, on success with salute
Text(statusLine)
.font(.title)
.onAppear(perform: {
closeTimer = Timer.scheduledTimer(withTimeInterval: 1.5, repeats: false) { timer in
closeTimerReady = true
maybeDismiss()
}
backendBookTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { timer in
book()
}
})
.sensoryFeedback(.success, trigger: backendTimerReady)
}
}
+81
View File
@@ -0,0 +1,81 @@
//
// UserDetailView.swift
// meterios
//
// (c) 2025 Martin "maride" Dessauer
//
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 {
List() {
Section {
HStack(alignment: .center) {
Spacer()
VStack(alignment: .center) {
// User image
AsyncImage(
url: URL(string: user.displayImageURL),
content: { image in image.resizable().aspectRatio(contentMode: .fill).frame(width: 256, height: 256).clipShape(Circle()) },
placeholder: {
ZStack {
Circle().fill(.gray).frame(width: 256, height: 256)
ProgressView()
}
}
).fixedSize()
// Basic info about user
VStack(alignment: .center) {
// User name
Text("\(user.displayName)").font(.largeTitle).fontWeight(.bold)
// Balance and Mete backend address
HStack {
VStack {
Text(String(format: "%.2f€", user.balance)).fontWeight(.bold)
.foregroundStyle(user.balance > 0 ? .green : .red)
Text("Balance").fontWeight(Font.Weight.thin)
}.fixedSize()
Divider()
VStack {
Text(String(meteHostAddr))
Text("Mete Server").fontWeight(Font.Weight.thin)
}
}
}.fixedSize()
}
Spacer()
}
}
// Open profile in browser
Section() {
Button(action: {
UIApplication.shared.open(URL(string: "https://\(meteHostAddr)/users/\(meteUserID)")!)
}) {
Text("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.
meteUserID = -1
dismiss()
}) {
Text("Log out").frame(maxWidth: .infinity)
}.tint(.red)
}
}
}
}