/ BalanceKit / DailyView.swift
DailyView.swift
1 // 2 // DailyView.swift 3 // BalanceKit 4 // 5 // Created by Alexander Kunau on 30.10.24. 6 // 7 8 import SwiftUI 9 10 // Helper View for a single metric in the dashboard 11 struct DashboardMetricView: View { 12 let label: String 13 let value: String 14 let unit: String 15 let color: Color 16 var progress: Double? = nil // Optional progress for circular view 17 18 var body: some View { 19 VStack(spacing: 4) { 20 if let progress = progress { 21 ZStack { 22 Circle() 23 .stroke(lineWidth: 10) 24 .opacity(0.2) 25 .foregroundColor(color) 26 27 Circle() 28 .trim(from: 0.0, to: CGFloat(min(progress, 1.0))) 29 .stroke(style: StrokeStyle(lineWidth: 10, lineCap: .round, lineJoin: .round)) 30 .foregroundColor(color) 31 .rotationEffect(Angle(degrees: -90)) // Start from top 32 .animation(.easeOut(duration: 0.5), value: progress) 33 34 VStack { 35 Text(value) 36 .font(.title) 37 .fontWeight(.bold) 38 Text(unit) 39 .font(.caption) 40 .foregroundColor(.secondary) 41 } 42 } 43 .frame(height: 100) // Angepasste Höhe für den Kreis 44 45 Text(label) 46 .font(.headline) 47 .fontWeight(.medium) 48 .lineLimit(1) 49 .minimumScaleFactor(0.8) 50 .padding(.top, 8) 51 52 } else { 53 Text(label) 54 .font(.headline) 55 .fontWeight(.medium) 56 .lineLimit(1) 57 .minimumScaleFactor(0.8) 58 .frame(height: 40) // Fixed height for labels to ensure alignment 59 Spacer() 60 Text(value) 61 .font(.largeTitle) 62 .fontWeight(.bold) 63 .foregroundColor(color) 64 Text(unit) 65 .font(.caption) 66 .foregroundColor(.secondary) 67 Spacer() 68 } 69 } 70 .frame(maxWidth: .infinity, minHeight: 140) // Mindesthöhe für alle Kacheln 71 .padding() 72 .background(Color(.secondarySystemGroupedBackground)) 73 .cornerRadius(16) 74 } 75 } 76 77 78 struct DailyView: View { 79 @ObservedObject var dataManager: FoodDataManager 80 @ObservedObject var workoutManager: WorkoutManager 81 @ObservedObject var userProfile: UserProfile 82 @State private var selectedDate = Date() 83 @State private var selectedMealType: MealType? = nil 84 @State private var showingPresetManager = false 85 @State private var showingReportView = false 86 @State private var showingWaterSheet = false 87 @State private var itemToEdit: FoodItem? = nil 88 89 private let columns = [GridItem(.flexible()), GridItem(.flexible())] 90 private let dashboardColumns = [GridItem(.flexible()), GridItem(.flexible())] 91 private let macroColumns = [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())] 92 93 var body: some View { 94 ScrollView { 95 VStack(spacing: 0) { 96 // Header Section with DatePicker 97 DatePicker("Datum", selection: $selectedDate, displayedComponents: .date) 98 .datePickerStyle(.compact) 99 .padding([.horizontal, .top]) 100 .padding(.bottom, 8) // Added space after the date picker 101 .background(Color(.systemGroupedBackground)) 102 .onChange(of: selectedDate) { 103 // Aktualisiere HealthKit-Daten, wenn sich das Datum ändert 104 if let healthManager = dataManager.healthManager { 105 // Wenn es das heutige Datum ist, normale Aktualisierung 106 if Calendar.current.isDateInToday(selectedDate) { 107 healthManager.fetchTodaysData() 108 } 109 // Ansonsten werden die Daten bei Bedarf durch WorkoutManager abgerufen 110 } 111 } 112 113 // HealthKit Aktivitäten anzeigen, wenn verfügbar 114 if let healthManager = dataManager.healthManager { 115 HealthActivityView(healthManager: healthManager, date: selectedDate) 116 .padding(.horizontal) 117 .padding(.bottom) 118 } 119 120 // Dashboard Grid 121 LazyVGrid(columns: dashboardColumns, spacing: 8) { 122 // Main Net Calorie Tile with Progress 123 DashboardMetricView( 124 label: "Netto-Kalorien", 125 value: "\(nettoCaloriesTodayWithHealthKit)", 126 unit: "Ziel: \(dataManager.dailyCalorieGoal)", 127 color: calorieProgressWithHealthKit <= 1.0 ? .green : .red, 128 progress: calorieProgressWithHealthKit, 129 ) 130 .gridCellColumns(2) // Span across two columns 131 132 // Consumed and Burned Calories 133 DashboardMetricView( 134 label: "Aufgenommen", 135 value: "\(dataManager.totalCaloriesForDay(selectedDate))", 136 unit: "kcal", 137 color: .blue 138 ) 139 140 DashboardMetricView( 141 label: "Verbrannt", 142 value: "\(workoutManager.totalCaloriesBurnedWithHealthKit(date: selectedDate, healthManager: dataManager.healthManager))", 143 unit: "kcal", 144 color: .orange 145 ) 146 147 // Wasserkonsum mit + Button zum schnellen Hinzufügen 148 if let healthManager = dataManager.healthManager { 149 if Calendar.current.isDateInToday(selectedDate) { 150 // Für heute direkt den aktuellen Wert verwenden 151 WaterDashboardView( 152 healthManager: healthManager 153 ) 154 } else { 155 // Für andere Tage den Wasserwert dynamisch laden 156 WaterDashboardViewHistorical( 157 date: selectedDate, 158 healthManager: healthManager 159 ) 160 } 161 } else { 162 DashboardMetricView( 163 label: "Wasser", 164 value: "0", 165 unit: "ml", 166 color: .blue 167 ) 168 } 169 } 170 .padding(.horizontal) 171 .padding(.bottom) 172 .background(Color(.systemGroupedBackground)) 173 174 // Macro Nutrients Section 175 VStack(spacing: 0) { 176 Text("Makronährstoffe") 177 .font(.title2) 178 .fontWeight(.bold) 179 .frame(maxWidth: .infinity, alignment: .leading) 180 .padding(.vertical) 181 182 LazyVGrid(columns: macroColumns, spacing: 8) { 183 DashboardMetricView( 184 label: "Protein", 185 value: String(format: "%.0f", dataManager.totalProteinForDay(selectedDate)), 186 unit: "von \(String(format: "%.0f", dataManager.dailyProteinGoal))g", 187 color: macroColor(current: dataManager.totalProteinForDay(selectedDate), goal: dataManager.dailyProteinGoal) 188 ) 189 .equalsFrame(in: macroColumns) 190 191 DashboardMetricView( 192 label: "Kohlenh.", 193 value: String(format: "%.0f", dataManager.totalCarbsForDay(selectedDate)), 194 unit: "von \(String(format: "%.0f", dataManager.dailyCarbsGoal))g", 195 color: macroColor(current: dataManager.totalCarbsForDay(selectedDate), goal: dataManager.dailyCarbsGoal) 196 ) 197 .equalsFrame(in: macroColumns) 198 199 DashboardMetricView( 200 label: "Fett", 201 value: String(format: "%.0f", dataManager.totalFatForDay(selectedDate)), 202 unit: "von \(String(format: "%.0f", dataManager.dailyFatGoal))g", 203 color: macroColor(current: dataManager.totalFatForDay(selectedDate), goal: dataManager.dailyFatGoal) 204 ) 205 .equalsFrame(in: macroColumns) 206 } 207 } 208 .padding() 209 .background(Color(.systemGroupedBackground)) 210 211 // Food items list section 212 foodList 213 } 214 } 215 .navigationTitle("Tagesübersicht") 216 .navigationBarTitleDisplayMode(.large) 217 .refreshable { 218 // Einfache Pull-to-Refresh Funktionalität 219 dataManager.objectWillChange.send() 220 } 221 .background(Color(.systemGroupedBackground).edgesIgnoringSafeArea(.all)) 222 .sheet(isPresented: $showingPresetManager) { 223 MealPresetView(dataManager: dataManager) 224 } 225 .sheet(isPresented: $showingReportView) { 226 NavigationStack { 227 ReportView(dataManager: dataManager) 228 } 229 } 230 .sheet(item: $itemToEdit) { item in 231 EditFoodView(dataManager: dataManager, foodItem: item) 232 } 233 .sheet(isPresented: $showingWaterSheet) { 234 if let healthManager = dataManager.healthManager { 235 AddWaterView(healthManager: healthManager, isPresented: $showingWaterSheet) 236 } 237 } 238 } 239 240 // Funktion zum Anzeigen des Wasser-Eingabe-Sheets 241 private func showWaterIntakeSheet() { 242 showingWaterSheet = true 243 } 244 245 // The list of food items, extracted for clarity 246 private var foodList: some View { 247 VStack(alignment: .leading, spacing: 16) { 248 HStack { 249 Text("Heutige Mahlzeiten") 250 .font(.title2) 251 .fontWeight(.bold) 252 Spacer() 253 Button("Vorlagen") { 254 showingPresetManager = true 255 } 256 .buttonStyle(.bordered) 257 } 258 .padding(.horizontal) 259 260 Picker("Mahlzeit filtern", selection: $selectedMealType) { 261 Text("Alle").tag(nil as MealType?) 262 ForEach(MealType.allCases, id: \.self) { type in 263 Text(type.rawValue).tag(type as MealType?) 264 } 265 } 266 .pickerStyle(.segmented) 267 .padding(.horizontal) 268 269 if dataManager.foodItemsForDay(selectedDate, mealType: selectedMealType).isEmpty { 270 emptyStateView 271 } else { 272 // Mahlzeiten nach Typ gruppieren 273 if selectedMealType == nil { 274 ForEach(MealType.allCases, id: \.self) { mealType in 275 let mealItems = dataManager.foodItemsForDay(selectedDate, mealType: mealType) 276 if !mealItems.isEmpty { 277 mealSection(title: mealType.rawValue, icon: mealType.icon, items: mealItems, mealType: mealType) 278 } 279 } 280 } else { 281 // Zeige nur die ausgewählte Mahlzeitenart 282 let filteredItems = dataManager.foodItemsForDay(selectedDate, mealType: selectedMealType) 283 mealSection(title: selectedMealType!.rawValue, icon: selectedMealType!.icon, items: filteredItems, mealType: selectedMealType) 284 } 285 } 286 } 287 .padding(.top) 288 .padding(.bottom, 8) 289 } 290 291 // A view for an empty list of food items 292 private var emptyStateView: some View { 293 VStack(spacing: 12) { 294 Image(systemName: "fork.knife.circle") 295 .font(.system(size: 50)) 296 .foregroundColor(.secondary) 297 Text("Keine Einträge") 298 .font(.headline) 299 Text("Füge deine erste Mahlzeit für \(formattedDate) hinzu.") 300 .foregroundColor(.secondary) 301 .multilineTextAlignment(.center) 302 } 303 .frame(maxWidth: .infinity) 304 .padding() 305 .background(Color(.secondarySystemGroupedBackground)) 306 .cornerRadius(16) 307 .padding() 308 } 309 310 // A reusable view for a meal section 311 private func mealSection(title: String, icon: String, items: [FoodItem], mealType: MealType?) -> some View { 312 VStack(alignment: .leading, spacing: 0) { 313 Label(title, systemImage: icon) 314 .font(.headline) 315 .padding([.horizontal, .top]) 316 .padding(.bottom, 8) 317 318 // Using List instead of VStack for better swipe gesture support 319 List { 320 ForEach(items) { item in 321 foodItemRow(item) 322 .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) 323 .listRowBackground(Color.clear) 324 .swipeActions(edge: .trailing) { 325 Button(role: .destructive) { 326 if let index = dataManager.foodItems.firstIndex(where: { $0.id == item.id }) { 327 dataManager.foodItems.remove(at: index) 328 } 329 } label: { 330 Label("Löschen", systemImage: "trash") 331 } 332 } 333 } 334 .listRowSeparator(.hidden) 335 } 336 .listStyle(PlainListStyle()) 337 .environment(\.defaultMinListRowHeight, 0) 338 .background(Color(.secondarySystemGroupedBackground)) 339 .cornerRadius(12) 340 .scrollDisabled(true) 341 .frame(height: CGFloat(items.count) * 72) 342 .padding(0) 343 } 344 .padding(.horizontal) 345 } 346 347 private func foodItemRow(_ item: FoodItem) -> some View { 348 Button(action: { 349 itemToEdit = item 350 }) { 351 VStack(alignment: .leading, spacing: 6) { 352 HStack { 353 VStack(alignment: .leading, spacing: 2) { 354 Text(item.name) 355 .font(.subheadline) 356 .fontWeight(.medium) 357 .foregroundColor(.primary) 358 Text("\(formattedTime(for: item.date))") 359 .font(.caption2) 360 .foregroundColor(.secondary) 361 } 362 363 Spacer() 364 365 HStack(spacing: 2) { 366 Text("\(item.calories)") 367 .font(.headline) 368 .fontWeight(.semibold) 369 .foregroundColor(.primary) 370 Text("kcal") 371 .font(.caption) 372 .foregroundColor(.secondary) 373 } 374 } 375 376 HStack(spacing: 10) { 377 Text("P: \(String(format: "%.0f", item.protein))g") 378 .font(.caption) 379 .foregroundColor(Color(red: 0xfe/255, green: 0x86/255, blue: 0x61/255)) 380 381 Text("K: \(String(format: "%.0f", item.carbs))g") 382 .font(.caption) 383 .foregroundColor(Color(red: 0x5D/255, green: 0xB9/255, blue: 0x85/255)) 384 385 Text("F: \(String(format: "%.0f", item.fat))g") 386 .font(.caption) 387 .foregroundColor(Color(red: 0xFF/255, green: 0xD2/255, blue: 0x5F/255)) 388 } 389 } 390 .padding(.top, 12) 391 .padding(.bottom, 12) 392 .padding(.horizontal, 16) 393 .background(Color(.secondarySystemGroupedBackground)) 394 } 395 .buttonStyle(PlainButtonStyle()) 396 .contextMenu { 397 Button(action: { 398 itemToEdit = item 399 }) { 400 Label("Bearbeiten", systemImage: "pencil") 401 } 402 403 Button(role: .destructive, action: { 404 if let index = dataManager.foodItems.firstIndex(where: { $0.id == item.id }) { 405 dataManager.foodItems.remove(at: index) 406 } 407 }) { 408 Label("Löschen", systemImage: "trash") 409 } 410 } 411 } 412 413 private var foodItems: [FoodItem] { 414 dataManager.foodItemsForDay(selectedDate) 415 } 416 417 private var formattedDate: String { 418 let formatter = DateFormatter() 419 formatter.dateStyle = .medium 420 return formatter.string(from: selectedDate) 421 } 422 423 private func formattedTime(for date: Date) -> String { 424 let formatter = DateFormatter() 425 formatter.timeStyle = .short 426 return formatter.string(from: date) 427 } 428 429 private func deleteFoodItems(at offsets: IndexSet, for mealType: MealType?) { 430 // Wir müssen die Indizes aus der gefilterten Liste auf die Hauptliste mappen 431 let dayItems = dataManager.foodItemsForDay(selectedDate, mealType: mealType) 432 let itemsToDelete = offsets.map { dayItems[$0] } 433 434 for item in itemsToDelete { 435 if let index = dataManager.foodItems.firstIndex(where: { $0.id == item.id }) { 436 dataManager.foodItems.remove(at: index) 437 } 438 } 439 } 440 441 // Berechnung der Netto-Kalorien (Aufnahme - Verbrauch) nur mit manuellen Workouts 442 private var nettoCaloriesToday: Int { 443 let consumed = dataManager.totalCaloriesForDay(selectedDate) 444 let burned = workoutManager.totalCaloriesBurnedForDay(selectedDate) 445 return consumed - burned 446 } 447 448 // Berechnung der Netto-Kalorien (Aufnahme - Verbrauch) mit HealthKit-Daten 449 private var nettoCaloriesTodayWithHealthKit: Int { 450 let consumed = dataManager.totalCaloriesForDay(selectedDate) 451 let burned = workoutManager.totalCaloriesBurnedWithHealthKit(date: selectedDate, healthManager: dataManager.healthManager) 452 let netto = consumed - burned 453 print("DEBUG DailyView: consumed=\(consumed), burned=\(burned), netto=\(netto)") 454 return netto // Subtrahiere verbrannte Kalorien (0 gegessen - 300 verbrannt = -300) 455 } 456 457 // Berechnung des Fortschritts für die Netto-Kalorien ohne HealthKit 458 private var calorieProgress: Double { 459 if dataManager.dailyCalorieGoal <= 0 { 460 return 0.0 461 } 462 return Double(nettoCaloriesToday) / Double(dataManager.dailyCalorieGoal) 463 } 464 465 // Berechnung des Fortschritts für die Netto-Kalorien mit HealthKit 466 private var calorieProgressWithHealthKit: Double { 467 if dataManager.dailyCalorieGoal <= 0 { 468 return 0.0 469 } 470 return Double(nettoCaloriesTodayWithHealthKit) / Double(dataManager.dailyCalorieGoal) 471 } 472 473 // Berechnet die Farbe für Makronährstoffe basierend auf Zielerreichung 474 private func macroColor(current: Double, goal: Double) -> Color { 475 guard goal > 0 else { return .gray } 476 477 let percentage = (current / goal) * 100 478 let difference = abs(percentage - 100) 479 480 // Grün: ±10% vom Ziel (90-110%) 481 if difference <= 10 { 482 return Color(red: 0x5D/255, green: 0xB9/255, blue: 0x85/255) 483 } 484 // Gelb: ±25% vom Ziel (75-125%) 485 else if difference <= 25 { 486 return Color(red: 0xFF/255, green: 0xD2/255, blue: 0x5F/255) 487 } 488 // Orange/Rot: >25% Abweichung 489 else { 490 return Color(red: 0xfe/255, green: 0x86/255, blue: 0x61/255) 491 } 492 } 493 } 494 495 #Preview { 496 NavigationStack { 497 DailyView( 498 dataManager: FoodDataManager(), 499 workoutManager: WorkoutManager(), 500 userProfile: UserProfile() 501 ) 502 } 503 } 504 505 // ViewModifier zum Erzwingen gleicher Kachelgrößen in einem Grid 506 extension View { 507 func equalsFrame(in columns: [GridItem]) -> some View { 508 self.frame(maxWidth: .infinity, minHeight: 140) 509 } 510 }