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:
@@ -26,10 +26,12 @@
|
|||||||
3C5225918DA8DE77F5AC1AA0 /* MealMoodApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C20D9FC522FEBF7206CFD32 /* MealMoodApp.swift */; };
|
3C5225918DA8DE77F5AC1AA0 /* MealMoodApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C20D9FC522FEBF7206CFD32 /* MealMoodApp.swift */; };
|
||||||
4218F220B7412965A5001A88 /* CalendarStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76719058DD3A4177B2B4163E /* CalendarStepView.swift */; };
|
4218F220B7412965A5001A88 /* CalendarStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76719058DD3A4177B2B4163E /* CalendarStepView.swift */; };
|
||||||
443CC3997A9A4818B51066FB /* StatsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E8AD94FC874467D863D464C /* StatsView.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 */; };
|
478AE18DA0F887D3D5164635 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C77C350D4A839B76A3D2EFE7 /* Foundation.framework */; };
|
||||||
48F9D3308375F820B2B88D1B /* PremiumUpsellBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CFA001E1BFA916ABC4C44C1 /* PremiumUpsellBanner.swift */; };
|
48F9D3308375F820B2B88D1B /* PremiumUpsellBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CFA001E1BFA916ABC4C44C1 /* PremiumUpsellBanner.swift */; };
|
||||||
4D53B5101AE845D7C03A0EB4 /* GoogleMobileAds in Frameworks */ = {isa = PBXBuildFile; productRef = 192E7CF40FD34C3FF4DDFDA3 /* GoogleMobileAds */; };
|
4D53B5101AE845D7C03A0EB4 /* GoogleMobileAds in Frameworks */ = {isa = PBXBuildFile; productRef = 192E7CF40FD34C3FF4DDFDA3 /* GoogleMobileAds */; };
|
||||||
511DDB82FF4DD30018101418 /* DishDrawerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23957C735075B0DFB1DF51D7 /* DishDrawerView.swift */; };
|
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 */; };
|
5BCED5AFADACD4118AE59AF9 /* Font+MealMood.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE73D5B7AF8B5E2D63105977 /* Font+MealMood.swift */; };
|
||||||
5E7322F3F3624211985F2D99 /* ICloudSyncService.swift in Sources */ = {isa = PBXBuildFile; fileRef = BED090ADA1FB259AD2AFEDE2 /* ICloudSyncService.swift */; };
|
5E7322F3F3624211985F2D99 /* ICloudSyncService.swift in Sources */ = {isa = PBXBuildFile; fileRef = BED090ADA1FB259AD2AFEDE2 /* ICloudSyncService.swift */; };
|
||||||
5ECA2501EC257777DBE670AD /* EmptySlotView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B68E5C82608F60FF8798BB81 /* EmptySlotView.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 */; };
|
B3C9E5149B70C477EACBDFFB /* AdBannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 050EA827CA951AB9E847E2CA /* AdBannerView.swift */; };
|
||||||
B4D6F8012C3E5F7A91B3D5F7 /* FeedbackStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3C5F7891B2D4E6F80A2C4E6 /* FeedbackStore.swift */; };
|
B4D6F8012C3E5F7A91B3D5F7 /* FeedbackStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3C5F7891B2D4E6F80A2C4E6 /* FeedbackStore.swift */; };
|
||||||
B8D7CA62789429C2F693E4F6 /* MealMoodLogoMark.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAC64110469AA91D5F28C423 /* MealMoodLogoMark.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 */; };
|
BE908B23F64F33D594AB8D82 /* DateHelpersTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBD33453B7DF86EDBAEB80F1 /* DateHelpersTests.swift */; };
|
||||||
C14BDE4C3EF6AF75F378D892 /* PrimaryButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFA97AD958C922D8AFF73314 /* PrimaryButton.swift */; };
|
C14BDE4C3EF6AF75F378D892 /* PrimaryButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFA97AD958C922D8AFF73314 /* PrimaryButton.swift */; };
|
||||||
C3CB4ACC213DAAD25E610C56 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = EB4FC89C90928DD833240244 /* WidgetKit.framework */; };
|
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 = "<group>"; };
|
2C5CD466A50C339519544347 /* TagRulesEditView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagRulesEditView.swift; sourceTree = "<group>"; };
|
||||||
2E113A29DEC0551C41AB7EBB /* WeekPlanShareView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeekPlanShareView.swift; sourceTree = "<group>"; };
|
2E113A29DEC0551C41AB7EBB /* WeekPlanShareView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeekPlanShareView.swift; sourceTree = "<group>"; };
|
||||||
324D77FEC4C8C371695F27CC /* PremiumView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PremiumView.swift; sourceTree = "<group>"; };
|
324D77FEC4C8C371695F27CC /* PremiumView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PremiumView.swift; sourceTree = "<group>"; };
|
||||||
|
3FC93C653B5540D7205BF294 /* ShoppingItem.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ShoppingItem.swift; sourceTree = "<group>"; };
|
||||||
4EDEE086FD20D16DE507B247 /* HomeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeViewModel.swift; sourceTree = "<group>"; };
|
4EDEE086FD20D16DE507B247 /* HomeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeViewModel.swift; sourceTree = "<group>"; };
|
||||||
50E1C12C3A693D7C41E9F80B /* TagPill.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagPill.swift; sourceTree = "<group>"; };
|
50E1C12C3A693D7C41E9F80B /* TagPill.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagPill.swift; sourceTree = "<group>"; };
|
||||||
5187023D83BBC5E8D304CE3A /* SettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewModel.swift; sourceTree = "<group>"; };
|
5187023D83BBC5E8D304CE3A /* SettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewModel.swift; sourceTree = "<group>"; };
|
||||||
574CB4BDE993DA0CEC251ABD /* MealMoodWidget.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = MealMoodWidget.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
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 = "<group>"; };
|
||||||
|
5C512046B1410E236A3F6286 /* ShoppingListService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ShoppingListService.swift; sourceTree = "<group>"; };
|
||||||
5C9BFFE7A4BC6961815D2334 /* TagViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagViewModel.swift; sourceTree = "<group>"; };
|
5C9BFFE7A4BC6961815D2334 /* TagViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagViewModel.swift; sourceTree = "<group>"; };
|
||||||
5D6A3FCADC4F4B7AAB73E5CA /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
5D6A3FCADC4F4B7AAB73E5CA /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||||
60F0A5C2FF1B797A4BB69B39 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
|
60F0A5C2FF1B797A4BB69B39 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
|
||||||
@@ -342,6 +348,7 @@
|
|||||||
797A41D292142240FBDF9F2A /* MealSlot.swift */,
|
797A41D292142240FBDF9F2A /* MealSlot.swift */,
|
||||||
049AE68D5B577514ADD6BACB /* Tag.swift */,
|
049AE68D5B577514ADD6BACB /* Tag.swift */,
|
||||||
A23267BAA116E1214870416C /* WeekPlan.swift */,
|
A23267BAA116E1214870416C /* WeekPlan.swift */,
|
||||||
|
3FC93C653B5540D7205BF294 /* ShoppingItem.swift */,
|
||||||
);
|
);
|
||||||
path = Models;
|
path = Models;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -378,6 +385,7 @@
|
|||||||
4B80B7CABE3B737897455A26 /* Settings */,
|
4B80B7CABE3B737897455A26 /* Settings */,
|
||||||
CE41931687CCC60F76A2B6D9 /* Tags */,
|
CE41931687CCC60F76A2B6D9 /* Tags */,
|
||||||
0D63499EC79D43728D1CA8C7 /* Stats */,
|
0D63499EC79D43728D1CA8C7 /* Stats */,
|
||||||
|
ACD59A09C6EFFA0E57082D0F /* Shopping */,
|
||||||
);
|
);
|
||||||
path = Views;
|
path = Views;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -422,6 +430,14 @@
|
|||||||
path = Resources;
|
path = Resources;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
ACD59A09C6EFFA0E57082D0F /* Shopping */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
5BA71D570D9882C3AFAFDC48 /* ShoppingListView.swift */,
|
||||||
|
);
|
||||||
|
name = Shopping;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
CE41931687CCC60F76A2B6D9 /* Tags */ = {
|
CE41931687CCC60F76A2B6D9 /* Tags */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
@@ -502,6 +518,7 @@
|
|||||||
928E6D4F6D78AD1840245FB4 /* PremiumSyncService.swift */,
|
928E6D4F6D78AD1840245FB4 /* PremiumSyncService.swift */,
|
||||||
A3C5F7891B2D4E6F80A2C4E6 /* FeedbackStore.swift */,
|
A3C5F7891B2D4E6F80A2C4E6 /* FeedbackStore.swift */,
|
||||||
71F0ADA350289AE410D572A7 /* IngredientGenerator.swift */,
|
71F0ADA350289AE410D572A7 /* IngredientGenerator.swift */,
|
||||||
|
5C512046B1410E236A3F6286 /* ShoppingListService.swift */,
|
||||||
);
|
);
|
||||||
path = Services;
|
path = Services;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -741,6 +758,9 @@
|
|||||||
B4D6F8012C3E5F7A91B3D5F7 /* FeedbackStore.swift in Sources */,
|
B4D6F8012C3E5F7A91B3D5F7 /* FeedbackStore.swift in Sources */,
|
||||||
2124FD777418A4EEDF64BDC3 /* WeekReadyStepView.swift in Sources */,
|
2124FD777418A4EEDF64BDC3 /* WeekReadyStepView.swift in Sources */,
|
||||||
D39BED40CA59849F181D80B5 /* IngredientGenerator.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;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ struct MealMoodApp: App {
|
|||||||
Tag.self,
|
Tag.self,
|
||||||
Dish.self,
|
Dish.self,
|
||||||
WeekPlan.self,
|
WeekPlan.self,
|
||||||
MealSlot.self
|
MealSlot.self,
|
||||||
|
ShoppingItem.self
|
||||||
])
|
])
|
||||||
let configuration = ModelConfiguration(cloudKitDatabase: .none)
|
let configuration = ModelConfiguration(cloudKitDatabase: .none)
|
||||||
do {
|
do {
|
||||||
|
|||||||
@@ -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) */
|
/* Offer codes + share link (1.2.0) */
|
||||||
"premium_redeem_code" = "Promo-Code einlösen";
|
"premium_redeem_code" = "Promo-Code einlösen";
|
||||||
"share_link_text" = "Unsere Woche, geplant mit MealMood → https://mealmood.app";
|
"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) */
|
/* Offer codes + share link (1.2.0) */
|
||||||
"premium_redeem_code" = "Redeem promo code";
|
"premium_redeem_code" = "Redeem promo code";
|
||||||
"share_link_text" = "Our week, planned with MealMood → https://mealmood.app";
|
"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) */
|
/* Offer codes + share link (1.2.0) */
|
||||||
"premium_redeem_code" = "Canjear código promocional";
|
"premium_redeem_code" = "Canjear código promocional";
|
||||||
"share_link_text" = "Nuestra semana, planificada con MealMood → https://mealmood.app";
|
"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) */
|
/* Offer codes + share link (1.2.0) */
|
||||||
"premium_redeem_code" = "Utiliser un code promo";
|
"premium_redeem_code" = "Utiliser un code promo";
|
||||||
"share_link_text" = "Notre semaine, planifiée avec MealMood → https://mealmood.app";
|
"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) */
|
/* Offer codes + share link (1.2.0) */
|
||||||
"premium_redeem_code" = "Riscatta codice promo";
|
"premium_redeem_code" = "Riscatta codice promo";
|
||||||
"share_link_text" = "La nostra settimana, pianificata con MealMood → https://mealmood.app";
|
"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) */
|
/* Offer codes + share link (1.2.0) */
|
||||||
"premium_redeem_code" = "Resgatar código promocional";
|
"premium_redeem_code" = "Resgatar código promocional";
|
||||||
"share_link_text" = "Nossa semana, planejada com MealMood → https://mealmood.app";
|
"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,6 +23,11 @@ enum AnalyticsEvent {
|
|||||||
static let autoAssignUsed = "auto_assign_used"
|
static let autoAssignUsed = "auto_assign_used"
|
||||||
static let weekCopiedPrevious = "week_copied_previous"
|
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
|
// Onboarding
|
||||||
static let onboardingStepViewed = "onboarding_step_viewed"
|
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) {
|
static func logICloudSyncToggled(enabled: Bool) {
|
||||||
logEvent(AnalyticsEvent.iCloudSyncToggled, parameters: ["enabled": enabled])
|
logEvent(AnalyticsEvent.iCloudSyncToggled, parameters: ["enabled": enabled])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,21 @@ enum PremiumAccess {
|
|||||||
static let freeDishLimit = 15
|
static let freeDishLimit = 15
|
||||||
static let freeFutureWeeks = 1
|
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 {
|
static func hasReachedFreeDishLimit(dishCount: Int, isPremium: Bool) -> Bool {
|
||||||
!isPremium && dishCount >= freeDishLimit
|
!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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ struct HomeView: View {
|
|||||||
@State private var weekPickerDate: Date = Date()
|
@State private var weekPickerDate: Date = Date()
|
||||||
@State private var showCopyPreviousConfirm: Bool = false
|
@State private var showCopyPreviousConfirm: Bool = false
|
||||||
@State private var copyPreviousSource: String = "menu"
|
@State private var copyPreviousSource: String = "menu"
|
||||||
|
@State private var showShoppingList: Bool = false
|
||||||
@State private var showReviewSentimentPrompt: Bool = false
|
@State private var showReviewSentimentPrompt: Bool = false
|
||||||
@State private var showReviewSupportPrompt: Bool = false
|
@State private var showReviewSupportPrompt: Bool = false
|
||||||
@State private var showPostOnboardingAutoAssignPrompt: Bool = false
|
@State private var showPostOnboardingAutoAssignPrompt: Bool = false
|
||||||
@@ -68,6 +69,14 @@ struct HomeView: View {
|
|||||||
.foregroundColor(.mealMoodTextPrimary)
|
.foregroundColor(.mealMoodTextPrimary)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
showShoppingList = true
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "cart")
|
||||||
|
.foregroundColor(.mealMoodTextPrimary)
|
||||||
|
}
|
||||||
|
.accessibilityLabel(Text("shopping_list_title"))
|
||||||
|
|
||||||
if settings?.isPremium == true {
|
if settings?.isPremium == true {
|
||||||
Button {
|
Button {
|
||||||
showStats = true
|
showStats = true
|
||||||
@@ -521,6 +530,13 @@ struct HomeView: View {
|
|||||||
.sheet(isPresented: $showStats) {
|
.sheet(isPresented: $showStats) {
|
||||||
StatsView(weekPlans: weekPlans, allDishes: dishes, allTags: tags, language: settings.languageEnum.resolved())
|
StatsView(weekPlans: weekPlans, allDishes: dishes, allTags: tags, language: settings.languageEnum.resolved())
|
||||||
}
|
}
|
||||||
|
.sheet(isPresented: $showShoppingList) {
|
||||||
|
ShoppingListView(
|
||||||
|
weekStartDate: viewModel.currentWeekStart,
|
||||||
|
plan: plan,
|
||||||
|
settings: settings
|
||||||
|
)
|
||||||
|
}
|
||||||
.task {
|
.task {
|
||||||
// Permission is now requested contextually (onboarding Week Ready step or
|
// Permission is now requested contextually (onboarding Week Ready step or
|
||||||
// the Settings toggle) — no cold OS prompt on first Home load.
|
// 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")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user