diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..1d8cff7 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,79 @@ +# HealthKitManager - AI Agent Instructions + +## Project Overview +SwiftUI-based iOS app that tracks and visualizes walking/running steps, distances, and cycling activities from Apple HealthKit. Features include a gamified Elbe river route progress tracker and a yearly habit calendar. + +## Architecture + +### Core Components +- **HealthKitManager.swift**: Central `ObservableObject` managing all HealthKit queries and data aggregation + - Publishes separate states for daily/yearly metrics (`dailySteps`, `yearlySteps`, etc.) + - Uses `HKObserverQuery` for real-time updates via `observeStepChanges()` and `observeCyclingChanges()` + - Data source attribution tracked in dictionaries: `stepSources`, `distanceSources`, `cyclingSources` + +- **ContentView.swift**: Main tabbed interface with 3 views + - Tab 1: Health data dashboard with today/thisYear picker + - Tab 2: Elbe river progress chart (`ElbeProgressVerticalChartView`) + - Tab 3: Habit calendar (`HabitCalendarView`) + +- **ElbeProgressLineChart.swift**: Visual journey along 1300km Elbe river route + - Milestone-based progress from Elbquelle to Cuxhaven + - Combines `yearlyDistance` and `yearlyCyclingDistance` for total progress + - Uses SwiftUI Charts with dynamic annotations + +- **HabitCalendarView.swift**: Year-at-a-glance habit tracker + - Persistent storage via `@AppStorage("completedDaysRaw")` as JSON string + - 12-month grid with toggleable completion status per day + +## Key Patterns + +### Data Fetching +- Always use `HKQuery.predicateForSamples(withStart:end:options:)` with `.strictStartDate` +- Fetch data via `HKStatisticsQuery` with `.cumulativeSum` option +- Update UI on `DispatchQueue.main.async` after queries complete + +### Localization +- All user-facing strings use `NSLocalizedString()` or `LocalizedStringKey` +- String keys defined in `Localizable.xcstrings` (e.g., "steps", "distance", "time_period") + +### State Management +- Health data flows: HealthKit → HealthKitManager (ObservableObject) → Views +- UI uses `@StateObject` for HealthKitManager, `@Binding` for child view data passing +- Habit calendar persists to UserDefaults via `@AppStorage` + +### HealthKit Authorization +Required entitlements in `HealthKitManager.entitlements`: +- `com.apple.developer.healthkit` +- `com.apple.developer.healthkit.access` +- `com.apple.developer.healthkit.background-delivery` + +## Development Workflow + +### Building & Running +- Open `HealthKitManager.xcodeproj` in Xcode +- Requires physical iOS device or simulator with HealthKit support +- Test HealthKit integration requires sample health data or actual device data + +### Adding Health Metrics +1. Add quantity type to `HealthKitManager.init()` (e.g., `HKQuantityType.quantityType(forIdentifier: .heartRate)`) +2. Request in `requestAuthorization()` typesToRead set +3. Create fetch method following `fetchDailyCyclingData()` pattern +4. Add `@Published` properties for new metric +5. Update UI in ContentView with conditional display based on `selectedPeriod` + +### Styling Conventions +- Use `.opacity(0.8)` for field backgrounds +- Background images set via `Image("backgroundImage2").resizable().scaledToFill().ignoresSafeArea()` +- Color scheme adapts to dark mode using `UIColor { $0.userInterfaceStyle == .dark ? ... }` + +## Important Notes +- Distance values stored in meters in HealthKit, displayed as km (divide by 1000) +- Time periods enum duplicated in ContentView.swift and HealthKitManager.swift - keep synchronized +- Elbe milestones array hardcoded - modify in `ElbeProgressVerticalChartView` if route changes +- Calendar view always shows current year only (`currentYear` computed at view creation) +- **Widget Extension**: Framework configured but not implemented - `HealthManagerWidgetExtension.entitlements` exists as placeholder + +## Testing +- Uses standard XCTest framework via `HealthKitManagerTests` and `HealthKitManagerUITests` targets +- HealthKit queries should be tested with mock data or actual device samples +- No HealthKit mocking infrastructure currently in place diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..bf01c5d --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,4 @@ +{ + "lldb.library": "/Applications/Xcode.app/Contents/SharedFrameworks/LLDB.framework/Versions/A/LLDB", + "lldb.launch.expressions": "native" +} \ No newline at end of file diff --git a/HealthKitManager.xcodeproj/project.pbxproj b/HealthKitManager.xcodeproj/project.pbxproj index 7779219..447d158 100644 --- a/HealthKitManager.xcodeproj/project.pbxproj +++ b/HealthKitManager.xcodeproj/project.pbxproj @@ -357,6 +357,9 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; + INFOPLIST_KEY_NSHealthClinicalHealthRecordsShareUsageDescription = "Diese App benötigt Zugriff, um Gesundheitsdaten zu aktualisieren."; + INFOPLIST_KEY_NSHealthShareUsageDescription = "Diese App benötigt Zugriff, um Gesundheitsdaten zu aktualisieren."; + INFOPLIST_KEY_NSHealthUpdateUsageDescription = "Diese App benötigt Zugriff, um Gesundheitsdaten zu aktualisieren."; IPHONEOS_DEPLOYMENT_TARGET = 18.2; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; @@ -415,6 +418,9 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; + INFOPLIST_KEY_NSHealthClinicalHealthRecordsShareUsageDescription = "Diese App benötigt Zugriff, um Gesundheitsdaten zu aktualisieren."; + INFOPLIST_KEY_NSHealthShareUsageDescription = "Diese App benötigt Zugriff, um Gesundheitsdaten zu aktualisieren."; + INFOPLIST_KEY_NSHealthUpdateUsageDescription = "Diese App benötigt Zugriff, um Gesundheitsdaten zu aktualisieren."; IPHONEOS_DEPLOYMENT_TARGET = 18.2; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MTL_ENABLE_DEBUG_INFO = NO; @@ -438,7 +444,9 @@ DEVELOPMENT_TEAM = 5TJCALMQQ8; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_NSHealthClinicalHealthRecordsShareUsageDescription = "Diese App benötigt Zugriff, um Gesundheitsdaten zu aktualisieren."; INFOPLIST_KEY_NSHealthShareUsageDescription = "This app reads your step count and distance walked from HealthKit."; + INFOPLIST_KEY_NSHealthUpdateUsageDescription = "Diese App benötigt Zugriff, um Gesundheitsdaten zu aktualisieren."; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; @@ -451,9 +459,13 @@ MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = "de.jan-gaehler.HealthKitManager"; 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,2"; + TARGETED_DEVICE_FAMILY = 1; }; name = Debug; }; @@ -469,7 +481,9 @@ DEVELOPMENT_TEAM = 5TJCALMQQ8; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_NSHealthClinicalHealthRecordsShareUsageDescription = "Diese App benötigt Zugriff, um Gesundheitsdaten zu aktualisieren."; INFOPLIST_KEY_NSHealthShareUsageDescription = "This app reads your step count and distance walked from HealthKit."; + INFOPLIST_KEY_NSHealthUpdateUsageDescription = "Diese App benötigt Zugriff, um Gesundheitsdaten zu aktualisieren."; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; @@ -482,9 +496,13 @@ MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = "de.jan-gaehler.HealthKitManager"; 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,2"; + TARGETED_DEVICE_FAMILY = 1; }; name = Release; }; diff --git a/HealthKitManager/AppTheme.swift b/HealthKitManager/AppTheme.swift new file mode 100644 index 0000000..64c094b --- /dev/null +++ b/HealthKitManager/AppTheme.swift @@ -0,0 +1,53 @@ +// +// AppTheme.swift +// HealthKitManager +// +// Theme colors and styles for the app +// + +import SwiftUI + +extension Color { + // MARK: - Elbe Chart Colors (2025) + static let elbePathCompleted = Color.yellow + static let elbeMilestone = Color.orange + static let elbeFigure = Color(UIColor { $0.userInterfaceStyle == .dark ? .white : .orange }) + static let elbeBicycle = Color(UIColor { $0.userInterfaceStyle == .dark ? .yellow : .black }) + + // MARK: - Rhein Chart Colors (2026) + static let rheinPathCompleted = Color(red: 0.2, green: 0.5, blue: 0.8) + static let rheinMilestone = Color(red: 0.4, green: 0.7, blue: 0.95) + static let rheinFigure = Color(UIColor { $0.userInterfaceStyle == .dark ? .white : UIColor(red: 0.2, green: 0.5, blue: 0.8, alpha: 1.0) }) + static let rheinBicycle = Color(UIColor { $0.userInterfaceStyle == .dark ? UIColor(red: 0.5, green: 0.7, blue: 0.9, alpha: 1.0) : UIColor(red: 0.15, green: 0.4, blue: 0.7, alpha: 1.0) }) + + // MARK: - UI Background Colors + static let fieldBackground = Color(.systemGray6).opacity(0.8) +} + +// MARK: - Chart Theme Configuration +struct ChartTheme { + let pathColor: Color + let milestoneColor: Color + let figureColor: Color + let bicycleColor: Color + let axisColor: Color + let remainingPathColor: Color + + static let elbe = ChartTheme( + pathColor: .elbePathCompleted, + milestoneColor: .elbeMilestone, + figureColor: .elbeFigure, + bicycleColor: .elbeBicycle, + axisColor: .orange.opacity(0.6), + remainingPathColor: .gray.opacity(0.3) + ) + + static let rhein = ChartTheme( + pathColor: .rheinPathCompleted, + milestoneColor: .rheinMilestone, + figureColor: .rheinFigure, + bicycleColor: .rheinBicycle, + axisColor: Color(red: 0.4, green: 0.7, blue: 0.95).opacity(0.6), + remainingPathColor: .gray.opacity(0.8) + ) +} diff --git a/HealthKitManager/ContentView.swift b/HealthKitManager/ContentView.swift index b285296..f1b00a2 100644 --- a/HealthKitManager/ContentView.swift +++ b/HealthKitManager/ContentView.swift @@ -4,8 +4,6 @@ struct ContentView: View { @StateObject private var healthKitManager = HealthKitManager() @State private var selectedPeriod: TimePeriod = .today - let fieldBackgroundColor: Color = Color(.systemGray6).opacity(0.8) - var body: some View { TabView { VStack(spacing: 20) { @@ -26,7 +24,7 @@ struct ContentView: View { .padding() .background( RoundedRectangle(cornerRadius: 10) - .fill(fieldBackgroundColor) + .fill(Color.fieldBackground) ) .foregroundColor(.white) @@ -57,7 +55,10 @@ struct ContentView: View { } else { HealthDataView( title: NSLocalizedString("steps", comment: ""), - value: "\(healthKitManager.yearlySteps)" + value: String( + format: "%d", + healthKitManager.yearlySteps + ) ) HealthDataView( title: NSLocalizedString("distance", comment: ""), @@ -82,12 +83,23 @@ struct ContentView: View { .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) // ✅ Fixes VStack to the top .padding() - // 📊 Fortschrittsansicht (Elbe-Radweg) + // 📊 Fortschrittsansicht (Rhein-Radweg 2026) + RheinProgressVerticalChartView( + yearlyDistance: $healthKitManager.year2026Distance, + yearlyCyclingDistance: $healthKitManager.year2026CyclingDistance + ) + .padding() + + // 📊 Fortschrittsansicht (Elbe-Radweg 2025) ElbeProgressVerticalChartView( - yearlyDistance: $healthKitManager.yearlyDistance, - yearlyCyclingDistance: $healthKitManager.yearlyCyclingDistance + yearlyDistance: $healthKitManager.year2025Distance, + yearlyCyclingDistance: $healthKitManager.year2025CyclingDistance ) .padding() + + HabitCalendarView().tabItem { + Label("Ziele", systemImage: "calendar") + } } .tabViewStyle(.page) .background( @@ -115,7 +127,7 @@ struct HealthDataView: View { .padding() .background( RoundedRectangle(cornerRadius: 10) - .fill(Color(.systemGray6).opacity(0.8)) + .fill(Color.fieldBackground) ) } } @@ -142,7 +154,7 @@ struct SourceListView: View { .padding() .background( RoundedRectangle(cornerRadius: 10) - .fill(Color(.systemGray6).opacity(0.8)) + .fill(Color.fieldBackground) ) } @@ -160,3 +172,10 @@ enum TimePeriod { case today, thisYear } +struct ContentView_Previews: PreviewProvider { + static var previews: some View { + ContentView() + } + +} + diff --git a/HealthKitManager/ElbeProgressLineChart.swift b/HealthKitManager/ElbeProgressLineChart.swift index 4b24182..b05b78f 100644 --- a/HealthKitManager/ElbeProgressLineChart.swift +++ b/HealthKitManager/ElbeProgressLineChart.swift @@ -4,6 +4,8 @@ import SwiftUI struct ElbeProgressVerticalChartView: View { @Binding var yearlyDistance: Double @Binding var yearlyCyclingDistance: Double + + let theme = ChartTheme.elbe let milestones: [(distance: Double, location: String)] = [ (0, "Elbquelle"), @@ -28,19 +30,31 @@ struct ElbeProgressVerticalChartView: View { (1150, "Stade"), (1181, "Wischhafen/Glückstadt"), (1239, "Cuxhaven (Bahnhof)"), - (1300, "Ziel") + (1300, "Ziel"), + (1500, "Nordsee") ] - let yAxisValues: [Int] = Array(stride(from: 0, through: 1300, by: 100)) + let yAxisValues: [Int] = Array(stride(from: 0, through: 1500, by: 100)) fileprivate func getCompletePath() -> ForEach<[(distance: Double, location: String)], Double, some ChartContent> { - return // Completed Path (Blue) + return // Completed Path ForEach(milestones.filter { $0.distance <= yearlyDistance }, id: \.distance) { milestone in LineMark( x: .value("Meilenstein", 1), y: .value("Distanz", milestone.distance) ) - .foregroundStyle(Color.yellow) + .foregroundStyle(theme.pathColor) + } + } + + fileprivate func getRemainingPath() -> ForEach<[(distance: Double, location: String)], Double, some ChartContent> { + return // Remaining Path + ForEach(milestones.filter { $0.distance > yearlyDistance }, id: \.distance) { milestone in + LineMark( + x: .value("Meilenstein", 1), + y: .value("Distanz", milestone.distance) + ) + .foregroundStyle(theme.remainingPathColor) } } @@ -51,7 +65,7 @@ struct ElbeProgressVerticalChartView: View { x: .value("Meilenstein", 1), y: .value("Distanz", milestone.distance) ) - .foregroundStyle(.orange) + .foregroundStyle(theme.milestoneColor) .annotation(position: .trailing, alignment: .leading) { Text(milestone.location) .font(.caption) @@ -74,53 +88,62 @@ struct ElbeProgressVerticalChartView: View { ) } + fileprivate func setFigureColor() -> Color { + return theme.figureColor + } + + fileprivate func setBicycleColor() -> Color { + return theme.bicycleColor + } + var body: some View { ZStack { VStack { GroupBox( label: HStack { Spacer() - Text("Dein Fortschritt entlang der Elbe") + Text("Dein Fortschritt an der Elbe 2025") Spacer() }.font(.headline) ) { Chart { + getRemainingPath() getCompletePath() getMilestonePoints() setDynamicProgressPoint() .symbolSize(60) - .foregroundStyle(.indigo) + .foregroundStyle(setFigureColor()) .annotation(position: .leading, alignment: .trailing) { Image(systemName: "figure.walk") .font(.system(size: 20)) // Größe ändern - .foregroundColor(.indigo) + .foregroundColor(setFigureColor()) .padding(5) } setCyclingProgressPoint() .symbolSize(40) - .foregroundStyle(.black) + .foregroundStyle(setBicycleColor()) .annotation(position: .leading, alignment: .trailing) { Image(systemName: "bicycle") .font(.system(size: 20)) // Größe ändern - .foregroundColor(.black) + .foregroundColor(setBicycleColor()) .padding(5) } } .chartXAxis(.hidden) .chartYAxis { AxisMarks(position: .leading, values: yAxisValues) { - AxisGridLine() - AxisTick() - AxisValueLabel() + AxisGridLine().foregroundStyle(theme.axisColor) + AxisTick().foregroundStyle(theme.axisColor) + AxisValueLabel().foregroundStyle(theme.axisColor) } } - .chartYScale(domain: 0 ... 1300) + .chartYScale(domain: 0 ... 1550) .frame(height: 600) .padding(20) VStack { - Text("\(Double(yearlyDistance / 1000).formatted(.number.locale(Locale(identifier: "de_DE")).precision(.fractionLength(2)))) km von 1.300 km") + Text("\(Double(yearlyDistance / 1000).formatted(.number.locale(Locale(identifier: "de_DE")).precision(.fractionLength(2)))) km von 1.500 km") .font(.headline) Text("\(Double(yearlyCyclingDistance / 1000).formatted(.number.locale(Locale(identifier: "de_DE")).precision(.fractionLength(2)))) km Fahrrad") diff --git a/HealthKitManager/HabitCalendarView.swift b/HealthKitManager/HabitCalendarView.swift new file mode 100644 index 0000000..2afbf4f --- /dev/null +++ b/HealthKitManager/HabitCalendarView.swift @@ -0,0 +1,119 @@ +// +// HabitCalendarView.swift +// HealthKitManager +// +// Created by Jan Gähler on 15.07.25. +// + +import SwiftUI + +struct HabitCalendarView: View { + @AppStorage("completedDaysRaw") private var completedDaysRaw: String = "{}" + + private let calendar = Calendar.current + private let currentYear = Calendar.current.component(.year, from: Date()) + private let columns = Array(repeating: GridItem(.flexible(), spacing: 4), count: 7) + + var body: some View { + ScrollView(.vertical) { + VStack(spacing: 8) { + // Month labels + LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 12), spacing: 4) { + ForEach(1...12, id: \.self) { month in + Text(monthAbbreviation(month)) + .font(.caption) + .frame(maxWidth: .infinity) + .padding(.bottom, 2) + } + } + // Day buttons + LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 12), spacing: 4) { + ForEach(1...maxDaysInAnyMonth, id: \.self) { day in + ForEach(1...12, id: \.self) { month in + let days = generateMonthDays(for: currentYear, month: month) + if day <= days.count { + let date = days[day - 1] + Button(action: { + toggleCompletion(for: date) + }) { + Circle() + .fill(isCompleted(date) ? Color.yellow : Color.gray.opacity(0.75)) + .frame(width: 28, height: 28) + } + } else { + // Empty cell for months with fewer days + Color.clear + .frame(width: 28, height: 28) + } + } + } + } + } + .padding() + } + } + + private func generateYearDays(for year: Int) -> [Date] { + let startOfYear = calendar.date(from: DateComponents(year: year, month: 1, day: 1))! + let range = calendar.range(of: .day, in: .year, for: startOfYear)! + return range.compactMap { calendar.date(byAdding: .day, value: $0 - 1, to: startOfYear) } + } + + private func toggleCompletion(for date: Date) { + let key = dateKey(for: date) + var updated = (try? JSONDecoder().decode([String: Bool].self, from: Data(completedDaysRaw.utf8))) ?? [:] + updated[key] = !(updated[key] ?? false) + if let data = try? JSONEncoder().encode(updated), + let string = String(data: data, encoding: .utf8) { + completedDaysRaw = string + } + } + + private func isCompleted(_ date: Date) -> Bool { + completedDays[dateKey(for: date)] ?? false + } + + private func dateKey(for date: Date) -> String { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + return formatter.string(from: date) + } + + private var completedDays: [String: Bool] { + get { + (try? JSONDecoder().decode([String: Bool].self, from: Data(completedDaysRaw.utf8))) ?? [:] + } + set { + if let data = try? JSONEncoder().encode(newValue), + let string = String(data: data, encoding: .utf8) { + completedDaysRaw = string + } + } + } + + private var maxDaysInAnyMonth: Int { + (1...12).map { month in + calendar.range(of: .day, in: .month, for: calendar.date(from: DateComponents(year: currentYear, month: month, day: 1))!)!.count + }.max() ?? 31 + } + + private func generateMonthDays(for year: Int, month: Int) -> [Date] { + let calendar = Calendar.current + let startOfMonth = calendar.date(from: DateComponents(year: year, month: month, day: 1))! + let range = calendar.range(of: .day, in: .month, for: startOfMonth)! + return range.compactMap { day in + calendar.date(from: DateComponents(year: year, month: month, day: day)) + } + } + + private func monthAbbreviation(_ month: Int) -> String { + let formatter = DateFormatter() + formatter.dateFormat = "MMM" + let date = Calendar.current.date(from: DateComponents(year: 2000, month: month, day: 1))! + return formatter.string(from: date) + } +} + +#Preview { + HabitCalendarView() +} diff --git a/HealthKitManager/HealthKitManager.swift b/HealthKitManager/HealthKitManager.swift index 167c33d..e11e143 100644 --- a/HealthKitManager/HealthKitManager.swift +++ b/HealthKitManager/HealthKitManager.swift @@ -26,6 +26,12 @@ class HealthKitManager: ObservableObject { @Published var yearlyCyclingTime: Double = 0.0 @Published var dailyCyclingSources: [String: Double] = [:] @Published var yearlyCyclingSources: [String: Double] = [:] + + // Year-specific data for 2025 (Elbe) and 2026 (Rhein) + @Published var year2025Distance: Double = 0.0 + @Published var year2025CyclingDistance: Double = 0.0 + @Published var year2026Distance: Double = 0.0 + @Published var year2026CyclingDistance: Double = 0.0 private let stepType = HKQuantityType.quantityType(forIdentifier: .stepCount)! private let distanceType = HKQuantityType.quantityType(forIdentifier: .distanceWalkingRunning)! @@ -50,6 +56,8 @@ class HealthKitManager: ObservableObject { self.fetchDailyCyclingData() self.fetchYearlyCyclingData() self.observeCyclingChanges() + self.fetchYear2025Data() + self.fetchYear2026Data() } else { print("HealthKit authorization failed: \(error?.localizedDescription ?? "Unknown error")") @@ -256,4 +264,82 @@ class HealthKitManager: ObservableObject { case today case thisYear } + + // Fetch data for year 2025 (Elbe) + private func fetchYear2025Data() { + let calendar = Calendar.current + var components = DateComponents() + components.year = 2025 + components.month = 1 + components.day = 1 + + guard let startOf2025 = calendar.date(from: components) else { return } + + components.year = 2026 + guard let endOf2025 = calendar.date(from: components) else { return } + + let predicate = HKQuery.predicateForSamples(withStart: startOf2025, end: endOf2025, options: .strictStartDate) + + // Fetch walking/running distance for 2025 + let distanceQuery = HKStatisticsQuery(quantityType: distanceType, quantitySamplePredicate: predicate, options: .cumulativeSum) { _, result, _ in + guard let result = result, let sum = result.sumQuantity() else { + DispatchQueue.main.async { self.year2025Distance = 0 } + return + } + let value = sum.doubleValue(for: HKUnit.meter()) + DispatchQueue.main.async { self.year2025Distance = value } + } + + // Fetch cycling distance for 2025 + let cyclingQuery = HKStatisticsQuery(quantityType: cyclingDistanceType, quantitySamplePredicate: predicate, options: .cumulativeSum) { _, result, _ in + guard let result = result, let sum = result.sumQuantity() else { + DispatchQueue.main.async { self.year2025CyclingDistance = 0 } + return + } + let value = sum.doubleValue(for: HKUnit.meter()) + DispatchQueue.main.async { self.year2025CyclingDistance = value } + } + + healthStore.execute(distanceQuery) + healthStore.execute(cyclingQuery) + } + + // Fetch data for year 2026 (Rhein) + private func fetchYear2026Data() { + let calendar = Calendar.current + var components = DateComponents() + components.year = 2026 + components.month = 1 + components.day = 1 + + guard let startOf2026 = calendar.date(from: components) else { return } + + components.year = 2027 + guard let endOf2026 = calendar.date(from: components) else { return } + + let predicate = HKQuery.predicateForSamples(withStart: startOf2026, end: endOf2026, options: .strictStartDate) + + // Fetch walking/running distance for 2026 + let distanceQuery = HKStatisticsQuery(quantityType: distanceType, quantitySamplePredicate: predicate, options: .cumulativeSum) { _, result, _ in + guard let result = result, let sum = result.sumQuantity() else { + DispatchQueue.main.async { self.year2026Distance = 0 } + return + } + let value = sum.doubleValue(for: HKUnit.meter()) + DispatchQueue.main.async { self.year2026Distance = value } + } + + // Fetch cycling distance for 2026 + let cyclingQuery = HKStatisticsQuery(quantityType: cyclingDistanceType, quantitySamplePredicate: predicate, options: .cumulativeSum) { _, result, _ in + guard let result = result, let sum = result.sumQuantity() else { + DispatchQueue.main.async { self.year2026CyclingDistance = 0 } + return + } + let value = sum.doubleValue(for: HKUnit.meter()) + DispatchQueue.main.async { self.year2026CyclingDistance = value } + } + + healthStore.execute(distanceQuery) + healthStore.execute(cyclingQuery) + } } diff --git a/HealthKitManager/RheinProgressLineChart.swift b/HealthKitManager/RheinProgressLineChart.swift new file mode 100644 index 0000000..4302b98 --- /dev/null +++ b/HealthKitManager/RheinProgressLineChart.swift @@ -0,0 +1,166 @@ +import Charts +import SwiftUI + +struct RheinProgressVerticalChartView: View { + @Binding var yearlyDistance: Double + @Binding var yearlyCyclingDistance: Double + + let theme = ChartTheme.rhein + + let milestones: [(distance: Double, location: String)] = [ + (0, "Rheinquelle (Tomasee)"), + (120, "Chur (Bündner Herrschaft)"), + (200, "Bodensee (Konstanz)"), + (260, "Schaffhausen (Rheinfall)"), + (360, "Basel"), + (430, "Breisach am Rhein (Kaiserstuhl)"), + (510, "Straßburg"), + (690, "Worms"), + (730, "Mainz (Rheinhessen)"), + (760, "Rüdesheim (Rheingau)"), + (770, "Bingen"), + (830, "Lorelei (St. Goarshausen)"), + (870, "Koblenz (Deutsches Eck)"), + (950, "Bonn"), + (980, "Köln"), + (1020, "Düsseldorf"), + (1120, "Arnhem"), + (1190, "Rotterdam"), + (1230, "Hoek van Holland (Nordsee)") + ] + + let yAxisValues: [Int] = Array(stride(from: 0, through: 1500, by: 100)) + + fileprivate func getCompletePath() -> ForEach<[(distance: Double, location: String)], Double, some ChartContent> { + return // Completed Path + ForEach(milestones.filter { $0.distance <= yearlyDistance }, id: \.distance) { milestone in + LineMark( + x: .value("Meilenstein", 1), + y: .value("Distanz", milestone.distance) + ) + .foregroundStyle(theme.pathColor) + } + } + + fileprivate func getRemainingPath() -> ForEach<[(distance: Double, location: String)], Double, some ChartContent> { + return // Remaining Path + ForEach(milestones.filter { $0.distance > yearlyDistance }, id: \.distance) { milestone in + LineMark( + x: .value("Meilenstein", 1), + y: .value("Distanz", milestone.distance) + ) + .foregroundStyle(theme.remainingPathColor) + } + } + + fileprivate func getMilestonePoints() -> ForEach<[(distance: Double, location: String)], Double, some ChartContent> { + return // Milestone Points + ForEach(milestones, id: \.distance) { milestone in + PointMark( + x: .value("Meilenstein", 1), + y: .value("Distanz", milestone.distance) + ) + .foregroundStyle(theme.milestoneColor) + .annotation(position: .trailing, alignment: .leading) { + Text(milestone.location) + .font(.caption) + } + } + } + + fileprivate func setDynamicProgressPoint() -> PointMark { + return // 📍 Dynamic Progress Point + PointMark( + x: .value("Meilenstein", 1), + y: .value("Distanz", yearlyDistance / 1000) + ) + } + + fileprivate func setCyclingProgressPoint() -> PointMark { + return PointMark( + x: .value("Meilenstein", 1), + y: .value("Distanz", yearlyCyclingDistance / 1000) // In km umrechnen + ) + } + + fileprivate func setFigureColor() -> Color { + return theme.figureColor + } + + fileprivate func setBicycleColor() -> Color { + return theme.bicycleColor + } + + var body: some View { + ZStack { + VStack { + GroupBox( + label: HStack { + Spacer() + Text("Dein Fortschritt am Rhein 2026") + Spacer() + }.font(.headline) + ) { + Chart { + getRemainingPath() + getCompletePath() + getMilestonePoints() + setDynamicProgressPoint() + .symbolSize(60) + .foregroundStyle(setFigureColor()) + .annotation(position: .leading, alignment: .trailing) { + Image(systemName: "figure.walk") + .font(.system(size: 20)) // Größe ändern + .foregroundColor(setFigureColor()) + .padding(5) + } + + setCyclingProgressPoint() + .symbolSize(40) + .foregroundStyle(setBicycleColor()) + .annotation(position: .leading, alignment: .trailing) { + Image(systemName: "bicycle") + .font(.system(size: 20)) // Größe ändern + .foregroundColor(setBicycleColor()) + .padding(5) + } + } + .chartXAxis(.hidden) + .chartYAxis { + AxisMarks(position: .leading, values: yAxisValues) { + AxisGridLine().foregroundStyle(theme.axisColor) + AxisTick().foregroundStyle(theme.axisColor) + AxisValueLabel().foregroundStyle(theme.axisColor) + } + } + .chartYScale(domain: 0 ... 1500) + .frame(height: 600) + .padding(20) + + VStack { + Text("\(Double(yearlyDistance / 1000).formatted(.number.locale(Locale(identifier: "de_DE")).precision(.fractionLength(2)))) km von 1.230 km") + .font(.headline) + + Text("\(Double(yearlyCyclingDistance / 1000).formatted(.number.locale(Locale(identifier: "de_DE")).precision(.fractionLength(2)))) km Fahrrad") + .font(.subheadline) + } + } + .groupBoxStyle(BlueGroupBoxStyle()) + } + .padding(5) + } + } +} + +struct BlueGroupBoxStyle: GroupBoxStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.content + .padding(30) + .background(Color(.systemGray6).opacity(0.8)) + .cornerRadius(20) + .overlay( + configuration.label.padding(10), + alignment: .topLeading + ) + } +} diff --git a/Localizable.xcstrings b/Localizable.xcstrings index 54dd181..200b31c 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -4,10 +4,16 @@ "%@ km Fahrrad" : { }, - "%@ km von 1.300 km" : { + "%@ km von 1.230 km" : { }, - "Dein Fortschritt entlang der Elbe" : { + "%@ km von 1.500 km" : { + + }, + "Dein Fortschritt am Rhein 2026" : { + + }, + "Dein Fortschritt an der Elbe 2025" : { }, "distance" : { @@ -180,7 +186,10 @@ } } } + }, + "Ziele" : { + } }, "version" : "1.0" -} +} \ No newline at end of file