diff --git a/RunnerTracker.xcodeproj/project.pbxproj b/RunnerTracker.xcodeproj/project.pbxproj index c3365aa..ec1a273 100644 --- a/RunnerTracker.xcodeproj/project.pbxproj +++ b/RunnerTracker.xcodeproj/project.pbxproj @@ -127,6 +127,8 @@ /* Begin XCBuildConfiguration section */ B13C13192FA11DF600C7A367 /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReferenceAnchor = B13C13122FA11DF300C7A367 /* RunnerTracker */; + baseConfigurationReferenceRelativePath = config.xcconfig; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; @@ -190,6 +192,8 @@ }; B13C131A2FA11DF600C7A367 /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReferenceAnchor = B13C13122FA11DF300C7A367 /* RunnerTracker */; + baseConfigurationReferenceRelativePath = config.xcconfig; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; @@ -246,13 +250,19 @@ }; B13C131C2FA11DF600C7A367 /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReferenceAnchor = B13C13122FA11DF300C7A367 /* RunnerTracker */; + baseConfigurationReferenceRelativePath = config.xcconfig; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = Q39WZ6UXL3; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = ""; + INFOPLIST_KEY_NSLocationAlwaysAndWhenInUseUsageDescription = "\"Die App trackt deinen Lauf auch, wenn das Handy gesperrt ist.\""; + INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "\"Deine Laufdaten werden für die Strecke benötigt.\""; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; @@ -277,13 +287,19 @@ }; B13C131D2FA11DF600C7A367 /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReferenceAnchor = B13C13122FA11DF300C7A367 /* RunnerTracker */; + baseConfigurationReferenceRelativePath = config.xcconfig; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = Q39WZ6UXL3; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = ""; + INFOPLIST_KEY_NSLocationAlwaysAndWhenInUseUsageDescription = "\"Die App trackt deinen Lauf auch, wenn das Handy gesperrt ist.\""; + INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "\"Deine Laufdaten werden für die Strecke benötigt.\""; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; diff --git a/RunnerTracker.xcodeproj/xcuserdata/benji.xcuserdatad/xcschemes/xcschememanagement.plist b/RunnerTracker.xcodeproj/xcuserdata/benji.xcuserdatad/xcschemes/xcschememanagement.plist new file mode 100644 index 0000000..9405eb0 --- /dev/null +++ b/RunnerTracker.xcodeproj/xcuserdata/benji.xcuserdatad/xcschemes/xcschememanagement.plist @@ -0,0 +1,14 @@ + + + + + SchemeUserState + + RunnerTracker.xcscheme_^#shared#^_ + + orderHint + 0 + + + + diff --git a/RunnerTracker/APIService.swift b/RunnerTracker/APIService.swift new file mode 100644 index 0000000..7177f22 --- /dev/null +++ b/RunnerTracker/APIService.swift @@ -0,0 +1,46 @@ +import Foundation + +class APIService { + static let shared = APIService() + + // Ersetze dies durch deine Cloudflare-URL oder lokale Pi-IP + let writeKey = (Bundle.main.object(forInfoDictionaryKey: "WriteKey") as? String) ?? "" + let baseURL = (Bundle.main.object(forInfoDictionaryKey: "BaseUrl") as? String) ?? "" + + func createRun(eventName: String, type: String, completion: @escaping (Result) -> Void) { + guard let url = URL(string: "\(baseURL)/runs/create") else { return } + + var request = URLRequest(url: url) + print(request) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue(writeKey, forHTTPHeaderField: "X-Write-Key") + + // Das schicken wir an den Pi (u_id und e_id sind Platzhalter) + let body: [String: Any] = [ + "u_id": (Bundle.main.object(forInfoDictionaryKey: "UserID") as? String) ?? "", + "e_id": "f0e061e2-4317-11f1-b48c-b827ebe636d1", + "run_type": type, + "event_name": eventName // Dein neues Feld! + ] + + request.httpBody = try? JSONSerialization.data(withJSONObject: body) + + URLSession.shared.dataTask(with: request) { data, response, error in + if let error = error { + completion(.failure(error)) + return + } + + guard let data = data, + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let runID = json["run_id"] as? String else { + let parseError = NSError(domain: "", code: 500, userInfo: [NSLocalizedDescriptionKey: "Fehler beim Lesen der RunID"]) + completion(.failure(parseError)) + return + } + + completion(.success(runID)) + }.resume() + } +} diff --git a/RunnerTracker/ContentView.swift b/RunnerTracker/ContentView.swift index 9506d90..c828f76 100644 --- a/RunnerTracker/ContentView.swift +++ b/RunnerTracker/ContentView.swift @@ -1,24 +1,124 @@ -// -// ContentView.swift -// RunnerTracker -// -// Created by Markus Benjamin Tabler on 28.04.26. -// - import SwiftUI +import CoreLocation struct ContentView: View { + // --- App States --- + @State private var appState: AppState = .idle + @State private var runID: String? = nil + + // --- Input Fields --- + @State private var eventName: String = "" + @State private var selectedType: String = "training" + + // --- Managers --- + @StateObject private var trackingManager = TrackingManager() + + enum AppState { + case idle, creatingRun, readyToRun, tracking + } + var body: some View { - VStack { - Image(systemName: "globe") - .imageScale(.large) - .foregroundStyle(.tint) - Text("Hello, world!") + NavigationView { + Form { + // SEKTION 1: SETUP + Section(header: Text("Lauf Vorbereiten")) { + TextField("Event Name (z.B. Feierabendrunde)", text: $eventName) + .disabled(appState == .tracking || appState == .readyToRun) + + Picker("Typ", selection: $selectedType) { + Text("Training").tag("training") + Text("Wettkampf").tag("race") + } + .pickerStyle(.segmented) + .disabled(appState == .tracking || appState == .readyToRun) + + if appState == .idle || appState == .creatingRun { + Button(action: createRun) { + HStack { + if appState == .creatingRun { ProgressView().padding(.trailing, 5) } + Text("Lauf in DB registrieren") + } + } + .disabled(eventName.isEmpty || appState == .creatingRun) + } else { + HStack { + Image(systemName: "checkmark.circle.fill").foregroundColor(.green) + Text("ID: \(runID?.prefix(8) ?? "")...") + .font(.caption).monospaced() + Spacer() + Button("Reset") { resetSetup() } + .font(.caption) + .foregroundColor(.red) + } + } + } + + // SEKTION 2: TRACKING + Section(header: Text("Action")) { + Button(action: toggleTracking) { + HStack { + Image(systemName: appState == .tracking ? "stop.fill" : "play.fill") + Text(appState == .tracking ? "Tracking Stoppen" : "JETZT STARTEN") + } + .frame(maxWidth: .infinity, minHeight: 44) + } + .buttonStyle(.borderedProminent) + .tint(appState == .tracking ? .red : .green) + .disabled(appState != .readyToRun && appState != .tracking) + + if appState == .tracking { + HStack { + VStack(alignment: .leading) { + Text("Lat: \(trackingManager.lastLocation?.coordinate.latitude ?? 0, specifier: "%.5f")") + Text("Lon: \(trackingManager.lastLocation?.coordinate.longitude ?? 0, specifier: "%.5f")") + } + .font(.caption).monospaced() + Spacer() + ProgressView() + } + } + } + } + .navigationTitle("Runner Tracker") } - .padding() + } + + // --- Logik Funktionen --- + + func createRun() { + appState = .creatingRun + + // Hier dein API Call an /runs/create + APIService.shared.createRun(eventName: eventName, type: selectedType) { result in + DispatchQueue.main.async { + switch result { + case .success(let id): + self.runID = id + self.appState = .readyToRun + print("✅ Run bereit mit ID: \(id)") + case .failure(let error): + print("❌ Fehler: \(error.localizedDescription)") + self.appState = .idle + } + } + } + } + + func toggleTracking() { + if appState == .tracking { + // Greife direkt auf das Objekt zu, NICHT auf das Binding (kein $) + trackingManager.stop() + appState = .idle + runID = nil + } else { + guard let id = runID else { return } + trackingManager.start(runID: id) + appState = .tracking + } + } + + func resetSetup() { + appState = .idle + runID = nil } } - -#Preview { - ContentView() -} diff --git a/RunnerTracker/Trackingmanager.swift b/RunnerTracker/Trackingmanager.swift new file mode 100644 index 0000000..6a9cdfe --- /dev/null +++ b/RunnerTracker/Trackingmanager.swift @@ -0,0 +1,92 @@ +import Foundation +import CoreLocation +import SwiftUI +import Combine + +class TrackingManager: NSObject, ObservableObject, CLLocationManagerDelegate { + + // Mit @Published signalisierst du der UI, wenn sich etwas ändert + @Published var lastLocation: CLLocation? + + private let locationManager = CLLocationManager() + private var timer: Timer? + var currentRunID: String? + + override init() { + super.init() + locationManager.delegate = self + locationManager.desiredAccuracy = kCLLocationAccuracyBest + locationManager.allowsBackgroundLocationUpdates = true + locationManager.pausesLocationUpdatesAutomatically = false + } + + func start(runID: String) { + print("🚀 TrackingManager gestartet mit Run-ID: \(runID)") + self.currentRunID = runID + locationManager.requestAlwaysAuthorization() + locationManager.startUpdatingLocation() + + // Timer für den 5-Sekunden-Takt + timer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { _ in + self.sendCurrentLocation() + } + } + + func stop() { + print("🛑 TrackingManager gestoppt") + locationManager.stopUpdatingLocation() + timer?.invalidate() + } + + private func sendCurrentLocation() { + guard let location = lastLocation else { + print("⏳ Warte auf GPS-Signal (lastLocation ist noch nil)...") + return + } + guard let rID = currentRunID else { + print("❌ Fehler: Keine currentRunID vorhanden") + return + } + + let base = (Bundle.main.object(forInfoDictionaryKey: "BaseUrl") as? String) ?? "" + guard let url = URL(string: base+"/logs/gps") else { return } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue((Bundle.main.object(forInfoDictionaryKey: "WriteKey") as? String) ?? "", forHTTPHeaderField: "X-API-KEY") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + let logData: [String: Any] = [ + "run_id": rID, + "lat": location.coordinate.latitude, + "lon": location.coordinate.longitude, + "alt": location.altitude, + "timestamp": ISO8601DateFormatter().string(from: Date()) + ] + + request.httpBody = try? JSONSerialization.data(withJSONObject: logData) + + URLSession.shared.dataTask(with: request) { data, response, error in + if let error = error { + print("❌ Netzwerk-Fehler: \(error.localizedDescription)") + return + } + + if let httpResponse = response as? HTTPURLResponse { + print("🌍 Pi-Antwort: Status \(httpResponse.statusCode)") + + // Falls es kein Erfolg (200-299) war, drucken wir die Fehlermeldung vom Pi + if !(200...299).contains(httpResponse.statusCode), let data = data { + let msg = String(data: data, encoding: .utf8) ?? "Keine Fehlerdetails" + print("📩 Pi-Fehlermeldung: \(msg)") + } else if httpResponse.statusCode == 200 { + print("✅ Datenpunkt erfolgreich an Pi gesendet") + } + } + }.resume() + } + + func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { + self.lastLocation = locations.last + } +} diff --git a/RunnerTracker/config.xcconfig b/RunnerTracker/config.xcconfig new file mode 100644 index 0000000..b10e801 --- /dev/null +++ b/RunnerTracker/config.xcconfig @@ -0,0 +1,10 @@ +// +// config.xcconfig +// RunnerTracker +// +// Created by Markus Benjamin Tabler on 28.04.26. +// + +API_URL = https:/$()/runnerapi.bybenji.de +WRITE_API_KEY = f27e1dffd25507c25955629b43313a20823276c0c0f64734f572ac5c7d6d1346 +USER_ID = a0e100c0-42e9-11f1-b48c-b827ebe636d1