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:
alexandrev-tibco
2026-05-27 15:05:39 +02:00
parent 0103ee98c6
commit 330f21a019
13 changed files with 433 additions and 59 deletions
+13 -3
View File
@@ -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")
+317
View File
@@ -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)
}
}
}