Files
FamilyMealPlanner/MealMood/Views/Stats/StatsView.swift
T
alexandrev-tibco a0f9fa63d3 platos: segundo plato por comida (ensalada + salmon)
- MealSlot.secondaryDishId (opcional, aditivo CloudKit); se limpia al
  sustituir/quitar/vaciar/marcar comer fuera y viaja en swaps, copia de
  semana anterior, undo y snapshot KV de iCloud (retrocompatible)
- Picker de hueco lleno: toggle "anadir como segundo plato" + boton para
  quitarlo; HomeViewModel.assignSecondaryDish/removeSecondaryDish
- Se muestra "Plato + Segundo" en calendario, widget y export; la lista
  de la compra incluye los ingredientes del segundo; stats lo cuentan
- Evento analytics secondary_dish_added; strings en 6 idiomas

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013H6bXqGX1ygwib1Dm3n3UG
2026-09-01 11:37:23 +02:00

322 lines
11 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 }
if let id = slot.secondaryDishId { 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 {
for dishId in [slot.dishId, slot.secondaryDishId].compactMap({ $0 }) {
guard 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)
}
}
}