2.0: weekly shopping list from planned dishes

- ShoppingItem model (per-week lines; dish-derived or free-text) + schema.
- ShoppingListService.reconcile mirrors planned dishes' ingredients into the
  list, preserving check-off state and user items; plain-text export helper.
- ShoppingListView: grouped by dish, check-off, swipe-delete, free-text adds,
  clear-checked, ShareLink export, and inline on-device "Generate" for planned
  dishes without ingredients (paywall after 3 free generations,
  source=ingredient_generation).
- Home toolbar: cart entry point (all users) + sheet.
- Analytics: shopping_list_opened, shopping_item_added, ingredients_generated.
- Localized in all 6 languages.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BkanrydYtrme8wipTzWssG
This commit is contained in:
alexandrev-tibco
2026-07-12 17:47:41 +02:00
parent a5c304e209
commit ce88d7b265
14 changed files with 513 additions and 1 deletions
+2 -1
View File
@@ -14,7 +14,8 @@ struct MealMoodApp: App {
Tag.self,
Dish.self,
WeekPlan.self,
MealSlot.self
MealSlot.self,
ShoppingItem.self
])
let configuration = ModelConfiguration(cloudKitDatabase: .none)
do {
+34
View File
@@ -0,0 +1,34 @@
import Foundation
import SwiftData
/// One line of the weekly shopping list. Items are either derived from a
/// planned dish's ingredients (`dishId` set) or added free-hand by the user
/// (`dishId == nil`). Check-off state persists across app launches.
@Model
final class ShoppingItem {
@Attribute(.unique) var id: UUID
var weekStartDate: Date
var title: String
var dishId: UUID?
var isChecked: Bool = false
var sortOrder: Int = 0
var createdAt: Date
init(
id: UUID = UUID(),
weekStartDate: Date,
title: String,
dishId: UUID? = nil,
isChecked: Bool = false,
sortOrder: Int = 0,
createdAt: Date = Date()
) {
self.id = id
self.weekStartDate = weekStartDate
self.title = title
self.dishId = dishId
self.isChecked = isChecked
self.sortOrder = sortOrder
self.createdAt = createdAt
}
}
@@ -384,3 +384,16 @@
/* Offer codes + share link (1.2.0) */
"premium_redeem_code" = "Promo-Code einlösen";
"share_link_text" = "Unsere Woche, geplant mit MealMood → https://mealmood.app";
// Shopping list (2.0)
"shopping_list_title" = "Einkaufsliste";
"shopping_done" = "Fertig";
"shopping_missing_ingredients" = "Noch keine Zutaten";
"shopping_other_items" = "Weitere Artikel";
"shopping_add_item_placeholder" = "Artikel hinzufügen…";
"shopping_generate" = "Generieren";
"shopping_clear_checked" = "Abgehakte entfernen";
"shopping_delete" = "Löschen";
"shopping_empty_title" = "Noch nichts einzukaufen";
"shopping_empty_message" = "Plane Gerichte für diese Woche und ihre Zutaten erscheinen hier.";
"shopping_share_header" = "🛒 Einkaufsliste — MealMood";
@@ -384,3 +384,16 @@
/* Offer codes + share link (1.2.0) */
"premium_redeem_code" = "Redeem promo code";
"share_link_text" = "Our week, planned with MealMood → https://mealmood.app";
// Shopping list (2.0)
"shopping_list_title" = "Shopping List";
"shopping_done" = "Done";
"shopping_missing_ingredients" = "No ingredients yet";
"shopping_other_items" = "Other items";
"shopping_add_item_placeholder" = "Add item…";
"shopping_generate" = "Generate";
"shopping_clear_checked" = "Clear checked items";
"shopping_delete" = "Delete";
"shopping_empty_title" = "Nothing to buy yet";
"shopping_empty_message" = "Plan dishes for this week and their ingredients will appear here.";
"shopping_share_header" = "🛒 Shopping list — MealMood";
@@ -384,3 +384,16 @@
/* Offer codes + share link (1.2.0) */
"premium_redeem_code" = "Canjear código promocional";
"share_link_text" = "Nuestra semana, planificada con MealMood → https://mealmood.app";
// Shopping list (2.0)
"shopping_list_title" = "Lista de la compra";
"shopping_done" = "Hecho";
"shopping_missing_ingredients" = "Aún sin ingredientes";
"shopping_other_items" = "Otros artículos";
"shopping_add_item_placeholder" = "Añadir artículo…";
"shopping_generate" = "Generar";
"shopping_clear_checked" = "Borrar los marcados";
"shopping_delete" = "Eliminar";
"shopping_empty_title" = "Nada que comprar todavía";
"shopping_empty_message" = "Planifica platos para esta semana y sus ingredientes aparecerán aquí.";
"shopping_share_header" = "🛒 Lista de la compra — MealMood";
@@ -384,3 +384,16 @@
/* Offer codes + share link (1.2.0) */
"premium_redeem_code" = "Utiliser un code promo";
"share_link_text" = "Notre semaine, planifiée avec MealMood → https://mealmood.app";
// Shopping list (2.0)
"shopping_list_title" = "Liste de courses";
"shopping_done" = "OK";
"shopping_missing_ingredients" = "Pas encore d'ingrédients";
"shopping_other_items" = "Autres articles";
"shopping_add_item_placeholder" = "Ajouter un article…";
"shopping_generate" = "Générer";
"shopping_clear_checked" = "Effacer les articles cochés";
"shopping_delete" = "Supprimer";
"shopping_empty_title" = "Rien à acheter pour l'instant";
"shopping_empty_message" = "Planifiez des plats pour cette semaine et leurs ingrédients apparaîtront ici.";
"shopping_share_header" = "🛒 Liste de courses — MealMood";
@@ -384,3 +384,16 @@
/* Offer codes + share link (1.2.0) */
"premium_redeem_code" = "Riscatta codice promo";
"share_link_text" = "La nostra settimana, pianificata con MealMood → https://mealmood.app";
// Shopping list (2.0)
"shopping_list_title" = "Lista della spesa";
"shopping_done" = "Fine";
"shopping_missing_ingredients" = "Ancora senza ingredienti";
"shopping_other_items" = "Altri articoli";
"shopping_add_item_placeholder" = "Aggiungi articolo…";
"shopping_generate" = "Genera";
"shopping_clear_checked" = "Rimuovi spuntati";
"shopping_delete" = "Elimina";
"shopping_empty_title" = "Niente da comprare per ora";
"shopping_empty_message" = "Pianifica i piatti per questa settimana e i loro ingredienti appariranno qui.";
"shopping_share_header" = "🛒 Lista della spesa — MealMood";
@@ -384,3 +384,16 @@
/* Offer codes + share link (1.2.0) */
"premium_redeem_code" = "Resgatar código promocional";
"share_link_text" = "Nossa semana, planejada com MealMood → https://mealmood.app";
// Shopping list (2.0)
"shopping_list_title" = "Lista de compras";
"shopping_done" = "OK";
"shopping_missing_ingredients" = "Ainda sem ingredientes";
"shopping_other_items" = "Outros itens";
"shopping_add_item_placeholder" = "Adicionar item…";
"shopping_generate" = "Gerar";
"shopping_clear_checked" = "Limpar itens marcados";
"shopping_delete" = "Excluir";
"shopping_empty_title" = "Nada para comprar ainda";
"shopping_empty_message" = "Planeje pratos para esta semana e os ingredientes deles aparecerão aqui.";
"shopping_share_header" = "🛒 Lista de compras — MealMood";
+23
View File
@@ -23,6 +23,11 @@ enum AnalyticsEvent {
static let autoAssignUsed = "auto_assign_used"
static let weekCopiedPrevious = "week_copied_previous"
// Shopping list (2.0)
static let shoppingListOpened = "shopping_list_opened"
static let shoppingItemAdded = "shopping_item_added"
static let ingredientsGenerated = "ingredients_generated"
// Onboarding
static let onboardingStepViewed = "onboarding_step_viewed"
@@ -120,6 +125,24 @@ enum AnalyticsService {
])
}
static func logShoppingListOpened(itemCount: Int, missingDishes: Int) {
logEvent(AnalyticsEvent.shoppingListOpened, parameters: [
"item_count": itemCount,
"missing_dishes": missingDishes
])
}
static func logShoppingItemAdded() {
logEvent(AnalyticsEvent.shoppingItemAdded)
}
static func logIngredientsGenerated(lineCount: Int, source: String) {
logEvent(AnalyticsEvent.ingredientsGenerated, parameters: [
"line_count": lineCount,
"source": source
])
}
static func logICloudSyncToggled(enabled: Bool) {
logEvent(AnalyticsEvent.iCloudSyncToggled, parameters: ["enabled": enabled])
}
+14
View File
@@ -4,7 +4,21 @@ enum PremiumAccess {
static let freeDishLimit = 15
static let freeFutureWeeks = 1
/// Free users get a taste of on-device ingredient generation; premium is
/// unlimited. Counted per install via UserDefaults.
static let freeIngredientGenerations = 3
private static let generationCountKey = "ingredient_generation_count"
static func hasReachedFreeDishLimit(dishCount: Int, isPremium: Bool) -> Bool {
!isPremium && dishCount >= freeDishLimit
}
static func canGenerateIngredients(isPremium: Bool) -> Bool {
isPremium || UserDefaults.standard.integer(forKey: generationCountKey) < freeIngredientGenerations
}
static func recordIngredientGeneration() {
let count = UserDefaults.standard.integer(forKey: generationCountKey)
UserDefaults.standard.set(count + 1, forKey: generationCountKey)
}
}
@@ -0,0 +1,72 @@
import Foundation
import SwiftData
/// Keeps the persisted shopping items for a week in sync with the planned
/// dishes' ingredients, preserving check-off state and user-added items.
enum ShoppingListService {
/// Mirrors the current plan into shopping items:
/// - inserts a line for every ingredient of every planned dish that isn't
/// there yet,
/// - removes dish-derived lines whose dish is no longer planned (or whose
/// ingredient text changed),
/// - never touches free-text items (`dishId == nil`) or check-off state.
static func reconcile(
context: ModelContext,
weekStartDate: Date,
plannedDishes: [Dish]
) {
let descriptor = FetchDescriptor<ShoppingItem>(
predicate: #Predicate<ShoppingItem> { $0.weekStartDate == weekStartDate }
)
let existing = (try? context.fetch(descriptor)) ?? []
var desired: [(dishId: UUID, title: String)] = []
for dish in plannedDishes {
for line in dish.ingredients {
let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty {
desired.append((dish.id, trimmed))
}
}
}
let desiredKeys = Set(desired.map { key($0.dishId, $0.title) })
let existingKeys = Set(existing.compactMap { item in
item.dishId.map { key($0, item.title) }
})
for item in existing {
guard let dishId = item.dishId else { continue }
if !desiredKeys.contains(key(dishId, item.title)) {
context.delete(item)
}
}
var order = (existing.map(\.sortOrder).max() ?? -1) + 1
for entry in desired where !existingKeys.contains(key(entry.dishId, entry.title)) {
context.insert(
ShoppingItem(
weekStartDate: weekStartDate,
title: entry.title,
dishId: entry.dishId,
sortOrder: order
)
)
order += 1
}
}
/// Plain-text export of the unchecked items (for the system share sheet).
static func exportText(items: [ShoppingItem], header: String) -> String {
let lines = items
.filter { !$0.isChecked }
.sorted { $0.sortOrder < $1.sortOrder }
.map { "· \($0.title)" }
return ([header] + lines).joined(separator: "\n")
}
private static func key(_ dishId: UUID, _ title: String) -> String {
dishId.uuidString + "|" + title.lowercased()
}
}
+16
View File
@@ -24,6 +24,7 @@ struct HomeView: View {
@State private var weekPickerDate: Date = Date()
@State private var showCopyPreviousConfirm: Bool = false
@State private var copyPreviousSource: String = "menu"
@State private var showShoppingList: Bool = false
@State private var showReviewSentimentPrompt: Bool = false
@State private var showReviewSupportPrompt: Bool = false
@State private var showPostOnboardingAutoAssignPrompt: Bool = false
@@ -68,6 +69,14 @@ struct HomeView: View {
.foregroundColor(.mealMoodTextPrimary)
}
Button {
showShoppingList = true
} label: {
Image(systemName: "cart")
.foregroundColor(.mealMoodTextPrimary)
}
.accessibilityLabel(Text("shopping_list_title"))
if settings?.isPremium == true {
Button {
showStats = true
@@ -521,6 +530,13 @@ struct HomeView: View {
.sheet(isPresented: $showStats) {
StatsView(weekPlans: weekPlans, allDishes: dishes, allTags: tags, language: settings.languageEnum.resolved())
}
.sheet(isPresented: $showShoppingList) {
ShoppingListView(
weekStartDate: viewModel.currentWeekStart,
plan: plan,
settings: settings
)
}
.task {
// Permission is now requested contextually (onboarding Week Ready step or
// the Settings toggle) no cold OS prompt on first Home load.
@@ -0,0 +1,254 @@
import SwiftUI
import SwiftData
/// Weekly shopping list: aggregates the ingredients of the dishes planned for
/// the given week. Dish-derived lines reconcile automatically with the plan;
/// the user can check items off, add loose items, and generate ingredients
/// on-device for planned dishes that have none yet.
struct ShoppingListView: View {
let weekStartDate: Date
let plan: WeekPlan
let settings: AppSettings
@Environment(\.modelContext) private var context
@Environment(\.dismiss) private var dismiss
@Query private var allItems: [ShoppingItem]
@Query private var allDishes: [Dish]
@State private var newItemText: String = ""
@State private var generatingDishId: UUID?
@State private var showPaywall = false
private var items: [ShoppingItem] {
allItems
.filter { $0.weekStartDate == weekStartDate }
.sorted { $0.sortOrder < $1.sortOrder }
}
/// Dishes planned this week, deduplicated, in slot order.
private var plannedDishes: [Dish] {
var seen = Set<UUID>()
var result: [Dish] = []
let sortedSlots = plan.slots.sorted {
($0.dayOfWeek, $0.mealType) < ($1.dayOfWeek, $1.mealType)
}
for slot in sortedSlots {
guard let dishId = slot.dishId, !seen.contains(dishId),
let dish = allDishes.first(where: { $0.id == dishId }) else { continue }
seen.insert(dishId)
result.append(dish)
}
return result
}
private var dishesWithoutIngredients: [Dish] {
plannedDishes.filter { $0.ingredients.isEmpty }
}
private var freeTextItems: [ShoppingItem] {
items.filter { $0.dishId == nil }
}
private func items(for dish: Dish) -> [ShoppingItem] {
items.filter { $0.dishId == dish.id }
}
var body: some View {
NavigationStack {
List {
if items.isEmpty && dishesWithoutIngredients.isEmpty {
emptyState
}
ForEach(plannedDishes, id: \.id) { dish in
let dishItems = items(for: dish)
if !dishItems.isEmpty {
Section(dish.name) {
ForEach(dishItems, id: \.id) { item in
itemRow(item)
}
}
}
}
if !dishesWithoutIngredients.isEmpty {
Section("shopping_missing_ingredients") {
ForEach(dishesWithoutIngredients, id: \.id) { dish in
missingIngredientsRow(dish)
}
}
}
Section("shopping_other_items") {
ForEach(freeTextItems, id: \.id) { item in
itemRow(item)
}
HStack {
TextField("shopping_add_item_placeholder", text: $newItemText)
.onSubmit(addFreeTextItem)
Button(action: addFreeTextItem) {
Image(systemName: "plus.circle.fill")
.foregroundColor(.mealMoodCoral)
}
.disabled(newItemText.trimmingCharacters(in: .whitespaces).isEmpty)
}
}
}
.navigationTitle("shopping_list_title")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("shopping_done") { dismiss() }
}
ToolbarItem(placement: .navigationBarTrailing) {
HStack(spacing: 12) {
ShareLink(item: exportText) {
Image(systemName: "square.and.arrow.up")
}
.disabled(items.allSatisfy(\.isChecked))
Button {
clearChecked()
} label: {
Image(systemName: "checkmark.circle.badge.xmark")
}
.disabled(!items.contains(where: \.isChecked))
.accessibilityLabel(Text("shopping_clear_checked"))
}
}
}
.onAppear {
ShoppingListService.reconcile(
context: context,
weekStartDate: weekStartDate,
plannedDishes: plannedDishes
)
AnalyticsService.logShoppingListOpened(
itemCount: items.count,
missingDishes: dishesWithoutIngredients.count
)
}
.sheet(isPresented: $showPaywall) {
PremiumView(settings: settings, source: "ingredient_generation")
}
}
}
private var emptyState: some View {
VStack(spacing: 8) {
Image(systemName: "cart")
.font(.system(size: 40))
.foregroundColor(.mealMoodTextSecondary)
Text("shopping_empty_title")
.font(.mealMoodBodyBold)
.foregroundColor(.mealMoodTextPrimary)
Text("shopping_empty_message")
.font(.mealMoodCaption)
.foregroundColor(.mealMoodTextSecondary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 24)
.listRowBackground(Color.clear)
}
private func itemRow(_ item: ShoppingItem) -> some View {
Button {
item.isChecked.toggle()
HapticManager.shared.selection()
} label: {
HStack {
Image(systemName: item.isChecked ? "checkmark.circle.fill" : "circle")
.foregroundColor(item.isChecked ? .mealMoodCoral : .mealMoodTextSecondary)
Text(item.title)
.strikethrough(item.isChecked)
.foregroundColor(item.isChecked ? .mealMoodTextSecondary : .mealMoodTextPrimary)
Spacer()
}
}
.buttonStyle(.plain)
.swipeActions(edge: .trailing) {
Button(role: .destructive) {
context.delete(item)
} label: {
Label("shopping_delete", systemImage: "trash")
}
}
}
@ViewBuilder
private func missingIngredientsRow(_ dish: Dish) -> some View {
HStack {
Text(dish.name)
.foregroundColor(.mealMoodTextPrimary)
Spacer()
if IngredientGenerator.isAvailable {
if generatingDishId == dish.id {
ProgressView()
} else {
Button {
generate(for: dish)
} label: {
Label("shopping_generate", systemImage: "sparkles")
.font(.mealMoodSmall.weight(.semibold))
.foregroundColor(.mealMoodCoral)
}
.buttonStyle(.borderless)
}
}
}
}
private func generate(for dish: Dish) {
guard PremiumAccess.canGenerateIngredients(isPremium: settings.isPremium) else {
showPaywall = true
return
}
generatingDishId = dish.id
Task {
let lines = await IngredientGenerator.generate(
dishName: dish.name,
language: settings.languageEnum
)
await MainActor.run {
generatingDishId = nil
guard !lines.isEmpty else { return }
dish.ingredients = lines
PremiumAccess.recordIngredientGeneration()
AnalyticsService.logIngredientsGenerated(
lineCount: lines.count,
source: "shopping_list"
)
ShoppingListService.reconcile(
context: context,
weekStartDate: weekStartDate,
plannedDishes: plannedDishes
)
}
}
}
private func addFreeTextItem() {
let title = newItemText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !title.isEmpty else { return }
let order = (items.map(\.sortOrder).max() ?? -1) + 1
context.insert(
ShoppingItem(weekStartDate: weekStartDate, title: title, sortOrder: order)
)
newItemText = ""
AnalyticsService.logShoppingItemAdded()
}
private func clearChecked() {
for item in items where item.isChecked {
context.delete(item)
}
}
private var exportText: String {
ShoppingListService.exportText(
items: items,
header: String(localized: "shopping_share_header")
)
}
}