Files
FamilyMealPlanner/MealMood/Views/Stats/StatsView.swift
T
alexandrev-tibco 5272f6533f 2.0: fix crash de arranque por IDs duplicados (CloudKit)
Fatal error "Duplicate values for key" al construir diccionarios con
Dictionary(uniqueKeysWithValues:) sobre dishes/tags/slots cuyos UUID se
duplican por la sincronización CloudKit de 2.0. Se sustituyen todas las
ocurrencias por Dictionary(_, uniquingKeysWith:) para tolerar duplicados
(se conserva el primero) en lugar de abortar. La app ya no crashea al
abrir con datos duplicados; la deduplicación real queda pendiente aparte.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A3HaWmmtTQ1vdTERtSYU6p
2026-07-22 18:23:50 +02:00

319 lines
10 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import SwiftUI
struct StatsView: View {
let weekPlans: [WeekPlan]
let allDishes: [Dish]
let allTags: [Tag]
let language: AppLanguage
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 24) {
summaryGrid
topDishesSection
tagBreakdownSection
}
.padding(.horizontal, 16)
.padding(.vertical, 20)
}
.background(Color.mealMoodBackground.ignoresSafeArea())
.onAppear { AnalyticsService.logStatsViewed() }
.navigationTitle("stats_title")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("tag_selector_done") { dismiss() }
}
}
}
}
// MARK: - Summary grid
private var summaryGrid: some View {
VStack(spacing: 12) {
HStack(spacing: 12) {
StatCard(
icon: "flame.fill",
iconColor: .mealMoodCoral,
value: "\(streak)",
label: "stats_streak_label"
)
StatCard(
icon: "calendar",
iconColor: .mealMoodMint,
value: "\(weeksWithAnyDish)",
label: "stats_weeks_planned_label"
)
}
HStack(spacing: 12) {
StatCard(
icon: "checkmark.circle.fill",
iconColor: .mealMoodSuccess,
value: "\(completeWeeks)",
label: "stats_complete_weeks_label"
)
StatCard(
icon: "chart.bar.fill",
iconColor: .mealMoodWarning,
value: completionRateText,
label: "stats_completion_rate_label"
)
}
}
}
// MARK: - Top dishes
private var topDishesSection: some View {
VStack(alignment: .leading, spacing: 12) {
Text("stats_top_dishes_title")
.font(.mealMoodH3)
.foregroundColor(.mealMoodTextPrimary)
if topDishes.isEmpty {
Text("stats_no_data")
.font(.mealMoodBody)
.foregroundColor(.mealMoodTextSecondary)
.frame(maxWidth: .infinity, alignment: .center)
.padding(.vertical, 16)
} else {
VStack(spacing: 0) {
ForEach(Array(topDishes.enumerated()), id: \.element.dish.id) { index, entry in
TopDishRow(
rank: index + 1,
dishName: entry.dish.name,
count: entry.count,
maxCount: topDishes.first?.count ?? 1
)
if index < topDishes.count - 1 {
Divider().padding(.leading, 44)
}
}
}
.background(Color.mealMoodSurface)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
}
}
// MARK: - Tag breakdown
private var tagBreakdownSection: some View {
VStack(alignment: .leading, spacing: 12) {
Text("stats_tags_title")
.font(.mealMoodH3)
.foregroundColor(.mealMoodTextPrimary)
if tagUsage.isEmpty {
Text("stats_no_data")
.font(.mealMoodBody)
.foregroundColor(.mealMoodTextSecondary)
.frame(maxWidth: .infinity, alignment: .center)
.padding(.vertical, 16)
} else {
VStack(spacing: 10) {
ForEach(tagUsage, id: \.tag.id) { entry in
TagBarRow(tagName: entry.tag.localizedName(language: language), tagColor: entry.tag.color, count: entry.count, maxCount: tagUsage.first?.count ?? 1)
}
}
.padding(14)
.background(Color.mealMoodSurface)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
}
}
// MARK: - Computed stats
private var plannedWeeks: [WeekPlan] {
weekPlans.filter { plan in plan.slotList.contains { $0.dishId != nil } }
}
private var weeksWithAnyDish: Int { plannedWeeks.count }
private var completeWeeks: Int {
weekPlans.filter { plan in
!plan.slotList.isEmpty && plan.slotList.allSatisfy { $0.dishId != nil || $0.isEatingOut }
}.count
}
private var completionRateText: String {
guard weeksWithAnyDish > 0 else { return "" }
let rate = Int(Double(completeWeeks) / Double(weeksWithAnyDish) * 100)
return "\(rate)%"
}
private var streak: Int {
let sorted = plannedWeeks
.map { $0.weekStartDate.startOfWeek() }
.sorted(by: >)
guard !sorted.isEmpty else { return 0 }
let calendar = Calendar.current
var count = 1
var previous = sorted[0]
for date in sorted.dropFirst() {
let diff = calendar.dateComponents([.day], from: date, to: previous).day ?? 0
if diff <= 7 {
count += 1
previous = date
} else {
break
}
}
return count
}
private var dishUsage: [UUID: Int] {
var counts: [UUID: Int] = [:]
for plan in weekPlans {
for slot in plan.slotList {
if let id = slot.dishId { counts[id, default: 0] += 1 }
}
}
return counts
}
private struct DishEntry { let dish: Dish; let count: Int }
private var topDishes: [DishEntry] {
let usage = dishUsage
let dishMap = Dictionary(allDishes.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first })
return usage
.compactMap { id, count in dishMap[id].map { DishEntry(dish: $0, count: count) } }
.sorted { $0.count > $1.count }
.prefix(8)
.map { $0 }
}
private struct TagEntry { let tag: Tag; let count: Int }
private var tagUsage: [TagEntry] {
let dishMap = Dictionary(allDishes.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first })
let tagMap = Dictionary(allTags.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first })
var counts: [UUID: Int] = [:]
for plan in weekPlans {
for slot in plan.slotList {
guard let dishId = slot.dishId, let dish = dishMap[dishId] else { continue }
for tagId in dish.tagIds { counts[tagId, default: 0] += 1 }
}
}
return counts
.compactMap { id, count in tagMap[id].map { TagEntry(tag: $0, count: count) } }
.sorted { $0.count > $1.count }
.prefix(6)
.map { $0 }
}
}
// MARK: - Sub-views
private struct StatCard: View {
let icon: String
let iconColor: Color
let value: String
let label: LocalizedStringKey
var body: some View {
VStack(spacing: 8) {
Image(systemName: icon)
.font(.system(size: 24))
.foregroundColor(iconColor)
Text(value)
.font(.mealMoodH2)
.foregroundColor(.mealMoodTextPrimary)
Text(label)
.font(.mealMoodCaption)
.foregroundColor(.mealMoodTextSecondary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 18)
.background(Color.mealMoodSurface)
.clipShape(RoundedRectangle(cornerRadius: 14))
}
}
private struct TopDishRow: View {
let rank: Int
let dishName: String
let count: Int
let maxCount: Int
var body: some View {
HStack(spacing: 12) {
Text("\(rank)")
.font(.mealMoodCaption)
.foregroundColor(.mealMoodTextSecondary)
.frame(width: 20, alignment: .center)
VStack(alignment: .leading, spacing: 4) {
Text(dishName)
.font(.mealMoodBody)
.foregroundColor(.mealMoodTextPrimary)
.lineLimit(1)
GeometryReader { geo in
RoundedRectangle(cornerRadius: 3)
.fill(Color.mealMoodCoral.opacity(0.25))
.frame(width: geo.size.width, height: 4)
.overlay(alignment: .leading) {
RoundedRectangle(cornerRadius: 3)
.fill(Color.mealMoodCoral)
.frame(width: geo.size.width * CGFloat(count) / CGFloat(maxCount), height: 4)
}
}
.frame(height: 4)
}
Text("\(count)×")
.font(.mealMoodCaption)
.foregroundColor(.mealMoodTextSecondary)
.frame(width: 28, alignment: .trailing)
}
.padding(.horizontal, 14)
.padding(.vertical, 12)
}
}
private struct TagBarRow: View {
let tagName: String
let tagColor: String
let count: Int
let maxCount: Int
var body: some View {
HStack(spacing: 10) {
Circle()
.fill(Color(hex: tagColor))
.frame(width: 10, height: 10)
Text(tagName)
.font(.mealMoodSmall)
.foregroundColor(.mealMoodTextPrimary)
.frame(width: 90, alignment: .leading)
.lineLimit(1)
GeometryReader { geo in
RoundedRectangle(cornerRadius: 3)
.fill(Color(hex: tagColor).opacity(0.2))
.frame(width: geo.size.width, height: 8)
.overlay(alignment: .leading) {
RoundedRectangle(cornerRadius: 3)
.fill(Color(hex: tagColor))
.frame(width: geo.size.width * CGFloat(count) / CGFloat(maxCount), height: 8)
}
}
.frame(height: 8)
Text("\(count)")
.font(.mealMoodCaption)
.foregroundColor(.mealMoodTextSecondary)
.frame(width: 28, alignment: .trailing)
}
}
}