Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
@@ -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
4 changes: 4 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"lldb.library": "/Applications/Xcode.app/Contents/SharedFrameworks/LLDB.framework/Versions/A/LLDB",
"lldb.launch.expressions": "native"
}
22 changes: 20 additions & 2 deletions HealthKitManager.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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;
};
Expand All @@ -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;
Expand All @@ -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;
};
Expand Down
53 changes: 53 additions & 0 deletions HealthKitManager/AppTheme.swift
Original file line number Diff line number Diff line change
@@ -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)
)
}
37 changes: 28 additions & 9 deletions HealthKitManager/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -26,7 +24,7 @@ struct ContentView: View {
.padding()
.background(
RoundedRectangle(cornerRadius: 10)
.fill(fieldBackgroundColor)
.fill(Color.fieldBackground)
)
.foregroundColor(.white)

Expand Down Expand Up @@ -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: ""),
Expand All @@ -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(
Expand Down Expand Up @@ -115,7 +127,7 @@ struct HealthDataView: View {
.padding()
.background(
RoundedRectangle(cornerRadius: 10)
.fill(Color(.systemGray6).opacity(0.8))
.fill(Color.fieldBackground)
)
}
}
Expand All @@ -142,7 +154,7 @@ struct SourceListView: View {
.padding()
.background(
RoundedRectangle(cornerRadius: 10)
.fill(Color(.systemGray6).opacity(0.8))
.fill(Color.fieldBackground)
)
}

Expand All @@ -160,3 +172,10 @@ enum TimePeriod {
case today, thisYear
}

struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}

}

Loading