diff --git a/BanYaroGo-Info.plist b/BanYaroGo-Info.plist new file mode 100644 index 0000000..5fc0d71 --- /dev/null +++ b/BanYaroGo-Info.plist @@ -0,0 +1,56 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Ban Yaro Go + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + LSRequiresIPhoneOS + + UILaunchScreen + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + + UIApplicationSupportsIndirectInputEvents + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + NSLocationWhenInUseUsageDescription + Wir brauchen deinen Standort, um deine Gassi-Tour als Karte aufzuzeichnen. + NSLocationAlwaysAndWhenInUseUsageDescription + Damit deine Gassi-Tour auch bei gesperrtem Bildschirm weiter aufgezeichnet wird, brauchen wir Standortzugriff im Hintergrund. + UIBackgroundModes + + location + + + diff --git a/BanYaroGo.xcodeproj/project.pbxproj b/BanYaroGo.xcodeproj/project.pbxproj index 98b9327..497db00 100644 --- a/BanYaroGo.xcodeproj/project.pbxproj +++ b/BanYaroGo.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 77; + objectVersion = 70; objects = { /* Begin PBXFileReference section */ @@ -11,11 +11,7 @@ /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ - AA0000000000000000000006 /* BanYaroGo */ = { - isa = PBXFileSystemSynchronizedRootGroup; - path = BanYaroGo; - sourceTree = ""; - }; + AA0000000000000000000006 /* BanYaroGo */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = BanYaroGo; sourceTree = ""; }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -244,14 +240,10 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = H436BR6YWX; 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"; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = "BanYaroGo-Info.plist"; IPHONEOS_DEPLOYMENT_TARGET = 18.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -273,14 +265,10 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = H436BR6YWX; 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"; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = "BanYaroGo-Info.plist"; IPHONEOS_DEPLOYMENT_TARGET = 18.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", diff --git a/BanYaroGo/API/APIClient.swift b/BanYaroGo/API/APIClient.swift index efca916..ecc5995 100644 --- a/BanYaroGo/API/APIClient.swift +++ b/BanYaroGo/API/APIClient.swift @@ -14,6 +14,7 @@ final class APIClient { }() private let encoder: JSONEncoder = { let e = JSONEncoder() + e.keyEncodingStrategy = .convertToSnakeCase return e }() diff --git a/BanYaroGo/API/DTOs.swift b/BanYaroGo/API/DTOs.swift index caf817d..99c534d 100644 --- a/BanYaroGo/API/DTOs.swift +++ b/BanYaroGo/API/DTOs.swift @@ -79,3 +79,12 @@ struct RouteDetail: Decodable, Identifiable { let userName: String? let dogIds: [Int]? } + +struct RouteCreateBody: Encodable { + let name: String + let gpsTrack: [GPSPoint] + let distanzKm: Double + let dauerMin: Int + let dogIds: [Int] + let isPublic: Bool +} diff --git a/BanYaroGo/Assets.xcassets/AppIcon.appiconset/AppIcon.png b/BanYaroGo/Assets.xcassets/AppIcon.appiconset/AppIcon.png index 5e71d5a..ab202a8 100644 Binary files a/BanYaroGo/Assets.xcassets/AppIcon.appiconset/AppIcon.png and b/BanYaroGo/Assets.xcassets/AppIcon.appiconset/AppIcon.png differ diff --git a/BanYaroGo/Tracking/LocationTracker.swift b/BanYaroGo/Tracking/LocationTracker.swift new file mode 100644 index 0000000..d19af16 --- /dev/null +++ b/BanYaroGo/Tracking/LocationTracker.swift @@ -0,0 +1,127 @@ +import Foundation +import Observation +import CoreLocation + +/// Wraps CLLocationManager for live Gassi-Tracking with background updates. +/// +/// Usage: +/// 1. `startOrRequest()` — handles permission flow + starts recording when granted. +/// 2. Watch `isTracking`, `points`, `totalDistanceMeters`, `startedAt`. +/// 3. `stop()` to end the recording and inspect the final `points`. +@Observable +@MainActor +final class LocationTracker: NSObject, CLLocationManagerDelegate { + private let manager = CLLocationManager() + + var points: [GPSPoint] = [] + var isTracking: Bool = false + var startedAt: Date? + var authorizationStatus: CLAuthorizationStatus + var permissionDenied: Bool = false + + /// Set when the user asked to start but we're still waiting for the system + /// permission prompt. The delegate auto-starts as soon as access is granted. + private var pendingStart: Bool = false + + override init() { + self.authorizationStatus = manager.authorizationStatus + super.init() + manager.delegate = self + manager.desiredAccuracy = kCLLocationAccuracyBestForNavigation + manager.distanceFilter = 5 // meters + manager.activityType = .fitness + manager.pausesLocationUpdatesAutomatically = false + } + + // MARK: - Public API + + func startOrRequest() { + permissionDenied = false + switch authorizationStatus { + case .notDetermined: + pendingStart = true + manager.requestWhenInUseAuthorization() + case .denied, .restricted: + permissionDenied = true + case .authorizedWhenInUse, .authorizedAlways: + beginTracking() + @unknown default: + permissionDenied = true + } + } + + func stop() { + manager.stopUpdatingLocation() + manager.allowsBackgroundLocationUpdates = false + isTracking = false + } + + /// Total distance walked so far, in meters. + var totalDistanceMeters: Double { + guard points.count >= 2 else { return 0 } + var total: Double = 0 + for i in 1..= 0 && $0.horizontalAccuracy < 30 } + .map { GPSPoint(lat: $0.coordinate.latitude, lon: $0.coordinate.longitude, alt: $0.altitude) } + guard !newPoints.isEmpty else { return } + Task { @MainActor in + self.points.append(contentsOf: newPoints) + } + } + + nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { + let status = manager.authorizationStatus + Task { @MainActor in + self.authorizationStatus = status + switch status { + case .denied, .restricted: + self.pendingStart = false + self.permissionDenied = true + case .authorizedWhenInUse, .authorizedAlways: + if self.pendingStart { + self.pendingStart = false + self.beginTracking() + } + default: + break + } + } + } + + nonisolated func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { + // Transient errors (e.g. no fix yet) are common — we just keep listening. + // Log to console so we can see during development. + print("LocationTracker error: \(error)") + } +} diff --git a/BanYaroGo/Views/FinishWalkSheet.swift b/BanYaroGo/Views/FinishWalkSheet.swift new file mode 100644 index 0000000..8ee069f --- /dev/null +++ b/BanYaroGo/Views/FinishWalkSheet.swift @@ -0,0 +1,172 @@ +import SwiftUI + +struct FinishWalkSheet: View { + let points: [GPSPoint] + let durationSeconds: Int + let distanceMeters: Double + let onDiscard: () -> Void + let onSaved: () -> Void + + @Environment(\.dismiss) private var dismiss + + @State private var name: String + @State private var selectedDogIds: Set = [] + @State private var dogs: [Dog] = [] + @State private var isLoadingDogs = false + @State private var isSaving = false + @State private var errorMessage: String? + + init( + points: [GPSPoint], + durationSeconds: Int, + distanceMeters: Double, + onDiscard: @escaping () -> Void, + onSaved: @escaping () -> Void + ) { + self.points = points + self.durationSeconds = durationSeconds + self.distanceMeters = distanceMeters + self.onDiscard = onDiscard + self.onSaved = onSaved + + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "de_DE") + formatter.dateStyle = .medium + _name = State(initialValue: "Gassi am \(formatter.string(from: .now))") + } + + var body: some View { + NavigationStack { + Form { + Section("Stats") { + LabeledContent("Distanz", value: String(format: "%.2f km", distanceMeters / 1000)) + LabeledContent("Dauer", value: durationLabel) + LabeledContent("Punkte", value: "\(points.count)") + } + + Section("Name") { + TextField("Name der Tour", text: $name) + } + + Section("Hunde") { + if isLoadingDogs && dogs.isEmpty { + HStack { ProgressView(); Text("Lade Hunde…") } + } else if dogs.isEmpty { + Text("Keine Hunde gefunden. Wird gespeichert ohne Hund-Zuordnung.") + .font(.footnote) + .foregroundStyle(.secondary) + } else { + ForEach(dogs) { dog in + dogRow(dog) + } + } + } + + if let errorMessage { + Section { + Text(errorMessage) + .font(.footnote) + .foregroundStyle(.red) + } + } + } + .navigationTitle("Tour speichern") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Verwerfen", role: .destructive) { + onDiscard() + dismiss() + } + } + ToolbarItem(placement: .confirmationAction) { + if isSaving { + ProgressView() + } else { + Button("Speichern") { + Task { await save() } + } + .disabled(canSave == false) + } + } + } + .task { await loadDogs() } + .interactiveDismissDisabled(isSaving) + } + } + + private func dogRow(_ dog: Dog) -> some View { + let selected = selectedDogIds.contains(dog.id) + return HStack { + Image(systemName: selected ? "checkmark.circle.fill" : "circle") + .foregroundStyle(selected ? Color.accentColor : .secondary) + VStack(alignment: .leading) { + Text(dog.name) + if let rasse = dog.rasse, !rasse.isEmpty { + Text(rasse).font(.caption).foregroundStyle(.secondary) + } + } + Spacer() + } + .contentShape(Rectangle()) + .onTapGesture { + if selected { + selectedDogIds.remove(dog.id) + } else { + selectedDogIds.insert(dog.id) + } + } + } + + private var durationLabel: String { + let mins = durationSeconds / 60 + let secs = durationSeconds % 60 + if mins >= 60 { + return "\(mins / 60) h \(mins % 60) min" + } + return "\(mins) min \(secs) s" + } + + private var canSave: Bool { + !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + && points.count >= 2 + && !isSaving + } + + private func loadDogs() async { + isLoadingDogs = true + defer { isLoadingDogs = false } + do { + let fetched: [Dog] = try await APIClient.shared.get("/api/dogs") + self.dogs = fetched + // If there's exactly one dog, pre-select it — saves a tap. + if fetched.count == 1 { + selectedDogIds = [fetched[0].id] + } + } catch { + // Fall back gracefully — user can still save without dogs. + print("FinishWalkSheet loadDogs failed: \(error)") + } + } + + private func save() async { + isSaving = true + errorMessage = nil + defer { isSaving = false } + let body = RouteCreateBody( + name: name.trimmingCharacters(in: .whitespacesAndNewlines), + gpsTrack: points, + distanzKm: distanceMeters / 1000, + dauerMin: max(1, durationSeconds / 60), + dogIds: Array(selectedDogIds), + isPublic: false + ) + do { + let _: RouteDetail = try await APIClient.shared.post("/api/routes", body: body) + onSaved() + dismiss() + } catch { + errorMessage = error.localizedDescription + } + } +} diff --git a/BanYaroGo/Views/MainTabView.swift b/BanYaroGo/Views/MainTabView.swift index fb10ad4..3f3bcc8 100644 --- a/BanYaroGo/Views/MainTabView.swift +++ b/BanYaroGo/Views/MainTabView.swift @@ -8,6 +8,9 @@ struct MainTabView: View { RoutesListView() .tabItem { Label("Touren", systemImage: "map.fill") } + TrackingView() + .tabItem { Label("Aufnehmen", systemImage: "figure.walk") } + DogsListView() .tabItem { Label("Hunde", systemImage: "pawprint.fill") } diff --git a/BanYaroGo/Views/TrackingView.swift b/BanYaroGo/Views/TrackingView.swift new file mode 100644 index 0000000..46c0bb3 --- /dev/null +++ b/BanYaroGo/Views/TrackingView.swift @@ -0,0 +1,176 @@ +import SwiftUI +import MapKit + +struct TrackingView: View { + @State private var tracker = LocationTracker() + @State private var now: Date = .now + @State private var showFinishSheet = false + + private let ticker = Timer.publish(every: 1, on: .main, in: .common).autoconnect() + + var body: some View { + NavigationStack { + Group { + if tracker.isTracking { + activeTracking + } else { + startScreen + } + } + .navigationTitle("Aufnehmen") + .navigationBarTitleDisplayMode(.inline) + } + .onReceive(ticker) { now = $0 } + .sheet(isPresented: $showFinishSheet) { + FinishWalkSheet( + points: tracker.points, + durationSeconds: durationSeconds, + distanceMeters: tracker.totalDistanceMeters, + onDiscard: { resetTracker() }, + onSaved: { resetTracker() } + ) + } + } + + // MARK: - Active tracking + + private var activeTracking: some View { + ZStack(alignment: .top) { + Map { + UserAnnotation() + if tracker.points.count >= 2 { + MapPolyline(coordinates: tracker.points.map { + CLLocationCoordinate2D(latitude: $0.lat, longitude: $0.lon) + }) + .stroke(Color.accentColor, style: StrokeStyle(lineWidth: 5, lineJoin: .round)) + } + } + .mapStyle(.standard(elevation: .flat, pointsOfInterest: .excludingAll)) + .mapControlVisibility(.hidden) + .ignoresSafeArea(edges: .bottom) + + VStack { + statsCard + Spacer() + stopButton + .padding(.bottom, 12) + } + .padding(.horizontal) + } + } + + private var statsCard: some View { + HStack(spacing: 0) { + stat(value: String(format: "%.2f", tracker.totalDistanceMeters / 1000), unit: "km", label: "Distanz") + divider + stat(value: formatDuration(durationSeconds), unit: "", label: "Dauer") + divider + stat(value: "\(tracker.points.count)", unit: "", label: "Punkte") + } + .padding() + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16)) + } + + private var divider: some View { + Divider().frame(height: 36) + } + + private func stat(value: String, unit: String, label: String) -> some View { + VStack(spacing: 4) { + HStack(alignment: .firstTextBaseline, spacing: 2) { + Text(value).font(.title2.bold().monospacedDigit()) + if !unit.isEmpty { + Text(unit).font(.callout).foregroundStyle(.secondary) + } + } + Text(label).font(.caption).foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity) + } + + private var stopButton: some View { + Button { + tracker.stop() + showFinishSheet = true + } label: { + HStack { + Image(systemName: "stop.circle.fill") + Text("Aufnahme stoppen").bold() + } + .frame(maxWidth: .infinity, minHeight: 56) + } + .background(.red, in: Capsule()) + .foregroundStyle(.white) + } + + // MARK: - Start screen + + private var startScreen: some View { + VStack(spacing: 28) { + Spacer() + Image(systemName: "figure.walk.circle.fill") + .font(.system(size: 96)) + .foregroundStyle(Color.accentColor) + VStack(spacing: 6) { + Text("Bereit für die nächste Gassi?") + .font(.title3.bold()) + Text("Tippe auf Start, um deine Tour aufzuzeichnen — auch wenn dein iPhone gesperrt ist.") + .multilineTextAlignment(.center) + .foregroundStyle(.secondary) + .padding(.horizontal) + } + + if tracker.permissionDenied { + permissionWarning + } + + Button { + tracker.startOrRequest() + } label: { + HStack { + Image(systemName: "play.fill") + Text("Aufnahme starten").bold() + } + .frame(maxWidth: .infinity, minHeight: 56) + } + .background(Color.accentColor, in: Capsule()) + .foregroundStyle(.white) + .padding(.horizontal) + + Spacer() + } + } + + private var permissionWarning: some View { + VStack(spacing: 6) { + Label("Standortzugriff fehlt", systemImage: "location.slash.fill") + .font(.subheadline.bold()) + .foregroundStyle(.red) + Text("Bitte erlaube den Standortzugriff in den iOS-Einstellungen unter Datenschutz → Ortungsdienste → Ban Yaro Go.") + .font(.footnote) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal) + } + } + + // MARK: - Helpers + + private var durationSeconds: Int { + guard let startedAt = tracker.startedAt else { return 0 } + return Int(now.timeIntervalSince(startedAt)) + } + + private func formatDuration(_ seconds: Int) -> String { + let h = seconds / 3600 + let m = (seconds % 3600) / 60 + let s = seconds % 60 + if h > 0 { return String(format: "%d:%02d:%02d", h, m, s) } + return String(format: "%d:%02d", m, s) + } + + private func resetTracker() { + // Fresh tracker for the next walk; old `points` arrays are released with it. + tracker = LocationTracker() + } +} diff --git a/icon/icon.png b/icon/icon.png new file mode 100644 index 0000000..43035bd Binary files /dev/null and b/icon/icon.png differ