diff --git a/MealMood/Resources/de.lproj/Localizable.strings b/MealMood/Resources/de.lproj/Localizable.strings index 09dad96..d9ec0ac 100644 --- a/MealMood/Resources/de.lproj/Localizable.strings +++ b/MealMood/Resources/de.lproj/Localizable.strings @@ -421,3 +421,4 @@ "snack" = "Snack"; "settings_show_dish_photos" = "Gerichtfotos im Planer anzeigen"; +"dish_sort_alphabetical" = "Alphabetisch sortieren"; diff --git a/MealMood/Resources/en.lproj/Localizable.strings b/MealMood/Resources/en.lproj/Localizable.strings index 41937be..8377b9a 100644 --- a/MealMood/Resources/en.lproj/Localizable.strings +++ b/MealMood/Resources/en.lproj/Localizable.strings @@ -421,3 +421,4 @@ "snack" = "Snack"; "settings_show_dish_photos" = "Show dish photos in planner"; +"dish_sort_alphabetical" = "Sort alphabetically"; diff --git a/MealMood/Resources/es.lproj/Localizable.strings b/MealMood/Resources/es.lproj/Localizable.strings index fab271f..3130a7b 100644 --- a/MealMood/Resources/es.lproj/Localizable.strings +++ b/MealMood/Resources/es.lproj/Localizable.strings @@ -421,3 +421,4 @@ "snack" = "Merienda"; "settings_show_dish_photos" = "Mostrar fotos de platos en el planificador"; +"dish_sort_alphabetical" = "Ordenar alfabéticamente"; diff --git a/MealMood/Resources/fr.lproj/Localizable.strings b/MealMood/Resources/fr.lproj/Localizable.strings index f8aac05..2356e59 100644 --- a/MealMood/Resources/fr.lproj/Localizable.strings +++ b/MealMood/Resources/fr.lproj/Localizable.strings @@ -421,3 +421,4 @@ "snack" = "Goûter"; "settings_show_dish_photos" = "Afficher les photos des plats dans le planning"; +"dish_sort_alphabetical" = "Trier par ordre alphabétique"; diff --git a/MealMood/Resources/it.lproj/Localizable.strings b/MealMood/Resources/it.lproj/Localizable.strings index 01a46e3..c8c5bc0 100644 --- a/MealMood/Resources/it.lproj/Localizable.strings +++ b/MealMood/Resources/it.lproj/Localizable.strings @@ -421,3 +421,4 @@ "snack" = "Merenda"; "settings_show_dish_photos" = "Mostra le foto dei piatti nel planner"; +"dish_sort_alphabetical" = "Ordina alfabeticamente"; diff --git a/MealMood/Resources/pt-BR.lproj/Localizable.strings b/MealMood/Resources/pt-BR.lproj/Localizable.strings index 652b208..e982ae8 100644 --- a/MealMood/Resources/pt-BR.lproj/Localizable.strings +++ b/MealMood/Resources/pt-BR.lproj/Localizable.strings @@ -421,3 +421,4 @@ "snack" = "Lanche"; "settings_show_dish_photos" = "Mostrar fotos dos pratos no planejador"; +"dish_sort_alphabetical" = "Ordenar alfabeticamente"; diff --git a/MealMood/Views/Dishes/DishFormView.swift b/MealMood/Views/Dishes/DishFormView.swift index fc30b32..81162c4 100644 --- a/MealMood/Views/Dishes/DishFormView.swift +++ b/MealMood/Views/Dishes/DishFormView.swift @@ -515,7 +515,7 @@ struct DishFormView: View { } // Simple flow layout for tags -private struct DishLimitModal: View { +struct DishLimitModal: View { let onSeePremium: () -> Void let onDismiss: () -> Void diff --git a/MealMood/Views/Dishes/DishListView.swift b/MealMood/Views/Dishes/DishListView.swift index 1ef425c..377b765 100644 --- a/MealMood/Views/Dishes/DishListView.swift +++ b/MealMood/Views/Dishes/DishListView.swift @@ -10,12 +10,19 @@ struct DishListView: View { @State private var showDishForm = false @State private var editingDish: Dish? @State private var showDeleteBlockedAlert = false - @State private var showPremium = false + @State private var showDishLimitModal = false + @State private var paywallPresentation: PaywallPresentation? + @AppStorage("dish_list_alphabetical_sort") private var alphabeticalSort = false private var language: AppLanguage { (allSettings.first?.languageEnum ?? .system).resolved() } + private var displayedDishes: [Dish] { + guard alphabeticalSort else { return dishes } + return dishes.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + } + var body: some View { ZStack { Color.mealMoodBackground.ignoresSafeArea() @@ -28,7 +35,7 @@ struct DishListView: View { Text("dish_list_empty") .font(.mealMoodBody) .foregroundColor(.mealMoodTextSecondary) - PrimaryButton(title: String(localized: "home_add_dish"), action: { showDishForm = true }) + PrimaryButton(title: String(localized: "home_add_dish"), action: attemptAddDish) .padding(.horizontal, 60) } } else { @@ -37,7 +44,7 @@ struct DishListView: View { dishCounterBanner } List { - ForEach(dishes, id: \.id) { dish in + ForEach(displayedDishes, id: \.id) { dish in let dishTags = tags.filter { dish.tagIds.contains($0.id) } Button { editingDish = dish @@ -77,18 +84,40 @@ struct DishListView: View { .toolbar { ToolbarItem(placement: .navigationBarTrailing) { Button { - showDishForm = true + withAnimation { alphabeticalSort.toggle() } + } label: { + Image(systemName: "arrow.up.arrow.down") + .foregroundColor(alphabeticalSort ? .mealMoodCoral : .mealMoodTextSecondary) + } + .accessibilityLabel(Text("dish_sort_alphabetical")) + } + ToolbarItem(placement: .navigationBarTrailing) { + Button { + attemptAddDish() } label: { Image(systemName: "plus") .foregroundColor(.mealMoodCoral) } } } - .sheet(isPresented: $showPremium) { + .sheet(item: $paywallPresentation) { presentation in if let settings = allSettings.first { - NavigationStack { PremiumView(settings: settings, source: "dish_counter") } + NavigationStack { PremiumView(settings: settings, source: presentation.source) } } } + .sheet(isPresented: $showDishLimitModal) { + DishLimitModal( + onSeePremium: { + showDishLimitModal = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { + paywallPresentation = PaywallPresentation(source: "dish_limit") + } + }, + onDismiss: { showDishLimitModal = false } + ) + .presentationDetents([.medium]) + .presentationDragIndicator(.visible) + } .sheet(isPresented: $showDishForm) { DishFormView() } @@ -110,7 +139,7 @@ struct DishListView: View { let accent: Color = isNearLimit ? .mealMoodCoral : .mealMoodTextSecondary return Button { - showPremium = true + paywallPresentation = PaywallPresentation(source: "dish_counter") } label: { HStack(spacing: 10) { Image(systemName: isNearLimit ? "exclamationmark.circle.fill" : "star.circle") @@ -143,12 +172,26 @@ struct DishListView: View { .padding(.bottom, 4) } + /// Same gate as the form's save button, but at the entry point: a user at + /// the free limit gets the paywall on tapping "add", not after filling in + /// a dish they can't save. + private func attemptAddDish() { + let isPremium = allSettings.first?.isPremium ?? false + if PremiumAccess.hasReachedFreeDishLimit(dishCount: dishes.count, isPremium: isPremium) { + AnalyticsService.logDishLimitHit() + HapticManager.shared.notification(type: .warning) + showDishLimitModal = true + } else { + showDishForm = true + } + } + private func deleteDishes(at offsets: IndexSet) { let currentWeekStart = Date().startOfWeek() let currentWeekPlan = weekPlans.first { $0.weekStartDate == currentWeekStart } for index in offsets { - let dish = dishes[index] + let dish = displayedDishes[index] if currentWeekPlan?.slotList.contains(where: { $0.dishId == dish.id }) == true { showDeleteBlockedAlert = true continue diff --git a/MealMood/Views/Home/HomeView.swift b/MealMood/Views/Home/HomeView.swift index 947036b..55ade77 100644 --- a/MealMood/Views/Home/HomeView.swift +++ b/MealMood/Views/Home/HomeView.swift @@ -16,6 +16,7 @@ struct HomeView: View { // separate @State vars the sheet sometimes rendered before the source was // set and logged paywall_viewed as "unknown". @State private var paywallPresentation: PaywallPresentation? + @State private var showDishLimitModal: Bool = false @State private var showMonthlyHistory: Bool = false @State private var showStats: Bool = false @State private var showWeekLimitUpsell: Bool = false @@ -451,6 +452,19 @@ struct HomeView: View { .sheet(isPresented: $viewModel.showDishForm) { DishFormView() } + .sheet(isPresented: $showDishLimitModal) { + DishLimitModal( + onSeePremium: { + showDishLimitModal = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { + paywallPresentation = PaywallPresentation(source: "dish_limit") + } + }, + onDismiss: { showDishLimitModal = false } + ) + .presentationDetents([.medium]) + .presentationDragIndicator(.visible) + } .sheet(isPresented: $showWeekPicker) { NavigationStack { Form { @@ -516,7 +530,7 @@ struct HomeView: View { onCreateDish: { selectedEmptySlotId = nil DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { - viewModel.showDishForm = true + attemptCreateDish() } }, onEatingOut: { @@ -554,7 +568,7 @@ struct HomeView: View { onCreateDish: { selectedFilledSlotId = nil DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { - viewModel.showDishForm = true + attemptCreateDish() } } ) @@ -653,11 +667,6 @@ struct HomeView: View { } } - private struct PaywallPresentation: Identifiable { - let id = UUID() - let source: String - } - private struct SlotDishPickerSheet: View { let dishes: [Dish] let tags: [Tag] @@ -804,7 +813,7 @@ struct HomeView: View { language: settings.languageEnum.resolved(), usedDishIds: Set(plan.slotList.compactMap(\.dishId)), usageRanking: dishUsageCounts, - onAddDish: { viewModel.showDishForm = true }, + onAddDish: { attemptCreateDish() }, onQuickAssignDish: { dish in viewModel.assignDishToFirstFreeSlot( dish, @@ -1183,6 +1192,20 @@ struct HomeView: View { AnalyticsService.logEvent("widget_promo_dismissed") } + /// Gate every "create dish" entry point on the free limit — the check used + /// to live only in the form's save button, so a user at the limit could + /// open and fill the whole form before hitting the paywall. + private func attemptCreateDish() { + let isPremium = allSettings.first?.isPremium ?? false + if PremiumAccess.hasReachedFreeDishLimit(dishCount: dishes.count, isPremium: isPremium) { + AnalyticsService.logDishLimitHit() + HapticManager.shared.notification(type: .warning) + showDishLimitModal = true + } else { + viewModel.showDishForm = true + } + } + private func evaluateReviewPrompt() { let descriptor = FetchDescriptor() guard let plans = try? context.fetch(descriptor) else { return } diff --git a/MealMood/Views/Premium/PremiumView.swift b/MealMood/Views/Premium/PremiumView.swift index 707a44b..2dabbf9 100644 --- a/MealMood/Views/Premium/PremiumView.swift +++ b/MealMood/Views/Premium/PremiumView.swift @@ -1,6 +1,14 @@ import SwiftUI import StoreKit +/// Wrapper for presenting the paywall via `sheet(item:)` so the attribution +/// source travels with the presentation (two separate @State vars sometimes +/// rendered the sheet before the source was set — GA4 logged "unknown"). +struct PaywallPresentation: Identifiable { + let id = UUID() + let source: String +} + struct PremiumView: View { @Environment(\.modelContext) private var context @StateObject private var storeManager = StoreManager()