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
123 changes: 123 additions & 0 deletions examples/ios-widget-steps/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
# iOS 26 – Schritte Widget (Apple Health)

Ein vollständiges, importierbares Xcode-Projekt mit einer iOS-App und einem WidgetKit-Extension, das täglich Schritt-Daten direkt aus Apple Health liest.

## Vorschau

| Small | Medium | Lock Screen (Circular) | Lock Screen (Rectangular) |
|-------|--------|------------------------|---------------------------|
| Fortschrittsring + Schrittzahl | Ring + Distanz, Ziel, Fortschrittsbalken | Gauge-Ring mit kompakter Zahl | Schrittzahl + Fortschrittsbalken |

## Funktionen

- **Echtzeitdaten** aus Apple Health (HealthKit)
- **Fortschrittsring** – animiert, wird grün bei Zielerreichung
- **Distanzberechnung** (km) und **Kalorienabschätzung** (kcal)
- **Anpassbares Tagesziel** (Standard: 10.000) – wird im App-Group-UserDefaults gespeichert
- **Automatische Widget-Aktualisierung** alle 15 Minuten
- **5 Widget-Größen:** Small, Medium, Accessory Circular, Rectangular, Inline (Lock Screen)
- Vollständige **Xcode 16 Previews** mit `#Preview(as:)` Makro

## Anforderungen

| | |
|---|---|
| iOS | 26.0+ |
| Xcode | 16.0+ |
| Swift | 6.0 |
| Frameworks | HealthKit, WidgetKit, SwiftUI |

## Projekt öffnen

```bash
open examples/ios-widget-steps/StepsWidget/StepsWidget.xcodeproj
```

## Projektstruktur

```
StepsWidget/
├── StepsWidget.xcodeproj/
│ └── project.pbxproj
├── StepsApp/ # Hauptapp-Target
│ ├── StepsApp.swift # @main App
│ ├── ContentView.swift # Fortschrittsring, Stats, Ziel-Editor
│ ├── HealthKitManager.swift # HealthKit-Abfragen + WidgetCenter reload
│ ├── Info.plist # NSHealthShareUsageDescription
│ ├── StepsApp.entitlements # HealthKit + App Group
│ └── Assets.xcassets/
└── StepsWidgetExtension/ # Widget-Extension-Target
├── StepsWidgetBundle.swift # @main WidgetBundle
├── StepsProvider.swift # TimelineProvider + StepsEntry
├── StepsWidgetView.swift # Alle Widget-Views + Widget-Config
├── Info.plist # NSExtensionPointIdentifier
├── StepsWidgetExtension.entitlements # HealthKit + App Group
└── Assets.xcassets/
```

## Setup nach dem Öffnen in Xcode

### 1. Team & Bundle ID setzen

Wähle in Xcode beide Targets (`StepsApp` und `StepsWidgetExtension`) → **Signing & Capabilities** → wähle dein Team.

Die Bundle-IDs sind vorbelegt als:
- App: `com.example.stepswidget`
- Widget: `com.example.stepswidget.widget`

Passe sie auf deine eigene Domain an, z. B. `de.deinname.stepswidget`.

### 2. App Group konfigurieren

Beide Targets müssen zur **gleichen App Group** gehören, damit das Widget das Tagesziel aus der App lesen kann:

1. Target `StepsApp` → **Signing & Capabilities** → **+ Capability** → *App Groups*
2. Gruppe hinzufügen: `group.com.example.stepswidget`
*(oder deine eigene: `group.de.deinname.stepswidget`)*
3. Dasselbe für `StepsWidgetExtension` wiederholen
4. Den App-Group-Identifier in `HealthKitManager.swift` und `StepsProvider.swift` anpassen:
```swift
private static let appGroup = "group.com.example.stepswidget"
```

### 3. HealthKit-Berechtigung

Die App fragt beim ersten Start automatisch nach HealthKit-Zugriff.
Das Widget fordert die Berechtigung ebenfalls beim ersten Aktualisieren an.

> **Hinweis:** Der iOS-Simulator enthält **keine** HealthKit-Daten.
> Zum Testen ein **echtes Gerät** verwenden oder die Simulator-Health-App zum Eintragen von Schritten nutzen.

## Architektur

```
┌─────────────────────────────────┐ App Group UserDefaults
│ StepsApp │ ───────────────────────────────┐
│ HealthKitManager (@MainActor) │ schreibt stepGoal │
│ ├─ requestAuthorization() │ │
│ ├─ fetchTodaySteps() async │ ▼
│ └─ updateGoal(_:) → WidgetCenter.reloadTimelines() ┌──────────────────────┐
└─────────────────────────────────┘ │ StepsWidgetExtension │
│ StepsProvider │
HealthKit Store │ ├─ liest stepGoal │
│ HKStatisticsQuery (stepCount) │ └─ HKStatisticsQuery │
└──────────────────────────────────────────────────┤ │
│ StepsWidget (Views) │
└──────────────────────┘
```

## Widget-Aktualisierung

Das Widget aktualisiert sich automatisch alle **15 Minuten** (WidgetKit policy).
Wenn der Nutzer das Tagesziel in der App ändert, wird das Widget sofort neu geladen:

```swift
WidgetCenter.shared.reloadTimelines(ofKind: "StepsWidget")
```

## Lokalisierung

Alle Texte sind auf Deutsch. Für andere Sprachen können die Strings in eine
`Localizable.xcstrings`-Datei extrahiert werden (`SWIFT_EMIT_LOC_STRINGS = YES` ist gesetzt).
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "1.000",
"green" : "0.478",
"red" : "0.000"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "1.000",
"green" : "0.478",
"red" : "0.000"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"images" : [
{
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
182 changes: 182 additions & 0 deletions examples/ios-widget-steps/StepsWidget/StepsApp/ContentView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
import SwiftUI

struct ContentView: View {
@StateObject private var manager = HealthKitManager()
@State private var showGoalSheet = false
@State private var goalDraft: Double = 10_000

private var ringColor: Color { manager.progress >= 1.0 ? .green : .blue }

var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 28) {
progressRing
statsGrid
goalCard
if manager.authorizationStatus == .denied { deniedBanner }
}
.padding(.bottom, 24)
}
.navigationTitle("Schritte")
.navigationBarTitleDisplayMode(.large)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
Task { await manager.fetchTodaySteps() }
} label: {
Image(systemName: "arrow.clockwise")
}
}
}
.sheet(isPresented: $showGoalSheet) {
GoalEditorSheet(goal: $goalDraft) { manager.updateGoal(Int(goalDraft)) }
}
.task { manager.requestAuthorization() }
}
}

// MARK: - Sub-views

private var progressRing: some View {
ZStack {
Circle()
.stroke(ringColor.opacity(0.15), lineWidth: 22)
Circle()
.trim(from: 0, to: manager.progress)
.stroke(ringColor, style: StrokeStyle(lineWidth: 22, lineCap: .round))
.rotationEffect(.degrees(-90))
.animation(.easeInOut(duration: 0.8), value: manager.progress)
VStack(spacing: 4) {
Text(manager.todaySteps.formatted())
.font(.system(size: 52, weight: .bold, design: .rounded))
.contentTransition(.numericText())
Text("Schritte heute")
.font(.subheadline)
.foregroundStyle(.secondary)
if manager.progress >= 1.0 {
Label("Ziel erreicht!", systemImage: "checkmark.circle.fill")
.font(.caption)
.fontWeight(.semibold)
.foregroundStyle(.green)
}
}
}
.frame(width: 260, height: 260)
.padding(.top, 8)
}

private var statsGrid: some View {
LazyVGrid(
columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())],
spacing: 12
) {
StatCard(icon: "flag.fill", color: .orange, value: "\(Int(manager.progress * 100)) %", label: "Fortschritt")
StatCard(icon: "figure.walk", color: .blue, value: String(format: "%.2f km", manager.distanceKm), label: "Distanz")
StatCard(icon: "flame.fill", color: .red, value: "\(manager.estimatedKcal) kcal", label: "Kalorien")
}
.padding(.horizontal)
}

private var goalCard: some View {
VStack(alignment: .leading, spacing: 10) {
HStack {
Label("Tagesziel", systemImage: "target")
.font(.headline)
Spacer()
Button("Anpassen") {
goalDraft = Double(manager.stepGoal)
showGoalSheet = true
}
.buttonStyle(.bordered)
.tint(.blue)
}
ProgressView(value: manager.progress)
.tint(ringColor)
Text("\(manager.todaySteps.formatted()) / \(manager.stepGoal.formatted()) Schritte")
.font(.caption)
.foregroundStyle(.secondary)
}
.padding()
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 16))
.padding(.horizontal)
}

private var deniedBanner: some View {
ContentUnavailableView(
"HealthKit-Zugriff verweigert",
systemImage: "heart.slash",
description: Text("Erlaube den Zugriff in Einstellungen > Datenschutz > Health.")
)
.padding(.horizontal)
}
}

// MARK: - Stat Card

struct StatCard: View {
let icon: String
let color: Color
let value: String
let label: String

var body: some View {
VStack(spacing: 6) {
Image(systemName: icon)
.font(.title3)
.foregroundStyle(color)
Text(value)
.font(.subheadline)
.fontWeight(.semibold)
.minimumScaleFactor(0.6)
.lineLimit(1)
Text(label)
.font(.caption2)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 14))
}
}

// MARK: - Goal Editor Sheet

struct GoalEditorSheet: View {
@Binding var goal: Double
@Environment(\.dismiss) private var dismiss
let onSave: () -> Void

var body: some View {
NavigationStack {
Form {
Section("Tagesziel in Schritten") {
Slider(value: $goal, in: 1_000...30_000, step: 500) {
Text("Ziel")
} minimumValueLabel: {
Text("1K").font(.caption)
} maximumValueLabel: {
Text("30K").font(.caption)
}
Text("\(Int(goal).formatted()) Schritte")
.font(.title2.bold())
.frame(maxWidth: .infinity, alignment: .center)
.padding(.vertical, 4)
}
}
.navigationTitle("Ziel anpassen")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) { Button("Abbrechen") { dismiss() } }
ToolbarItem(placement: .confirmationAction) {
Button("Speichern") { onSave(); dismiss() }
}
}
}
.presentationDetents([.medium])
}
}

#Preview {
ContentView()
}
Loading