/ BalanceKit / WorkoutDashboardView.swift
WorkoutDashboardView.swift
1 // 2 // WorkoutDashboardView.swift 3 // BalanceKit 4 // 5 // Created by Alexander Kunau on 02.11.24. 6 // 7 8 import SwiftUI 9 import Charts 10 11 struct WorkoutDashboardView: View { 12 @ObservedObject var workoutManager: WorkoutManager 13 @State private var selectedPeriod: TimePeriod = .week 14 @State private var selectedMonth = Date() 15 16 enum TimePeriod: String, CaseIterable { 17 case week = "Woche" 18 case month = "Monat" 19 } 20 21 var body: some View { 22 ScrollView { 23 VStack(spacing: 20) { 24 // Übersicht Metriken 25 overviewSection 26 27 // Wertungs-Chart 28 chartSection 29 30 // Verlaufs-Chart 31 frequencyChartSection 32 33 // Kalender 34 calendarSection 35 36 // Beste Serien 37 bestStreaksSection 38 39 // Häufigkeit Heatmap 40 frequencyHeatmapSection 41 } 42 .padding() 43 } 44 .navigationTitle("Workout") 45 .navigationBarTitleDisplayMode(.inline) 46 } 47 48 // MARK: - Übersicht Section 49 private var overviewSection: some View { 50 VStack(spacing: 16) { 51 Text("Übersicht") 52 .font(.headline) 53 .frame(maxWidth: .infinity, alignment: .leading) 54 55 HStack(spacing: 16) { 56 // Progress Ring 57 ZStack { 58 Circle() 59 .stroke(lineWidth: 12) 60 .opacity(0.2) 61 .foregroundColor(.green) 62 63 Circle() 64 .trim(from: 0.0, to: CGFloat(min(weeklyProgress, 1.0))) 65 .stroke(style: StrokeStyle(lineWidth: 12, lineCap: .round)) 66 .foregroundColor(.green) 67 .rotationEffect(Angle(degrees: -90)) 68 69 VStack { 70 Text("\(Int(weeklyProgress * 100))%") 71 .font(.title2) 72 .fontWeight(.bold) 73 Text("Wertung") 74 .font(.caption) 75 .foregroundColor(.secondary) 76 } 77 } 78 .frame(width: 100, height: 100) 79 80 VStack(spacing: 12) { 81 // Monat 82 VStack(alignment: .leading) { 83 Text("Monat") 84 .font(.caption) 85 .foregroundColor(.secondary) 86 Text("\(monthlyChange >= 0 ? "+" : "")\(monthlyChange)%") 87 .font(.title3) 88 .fontWeight(.bold) 89 .foregroundColor(monthlyChange >= 0 ? .green : .red) 90 } 91 .frame(maxWidth: .infinity, alignment: .leading) 92 93 // Jahr 94 VStack(alignment: .leading) { 95 Text("Jahr") 96 .font(.caption) 97 .foregroundColor(.secondary) 98 Text("\(yearlyChange >= 0 ? "+" : "")\(yearlyChange)%") 99 .font(.title3) 100 .fontWeight(.bold) 101 .foregroundColor(yearlyChange >= 0 ? .green : .red) 102 } 103 .frame(maxWidth: .infinity, alignment: .leading) 104 } 105 106 // Insgesamt 107 VStack(alignment: .trailing, spacing: 4) { 108 Text("\(totalWorkouts)") 109 .font(.system(size: 36, weight: .bold)) 110 Text("Insgesamt") 111 .font(.caption) 112 .foregroundColor(.secondary) 113 } 114 } 115 .padding() 116 .background(Color(.secondarySystemGroupedBackground)) 117 .cornerRadius(16) 118 } 119 } 120 121 // MARK: - Chart Section (Wertung) 122 private var chartSection: some View { 123 VStack(spacing: 12) { 124 HStack { 125 Text("Wertung") 126 .font(.headline) 127 Spacer() 128 Picker("Zeitraum", selection: $selectedPeriod) { 129 ForEach(TimePeriod.allCases, id: \.self) { period in 130 Text(period.rawValue).tag(period) 131 } 132 } 133 .pickerStyle(.menu) 134 } 135 136 Chart { 137 ForEach(chartData, id: \.date) { data in 138 LineMark( 139 x: .value("Datum", data.date), 140 y: .value("Prozent", data.percentage) 141 ) 142 .foregroundStyle(.green) 143 .interpolationMethod(.catmullRom) 144 145 PointMark( 146 x: .value("Datum", data.date), 147 y: .value("Prozent", data.percentage) 148 ) 149 .foregroundStyle(.green) 150 } 151 } 152 .frame(height: 200) 153 .chartYScale(domain: 0...100) 154 .chartXAxis { 155 AxisMarks(values: .automatic(desiredCount: 6)) 156 } 157 .padding() 158 .background(Color(.secondarySystemGroupedBackground)) 159 .cornerRadius(16) 160 } 161 } 162 163 // MARK: - Frequency Chart Section (Verlauf) 164 private var frequencyChartSection: some View { 165 VStack(spacing: 12) { 166 HStack { 167 Text("Verlauf") 168 .font(.headline) 169 Spacer() 170 Text("Woche") 171 .font(.caption) 172 .foregroundColor(.secondary) 173 } 174 175 Chart { 176 ForEach(weeklyFrequencyData, id: \.date) { data in 177 BarMark( 178 x: .value("Datum", data.date), 179 y: .value("Anzahl", data.count) 180 ) 181 .foregroundStyle(.green) 182 } 183 } 184 .frame(height: 150) 185 .chartYScale(domain: 0...maxWeeklyCount + 1) 186 .chartXAxis { 187 AxisMarks(values: .automatic(desiredCount: 8)) 188 } 189 .padding() 190 .background(Color(.secondarySystemGroupedBackground)) 191 .cornerRadius(16) 192 } 193 } 194 195 // MARK: - Kalender Section 196 private var calendarSection: some View { 197 VStack(spacing: 12) { 198 Text("Kalender") 199 .font(.headline) 200 .frame(maxWidth: .infinity, alignment: .leading) 201 202 VStack(spacing: 0) { 203 // Monats-Selector 204 HStack { 205 Button(action: { changeMonth(by: -1) }) { 206 Image(systemName: "chevron.left") 207 } 208 209 Spacer() 210 211 Text(monthYearString) 212 .font(.headline) 213 214 Spacer() 215 216 Button(action: { changeMonth(by: 1) }) { 217 Image(systemName: "chevron.right") 218 } 219 } 220 .padding(.horizontal) 221 .padding(.top, 16) 222 .padding(.bottom, 8) 223 224 // Kalender Grid 225 LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 7), spacing: 8) { 226 // Wochentage 227 ForEach(Array(["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"].enumerated()), id: \.offset) { index, day in 228 Text(day) 229 .font(.caption) 230 .foregroundColor(.secondary) 231 } 232 233 // Leere Tage am Anfang 234 ForEach(0..<firstWeekdayOffset, id: \.self) { offset in 235 Text("") 236 .frame(width: 36, height: 36) 237 .id("empty_\(offset)") 238 } 239 240 // Tage des Monats 241 ForEach(1...daysInMonth, id: \.self) { day in 242 let date = dateForDay(day) 243 let hasWorkout = workoutsOnDate(date) > 0 244 245 Text("\(day)") 246 .font(.caption) 247 .frame(width: 36, height: 36) 248 .background(hasWorkout ? Color.green.opacity(0.7) : Color.clear) 249 .cornerRadius(8) 250 .id("day_\(day)") 251 } 252 } 253 .padding() 254 } 255 .background(Color(.secondarySystemGroupedBackground)) 256 .cornerRadius(16) 257 258 Button("BEARBEITEN") { 259 // Aktion für Bearbeiten 260 } 261 .font(.caption) 262 .foregroundColor(.secondary) 263 } 264 } 265 266 // MARK: - Beste Serien Section 267 private var bestStreaksSection: some View { 268 VStack(spacing: 12) { 269 Text("Beste Serien") 270 .font(.headline) 271 .frame(maxWidth: .infinity, alignment: .leading) 272 273 VStack(spacing: 8) { 274 ForEach(bestStreaks, id: \.startDate) { streak in 275 HStack { 276 Text(formatDate(streak.startDate)) 277 .font(.caption) 278 .foregroundColor(.secondary) 279 280 Spacer() 281 282 Text("\(streak.days)") 283 .font(.headline) 284 .padding(.horizontal, 16) 285 .padding(.vertical, 8) 286 .background(Color.gray.opacity(0.3)) 287 .cornerRadius(8) 288 289 Spacer() 290 291 Text(formatDate(streak.endDate)) 292 .font(.caption) 293 .foregroundColor(.secondary) 294 } 295 .padding(.vertical, 4) 296 } 297 } 298 .padding() 299 .background(Color(.secondarySystemGroupedBackground)) 300 .cornerRadius(16) 301 } 302 } 303 304 // MARK: - Häufigkeit Heatmap Section 305 private var frequencyHeatmapSection: some View { 306 VStack(spacing: 12) { 307 Text("Häufigkeit") 308 .font(.headline) 309 .frame(maxWidth: .infinity, alignment: .leading) 310 311 VStack(spacing: 4) { 312 // Wochentage Header 313 HStack(spacing: 4) { 314 ForEach(["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"], id: \.self) { day in 315 Text(day) 316 .font(.caption2) 317 .frame(maxWidth: .infinity) 318 } 319 } 320 321 // Heatmap Rows 322 ForEach(0..<weeksToShow, id: \.self) { weekIndex in 323 HStack(spacing: 4) { 324 ForEach(0..<7) { dayIndex in 325 let date = getDateForHeatmap(weekIndex: weekIndex, dayIndex: dayIndex) 326 let workoutCount = workoutsOnDate(date) 327 328 Circle() 329 .fill(workoutCount > 0 ? Color.green.opacity(min(Double(workoutCount) / 3.0, 1.0)) : Color.gray.opacity(0.1)) 330 .frame(maxWidth: .infinity) 331 .aspectRatio(1, contentMode: .fit) 332 .padding(2) 333 } 334 } 335 } 336 } 337 .padding() 338 .background(Color(.secondarySystemGroupedBackground)) 339 .cornerRadius(16) 340 } 341 } 342 343 // MARK: - Berechnungen 344 345 private var weeklyProgress: Double { 346 let thisWeekWorkouts = workoutManager.workouts.filter { workout in 347 Calendar.current.isDate(workout.date, equalTo: Date(), toGranularity: .weekOfYear) 348 } 349 let goal = 3.0 // 3 Workouts pro Woche als Ziel 350 return min(Double(thisWeekWorkouts.count) / goal, 1.0) 351 } 352 353 private var monthlyChange: Int { 354 let calendar = Calendar.current 355 let now = Date() 356 357 // Dieser Monat 358 let thisMonthWorkouts = workoutManager.workouts.filter { 359 calendar.isDate($0.date, equalTo: now, toGranularity: .month) 360 }.count 361 362 // Letzter Monat 363 guard let lastMonth = calendar.date(byAdding: .month, value: -1, to: now) else { return 0 } 364 let lastMonthWorkouts = workoutManager.workouts.filter { 365 calendar.isDate($0.date, equalTo: lastMonth, toGranularity: .month) 366 }.count 367 368 if lastMonthWorkouts == 0 { 369 return thisMonthWorkouts > 0 ? 100 : 0 370 } 371 372 return Int((Double(thisMonthWorkouts - lastMonthWorkouts) / Double(lastMonthWorkouts)) * 100) 373 } 374 375 private var yearlyChange: Int { 376 let calendar = Calendar.current 377 let now = Date() 378 379 // Dieses Jahr 380 let thisYearWorkouts = workoutManager.workouts.filter { 381 calendar.isDate($0.date, equalTo: now, toGranularity: .year) 382 }.count 383 384 // Letztes Jahr 385 guard let lastYear = calendar.date(byAdding: .year, value: -1, to: now) else { return 0 } 386 let lastYearWorkouts = workoutManager.workouts.filter { 387 calendar.isDate($0.date, equalTo: lastYear, toGranularity: .year) 388 }.count 389 390 if lastYearWorkouts == 0 { 391 return thisYearWorkouts > 0 ? 100 : 0 392 } 393 394 return Int((Double(thisYearWorkouts - lastYearWorkouts) / Double(lastYearWorkouts)) * 100) 395 } 396 397 private var totalWorkouts: Int { 398 workoutManager.workouts.count 399 } 400 401 private var chartData: [(date: Date, percentage: Double)] { 402 let calendar = Calendar.current 403 let now = Date() 404 var data: [(date: Date, percentage: Double)] = [] 405 406 let periods = selectedPeriod == .week ? 12 : 12 // 12 Wochen oder 12 Monate 407 let component: Calendar.Component = selectedPeriod == .week ? .weekOfYear : .month 408 409 for i in (0..<periods).reversed() { 410 guard let date = calendar.date(byAdding: component, value: -i, to: now) else { continue } 411 412 let workoutsInPeriod = workoutManager.workouts.filter { 413 calendar.isDate($0.date, equalTo: date, toGranularity: component) 414 }.count 415 416 let goal = selectedPeriod == .week ? 3.0 : 12.0 417 let percentage = min((Double(workoutsInPeriod) / goal) * 100, 100) 418 419 data.append((date: date, percentage: percentage)) 420 } 421 422 return data 423 } 424 425 private var weeklyFrequencyData: [(date: Date, count: Int)] { 426 let calendar = Calendar.current 427 let now = Date() 428 var data: [(date: Date, count: Int)] = [] 429 430 for i in (0..<12).reversed() { 431 guard let weekStart = calendar.date(byAdding: .weekOfYear, value: -i, to: now) else { continue } 432 433 let workoutsInWeek = workoutManager.workouts.filter { 434 calendar.isDate($0.date, equalTo: weekStart, toGranularity: .weekOfYear) 435 }.count 436 437 data.append((date: weekStart, count: workoutsInWeek)) 438 } 439 440 return data 441 } 442 443 private var maxWeeklyCount: Int { 444 weeklyFrequencyData.map { $0.count }.max() ?? 3 445 } 446 447 private var bestStreaks: [(startDate: Date, endDate: Date, days: Int)] { 448 let sortedWorkouts = workoutManager.workouts.sorted { $0.date < $1.date } 449 var streaks: [(startDate: Date, endDate: Date, days: Int)] = [] 450 451 if sortedWorkouts.isEmpty { return [] } 452 453 var currentStreak: (start: Date, end: Date, days: Int) = (sortedWorkouts[0].date, sortedWorkouts[0].date, 1) 454 var previousDate = sortedWorkouts[0].date 455 456 for workout in sortedWorkouts.dropFirst() { 457 let daysDifference = Calendar.current.dateComponents([.day], from: previousDate, to: workout.date).day ?? 0 458 459 if daysDifference <= 7 { // Maximal 7 Tage Pause 460 currentStreak.end = workout.date 461 currentStreak.days += 1 462 } else { 463 if currentStreak.days > 1 { 464 streaks.append((currentStreak.start, currentStreak.end, currentStreak.days)) 465 } 466 currentStreak = (workout.date, workout.date, 1) 467 } 468 469 previousDate = workout.date 470 } 471 472 if currentStreak.days > 1 { 473 streaks.append((currentStreak.start, currentStreak.end, currentStreak.days)) 474 } 475 476 return Array(streaks.sorted { $0.days > $1.days }.prefix(5)) 477 } 478 479 // MARK: - Kalender Helpers 480 481 private var monthYearString: String { 482 let formatter = DateFormatter() 483 formatter.dateFormat = "MMMM yyyy" 484 formatter.locale = Locale(identifier: "de_DE") 485 return formatter.string(from: selectedMonth) 486 } 487 488 private var daysInMonth: Int { 489 let calendar = Calendar.current 490 let range = calendar.range(of: .day, in: .month, for: selectedMonth) 491 return range?.count ?? 30 492 } 493 494 private var firstWeekdayOffset: Int { 495 let calendar = Calendar.current 496 let firstDay = calendar.date(from: calendar.dateComponents([.year, .month], from: selectedMonth))! 497 let weekday = calendar.component(.weekday, from: firstDay) 498 // Montag = 1, Sonntag = 7 (angepasst für Montag-Start) 499 return weekday == 1 ? 6 : weekday - 2 500 } 501 502 private func dateForDay(_ day: Int) -> Date { 503 let calendar = Calendar.current 504 var components = calendar.dateComponents([.year, .month], from: selectedMonth) 505 components.day = day 506 return calendar.date(from: components) ?? Date() 507 } 508 509 private func workoutsOnDate(_ date: Date) -> Int { 510 workoutManager.workouts.filter { 511 Calendar.current.isDate($0.date, inSameDayAs: date) 512 }.count 513 } 514 515 private func changeMonth(by value: Int) { 516 if let newMonth = Calendar.current.date(byAdding: .month, value: value, to: selectedMonth) { 517 selectedMonth = newMonth 518 } 519 } 520 521 private func formatDate(_ date: Date) -> String { 522 let formatter = DateFormatter() 523 formatter.dateFormat = "dd.MM.yyyy" 524 return formatter.string(from: date) 525 } 526 527 // MARK: - Heatmap Helpers 528 529 private var weeksToShow: Int { 6 } // 6 Wochen = ~1.5 Monate 530 531 private func getDateForHeatmap(weekIndex: Int, dayIndex: Int) -> Date { 532 let calendar = Calendar.current 533 let now = Date() 534 535 // Gehe zurück zur gewünschten Woche 536 guard let weekStart = calendar.date(byAdding: .weekOfYear, value: -(weeksToShow - weekIndex - 1), to: now) else { 537 return now 538 } 539 540 // Finde Montag dieser Woche 541 let weekday = calendar.component(.weekday, from: weekStart) 542 let daysToMonday = weekday == 1 ? -6 : 2 - weekday 543 guard let monday = calendar.date(byAdding: .day, value: daysToMonday, to: weekStart) else { 544 return now 545 } 546 547 // Addiere Tage für den gewünschten Wochentag 548 return calendar.date(byAdding: .day, value: dayIndex, to: monday) ?? now 549 } 550 } 551 552 #Preview { 553 NavigationStack { 554 WorkoutDashboardView(workoutManager: WorkoutManager()) 555 } 556 }