commit 81681130e6ba677437e283bd40aa0edec18d3c24 Author: rene Date: Sat May 30 09:25:48 2026 +0200 Ban Yaro Go — Phase 1 Foundation SwiftUI/SwiftData iOS-Client, redet mit https://banyaro.app FastAPI-Backend. Bundle-ID app.banyaro.ios, Xcode-26-Projekt mit synchronisierten Ordnern. Drin: - APIClient (URLSession + Bearer + convertFromSnakeCase Decoder) - KeychainStore + AuthSession (@Observable) für persistenten Login - LoginView, MainTabView, SettingsView (mit Logout) - RoutesListView + RouteDetailView mit MapKit-Polyline aus preview_track - DogsListView mit Foto-Avatar - App-Icon (Pfote auf Banyaro-Amber) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6a015bf --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +# Xcode +build/ +DerivedData/ +*.xcuserstate +*.xcscmblueprint + +# Xcode user-specific project settings +xcuserdata/ + +# Swift Package Manager +.build/ +.swiftpm/ + +# macOS +.DS_Store diff --git a/BanYaroGo.xcodeproj/project.pbxproj b/BanYaroGo.xcodeproj/project.pbxproj new file mode 100644 index 0000000..98b9327 --- /dev/null +++ b/BanYaroGo.xcodeproj/project.pbxproj @@ -0,0 +1,322 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXFileReference section */ + AA0000000000000000000005 /* BanYaroGo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = BanYaroGo.app; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + AA0000000000000000000006 /* BanYaroGo */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = BanYaroGo; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + AA000000000000000000000E /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + AA0000000000000000000002 = { + isa = PBXGroup; + children = ( + AA0000000000000000000006 /* BanYaroGo */, + AA0000000000000000000003 /* Products */, + ); + sourceTree = ""; + }; + AA0000000000000000000003 /* Products */ = { + isa = PBXGroup; + children = ( + AA0000000000000000000005 /* BanYaroGo.app */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + AA0000000000000000000004 /* BanYaroGo */ = { + isa = PBXNativeTarget; + buildConfigurationList = AA0000000000000000000008 /* Build configuration list for PBXNativeTarget "BanYaroGo" */; + buildPhases = ( + AA000000000000000000000D /* Sources */, + AA000000000000000000000E /* Frameworks */, + AA000000000000000000000F /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + AA0000000000000000000006 /* BanYaroGo */, + ); + name = BanYaroGo; + productName = BanYaroGo; + productReference = AA0000000000000000000005 /* BanYaroGo.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + AA0000000000000000000001 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 2650; + LastUpgradeCheck = 2650; + TargetAttributes = { + AA0000000000000000000004 = { + CreatedOnToolsVersion = 26.5; + }; + }; + }; + buildConfigurationList = AA0000000000000000000007 /* Build configuration list for PBXProject "BanYaroGo" */; + compatibilityVersion = "Xcode 15.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = AA0000000000000000000002; + productRefGroup = AA0000000000000000000003 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + AA0000000000000000000004 /* BanYaroGo */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + AA000000000000000000000F /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + AA000000000000000000000D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + AA0000000000000000000009 /* 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_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_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.0; + 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; + }; + AA000000000000000000000A /* 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_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_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.0; + 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; + }; + AA000000000000000000000B /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = "Ban Yaro Go"; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = app.banyaro.ios; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + AA000000000000000000000C /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = "Ban Yaro Go"; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = app.banyaro.ios; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + AA0000000000000000000007 /* Build configuration list for PBXProject "BanYaroGo" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + AA0000000000000000000009 /* Debug */, + AA000000000000000000000A /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + AA0000000000000000000008 /* Build configuration list for PBXNativeTarget "BanYaroGo" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + AA000000000000000000000B /* Debug */, + AA000000000000000000000C /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = AA0000000000000000000001 /* Project object */; +} diff --git a/BanYaroGo/API/APIClient.swift b/BanYaroGo/API/APIClient.swift new file mode 100644 index 0000000..efca916 --- /dev/null +++ b/BanYaroGo/API/APIClient.swift @@ -0,0 +1,67 @@ +import Foundation + +final class APIClient { + static let shared = APIClient() + + let baseURL = URL(string: "https://banyaro.app")! + var token: String? + + private let session: URLSession = .shared + private let decoder: JSONDecoder = { + let d = JSONDecoder() + d.keyDecodingStrategy = .convertFromSnakeCase + return d + }() + private let encoder: JSONEncoder = { + let e = JSONEncoder() + return e + }() + + func get(_ path: String) async throws -> T { + try await perform(method: "GET", path: path, body: nil) + } + + func post(_ path: String, body: B) async throws -> T { + let data = try encoder.encode(body) + return try await perform(method: "POST", path: path, body: data) + } + + func post(_ path: String) async throws -> T { + try await perform(method: "POST", path: path, body: nil) + } + + private func perform(method: String, path: String, body: Data?) async throws -> T { + let url = baseURL.appending(path: path) + var req = URLRequest(url: url) + req.httpMethod = method + req.setValue("application/json", forHTTPHeaderField: "Accept") + if let token { + req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + } + if let body { + req.setValue("application/json", forHTTPHeaderField: "Content-Type") + req.httpBody = body + } + + let (data, response) = try await session.data(for: req) + guard let http = response as? HTTPURLResponse else { + throw APIError.invalidResponse + } + guard (200..<300).contains(http.statusCode) else { + let detail = Self.parseErrorDetail(from: data) + throw APIError.server(status: http.statusCode, message: detail) + } + return try decoder.decode(T.self, from: data) + } + + /// FastAPI returns errors as {"detail": "message"} or {"detail": [{...}]}. + private static func parseErrorDetail(from data: Data) -> String? { + if let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + if let s = obj["detail"] as? String { return s } + if let arr = obj["detail"] as? [[String: Any]], + let first = arr.first, + let msg = first["msg"] as? String { return msg } + } + return nil + } +} diff --git a/BanYaroGo/API/APIError.swift b/BanYaroGo/API/APIError.swift new file mode 100644 index 0000000..e283a75 --- /dev/null +++ b/BanYaroGo/API/APIError.swift @@ -0,0 +1,16 @@ +import Foundation + +enum APIError: LocalizedError { + case invalidResponse + case server(status: Int, message: String?) + + var errorDescription: String? { + switch self { + case .invalidResponse: + return "Ungültige Server-Antwort." + case .server(let status, let message): + if let msg = message, !msg.isEmpty { return msg } + return "Fehler vom Server (HTTP \(status))." + } + } +} diff --git a/BanYaroGo/API/DTOs.swift b/BanYaroGo/API/DTOs.swift new file mode 100644 index 0000000..4935cd3 --- /dev/null +++ b/BanYaroGo/API/DTOs.swift @@ -0,0 +1,60 @@ +import Foundation + +// MARK: - Auth + +struct LoginRequest: Encodable { + let email: String + let password: String +} + +struct LoginResponse: Decodable { + let token: String + let name: String + let isPremium: Bool +} + +// MARK: - Dogs + +struct Dog: Decodable, Identifiable { + let id: Int + let name: String + let rasse: String? + let fotoUrl: String? + let geburtstag: String? +} + +// MARK: - Routes + +struct GPSPoint: Codable, Hashable { + let lat: Double + let lon: Double + let alt: Double? +} + +struct RouteListItem: Decodable, Identifiable { + let id: Int + let userId: Int + let name: String + let beschreibung: String? + let distanzKm: Double? + let dauerMin: Int? + let createdAt: String? + let previewTrack: [GPSPoint] + let fotoUrls: [String]? + let userName: String? + let isPublic: Bool? +} + +struct RouteDetail: Decodable, Identifiable { + let id: Int + let userId: Int + let name: String + let beschreibung: String? + let distanzKm: Double? + let dauerMin: Int? + let gpsTrack: [GPSPoint] + let fotoUrls: [String]? + let createdAt: String? + let userName: String? + let dogIds: [Int]? +} diff --git a/BanYaroGo/Assets.xcassets/AccentColor.colorset/Contents.json b/BanYaroGo/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..e4da108 --- /dev/null +++ b/BanYaroGo/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x3A", + "green" : "0x84", + "red" : "0xC4" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/BanYaroGo/Assets.xcassets/AppIcon.appiconset/AppIcon.png b/BanYaroGo/Assets.xcassets/AppIcon.appiconset/AppIcon.png new file mode 100644 index 0000000..5e71d5a Binary files /dev/null and b/BanYaroGo/Assets.xcassets/AppIcon.appiconset/AppIcon.png differ diff --git a/BanYaroGo/Assets.xcassets/AppIcon.appiconset/Contents.json b/BanYaroGo/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..cefcc87 --- /dev/null +++ b/BanYaroGo/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "AppIcon.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/BanYaroGo/Assets.xcassets/Contents.json b/BanYaroGo/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/BanYaroGo/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/BanYaroGo/Auth/AuthSession.swift b/BanYaroGo/Auth/AuthSession.swift new file mode 100644 index 0000000..e34d740 --- /dev/null +++ b/BanYaroGo/Auth/AuthSession.swift @@ -0,0 +1,50 @@ +import Foundation +import Observation + +@Observable +@MainActor +final class AuthSession { + var token: String? + var userName: String? + var isPremium: Bool = false + var isLoggingIn: Bool = false + var errorMessage: String? + + private let tokenKey = "by_token" + + init() { + if let savedToken = KeychainStore.read(tokenKey) { + token = savedToken + APIClient.shared.token = savedToken + } + } + + var isLoggedIn: Bool { token != nil } + + func login(email: String, password: String) async { + isLoggingIn = true + errorMessage = nil + defer { isLoggingIn = false } + do { + let response: LoginResponse = try await APIClient.shared.post( + "/api/auth/login", + body: LoginRequest(email: email, password: password) + ) + KeychainStore.save(response.token, for: tokenKey) + APIClient.shared.token = response.token + self.token = response.token + self.userName = response.name + self.isPremium = response.isPremium + } catch { + self.errorMessage = error.localizedDescription + } + } + + func logout() { + KeychainStore.delete(tokenKey) + APIClient.shared.token = nil + token = nil + userName = nil + isPremium = false + } +} diff --git a/BanYaroGo/Auth/KeychainStore.swift b/BanYaroGo/Auth/KeychainStore.swift new file mode 100644 index 0000000..4db1b85 --- /dev/null +++ b/BanYaroGo/Auth/KeychainStore.swift @@ -0,0 +1,42 @@ +import Foundation +import Security + +enum KeychainStore { + static let service = "app.banyaro.ios" + + static func save(_ value: String, for key: String) { + let data = Data(value.utf8) + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key, + ] + SecItemDelete(query as CFDictionary) + var item = query + item[kSecValueData as String] = data + SecItemAdd(item as CFDictionary, nil) + } + + static func read(_ key: String) -> String? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + var item: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &item) + guard status == errSecSuccess, let data = item as? Data else { return nil } + return String(data: data, encoding: .utf8) + } + + static func delete(_ key: String) { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key, + ] + SecItemDelete(query as CFDictionary) + } +} diff --git a/BanYaroGo/BanYaroGoApp.swift b/BanYaroGo/BanYaroGoApp.swift new file mode 100644 index 0000000..7423f0c --- /dev/null +++ b/BanYaroGo/BanYaroGoApp.swift @@ -0,0 +1,13 @@ +import SwiftUI + +@main +struct BanYaroGoApp: App { + @State private var auth = AuthSession() + + var body: some Scene { + WindowGroup { + RootView() + .environment(auth) + } + } +} diff --git a/BanYaroGo/Views/DogsListView.swift b/BanYaroGo/Views/DogsListView.swift new file mode 100644 index 0000000..087a440 --- /dev/null +++ b/BanYaroGo/Views/DogsListView.swift @@ -0,0 +1,96 @@ +import SwiftUI + +struct DogsListView: View { + @State private var dogs: [Dog] = [] + @State private var isLoading = false + @State private var errorMessage: String? + + var body: some View { + NavigationStack { + content + .navigationTitle("Hunde") + .task { await load() } + } + } + + @ViewBuilder + private var content: some View { + if isLoading && dogs.isEmpty { + ProgressView() + } else if let error = errorMessage, dogs.isEmpty { + ContentUnavailableView( + "Konnte Hunde nicht laden", + systemImage: "wifi.slash", + description: Text(error) + ) + } else if dogs.isEmpty { + ContentUnavailableView( + "Keine Hunde", + systemImage: "pawprint", + description: Text("Lege deinen ersten Hund in der PWA an.") + ) + } else { + List(dogs) { dog in + DogRow(dog: dog) + } + .refreshable { await load() } + } + } + + private func load() async { + isLoading = true + errorMessage = nil + defer { isLoading = false } + do { + dogs = try await APIClient.shared.get("/api/dogs") + } catch { + errorMessage = error.localizedDescription + } + } +} + +struct DogRow: View { + let dog: Dog + + var body: some View { + HStack(spacing: 12) { + avatar + .frame(width: 56, height: 56) + .background(.background.secondary) + .clipShape(Circle()) + + VStack(alignment: .leading, spacing: 2) { + Text(dog.name).font(.headline) + if let rasse = dog.rasse, !rasse.isEmpty { + Text(rasse) + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + } + .padding(.vertical, 4) + } + + @ViewBuilder + private var avatar: some View { + if let path = dog.fotoUrl, let url = URL(string: "https://banyaro.app\(path)") { + AsyncImage(url: url) { phase in + switch phase { + case .success(let img): + img.resizable().scaledToFill() + default: + placeholder + } + } + } else { + placeholder + } + } + + private var placeholder: some View { + Image(systemName: "pawprint.fill") + .font(.title2) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} diff --git a/BanYaroGo/Views/LoginView.swift b/BanYaroGo/Views/LoginView.swift new file mode 100644 index 0000000..eb2fd77 --- /dev/null +++ b/BanYaroGo/Views/LoginView.swift @@ -0,0 +1,78 @@ +import SwiftUI + +struct LoginView: View { + @Environment(AuthSession.self) private var auth + + @State private var email = "" + @State private var password = "" + + var body: some View { + VStack(spacing: 24) { + Spacer() + + Image(systemName: "pawprint.fill") + .font(.system(size: 80)) + .foregroundStyle(Color.accentColor) + + VStack(spacing: 6) { + Text("Ban Yaro Go") + .font(.largeTitle.bold()) + Text("Melde dich mit deinem banyaro-Account an.") + .font(.callout) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + + VStack(spacing: 12) { + TextField("E-Mail", text: $email) + .textContentType(.emailAddress) + .keyboardType(.emailAddress) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .padding() + .background(.background.secondary, in: RoundedRectangle(cornerRadius: 12)) + + SecureField("Passwort", text: $password) + .textContentType(.password) + .padding() + .background(.background.secondary, in: RoundedRectangle(cornerRadius: 12)) + } + + if let error = auth.errorMessage { + Text(error) + .font(.footnote) + .foregroundStyle(.red) + .multilineTextAlignment(.center) + } + + Button { + Task { + await auth.login( + email: email.trimmingCharacters(in: .whitespacesAndNewlines), + password: password + ) + } + } label: { + Group { + if auth.isLoggingIn { + ProgressView().tint(.white) + } else { + Text("Login").bold() + } + } + .frame(maxWidth: .infinity, minHeight: 50) + } + .background(Color.accentColor, in: RoundedRectangle(cornerRadius: 12)) + .foregroundStyle(.white) + .disabled(auth.isLoggingIn || email.isEmpty || password.isEmpty) + + Spacer() + } + .padding(.horizontal, 28) + } +} + +#Preview { + LoginView() + .environment(AuthSession()) +} diff --git a/BanYaroGo/Views/MainTabView.swift b/BanYaroGo/Views/MainTabView.swift new file mode 100644 index 0000000..1a77171 --- /dev/null +++ b/BanYaroGo/Views/MainTabView.swift @@ -0,0 +1,16 @@ +import SwiftUI + +struct MainTabView: View { + var body: some View { + TabView { + RoutesListView() + .tabItem { Label("Touren", systemImage: "map.fill") } + + DogsListView() + .tabItem { Label("Hunde", systemImage: "pawprint.fill") } + + SettingsView() + .tabItem { Label("Mehr", systemImage: "person.crop.circle") } + } + } +} diff --git a/BanYaroGo/Views/MiniRouteMap.swift b/BanYaroGo/Views/MiniRouteMap.swift new file mode 100644 index 0000000..3f07d70 --- /dev/null +++ b/BanYaroGo/Views/MiniRouteMap.swift @@ -0,0 +1,42 @@ +import SwiftUI +import MapKit + +/// Non-interactive map showing a polyline for a GPS track. Suitable for +/// list-row previews as well as larger detail headers. +struct MiniRouteMap: View { + let track: [GPSPoint] + var lineWidth: CGFloat = 3 + + var body: some View { + Map(initialPosition: .region(region)) { + MapPolyline(coordinates: coordinates) + .stroke(Color.accentColor, style: StrokeStyle(lineWidth: lineWidth, lineJoin: .round)) + } + .mapStyle(.standard(elevation: .flat, pointsOfInterest: .excludingAll)) + .allowsHitTesting(false) + } + + private var coordinates: [CLLocationCoordinate2D] { + track.map { CLLocationCoordinate2D(latitude: $0.lat, longitude: $0.lon) } + } + + private var region: MKCoordinateRegion { + let lats = track.map(\.lat) + let lons = track.map(\.lon) + let minLat = lats.min() ?? 0 + let maxLat = lats.max() ?? 0 + let minLon = lons.min() ?? 0 + let maxLon = lons.max() ?? 0 + let center = CLLocationCoordinate2D( + latitude: (minLat + maxLat) / 2, + longitude: (minLon + maxLon) / 2 + ) + // Padding ~20% beyond bounding box; minimum span so very small tracks stay visible. + let latDelta = max((maxLat - minLat) * 1.4, 0.002) + let lonDelta = max((maxLon - minLon) * 1.4, 0.002) + return MKCoordinateRegion( + center: center, + span: MKCoordinateSpan(latitudeDelta: latDelta, longitudeDelta: lonDelta) + ) + } +} diff --git a/BanYaroGo/Views/RootView.swift b/BanYaroGo/Views/RootView.swift new file mode 100644 index 0000000..fced642 --- /dev/null +++ b/BanYaroGo/Views/RootView.swift @@ -0,0 +1,13 @@ +import SwiftUI + +struct RootView: View { + @Environment(AuthSession.self) private var auth + + var body: some View { + if auth.isLoggedIn { + MainTabView() + } else { + LoginView() + } + } +} diff --git a/BanYaroGo/Views/RouteDetailView.swift b/BanYaroGo/Views/RouteDetailView.swift new file mode 100644 index 0000000..12a9f54 --- /dev/null +++ b/BanYaroGo/Views/RouteDetailView.swift @@ -0,0 +1,121 @@ +import SwiftUI + +struct RouteDetailView: View { + let routeId: Int + let fallbackName: String + + @State private var detail: RouteDetail? + @State private var isLoading = false + @State private var errorMessage: String? + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + if let detail { + MiniRouteMap(track: detail.gpsTrack, lineWidth: 4) + .frame(height: 320) + .clipShape(RoundedRectangle(cornerRadius: 16)) + .padding(.horizontal) + + HStack(spacing: 12) { + StatTile(value: formatKm(detail.distanzKm), label: "Distanz", icon: "ruler") + StatTile(value: formatMin(detail.dauerMin), label: "Dauer", icon: "clock") + StatTile(value: "\(detail.gpsTrack.count)", label: "Punkte", icon: "point.3.connected.trianglepath.dotted") + } + .padding(.horizontal) + + if let beschreibung = detail.beschreibung, !beschreibung.isEmpty { + Text(beschreibung) + .font(.body) + .padding(.horizontal) + } + + if let urls = detail.fotoUrls, !urls.isEmpty { + VStack(alignment: .leading, spacing: 8) { + Text("Fotos").font(.headline) + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 10) { + ForEach(urls, id: \.self) { path in + photoThumb(path) + } + } + } + } + .padding(.horizontal) + } + + Spacer(minLength: 24) + } else if isLoading { + ProgressView().padding(.top, 80) + } else if let error = errorMessage { + ContentUnavailableView( + "Fehler", + systemImage: "exclamationmark.triangle", + description: Text(error) + ) + .padding(.top, 60) + } + } + } + .navigationTitle(detail?.name ?? fallbackName) + .navigationBarTitleDisplayMode(.inline) + .task { await load() } + } + + private func photoThumb(_ path: String) -> some View { + let url = URL(string: "https://banyaro.app\(path)") + return AsyncImage(url: url) { phase in + switch phase { + case .success(let img): + img.resizable().scaledToFill() + default: + Rectangle().fill(.gray.opacity(0.15)) + } + } + .frame(width: 160, height: 160) + .clipShape(RoundedRectangle(cornerRadius: 10)) + } + + private func load() async { + isLoading = true + errorMessage = nil + defer { isLoading = false } + do { + detail = try await APIClient.shared.get("/api/routes/\(routeId)") + } catch { + errorMessage = error.localizedDescription + } + } + + private func formatKm(_ km: Double?) -> String { + guard let km else { return "—" } + return String(format: "%.2f km", km) + } + + private func formatMin(_ mins: Int?) -> String { + guard let mins else { return "—" } + if mins >= 60 { return "\(mins / 60) h \(mins % 60) min" } + return "\(mins) min" + } +} + +private struct StatTile: View { + let value: String + let label: String + let icon: String + + var body: some View { + VStack(spacing: 6) { + Image(systemName: icon) + .foregroundStyle(Color.accentColor) + Text(value) + .font(.headline) + Text(label) + .font(.caption) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .background(.background.secondary, in: RoundedRectangle(cornerRadius: 12)) + } +} diff --git a/BanYaroGo/Views/RoutesListView.swift b/BanYaroGo/Views/RoutesListView.swift new file mode 100644 index 0000000..dedf87c --- /dev/null +++ b/BanYaroGo/Views/RoutesListView.swift @@ -0,0 +1,111 @@ +import SwiftUI + +struct RoutesListView: View { + @State private var routes: [RouteListItem] = [] + @State private var isLoading = false + @State private var errorMessage: String? + + var body: some View { + NavigationStack { + content + .navigationTitle("Touren") + .task { await load() } + } + } + + @ViewBuilder + private var content: some View { + if isLoading && routes.isEmpty { + ProgressView() + } else if let error = errorMessage, routes.isEmpty { + ContentUnavailableView( + "Konnte Touren nicht laden", + systemImage: "wifi.slash", + description: Text(error) + ) + } else if routes.isEmpty { + ContentUnavailableView( + "Keine Touren", + systemImage: "map", + description: Text("Lege deine erste Gassi-Tour in der PWA an — oder warte auf Phase 2.") + ) + } else { + List(routes) { route in + NavigationLink { + RouteDetailView(routeId: route.id, fallbackName: route.name) + } label: { + RouteRowView(route: route) + } + } + .refreshable { await load() } + } + } + + private func load() async { + isLoading = true + errorMessage = nil + defer { isLoading = false } + do { + routes = try await APIClient.shared.get("/api/routes") + } catch { + errorMessage = error.localizedDescription + } + } +} + +struct RouteRowView: View { + let route: RouteListItem + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text(route.name) + .font(.headline) + Spacer() + if let km = route.distanzKm { + Text(String(format: "%.1f km", km)) + .font(.subheadline.monospacedDigit()) + .foregroundStyle(.secondary) + } + } + HStack(spacing: 12) { + if let mins = route.dauerMin { + Label("\(mins) min", systemImage: "clock") + } + if let date = route.createdAt { + Text(DateUtil.format(date)) + } + Spacer() + } + .font(.caption) + .foregroundStyle(.secondary) + + if route.previewTrack.count >= 2 { + MiniRouteMap(track: route.previewTrack) + .frame(height: 110) + .clipShape(RoundedRectangle(cornerRadius: 10)) + } + } + .padding(.vertical, 4) + } +} + +enum DateUtil { + /// Parses backend timestamps (SQLite `YYYY-MM-DD HH:MM:SS` or ISO-8601) + /// into a German short date. + static func format(_ input: String) -> String { + let parser = DateFormatter() + parser.locale = Locale(identifier: "en_US_POSIX") + parser.timeZone = TimeZone(identifier: "UTC") + for format in ["yyyy-MM-dd HH:mm:ss", "yyyy-MM-dd'T'HH:mm:ss", "yyyy-MM-dd'T'HH:mm:ssZ"] { + parser.dateFormat = format + if let date = parser.date(from: input) { + let out = DateFormatter() + out.locale = Locale(identifier: "de_DE") + out.dateStyle = .medium + return out.string(from: date) + } + } + return String(input.prefix(10)) + } +} diff --git a/BanYaroGo/Views/SettingsView.swift b/BanYaroGo/Views/SettingsView.swift new file mode 100644 index 0000000..eed2478 --- /dev/null +++ b/BanYaroGo/Views/SettingsView.swift @@ -0,0 +1,27 @@ +import SwiftUI + +struct SettingsView: View { + @Environment(AuthSession.self) private var auth + + var body: some View { + NavigationStack { + Form { + Section("Account") { + LabeledContent("Name", value: auth.userName ?? "—") + LabeledContent("Premium", value: auth.isPremium ? "Ja" : "Nein") + } + Section { + Button("Abmelden", role: .destructive) { + auth.logout() + } + } + Section("Über") { + Text("Ban Yaro Go ist die native iOS-Ergänzung zur banyaro.app PWA. Phase 1: deine Touren ansehen.") + .font(.footnote) + .foregroundStyle(.secondary) + } + } + .navigationTitle("Mehr") + } + } +}