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