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
+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()
}
}