diff --git a/MealMood.xcodeproj/project.pbxproj b/MealMood.xcodeproj/project.pbxproj index 866e91e..8aa333a 100644 --- a/MealMood.xcodeproj/project.pbxproj +++ b/MealMood.xcodeproj/project.pbxproj @@ -26,10 +26,12 @@ 3C5225918DA8DE77F5AC1AA0 /* MealMoodApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C20D9FC522FEBF7206CFD32 /* MealMoodApp.swift */; }; 4218F220B7412965A5001A88 /* CalendarStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76719058DD3A4177B2B4163E /* CalendarStepView.swift */; }; 443CC3997A9A4818B51066FB /* StatsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E8AD94FC874467D863D464C /* StatsView.swift */; }; + 44E43D5F00D1B513F9BABE80 /* ShoppingListService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C512046B1410E236A3F6286 /* ShoppingListService.swift */; }; 478AE18DA0F887D3D5164635 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C77C350D4A839B76A3D2EFE7 /* Foundation.framework */; }; 48F9D3308375F820B2B88D1B /* PremiumUpsellBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CFA001E1BFA916ABC4C44C1 /* PremiumUpsellBanner.swift */; }; 4D53B5101AE845D7C03A0EB4 /* GoogleMobileAds in Frameworks */ = {isa = PBXBuildFile; productRef = 192E7CF40FD34C3FF4DDFDA3 /* GoogleMobileAds */; }; 511DDB82FF4DD30018101418 /* DishDrawerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23957C735075B0DFB1DF51D7 /* DishDrawerView.swift */; }; + 57F1EA6B5E3AF88A61C48354 /* ShoppingListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BA71D570D9882C3AFAFDC48 /* ShoppingListView.swift */; }; 5BCED5AFADACD4118AE59AF9 /* Font+MealMood.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE73D5B7AF8B5E2D63105977 /* Font+MealMood.swift */; }; 5E7322F3F3624211985F2D99 /* ICloudSyncService.swift in Sources */ = {isa = PBXBuildFile; fileRef = BED090ADA1FB259AD2AFEDE2 /* ICloudSyncService.swift */; }; 5ECA2501EC257777DBE670AD /* EmptySlotView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B68E5C82608F60FF8798BB81 /* EmptySlotView.swift */; }; @@ -65,6 +67,7 @@ B3C9E5149B70C477EACBDFFB /* AdBannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 050EA827CA951AB9E847E2CA /* AdBannerView.swift */; }; B4D6F8012C3E5F7A91B3D5F7 /* FeedbackStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3C5F7891B2D4E6F80A2C4E6 /* FeedbackStore.swift */; }; B8D7CA62789429C2F693E4F6 /* MealMoodLogoMark.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAC64110469AA91D5F28C423 /* MealMoodLogoMark.swift */; }; + B8FDF31D3BD7DD7220CE21A3 /* ShoppingItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FC93C653B5540D7205BF294 /* ShoppingItem.swift */; }; BE908B23F64F33D594AB8D82 /* DateHelpersTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBD33453B7DF86EDBAEB80F1 /* DateHelpersTests.swift */; }; C14BDE4C3EF6AF75F378D892 /* PrimaryButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFA97AD958C922D8AFF73314 /* PrimaryButton.swift */; }; C3CB4ACC213DAAD25E610C56 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = EB4FC89C90928DD833240244 /* WidgetKit.framework */; }; @@ -140,10 +143,13 @@ 2C5CD466A50C339519544347 /* TagRulesEditView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagRulesEditView.swift; sourceTree = ""; }; 2E113A29DEC0551C41AB7EBB /* WeekPlanShareView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeekPlanShareView.swift; sourceTree = ""; }; 324D77FEC4C8C371695F27CC /* PremiumView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PremiumView.swift; sourceTree = ""; }; + 3FC93C653B5540D7205BF294 /* ShoppingItem.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ShoppingItem.swift; sourceTree = ""; }; 4EDEE086FD20D16DE507B247 /* HomeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeViewModel.swift; sourceTree = ""; }; 50E1C12C3A693D7C41E9F80B /* TagPill.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagPill.swift; sourceTree = ""; }; 5187023D83BBC5E8D304CE3A /* SettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewModel.swift; sourceTree = ""; }; 574CB4BDE993DA0CEC251ABD /* MealMoodWidget.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = MealMoodWidget.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + 5BA71D570D9882C3AFAFDC48 /* ShoppingListView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ShoppingListView.swift; path = Shopping/ShoppingListView.swift; sourceTree = ""; }; + 5C512046B1410E236A3F6286 /* ShoppingListService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ShoppingListService.swift; sourceTree = ""; }; 5C9BFFE7A4BC6961815D2334 /* TagViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagViewModel.swift; sourceTree = ""; }; 5D6A3FCADC4F4B7AAB73E5CA /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 60F0A5C2FF1B797A4BB69B39 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; @@ -342,6 +348,7 @@ 797A41D292142240FBDF9F2A /* MealSlot.swift */, 049AE68D5B577514ADD6BACB /* Tag.swift */, A23267BAA116E1214870416C /* WeekPlan.swift */, + 3FC93C653B5540D7205BF294 /* ShoppingItem.swift */, ); path = Models; sourceTree = ""; @@ -378,6 +385,7 @@ 4B80B7CABE3B737897455A26 /* Settings */, CE41931687CCC60F76A2B6D9 /* Tags */, 0D63499EC79D43728D1CA8C7 /* Stats */, + ACD59A09C6EFFA0E57082D0F /* Shopping */, ); path = Views; sourceTree = ""; @@ -422,6 +430,14 @@ path = Resources; sourceTree = ""; }; + ACD59A09C6EFFA0E57082D0F /* Shopping */ = { + isa = PBXGroup; + children = ( + 5BA71D570D9882C3AFAFDC48 /* ShoppingListView.swift */, + ); + name = Shopping; + sourceTree = ""; + }; CE41931687CCC60F76A2B6D9 /* Tags */ = { isa = PBXGroup; children = ( @@ -502,6 +518,7 @@ 928E6D4F6D78AD1840245FB4 /* PremiumSyncService.swift */, A3C5F7891B2D4E6F80A2C4E6 /* FeedbackStore.swift */, 71F0ADA350289AE410D572A7 /* IngredientGenerator.swift */, + 5C512046B1410E236A3F6286 /* ShoppingListService.swift */, ); path = Services; sourceTree = ""; @@ -741,6 +758,9 @@ B4D6F8012C3E5F7A91B3D5F7 /* FeedbackStore.swift in Sources */, 2124FD777418A4EEDF64BDC3 /* WeekReadyStepView.swift in Sources */, D39BED40CA59849F181D80B5 /* IngredientGenerator.swift in Sources */, + B8FDF31D3BD7DD7220CE21A3 /* ShoppingItem.swift in Sources */, + 44E43D5F00D1B513F9BABE80 /* ShoppingListService.swift in Sources */, + 57F1EA6B5E3AF88A61C48354 /* ShoppingListView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/MealMood/MealMoodApp.swift b/MealMood/MealMoodApp.swift index 47343cb..f7df5d4 100644 --- a/MealMood/MealMoodApp.swift +++ b/MealMood/MealMoodApp.swift @@ -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 { diff --git a/MealMood/Models/ShoppingItem.swift b/MealMood/Models/ShoppingItem.swift new file mode 100644 index 0000000..6774651 --- /dev/null +++ b/MealMood/Models/ShoppingItem.swift @@ -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 + } +} diff --git a/MealMood/Resources/de.lproj/Localizable.strings b/MealMood/Resources/de.lproj/Localizable.strings index 3c2d4d9..a7706da 100644 --- a/MealMood/Resources/de.lproj/Localizable.strings +++ b/MealMood/Resources/de.lproj/Localizable.strings @@ -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"; diff --git a/MealMood/Resources/en.lproj/Localizable.strings b/MealMood/Resources/en.lproj/Localizable.strings index 470f59d..d0417c2 100644 --- a/MealMood/Resources/en.lproj/Localizable.strings +++ b/MealMood/Resources/en.lproj/Localizable.strings @@ -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"; diff --git a/MealMood/Resources/es.lproj/Localizable.strings b/MealMood/Resources/es.lproj/Localizable.strings index 670b1eb..42766c7 100644 --- a/MealMood/Resources/es.lproj/Localizable.strings +++ b/MealMood/Resources/es.lproj/Localizable.strings @@ -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"; diff --git a/MealMood/Resources/fr.lproj/Localizable.strings b/MealMood/Resources/fr.lproj/Localizable.strings index 6aef1fe..090ff15 100644 --- a/MealMood/Resources/fr.lproj/Localizable.strings +++ b/MealMood/Resources/fr.lproj/Localizable.strings @@ -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"; diff --git a/MealMood/Resources/it.lproj/Localizable.strings b/MealMood/Resources/it.lproj/Localizable.strings index 099493d..58b0823 100644 --- a/MealMood/Resources/it.lproj/Localizable.strings +++ b/MealMood/Resources/it.lproj/Localizable.strings @@ -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"; diff --git a/MealMood/Resources/pt-BR.lproj/Localizable.strings b/MealMood/Resources/pt-BR.lproj/Localizable.strings index 5adbb8b..46aa7e3 100644 --- a/MealMood/Resources/pt-BR.lproj/Localizable.strings +++ b/MealMood/Resources/pt-BR.lproj/Localizable.strings @@ -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"; diff --git a/MealMood/Services/AnalyticsService.swift b/MealMood/Services/AnalyticsService.swift index 8483ec2..5394799 100644 --- a/MealMood/Services/AnalyticsService.swift +++ b/MealMood/Services/AnalyticsService.swift @@ -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]) } diff --git a/MealMood/Services/PremiumAccess.swift b/MealMood/Services/PremiumAccess.swift index fe12fbb..928c36b 100644 --- a/MealMood/Services/PremiumAccess.swift +++ b/MealMood/Services/PremiumAccess.swift @@ -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) + } } diff --git a/MealMood/Services/ShoppingListService.swift b/MealMood/Services/ShoppingListService.swift new file mode 100644 index 0000000..957258c --- /dev/null +++ b/MealMood/Services/ShoppingListService.swift @@ -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( + predicate: #Predicate { $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() + } +} diff --git a/MealMood/Views/Home/HomeView.swift b/MealMood/Views/Home/HomeView.swift index f4577bd..fe9b48f 100644 --- a/MealMood/Views/Home/HomeView.swift +++ b/MealMood/Views/Home/HomeView.swift @@ -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. diff --git a/MealMood/Views/Shopping/ShoppingListView.swift b/MealMood/Views/Shopping/ShoppingListView.swift new file mode 100644 index 0000000..d8b015b --- /dev/null +++ b/MealMood/Views/Shopping/ShoppingListView.swift @@ -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() + 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") + ) + } +}