1.1.0: Smarter autocomplete + Statistics view
- AutocompleteEngine: MRV heuristic picks the most-constrained slot first at each step; historical scoring deprioritises dishes used in recent weeks (decay: 0.20 last week → 0.90 four+ weeks ago) - HomeViewModel: passes last 4 weeks as recentPlans to autocomplete - StatsView (Premium): streak, weeks planned, completion rate, top 8 dishes with bar charts, top 6 tags with colour bars — 6 languages - StoreManager: update product ID to approved bundle-ID convention - Version bump to 1.1.3 / build 48 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -85,6 +85,7 @@
|
||||
FB0011111111111111111106 /* AnalyticsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB0011111111111111111107 /* AnalyticsService.swift */; };
|
||||
FB0011111111111111111108 /* FirebaseCrashlytics in Frameworks */ = {isa = PBXBuildFile; productRef = FB001111111111111111110B /* FirebaseCrashlytics */; };
|
||||
FB0011111111111111111109 /* CrashlyticsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB001111111111111111110A /* CrashlyticsService.swift */; };
|
||||
443CC3997A9A4818B51066FB /* StatsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E8AD94FC874467D863D464C /* StatsView.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
@@ -198,6 +199,7 @@
|
||||
FD621CE9B44512AE8894D92E /* OnboardingViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingViewModel.swift; sourceTree = "<group>"; };
|
||||
FE73D5B7AF8B5E2D63105977 /* Font+MealMood.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Font+MealMood.swift"; sourceTree = "<group>"; };
|
||||
FF2DE77AD57DB6DAB188CD9E /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
1E8AD94FC874467D863D464C /* StatsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatsView.swift; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
@@ -357,10 +359,19 @@
|
||||
35F15C6817E504B1114420CD /* Premium */,
|
||||
4B80B7CABE3B737897455A26 /* Settings */,
|
||||
CE41931687CCC60F76A2B6D9 /* Tags */,
|
||||
0D63499EC79D43728D1CA8C7 /* Stats */,
|
||||
);
|
||||
path = Views;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
0D63499EC79D43728D1CA8C7 /* Stats */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
1E8AD94FC874467D863D464C /* StatsView.swift */,
|
||||
);
|
||||
path = Stats;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
8BE925662F55707DF083677A /* Dishes */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -685,6 +696,7 @@
|
||||
233B9C1A5CA7E938318491C0 /* SocialShareSheet.swift in Sources */,
|
||||
25B3C328E088A403CABAF20A /* PremiumSyncService.swift in Sources */,
|
||||
FB0011111111111111111109 /* CrashlyticsService.swift in Sources */,
|
||||
443CC3997A9A4818B51066FB /* StatsView.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
|
||||
@@ -15,9 +15,9 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.1.2</string>
|
||||
<string>1.1.3</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>47</string>
|
||||
<string>48</string>
|
||||
<key>GADApplicationIdentifier</key>
|
||||
<string>ca-app-pub-1549720748100858~9985112590</string>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
|
||||
@@ -180,7 +180,7 @@
|
||||
"premium_yearly" = "Jährlich";
|
||||
"premium_save" = "44 % sparen";
|
||||
"premium_month" = "Monat";
|
||||
"premium_price_note" = "2,99 EUR pro Monat";
|
||||
"premium_price_note" = "Jederzeit über dein Apple-Konto kündbar.";
|
||||
"premium_terms_note" = "Automatisch verlängerbares Abo. Jederzeit in deinen Apple-Kontoeinstellungen kündbar.";
|
||||
"premium_active" = "Premium ist aktiv";
|
||||
"premium_subscribe" = "Abonnieren";
|
||||
@@ -324,3 +324,4 @@
|
||||
"home_auto_assign_cta_button" = "Auto-ausfüllen";
|
||||
"notification_sunday_title" = "Plane deine nächste Woche";
|
||||
"notification_sunday_body" = "Es ist Sonntag — nimm dir 2 Minuten, um die Mahlzeiten der nächsten Woche zu planen.";
|
||||
|
||||
|
||||
@@ -180,7 +180,7 @@
|
||||
"premium_yearly" = "Yearly";
|
||||
"premium_save" = "Save 44%";
|
||||
"premium_month" = "month";
|
||||
"premium_price_note" = "2.99 EUR per month";
|
||||
"premium_price_note" = "Cancel anytime from your Apple account settings.";
|
||||
"premium_terms_note" = "Auto-renewing subscription. Cancel anytime from your Apple account settings.";
|
||||
"premium_active" = "Premium is active";
|
||||
"premium_subscribe" = "Subscribe";
|
||||
@@ -324,3 +324,4 @@
|
||||
"home_auto_assign_cta_button" = "Auto-assign";
|
||||
"notification_sunday_title" = "Plan your next week";
|
||||
"notification_sunday_body" = "Sunday is here — take 2 minutes to plan next week's meals.";
|
||||
|
||||
|
||||
@@ -180,7 +180,7 @@
|
||||
"premium_yearly" = "Anual";
|
||||
"premium_save" = "Ahorra 44%";
|
||||
"premium_month" = "mes";
|
||||
"premium_price_note" = "2,99 EUR al mes";
|
||||
"premium_price_note" = "Cancela cuando quieras desde tu cuenta de Apple.";
|
||||
"premium_terms_note" = "Suscripción renovable automáticamente. Cancela cuando quieras desde los ajustes de Apple.";
|
||||
"premium_active" = "Premium activo";
|
||||
"premium_subscribe" = "Suscribirse";
|
||||
@@ -324,3 +324,4 @@
|
||||
"home_auto_assign_cta_button" = "Auto-rellenar";
|
||||
"notification_sunday_title" = "Planifica la semana que viene";
|
||||
"notification_sunday_body" = "Es domingo — tómate 2 minutos para planificar las comidas de la próxima semana.";
|
||||
|
||||
|
||||
@@ -180,7 +180,7 @@
|
||||
"premium_yearly" = "Annuel";
|
||||
"premium_save" = "Économisez 44 %";
|
||||
"premium_month" = "mois";
|
||||
"premium_price_note" = "2,99 EUR par mois";
|
||||
"premium_price_note" = "Annulez à tout moment depuis votre compte Apple.";
|
||||
"premium_terms_note" = "Abonnement à renouvellement automatique. Annulez à tout moment dans les réglages de votre compte Apple.";
|
||||
"premium_active" = "Premium est actif";
|
||||
"premium_subscribe" = "S'abonner";
|
||||
@@ -324,3 +324,4 @@
|
||||
"home_auto_assign_cta_button" = "Auto-remplir";
|
||||
"notification_sunday_title" = "Planifiez votre prochaine semaine";
|
||||
"notification_sunday_body" = "C'est dimanche — prenez 2 minutes pour planifier vos repas de la semaine prochaine.";
|
||||
|
||||
|
||||
@@ -180,7 +180,7 @@
|
||||
"premium_yearly" = "Annuale";
|
||||
"premium_save" = "Risparmia il 44%";
|
||||
"premium_month" = "mese";
|
||||
"premium_price_note" = "2,99 EUR al mese";
|
||||
"premium_price_note" = "Annulla quando vuoi dalle impostazioni del tuo account Apple.";
|
||||
"premium_terms_note" = "Abbonamento con rinnovo automatico. Annulla quando vuoi dalle impostazioni del tuo account Apple.";
|
||||
"premium_active" = "Premium è attivo";
|
||||
"premium_subscribe" = "Abbonati";
|
||||
@@ -324,3 +324,4 @@
|
||||
"home_auto_assign_cta_button" = "Auto-assegna";
|
||||
"notification_sunday_title" = "Pianifica la prossima settimana";
|
||||
"notification_sunday_body" = "È domenica — prenditi 2 minuti per pianificare i pasti della prossima settimana.";
|
||||
|
||||
|
||||
@@ -180,7 +180,7 @@
|
||||
"premium_yearly" = "Anual";
|
||||
"premium_save" = "Economize 44%";
|
||||
"premium_month" = "mês";
|
||||
"premium_price_note" = "2,99 EUR por mês";
|
||||
"premium_price_note" = "Cancele quando quiser nas configurações da sua conta Apple.";
|
||||
"premium_terms_note" = "Assinatura com renovação automática. Cancele a qualquer momento nos ajustes da sua conta Apple.";
|
||||
"premium_active" = "O Premium está ativo";
|
||||
"premium_subscribe" = "Assinar";
|
||||
@@ -324,3 +324,4 @@
|
||||
"home_auto_assign_cta_button" = "Auto-atribuir";
|
||||
"notification_sunday_title" = "Planeje a próxima semana";
|
||||
"notification_sunday_body" = "É domingo — reserve 2 minutos para planejar as refeições da próxima semana.";
|
||||
|
||||
|
||||
@@ -15,44 +15,44 @@ struct AutocompleteEngine {
|
||||
let dishName: String
|
||||
}
|
||||
|
||||
// recentPlans: up to 4 previous weeks (sorted newest-first) for historical scoring.
|
||||
static func autocomplete(
|
||||
emptySlots: [MealSlot],
|
||||
currentPlan: WeekPlan,
|
||||
allDishes: [Dish],
|
||||
allTags: [Tag]
|
||||
allTags: [Tag],
|
||||
recentPlans: [WeekPlan] = []
|
||||
) -> AutocompleteResult {
|
||||
let tagMap = Dictionary(uniqueKeysWithValues: allTags.map { ($0.id, $0) })
|
||||
let dishMap = Dictionary(uniqueKeysWithValues: allDishes.map { ($0.id, $0) })
|
||||
let historyScores = historicalScores(dishes: allDishes, recentPlans: recentPlans)
|
||||
|
||||
var filledSlots: [(slotId: UUID, dishId: UUID)] = []
|
||||
var unfilledCount = 0
|
||||
var pendingSlots = emptySlots.filter { !$0.isEatingOut }
|
||||
|
||||
let sortedEmpty = emptySlots
|
||||
.filter { !$0.isEatingOut }
|
||||
.sorted { ($0.dayOfWeek, $0.mealType) < ($1.dayOfWeek, $1.mealType) }
|
||||
|
||||
for slot in sortedEmpty {
|
||||
let strictCandidates = allDishes.filter { dish in
|
||||
!violatesRules(
|
||||
dish: dish,
|
||||
slot: slot,
|
||||
plan: currentPlan,
|
||||
tagMap: tagMap,
|
||||
dishMap: dishMap
|
||||
)
|
||||
// MRV: at each step pick the slot with fewest valid candidates first.
|
||||
// Assignments update currentPlan.slots in-place, so subsequent candidate
|
||||
// counts automatically reflect the growing set of committed dishes.
|
||||
while !pendingSlots.isEmpty {
|
||||
// Score each remaining slot by strict candidate count
|
||||
let ranked = pendingSlots
|
||||
.map { slot -> (slot: MealSlot, strict: [Dish]) in
|
||||
let strict = allDishes.filter {
|
||||
!violatesRules(dish: $0, slot: slot, plan: currentPlan, tagMap: tagMap, dishMap: dishMap)
|
||||
}
|
||||
return (slot, strict)
|
||||
}
|
||||
.sorted { $0.strict.count < $1.strict.count }
|
||||
|
||||
// Fallback: if strict rules cannot fill a slot, allow repeating dishes
|
||||
// that only fail the implicit "no repeat this week" rule.
|
||||
let (slot, strictCandidates) = ranked[0]
|
||||
pendingSlots.removeAll { $0.id == slot.id }
|
||||
|
||||
// Fallback: relax the implicit no-repeat rule if strict set is empty
|
||||
let candidates: [Dish]
|
||||
if strictCandidates.isEmpty {
|
||||
candidates = allDishes.filter { dish in
|
||||
!violatesExplicitRules(
|
||||
dish: dish,
|
||||
slot: slot,
|
||||
plan: currentPlan,
|
||||
tagMap: tagMap,
|
||||
dishMap: dishMap
|
||||
)
|
||||
candidates = allDishes.filter {
|
||||
!violatesExplicitRules(dish: $0, slot: slot, plan: currentPlan, tagMap: tagMap, dishMap: dishMap)
|
||||
}
|
||||
} else {
|
||||
candidates = strictCandidates
|
||||
@@ -63,7 +63,7 @@ struct AutocompleteEngine {
|
||||
continue
|
||||
}
|
||||
|
||||
if let picked = weightedRandomPick(candidates: candidates, plan: currentPlan) {
|
||||
if let picked = weightedRandomPick(candidates: candidates, plan: currentPlan, historyScores: historyScores) {
|
||||
slot.dishId = picked.id
|
||||
filledSlots.append((slotId: slot.id, dishId: picked.id))
|
||||
} else {
|
||||
@@ -103,6 +103,8 @@ struct AutocompleteEngine {
|
||||
return !violatesRules(dish: dish, slot: slot, plan: plan, tagMap: tagMap, dishMap: dishMap)
|
||||
}
|
||||
|
||||
// MARK: - Rule checks
|
||||
|
||||
private static func violatesRules(
|
||||
dish: Dish,
|
||||
slot: MealSlot,
|
||||
@@ -110,20 +112,12 @@ struct AutocompleteEngine {
|
||||
tagMap: [UUID: Tag],
|
||||
dishMap: [UUID: Dish]
|
||||
) -> Bool {
|
||||
violatesDefaultNoRepeatRule(dish: dish, slot: slot, plan: plan, tagMap: tagMap) ||
|
||||
violatesDefaultNoRepeatRule(dish: dish, slot: slot, plan: plan) ||
|
||||
violatesExplicitRules(dish: dish, slot: slot, plan: plan, tagMap: tagMap, dishMap: dishMap)
|
||||
}
|
||||
|
||||
private static func violatesDefaultNoRepeatRule(
|
||||
dish: Dish,
|
||||
slot: MealSlot,
|
||||
plan: WeekPlan,
|
||||
tagMap: [UUID: Tag]
|
||||
) -> Bool {
|
||||
// Default behavior: avoid assigning the same dish twice in the same week,
|
||||
// regardless of whether it has explicit tag rules. The fallback path in
|
||||
// autocomplete() still allows repeating when no alternative exists.
|
||||
return plan.slots.contains { $0.id != slot.id && $0.dishId == dish.id }
|
||||
private static func violatesDefaultNoRepeatRule(dish: Dish, slot: MealSlot, plan: WeekPlan) -> Bool {
|
||||
plan.slots.contains { $0.id != slot.id && $0.dishId == dish.id }
|
||||
}
|
||||
|
||||
private static func violatesExplicitRules(
|
||||
@@ -136,7 +130,6 @@ struct AutocompleteEngine {
|
||||
for tagId in dish.tagIds {
|
||||
guard let tag = tagMap[tagId] else { continue }
|
||||
|
||||
// Max per week
|
||||
if let maxPerWeek = tag.maxPerWeek {
|
||||
var count = 0
|
||||
for s in plan.slots {
|
||||
@@ -146,7 +139,6 @@ struct AutocompleteEngine {
|
||||
if count >= maxPerWeek { return true }
|
||||
}
|
||||
|
||||
// No consecutive
|
||||
if tag.noConsecutive {
|
||||
for adjSlot in adjacentSlots(of: slot, in: plan) {
|
||||
guard let did = adjSlot.dishId, let d = dishMap[did] else { continue }
|
||||
@@ -154,7 +146,6 @@ struct AutocompleteEngine {
|
||||
}
|
||||
}
|
||||
|
||||
// No duplicate in day
|
||||
if tag.noDuplicateInDay {
|
||||
for sdSlot in plan.slots where sdSlot.dayOfWeek == slot.dayOfWeek && sdSlot.id != slot.id {
|
||||
guard let did = sdSlot.dishId, let d = dishMap[did] else { continue }
|
||||
@@ -162,12 +153,10 @@ struct AutocompleteEngine {
|
||||
}
|
||||
}
|
||||
|
||||
// Meal type restriction
|
||||
if let restriction = tag.mealTypeRestriction, slot.mealType != restriction {
|
||||
return true
|
||||
}
|
||||
|
||||
// Day restriction
|
||||
if let dayRestriction = tag.dayRestriction {
|
||||
switch dayRestriction {
|
||||
case "weekdays" where slot.dayOfWeek > 4: return true
|
||||
@@ -192,16 +181,50 @@ struct AutocompleteEngine {
|
||||
return result
|
||||
}
|
||||
|
||||
private static func weightedRandomPick(candidates: [Dish], plan: WeekPlan) -> Dish? {
|
||||
// MARK: - Historical scoring
|
||||
|
||||
// Returns a recency-penalty multiplier [0.0, 1.0] per dish.
|
||||
// Dishes used more recently get a lower multiplier (deprioritised).
|
||||
private static func historicalScores(dishes: [Dish], recentPlans: [WeekPlan]) -> [UUID: Double] {
|
||||
guard !recentPlans.isEmpty else { return [:] }
|
||||
let sorted = recentPlans.sorted { $0.weekStartDate > $1.weekStartDate }
|
||||
var scores: [UUID: Double] = [:]
|
||||
for dish in dishes {
|
||||
for (weekIndex, plan) in sorted.enumerated() {
|
||||
guard plan.slots.contains(where: { $0.dishId == dish.id }) else { continue }
|
||||
let multiplier: Double
|
||||
switch weekIndex {
|
||||
case 0: multiplier = 0.20 // used last week — strongly deprioritise
|
||||
case 1: multiplier = 0.50 // 2 weeks ago
|
||||
case 2: multiplier = 0.75 // 3 weeks ago
|
||||
default: multiplier = 0.90
|
||||
}
|
||||
scores[dish.id] = multiplier
|
||||
break // only the most recent appearance matters
|
||||
}
|
||||
}
|
||||
return scores
|
||||
}
|
||||
|
||||
// MARK: - Weighted pick
|
||||
|
||||
private static func weightedRandomPick(
|
||||
candidates: [Dish],
|
||||
plan: WeekPlan,
|
||||
historyScores: [UUID: Double] = [:]
|
||||
) -> Dish? {
|
||||
guard !candidates.isEmpty else { return nil }
|
||||
let weights: [Double] = candidates.map { dish in
|
||||
let usage = plan.slots.filter { $0.dishId == dish.id }.count
|
||||
let priorityBoost: Double = dish.isPriority ? 2.5 : 1.0
|
||||
let recencyMultiplier = historyScores[dish.id] ?? 1.0
|
||||
let baseWeight: Double
|
||||
switch usage {
|
||||
case 0: return 3.0 * priorityBoost
|
||||
case 1: return 2.0 * priorityBoost
|
||||
default: return 1.0 * priorityBoost
|
||||
case 0: baseWeight = 3.0
|
||||
case 1: baseWeight = 2.0
|
||||
default: baseWeight = 1.0
|
||||
}
|
||||
return baseWeight * priorityBoost * recencyMultiplier
|
||||
}
|
||||
let total = weights.reduce(0, +)
|
||||
var r = Double.random(in: 0..<total)
|
||||
|
||||
@@ -27,7 +27,7 @@ final class StoreManager: ObservableObject {
|
||||
@Published var debugLoadedProductIds: [String] = []
|
||||
@Published var debugLoadedProducts: [String] = []
|
||||
|
||||
static let monthlyProductId = "com.mealmood.premium.monthly.sub"
|
||||
static let monthlyProductId = "com.alexandrevazquez.mealmood.premium.monthly.sub"
|
||||
static let legacyOneTimeProductId = "com.mealmood.premium.monthly"
|
||||
private var productIds: [String] {
|
||||
var ids = [Self.monthlyProductId, Self.legacyOneTimeProductId]
|
||||
|
||||
@@ -113,16 +113,22 @@ final class HomeViewModel: ObservableObject {
|
||||
HapticManager.shared.notification(type: .warning)
|
||||
}
|
||||
|
||||
func autoComplete(plan: WeekPlan, dishes: [Dish], tags: [Tag], settings: AppSettings) {
|
||||
func autoComplete(plan: WeekPlan, dishes: [Dish], tags: [Tag], settings: AppSettings, allPlans: [WeekPlan] = []) {
|
||||
captureUndoSnapshot(plan: plan)
|
||||
isAutoCompleting = true
|
||||
|
||||
let recentPlans = allPlans
|
||||
.filter { $0.weekStartDate < plan.weekStartDate }
|
||||
.sorted { $0.weekStartDate > $1.weekStartDate }
|
||||
.prefix(4)
|
||||
|
||||
let emptySlots = plan.slots.filter { $0.dishId == nil && !$0.isEatingOut }
|
||||
let result = AutocompleteEngine.autocomplete(
|
||||
emptySlots: emptySlots,
|
||||
currentPlan: plan,
|
||||
allDishes: dishes,
|
||||
allTags: tags
|
||||
allTags: tags,
|
||||
recentPlans: Array(recentPlans)
|
||||
)
|
||||
|
||||
plan.updatedAt = Date()
|
||||
|
||||
@@ -14,6 +14,7 @@ struct HomeView: View {
|
||||
@State private var editingDish: Dish?
|
||||
@State private var showPremiumFromExport: Bool = false
|
||||
@State private var showMonthlyHistory: Bool = false
|
||||
@State private var showStats: Bool = false
|
||||
@State private var showWeekLimitUpsell: Bool = false
|
||||
@State private var wasWeekComplete: Bool = false
|
||||
@State private var selectedEmptySlotId: UUID?
|
||||
@@ -62,6 +63,12 @@ struct HomeView: View {
|
||||
}
|
||||
|
||||
if settings?.isPremium == true {
|
||||
Button {
|
||||
showStats = true
|
||||
} label: {
|
||||
Image(systemName: "chart.bar.xaxis")
|
||||
.foregroundColor(.mealMoodTextPrimary)
|
||||
}
|
||||
Button {
|
||||
showMonthlyHistory = true
|
||||
} label: {
|
||||
@@ -121,7 +128,7 @@ struct HomeView: View {
|
||||
let plan = fetchWeekPlan(for: viewModel.currentWeekStart) {
|
||||
if viewModel.canEditCurrentWeek {
|
||||
Button {
|
||||
viewModel.autoComplete(plan: plan, dishes: dishes, tags: tags, settings: settings)
|
||||
viewModel.autoComplete(plan: plan, dishes: dishes, tags: tags, settings: settings, allPlans: weekPlans)
|
||||
} label: {
|
||||
Image(systemName: "wand.and.stars")
|
||||
.foregroundColor(.mealMoodTextPrimary)
|
||||
@@ -287,7 +294,7 @@ struct HomeView: View {
|
||||
}
|
||||
.alert("onboarding_auto_assign_title", isPresented: $showPostOnboardingAutoAssignPrompt) {
|
||||
Button("onboarding_auto_assign_yes") {
|
||||
viewModel.autoComplete(plan: plan, dishes: dishes, tags: tags, settings: settings)
|
||||
viewModel.autoComplete(plan: plan, dishes: dishes, tags: tags, settings: settings, allPlans: weekPlans)
|
||||
UserDefaults.standard.set(false, forKey: OnboardingViewModel.pendingAutoAssignPromptKey)
|
||||
schedulePostOnboardingPremiumPromptIfNeeded(settings: settings)
|
||||
}
|
||||
@@ -495,6 +502,9 @@ struct HomeView: View {
|
||||
showMonthlyHistory = false
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showStats) {
|
||||
StatsView(weekPlans: weekPlans, allDishes: dishes, allTags: tags, language: settings.languageEnum.resolved())
|
||||
}
|
||||
.task {
|
||||
await NotificationService.shared.requestPermissionIfNeeded()
|
||||
let nextPlan = fetchWeekPlan(for: Date().startOfWeek().addingDays(7))
|
||||
@@ -717,7 +727,7 @@ struct HomeView: View {
|
||||
}
|
||||
Spacer()
|
||||
Button {
|
||||
viewModel.autoComplete(plan: plan, dishes: dishes, tags: tags, settings: settings)
|
||||
viewModel.autoComplete(plan: plan, dishes: dishes, tags: tags, settings: settings, allPlans: weekPlans)
|
||||
} label: {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "wand.and.stars")
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
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())
|
||||
.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.slots.contains { $0.dishId != nil } }
|
||||
}
|
||||
|
||||
private var weeksWithAnyDish: Int { plannedWeeks.count }
|
||||
|
||||
private var completeWeeks: Int {
|
||||
weekPlans.filter { plan in
|
||||
!plan.slots.isEmpty && plan.slots.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.slots {
|
||||
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(uniqueKeysWithValues: allDishes.map { ($0.id, $0) })
|
||||
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(uniqueKeysWithValues: allDishes.map { ($0.id, $0) })
|
||||
let tagMap = Dictionary(uniqueKeysWithValues: allTags.map { ($0.id, $0) })
|
||||
var counts: [UUID: Int] = [:]
|
||||
for plan in weekPlans {
|
||||
for slot in plan.slots {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user