Merge 1.0.1 into main

This commit is contained in:
alexandrev-tibco
2026-02-27 13:10:53 +01:00
23 changed files with 662 additions and 176 deletions
+4 -4
View File
@@ -643,7 +643,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = MealMood/Resources/MealMood.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 2;
DEVELOPMENT_TEAM = 2825Q76T7H;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = MealMood/Resources/Info.plist;
@@ -652,7 +652,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0.0;
MARKETING_VERSION = 1.0.1;
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.mealmood;
PRODUCT_NAME = MealMood;
SDKROOT = iphoneos;
@@ -747,7 +747,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = MealMood/Resources/MealMood.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 2;
DEVELOPMENT_TEAM = 2825Q76T7H;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = MealMood/Resources/Info.plist;
@@ -756,7 +756,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0.0;
MARKETING_VERSION = 1.0.1;
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.mealmood;
PRODUCT_NAME = MealMood;
SDKROOT = iphoneos;
+13
View File
@@ -8,6 +8,10 @@ struct ContentView: View {
@State private var isReady = false
private var settings: AppSettings? { allSettings.first }
private var isTestFlightBuild: Bool {
guard let receiptURL = Bundle.main.appStoreReceiptURL else { return false }
return receiptURL.lastPathComponent == "sandboxReceipt"
}
var body: some View {
Group {
@@ -72,6 +76,15 @@ struct ContentView: View {
private func syncPremiumStatus() async {
let descriptor = FetchDescriptor<AppSettings>()
guard let settings = try? context.fetch(descriptor).first else { return }
if isTestFlightBuild {
if settings.isPremium != true {
settings.isPremium = true
try? context.save()
}
return
}
let premium = await StoreManager.hasActiveSubscription()
if settings.isPremium != premium {
settings.isPremium = premium
+11
View File
@@ -23,3 +23,14 @@ final class Dish {
self.createdAt = createdAt
}
}
extension Dish {
static func stableSortedForDisplay(_ dishes: [Dish]) -> [Dish] {
dishes.sorted { lhs, rhs in
if lhs.createdAt != rhs.createdAt {
return lhs.createdAt > rhs.createdAt
}
return lhs.id.uuidString < rhs.id.uuidString
}
}
}
+16
View File
@@ -33,3 +33,19 @@ final class MealSlot {
set { mealType = newValue.rawValue }
}
}
extension MealSlot {
static func preferredForDuplicateResolution(_ slots: [MealSlot]) -> MealSlot {
slots.sorted { lhs, rhs in
let lhsHasDish = lhs.dishId != nil
let rhsHasDish = rhs.dishId != nil
if lhsHasDish != rhsHasDish { return lhsHasDish }
let lhsHasEvent = lhs.calendarEventId != nil
let rhsHasEvent = rhs.calendarEventId != nil
if lhsHasEvent != rhsHasEvent { return lhsHasEvent }
return lhs.id.uuidString < rhs.id.uuidString
}.first ?? slots[0]
}
}
@@ -1,27 +1,32 @@
/* Onboarding */
"onboarding_welcome_title" = "MealMood";
"onboarding_welcome_subtitle" = "Plan your week, enjoy your meals";
"onboarding_welcome_benefit1" = "No stress";
"onboarding_welcome_benefit2" = "No calorie counting";
"onboarding_welcome_benefit3" = "Sync with your calendar";
"onboarding_welcome_title" = "Organize your meals in 1 minute";
"onboarding_welcome_subtitle" = "Answer 4 quick steps and start planning today.";
"onboarding_welcome_step_1" = "Choose if you want to plan lunches, dinners, or both";
"onboarding_welcome_step_2" = "Decide whether to include weekends";
"onboarding_welcome_step_3" = "Optionally connect your calendar";
"onboarding_welcome_step_4" = "Add your usual dishes to start fast";
"onboarding_nav_intro" = "Quick setup";
"onboarding_nav_setup" = "Setup";
"onboarding_nav_step_short" = "Step %d";
"onboarding_nav_step_counter" = "Step %d/%d";
"onboarding_start" = "Get Started";
"onboarding_continue" = "Continue";
"onboarding_skip" = "Skip";
"onboarding_finish" = "Finish";
/* Meal Windows */
"meal_windows_title" = "What do you want to plan?";
"meal_windows_title" = "Step 1: Lunches, dinners, or both?";
"meal_windows_dinner_only" = "Dinners only";
"meal_windows_lunch_only" = "Lunches only";
"meal_windows_both" = "Lunches and dinners";
/* Weekends */
"weekends_title" = "Include weekends?";
"weekends_title" = "Step 2: Also plan weekends?";
"weekends_toggle" = "Also plan Saturdays and Sundays";
"weekends_note" = "You can change this later in settings";
/* Calendar */
"calendar_title" = "Sync with your calendar?";
"calendar_title" = "Step 3: Connect your calendar?";
"calendar_sync" = "Sync";
"calendar_select" = "Calendar";
"calendar_lunch_time" = "Lunch time";
@@ -32,7 +37,7 @@
"calendar_permission_settings" = "Go to Settings";
/* First Dishes */
"first_dishes_title" = "Add your first dishes";
"first_dishes_title" = "Step 4: Add your go-to dishes";
"first_dishes_subtitle" = "Add at least 2 dishes to get started";
"first_dishes_name_placeholder" = "Dish name";
"first_dishes_add_tag" = "Add tag";
@@ -149,6 +154,8 @@
"settings_website" = "Website";
"settings_support" = "Support email";
"settings_rate_app" = "Rate MealMood";
"settings_app_version" = "Version";
"settings_app_build" = "Build";
"settings_danger_zone" = "Danger zone";
"settings_reset_all_data" = "Reset all data";
"settings_reset_all_data_title" = "Reset all app data?";
@@ -215,6 +222,29 @@
"first_dishes_suggestion_added" = "Added";
"first_dishes_no_tags" = "Default tags are loading, please retry in a moment";
/* Onboarding trial */
"onboarding_trial_title" = "Unlock Premium";
"onboarding_trial_subtitle" = "Unlock all premium features now.";
"onboarding_trial_cta" = "Unlock Premium";
"onboarding_trial_skip" = "Continue with free plan";
"onboarding_trial_price_format" = "%@ / month";
/* Review funnel */
"review_funnel_title" = "How is MealMood going?";
"review_funnel_message" = "Your opinion helps us improve MealMood.";
"review_funnel_positive" = "I like it";
"review_funnel_negative" = "Needs improvement";
"review_feedback_title" = "Tell us what to improve";
"review_feedback_message" = "Send us your feedback and we'll use it to improve the app.";
"review_feedback_contact" = "Send feedback";
"onboarding_auto_assign_title" = "Want us to auto-assign your week?";
"onboarding_auto_assign_message" = "We can fill this week automatically using your dishes and rules.";
"onboarding_auto_assign_yes" = "Yes, auto-assign";
"onboarding_auto_assign_no" = "No, I'll do it";
"onboarding_premium_prompt_title" = "Want to unlock Premium?";
"onboarding_premium_prompt_message" = "Get no ads, unlimited dishes, advanced rules, and future-week planning.";
"onboarding_premium_prompt_cta" = "See Premium";
/* Notifications */
"notification_planning_title" = "Plan your next week";
"notification_planning_body" = "Your new week starts tomorrow and it is still not planned.";
@@ -1,27 +1,32 @@
/* Onboarding */
"onboarding_welcome_title" = "MealMood";
"onboarding_welcome_subtitle" = "Planifica tu semana, disfruta tus comidas";
"onboarding_welcome_benefit1" = "Sin estrés";
"onboarding_welcome_benefit2" = "Sin contar calorías";
"onboarding_welcome_benefit3" = "Sincroniza con tu calendario";
"onboarding_welcome_title" = "Organiza tus comidas en 1 minuto";
"onboarding_welcome_subtitle" = "Responde 4 pasos rápidos y empieza a planificar hoy.";
"onboarding_welcome_step_1" = "Elige si quieres planificar comidas, cenas o ambas";
"onboarding_welcome_step_2" = "Decide si incluir fines de semana";
"onboarding_welcome_step_3" = "Conecta tu calendario de forma opcional";
"onboarding_welcome_step_4" = "Añade tus platos habituales para empezar rápido";
"onboarding_nav_intro" = "Configuración rápida";
"onboarding_nav_setup" = "Config";
"onboarding_nav_step_short" = "Paso %d";
"onboarding_nav_step_counter" = "Paso %d/%d";
"onboarding_start" = "Comenzar";
"onboarding_continue" = "Continuar";
"onboarding_skip" = "Omitir";
"onboarding_finish" = "Finalizar";
/* Meal Windows */
"meal_windows_title" = "¿Qué quieres planificar?";
"meal_windows_title" = "Paso 1: ¿Comidas, cenas o ambas?";
"meal_windows_dinner_only" = "Solo cenas";
"meal_windows_lunch_only" = "Solo comidas";
"meal_windows_both" = "Comidas y cenas";
/* Weekends */
"weekends_title" = "¿Incluir fines de semana?";
"weekends_title" = "Paso 2: ¿Planificamos también fines de semana?";
"weekends_toggle" = "Planificar también sábados y domingos";
"weekends_note" = "Puedes cambiar esto después en ajustes";
/* Calendar */
"calendar_title" = "¿Sincronizar con tu calendario?";
"calendar_title" = "Paso 3: ¿Lo integramos con tu calendario?";
"calendar_sync" = "Sincronizar";
"calendar_select" = "Calendario";
"calendar_lunch_time" = "Hora de comidas";
@@ -32,7 +37,7 @@
"calendar_permission_settings" = "Ir a Ajustes";
/* First Dishes */
"first_dishes_title" = "Añade tus primeros platos";
"first_dishes_title" = "Paso 4: Añade tus platos habituales";
"first_dishes_subtitle" = "Añade al menos 2 platos para empezar";
"first_dishes_name_placeholder" = "Nombre del plato";
"first_dishes_add_tag" = "Añadir etiqueta";
@@ -149,6 +154,8 @@
"settings_website" = "Web";
"settings_support" = "Correo de soporte";
"settings_rate_app" = "Valorar MealMood";
"settings_app_version" = "Versión";
"settings_app_build" = "Build";
"settings_danger_zone" = "Zona de peligro";
"settings_reset_all_data" = "Resetear todos los datos";
"settings_reset_all_data_title" = "¿Resetear todos los datos de la app?";
@@ -215,6 +222,29 @@
"first_dishes_suggestion_added" = "Añadido";
"first_dishes_no_tags" = "Las etiquetas por defecto se están cargando, inténtalo de nuevo en un momento";
/* Onboarding trial */
"onboarding_trial_title" = "Activa Premium";
"onboarding_trial_subtitle" = "Desbloquea todas las funciones premium ahora.";
"onboarding_trial_cta" = "Activar Premium";
"onboarding_trial_skip" = "Continuar con plan gratuito";
"onboarding_trial_price_format" = "%@ / mes";
/* Review funnel */
"review_funnel_title" = "¿Qué te está pareciendo MealMood?";
"review_funnel_message" = "Tu opinión nos ayuda a mejorar MealMood.";
"review_funnel_positive" = "Me gusta";
"review_funnel_negative" = "Se puede mejorar";
"review_feedback_title" = "Cuéntanos qué mejorar";
"review_feedback_message" = "Envíanos tu feedback y lo usaremos para mejorar la app.";
"review_feedback_contact" = "Enviar feedback";
"onboarding_auto_assign_title" = "¿Quieres que hagamos una asignación automática?";
"onboarding_auto_assign_message" = "Podemos completar esta semana automáticamente usando tus platos y reglas.";
"onboarding_auto_assign_yes" = "Sí, asignar automáticamente";
"onboarding_auto_assign_no" = "No, lo haré yo";
"onboarding_premium_prompt_title" = "¿Quieres desbloquear Premium?";
"onboarding_premium_prompt_message" = "Consigue sin anuncios, platos ilimitados, reglas avanzadas y planificación de semanas futuras.";
"onboarding_premium_prompt_cta" = "Ver Premium";
/* Notifications */
"notification_planning_title" = "Planifica tu próxima semana";
"notification_planning_body" = "Mañana empieza la semana y aún no está planificada.";
+3 -1
View File
@@ -3,7 +3,7 @@ import SwiftData
struct DefaultDataService {
static func createDefaultTags(context: ModelContext) {
static func createDefaultTags(context: ModelContext, saveImmediately: Bool = true) {
let defaults: [(name: String, nameEN: String, color: String, maxPerWeek: Int?, noConsecutive: Bool, noDuplicateInDay: Bool, mealTypeRestriction: String?, sortOrder: Int)] = [
("Carne", "Meat", "#E74C3C", 3, true, true, nil, 0),
("Pescado", "Fish", "#3498DB", 2, true, true, nil, 1),
@@ -32,8 +32,10 @@ struct DefaultDataService {
context.insert(tag)
}
if saveImmediately {
try? context.save()
}
}
static func createDefaultSettings(context: ModelContext) {
let settings = AppSettings()
+21 -17
View File
@@ -5,35 +5,39 @@ import StoreKit
final class ReviewPromptService {
static let shared = ReviewPromptService()
private let milestones = [1, 2, 4, 8]
private let minimumDaysBetweenPrompts: Double = 30
private let promptedMilestoneKey = "review_prompted_milestone"
private let lastPromptDateKey = "review_prompt_last_date"
private let reviewCompletedKey = "review_funnel_completed"
private let lastPromptedCompletedWeeksKey = "review_funnel_last_prompted_completed_weeks"
private let minimumCompletedWeeksToStart = 2
private let promptIntervalWeeks = 2
private init() {}
func considerPromptAfterWeekCompletion(completedWeeks: Int) {
guard let milestone = milestones.first(where: { completedWeeks >= $0 }) else { return }
let alreadyPrompted = UserDefaults.standard.integer(forKey: promptedMilestoneKey)
guard milestone > alreadyPrompted else { return }
guard canPromptNow() else { return }
func shouldShowFunnelAfterWeekCompletion(completedWeeks: Int) -> Bool {
guard !hasCompletedReviewFlow else { return false }
guard completedWeeks >= minimumCompletedWeeksToStart else { return false }
guard completedWeeks % promptIntervalWeeks == 0 else { return false }
requestReview()
UserDefaults.standard.set(milestone, forKey: promptedMilestoneKey)
UserDefaults.standard.set(Date().timeIntervalSince1970, forKey: lastPromptDateKey)
let lastPrompted = UserDefaults.standard.integer(forKey: lastPromptedCompletedWeeksKey)
return completedWeeks > lastPrompted
}
func markFunnelShown(completedWeeks: Int) {
UserDefaults.standard.set(completedWeeks, forKey: lastPromptedCompletedWeeksKey)
}
func markReviewCompleted() {
UserDefaults.standard.set(true, forKey: reviewCompletedKey)
}
func requestFromSettings() {
requestReview()
}
private func canPromptNow() -> Bool {
let lastPrompt = UserDefaults.standard.double(forKey: lastPromptDateKey)
guard lastPrompt > 0 else { return true }
return Date().timeIntervalSince1970 - lastPrompt >= minimumDaysBetweenPrompts * 24 * 60 * 60
var hasCompletedReviewFlow: Bool {
UserDefaults.standard.bool(forKey: reviewCompletedKey)
}
private func requestReview() {
func requestReview() {
guard let scene = UIApplication.shared.connectedScenes
.compactMap({ $0 as? UIWindowScene })
.first(where: { $0.activationState == .foregroundActive }) else { return }
+27 -1
View File
@@ -429,6 +429,33 @@ final class HomeViewModel: ObservableObject {
}
}()
// Clean up any legacy duplicates for the same day+mealType key to keep UI mapping stable.
var groupedByKey: [SlotKey: [MealSlot]] = [:]
for slot in plan.slots {
let key = SlotKey(dayOfWeek: slot.dayOfWeek, mealType: slot.mealType)
groupedByKey[key, default: []].append(slot)
}
var changed = false
for (_, duplicates) in groupedByKey where duplicates.count > 1 {
let keeper = MealSlot.preferredForDuplicateResolution(duplicates)
for duplicate in duplicates where duplicate.id != keeper.id {
if keeper.dishId == nil, let dishId = duplicate.dishId {
keeper.dishId = dishId
keeper.isRuleOverridden = duplicate.isRuleOverridden
if keeper.calendarEventId == nil {
keeper.calendarEventId = duplicate.calendarEventId
}
} else if let duplicateEventId = duplicate.calendarEventId {
CalendarService.shared.deleteEvent(eventId: duplicateEventId)
}
plan.slots.removeAll { $0.id == duplicate.id }
context.delete(duplicate)
changed = true
}
}
var desired = Set<SlotKey>()
for day in 0...maxDay {
for mealType in mealTypes {
@@ -443,7 +470,6 @@ final class HomeViewModel: ObservableObject {
existingByKey[key] = slot
}
}
var changed = false
let toDelete = plan.slots.filter { slot in
!desired.contains(SlotKey(dayOfWeek: slot.dayOfWeek, mealType: slot.mealType))
@@ -3,6 +3,9 @@ import SwiftData
@MainActor
final class OnboardingViewModel: ObservableObject {
static let pendingAutoAssignPromptKey = "onboarding_pending_auto_assign_prompt"
static let pendingPremiumPromptKey = "onboarding_pending_premium_prompt"
@Published var currentStep: Int = 0
@Published var selectedMealWindows: MealWindows = .dinnerOnly
@Published var includeWeekends: Bool = true
@@ -148,6 +151,8 @@ final class OnboardingViewModel: ObservableObject {
do {
try context.save()
UserDefaults.standard.set(true, forKey: Self.pendingAutoAssignPromptKey)
UserDefaults.standard.set(true, forKey: Self.pendingPremiumPromptKey)
return true
} catch {
print("Onboarding save failed: \(error)")
+38 -14
View File
@@ -9,6 +9,14 @@ final class SettingsViewModel: ObservableObject {
@Published var showToast: Bool = false
@Published var toastMessage: String = ""
var appVersion: String {
Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "-"
}
var appBuild: String {
Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "-"
}
func loadCalendars() {
availableCalendars = CalendarService.shared.availableCalendars()
}
@@ -60,26 +68,43 @@ final class SettingsViewModel: ObservableObject {
func resetAllData(context: ModelContext) {
do {
try deleteAll(of: MealSlot.self, in: context)
let settingsDescriptor = FetchDescriptor<AppSettings>()
let targetSettings: AppSettings
if let existingSettings = try context.fetch(settingsDescriptor).first {
targetSettings = existingSettings
} else {
let created = AppSettings()
context.insert(created)
targetSettings = created
}
// Phase 1: switch app flow out of Home before destructive deletes.
resetSettings(targetSettings)
try context.save()
Task { @MainActor in
do {
try? await Task.sleep(nanoseconds: 200_000_000)
try deleteAll(of: WeekPlan.self, in: context)
try deleteAll(of: Dish.self, in: context)
try deleteAll(of: Tag.self, in: context)
DefaultDataService.createDefaultTags(context: context)
let settingsDescriptor = FetchDescriptor<AppSettings>()
if let existingSettings = try context.fetch(settingsDescriptor).first {
resetSettings(existingSettings)
} else {
DefaultDataService.createDefaultSettings(context: context)
if let createdSettings = try context.fetch(settingsDescriptor).first {
resetSettings(createdSettings)
}
}
DefaultDataService.createDefaultTags(context: context, saveImmediately: false)
// Ensure a true "fresh launch" onboarding flow.
UserDefaults.standard.set(false, forKey: OnboardingViewModel.pendingAutoAssignPromptKey)
UserDefaults.standard.set(false, forKey: OnboardingViewModel.pendingPremiumPromptKey)
try context.save()
} catch {
#if DEBUG
print("Failed to reset all data: \(error)")
print("Failed to complete reset all data: \(error)")
#endif
}
}
} catch {
#if DEBUG
print("Failed to start reset all data: \(error)")
#endif
}
}
@@ -90,7 +115,6 @@ final class SettingsViewModel: ObservableObject {
for model in models {
context.delete(model)
}
try context.save()
}
private func showToastMessage(_ message: String) {
+1 -1
View File
@@ -32,7 +32,7 @@ struct DishListView: View {
}
} else {
List {
ForEach(dishes) { dish in
ForEach(dishes, id: \.id) { dish in
let dishTags = tags.filter { dish.tagIds.contains($0.id) }
Button {
editingDish = dish
+2 -2
View File
@@ -15,7 +15,7 @@ struct DishDrawerView: View {
private var filteredDishes: [Dish] {
let term = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
return dishes.filter { dish in
return Dish.stableSortedForDisplay(dishes).filter { dish in
let matchesSearch = term.isEmpty || dish.name.localizedCaseInsensitiveContains(term)
let matchesUsedFilter = !hideUsedThisWeek || !usedDishIds.contains(dish.id)
return matchesSearch && matchesUsedFilter
@@ -95,7 +95,7 @@ struct DishDrawerView: View {
.buttonStyle(.plain)
LazyVStack(spacing: 8) {
ForEach(filteredDishes) { dish in
ForEach(filteredDishes, id: \.id) { dish in
DishCardView(
dish: dish,
tags: tags,
+123 -10
View File
@@ -4,7 +4,8 @@ import SwiftData
struct HomeView: View {
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@Environment(\.modelContext) private var context
@Query private var dishes: [Dish]
@Environment(\.openURL) private var openURL
@Query(sort: \Dish.createdAt, order: .reverse) private var dishes: [Dish]
@Query private var tags: [Tag]
@Query(sort: \WeekPlan.weekStartDate, order: .forward) private var weekPlans: [WeekPlan]
@Query private var allSettings: [AppSettings]
@@ -18,6 +19,11 @@ struct HomeView: View {
@State private var showWeekPicker: Bool = false
@State private var weekPickerDate: Date = Date()
@State private var showCopyPreviousConfirm: Bool = false
@State private var showReviewSentimentPrompt: Bool = false
@State private var showReviewSupportPrompt: Bool = false
@State private var showPostOnboardingAutoAssignPrompt: Bool = false
@State private var showPostOnboardingPremiumPrompt: Bool = false
@State private var hasEvaluatedPostOnboardingPrompts: Bool = false
private var settings: AppSettings? { allSettings.first }
@@ -27,7 +33,7 @@ struct HomeView: View {
Color.mealMoodBackground.ignoresSafeArea()
if let settings = settings {
let plan = viewModel.getOrCreateWeekPlan(context: context, settings: settings)
let plan = fetchWeekPlan(for: viewModel.currentWeekStart)
if let plan {
mainContent(plan: plan, settings: settings)
}
@@ -103,7 +109,7 @@ struct HomeView: View {
ToolbarItem(placement: .navigationBarTrailing) {
HStack(spacing: 10) {
if let settings = settings,
let plan = viewModel.getOrCreateWeekPlan(context: context, settings: settings) {
let plan = fetchWeekPlan(for: viewModel.currentWeekStart) {
if viewModel.canEditCurrentWeek {
Button {
viewModel.autoComplete(plan: plan, dishes: dishes, tags: tags, settings: settings)
@@ -155,22 +161,22 @@ struct HomeView: View {
}
.onAppear {
guard let settings = settings,
let plan = viewModel.getOrCreateWeekPlan(context: context, settings: settings) else { return }
let plan = ensureCurrentWeekPlanExists(settings: settings) else { return }
viewModel.reconcileSlotsIfNeeded(plan: plan, settings: settings, context: context)
}
.onChange(of: settings?.includeWeekends) { _, _ in
guard let settings = settings,
let plan = viewModel.getOrCreateWeekPlan(context: context, settings: settings) else { return }
let plan = ensureCurrentWeekPlanExists(settings: settings) else { return }
viewModel.reconcileSlotsIfNeeded(plan: plan, settings: settings, context: context)
}
.onChange(of: settings?.mealWindows) { _, _ in
guard let settings = settings,
let plan = viewModel.getOrCreateWeekPlan(context: context, settings: settings) else { return }
let plan = ensureCurrentWeekPlanExists(settings: settings) else { return }
viewModel.reconcileSlotsIfNeeded(plan: plan, settings: settings, context: context)
}
.onChange(of: viewModel.currentWeekStart) { _, _ in
guard let settings = settings,
let plan = viewModel.getOrCreateWeekPlan(context: context, settings: settings) else { return }
let plan = ensureCurrentWeekPlanExists(settings: settings) else { return }
viewModel.reconcileSlotsIfNeeded(plan: plan, settings: settings, context: context)
}
}
@@ -248,6 +254,52 @@ struct HomeView: View {
secondaryButton: .cancel(Text("reset_cancel"))
)
}
.alert("onboarding_auto_assign_title", isPresented: $showPostOnboardingAutoAssignPrompt) {
Button("onboarding_auto_assign_yes") {
viewModel.autoComplete(plan: plan, dishes: dishes, tags: tags, settings: settings)
UserDefaults.standard.set(false, forKey: OnboardingViewModel.pendingAutoAssignPromptKey)
schedulePostOnboardingPremiumPromptIfNeeded(settings: settings)
}
Button("onboarding_auto_assign_no", role: .cancel) {
UserDefaults.standard.set(false, forKey: OnboardingViewModel.pendingAutoAssignPromptKey)
schedulePostOnboardingPremiumPromptIfNeeded(settings: settings)
}
} message: {
Text("onboarding_auto_assign_message")
}
.alert("onboarding_premium_prompt_title", isPresented: $showPostOnboardingPremiumPrompt) {
Button("onboarding_premium_prompt_cta") {
UserDefaults.standard.set(false, forKey: OnboardingViewModel.pendingPremiumPromptKey)
showPremiumFromExport = true
}
Button("reset_cancel", role: .cancel) {
UserDefaults.standard.set(false, forKey: OnboardingViewModel.pendingPremiumPromptKey)
}
} message: {
Text("onboarding_premium_prompt_message")
}
.alert("review_funnel_title", isPresented: $showReviewSentimentPrompt) {
Button("review_funnel_positive") {
ReviewPromptService.shared.requestReview()
ReviewPromptService.shared.markReviewCompleted()
}
Button("review_funnel_negative", role: .destructive) {
showReviewSupportPrompt = true
}
Button("reset_cancel", role: .cancel) {}
} message: {
Text("review_funnel_message")
}
.alert("review_feedback_title", isPresented: $showReviewSupportPrompt) {
Button("review_feedback_contact") {
if let url = URL(string: "mailto:support@mealmood.app?subject=MealMood%20Feedback") {
openURL(url)
}
}
Button("reset_cancel", role: .cancel) {}
} message: {
Text("review_feedback_message")
}
.sheet(isPresented: $viewModel.showDishForm) {
DishFormView()
}
@@ -349,6 +401,7 @@ struct HomeView: View {
language: settings.languageEnum.resolved()
)
wasWeekComplete = isWeekComplete(plan: plan)
evaluatePostOnboardingPromptsIfNeeded(plan: plan, settings: settings)
}
.onChange(of: plan.updatedAt) { _, _ in
let nowComplete = isWeekComplete(plan: plan)
@@ -399,7 +452,7 @@ struct HomeView: View {
.padding(24)
} else {
List {
ForEach(filteredDishes) { dish in
ForEach(filteredDishes, id: \.id) { dish in
Button {
onPickDish(dish)
dismiss()
@@ -602,19 +655,79 @@ struct HomeView: View {
}
private func fetchWeekPlan(for weekStartDate: Date) -> WeekPlan? {
let matches = weekPlanCandidates(for: weekStartDate)
guard !matches.isEmpty else { return nil }
return preferredWeekPlan(from: matches)
}
private func weekPlanCandidates(for weekStartDate: Date) -> [WeekPlan] {
let descriptor = FetchDescriptor<WeekPlan>(
predicate: #Predicate<WeekPlan> { plan in
plan.weekStartDate == weekStartDate
}
)
return try? context.fetch(descriptor).first
return (try? context.fetch(descriptor)) ?? []
}
private func preferredWeekPlan(from plans: [WeekPlan]) -> WeekPlan? {
plans.max { lhs, rhs in
let lhsAssigned = lhs.slots.filter { $0.dishId != nil }.count
let rhsAssigned = rhs.slots.filter { $0.dishId != nil }.count
if lhsAssigned != rhsAssigned { return lhsAssigned < rhsAssigned }
if lhs.slots.count != rhs.slots.count { return lhs.slots.count < rhs.slots.count }
return lhs.updatedAt < rhs.updatedAt
}
}
private func ensureCurrentWeekPlanExists(settings: AppSettings) -> WeekPlan? {
let matches = weekPlanCandidates(for: viewModel.currentWeekStart)
if let preferred = preferredWeekPlan(from: matches) {
if matches.count > 1 {
for duplicate in matches where duplicate.id != preferred.id {
context.delete(duplicate)
}
try? context.save()
}
return preferred
}
return DefaultDataService.createWeekPlan(for: viewModel.currentWeekStart, settings: settings, context: context)
}
private func evaluateReviewPrompt() {
let descriptor = FetchDescriptor<WeekPlan>()
guard let plans = try? context.fetch(descriptor) else { return }
let completedWeeks = plans.filter { !$0.slots.isEmpty && $0.slots.allSatisfy { $0.dishId != nil } }.count
ReviewPromptService.shared.considerPromptAfterWeekCompletion(completedWeeks: completedWeeks)
guard ReviewPromptService.shared.shouldShowFunnelAfterWeekCompletion(completedWeeks: completedWeeks) else { return }
ReviewPromptService.shared.markFunnelShown(completedWeeks: completedWeeks)
showReviewSentimentPrompt = true
}
private func evaluatePostOnboardingPromptsIfNeeded(plan: WeekPlan, settings: AppSettings) {
guard !hasEvaluatedPostOnboardingPrompts else { return }
hasEvaluatedPostOnboardingPrompts = true
let shouldAskAutoAssign = UserDefaults.standard.bool(forKey: OnboardingViewModel.pendingAutoAssignPromptKey)
if shouldAskAutoAssign && plan.slots.contains(where: { $0.dishId == nil }) {
showPostOnboardingAutoAssignPrompt = true
return
}
UserDefaults.standard.set(false, forKey: OnboardingViewModel.pendingAutoAssignPromptKey)
schedulePostOnboardingPremiumPromptIfNeeded(settings: settings)
}
private func schedulePostOnboardingPremiumPromptIfNeeded(settings: AppSettings) {
guard !settings.isPremium else {
UserDefaults.standard.set(false, forKey: OnboardingViewModel.pendingPremiumPromptKey)
return
}
let shouldShowPremiumPrompt = UserDefaults.standard.bool(forKey: OnboardingViewModel.pendingPremiumPromptKey)
guard shouldShowPremiumPrompt else { return }
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
showPostOnboardingPremiumPrompt = true
}
}
}
+133 -14
View File
@@ -2,6 +2,8 @@ import SwiftUI
struct WeekCalendarView: View {
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@State private var dishSnapshotsById: [UUID: DishSnapshot] = [:]
@State private var displaySlotsByKey: [String: DisplaySlot] = [:]
let plan: WeekPlan
let settings: AppSettings
@@ -34,6 +36,23 @@ struct WeekCalendarView: View {
compactLayout
}
}
.onAppear {
refreshDisplaySlots()
refreshDishSnapshots()
}
.onChange(of: dishesSnapshotKey) { _, _ in
refreshDishSnapshots()
}
.onChange(of: plan.updatedAt) { _, _ in
refreshDisplaySlots()
refreshDishSnapshots()
}
.onChange(of: settings.includeWeekends) { _, _ in
refreshDisplaySlots()
}
.onChange(of: settings.mealWindows) { _, _ in
refreshDisplaySlots()
}
}
private var compactLayout: some View {
@@ -55,7 +74,7 @@ struct WeekCalendarView: View {
ForEach(Array(mealTypes.enumerated()), id: \.element) { index, mealType in
HStack(spacing: 0) {
ForEach(Array(days.enumerated()), id: \.element) { index, day in
let slot = slotFor(day: day, mealType: mealType)
let slot = displayedSlot(day: day, mealType: mealType)
draggableSlotView(slot: slot, mealType: mealType)
.frame(width: 96)
if index < days.count - 1 {
@@ -111,7 +130,7 @@ struct WeekCalendarView: View {
.padding(.trailing, spacing)
ForEach(Array(days.enumerated()), id: \.element) { index, day in
let slot = slotFor(day: day, mealType: mealType)
let slot = displayedSlot(day: day, mealType: mealType)
draggableSlotView(slot: slot, mealType: mealType)
.frame(width: dayWidth, height: slotRowHeight)
if index < days.count - 1 {
@@ -186,16 +205,30 @@ struct WeekCalendarView: View {
.cornerRadius(10)
}
private func slotFor(day: Int, mealType: MealType) -> MealSlot? {
plan.slots.first { $0.dayOfWeek == day && $0.mealType == mealType.rawValue }
private func displayedSlot(day: Int, mealType: MealType) -> DisplaySlot? {
let key = slotKey(day: day, mealType: mealType.rawValue)
if let cached = displaySlotsByKey[key] {
return cached
}
let matching = plan.slots.filter { $0.dayOfWeek == day && $0.mealType == mealType.rawValue }
guard !matching.isEmpty else { return nil }
let preferred = MealSlot.preferredForDuplicateResolution(matching)
return DisplaySlot(
slotId: preferred.id,
dayOfWeek: day,
mealType: mealType.rawValue,
dishId: preferred.dishId,
isRuleOverridden: preferred.isRuleOverridden
)
}
private func draggableSlotView(slot: MealSlot?, mealType: MealType) -> some View {
private func draggableSlotView(slot: DisplaySlot?, mealType: MealType) -> some View {
slotView(slot: slot, mealType: mealType)
.dropDestination(for: String.self) { items, _ in
guard let payloadRaw = items.first,
let payload = parseDropPayload(payloadRaw),
let targetSlot = slot,
let targetDisplaySlot = slot,
let targetSlot = liveSlot(for: targetDisplaySlot),
viewModel.canEditCurrentWeek else { return false }
switch payload {
@@ -230,27 +263,29 @@ struct WeekCalendarView: View {
}
@ViewBuilder
private func slotView(slot: MealSlot?, mealType: MealType) -> some View {
private func slotView(slot: DisplaySlot?, mealType: MealType) -> some View {
if let slot = slot, let dishId = slot.dishId,
let dish = dishes.first(where: { $0.id == dishId }) {
let dishTags = tags.filter { dish.tagIds.contains($0.id) }
let dishData = dishData(for: dishId) {
let dishTags = tags.filter { dishData.tagIds.contains($0.id) }
FilledSlotView(
dishName: dish.name,
dishDescription: dish.descriptionText,
dishName: dishData.name,
dishDescription: dishData.descriptionText,
tags: dishTags.map { (name: $0.localizedName(language: settings.languageEnum.resolved()), color: $0.color) },
mealType: mealType,
showsRuleWarning: slot.isRuleOverridden,
onRemove: viewModel.canEditCurrentWeek ? {
viewModel.removeDish(from: slot, plan: plan, settings: settings)
guard let live = liveSlot(for: slot) else { return }
viewModel.removeDish(from: live, plan: plan, settings: settings)
} : nil
)
.draggable("slot:\(slot.id.uuidString)")
.draggable("slot:\(slot.slotId.uuidString)")
} else if let slot = slot {
EmptySlotView(mealType: mealType)
.contentShape(Rectangle())
.onTapGesture {
guard viewModel.canEditCurrentWeek else { return }
onTapEmptySlot?(slot)
guard let live = liveSlot(for: slot) else { return }
onTapEmptySlot?(live)
}
} else {
EmptySlotView(mealType: mealType)
@@ -262,6 +297,90 @@ struct WeekCalendarView: View {
case slot(UUID)
}
private struct DishSnapshot {
let name: String
let descriptionText: String?
let tagIds: [UUID]
}
private struct DisplaySlot {
let slotId: UUID
let dayOfWeek: Int
let mealType: String
let dishId: UUID?
let isRuleOverridden: Bool
}
private var dishesSnapshotKey: String {
dishes
.map { "\($0.id.uuidString)|\($0.name)|\($0.tagIds.count)|\($0.descriptionText ?? "")" }
.joined(separator: ";")
}
private func dishData(for dishId: UUID) -> DishSnapshot? {
if let live = dishes.first(where: { $0.id == dishId }) {
return DishSnapshot(
name: live.name,
descriptionText: live.descriptionText,
tagIds: live.tagIds
)
}
return dishSnapshotsById[dishId]
}
private func refreshDishSnapshots() {
let liveSnapshots = Dictionary(uniqueKeysWithValues: dishes.map { dish in
(
dish.id,
DishSnapshot(
name: dish.name,
descriptionText: dish.descriptionText,
tagIds: dish.tagIds
)
)
})
dishSnapshotsById = dishSnapshotsById.merging(liveSnapshots) { _, new in new }
}
private func refreshDisplaySlots() {
var resolved: [String: DisplaySlot] = [:]
let grouped = Dictionary(grouping: plan.slots, by: { slotKey(day: $0.dayOfWeek, mealType: $0.mealType) })
for (key, candidates) in grouped {
guard !candidates.isEmpty else { continue }
let preferred = MealSlot.preferredForDuplicateResolution(candidates)
resolved[key] = DisplaySlot(
slotId: preferred.id,
dayOfWeek: preferred.dayOfWeek,
mealType: preferred.mealType,
dishId: preferred.dishId,
isRuleOverridden: preferred.isRuleOverridden
)
}
// Ignore incomplete transient relationship states that occur during SwiftData refreshes.
if resolved.count == expectedSlotCount || displaySlotsByKey.isEmpty {
displaySlotsByKey = resolved
}
}
private var expectedSlotCount: Int {
days.count * mealTypes.count
}
private func slotKey(day: Int, mealType: String) -> String {
"\(day)|\(mealType)"
}
private func liveSlot(for display: DisplaySlot) -> MealSlot? {
if let exact = plan.slots.first(where: { $0.id == display.slotId }) {
return exact
}
let matching = plan.slots.filter { $0.dayOfWeek == display.dayOfWeek && $0.mealType == display.mealType }
guard !matching.isEmpty else { return nil }
return MealSlot.preferredForDuplicateResolution(matching)
}
private func parseDropPayload(_ raw: String) -> DropPayload? {
let parts = raw.split(separator: ":", maxSplits: 1).map(String.init)
guard parts.count == 2, let id = UUID(uuidString: parts[1]) else { return nil }
@@ -18,11 +18,12 @@ struct CalendarStepView: View {
ScrollView {
VStack(spacing: 24) {
Text("calendar_title")
.font(.mealMoodH2)
.font(.mealMoodH1)
.foregroundColor(.mealMoodTextPrimary)
.multilineTextAlignment(.leading)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 24)
.padding(.top, 32)
.padding(.top, 12)
VStack(spacing: 16) {
// iCloud sync toggle
@@ -151,5 +152,6 @@ struct CalendarStepView: View {
} message: {
Text("calendar_permission_message")
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
}
}
@@ -30,22 +30,26 @@ struct FirstDishesStepView: View {
}
private let maxVisibleSuggestions = 3
private var addedDishesReversed: [(index: Int, dish: (name: String, tagIds: [UUID]))] {
Array(viewModel.addedDishes.enumerated()).reversed().map { ($0.offset, $0.element) }
}
var body: some View {
ScrollView {
VStack(spacing: 0) {
VStack(spacing: 24) {
VStack(alignment: .leading, spacing: 8) {
Text("first_dishes_title")
.font(.mealMoodH2)
.font(.mealMoodH1)
.foregroundColor(.mealMoodTextPrimary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 24)
.padding(.top, 12)
Text("first_dishes_subtitle")
.font(.mealMoodSmall)
.foregroundColor(.mealMoodTextSecondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 24)
.padding(.top, 32)
// New dish form
VStack(spacing: 12) {
@@ -127,7 +131,11 @@ struct FirstDishesStepView: View {
.foregroundColor(.mealMoodTextSecondary)
.padding(.horizontal, 24)
ForEach(Array(viewModel.addedDishes.enumerated()), id: \.offset) { index, dish in
ScrollView {
LazyVStack(spacing: 8) {
ForEach(addedDishesReversed, id: \.index) { item in
let index = item.index
let dish = item.dish
HStack {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.mealMoodSuccess)
@@ -156,10 +164,13 @@ struct FirstDishesStepView: View {
.padding(12)
.background(Color.mealMoodSurface)
.cornerRadius(12)
.padding(.horizontal, 24)
}
}
}
.frame(maxHeight: 190)
.padding(.horizontal, 24)
}
}
VStack(alignment: .leading, spacing: 10) {
Text("first_dishes_suggestions")
@@ -209,7 +220,9 @@ struct FirstDishesStepView: View {
}
}
Spacer(minLength: 40)
Spacer(minLength: 20)
}
.frame(maxWidth: .infinity, alignment: .top)
PrimaryButton(
title: String(localized: "onboarding_finish"),
@@ -222,7 +235,8 @@ struct FirstDishesStepView: View {
isEnabled: viewModel.addedDishes.count >= 2
)
.padding(.horizontal, 24)
.padding(.bottom, 40)
.padding(.top, 12)
.padding(.bottom, 24)
}
}
.sheet(isPresented: $showTagSelector) {
@@ -239,6 +253,7 @@ struct FirstDishesStepView: View {
ensureDefaultTagsIfNeeded()
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
}
private func suggestedDishes() -> [(name: String, tagHints: [String])] {
@@ -6,13 +6,13 @@ struct MealWindowsStepView: View {
var body: some View {
VStack(spacing: 24) {
Spacer()
Text("meal_windows_title")
.font(.mealMoodH2)
.font(.mealMoodH1)
.foregroundColor(.mealMoodTextPrimary)
.multilineTextAlignment(.leading)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 24)
.padding(.top, 12)
VStack(spacing: 12) {
ForEach(MealWindows.allCases, id: \.self) { option in
@@ -30,12 +30,12 @@ struct MealWindowsStepView: View {
}
.padding(.horizontal, 24)
Spacer()
PrimaryButton(title: String(localized: "onboarding_continue"), action: onNext)
.padding(.horizontal, 24)
.padding(.top, 8)
.padding(.bottom, 40)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
}
private func optionTitle(_ option: MealWindows) -> String {
+58 -4
View File
@@ -12,8 +12,8 @@ struct OnboardingView: View {
Color.mealMoodBackground.ignoresSafeArea()
VStack(spacing: 0) {
// Progress indicator
if viewModel.currentStep > 0 {
onboardingHeader
HStack(spacing: 8) {
ForEach(1..<viewModel.totalSteps, id: \.self) { step in
Capsule()
@@ -22,23 +22,26 @@ struct OnboardingView: View {
}
}
.padding(.horizontal, 24)
.padding(.top, 16)
}
.padding(.top, 8)
.padding(.bottom, 12)
TabView(selection: $viewModel.currentStep) {
WelcomeStepView(onNext: { viewModel.nextStep() })
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.tag(0)
MealWindowsStepView(
selection: $viewModel.selectedMealWindows,
onNext: { viewModel.nextStep() }
)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.tag(1)
WeekendsStepView(
includeWeekends: $viewModel.includeWeekends,
onNext: { viewModel.nextStep() }
)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.tag(2)
CalendarStepView(
@@ -53,6 +56,7 @@ struct OnboardingView: View {
onNext: { viewModel.nextStep() },
onSkip: { viewModel.nextStep() }
)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.tag(3)
FirstDishesStepView(
@@ -64,10 +68,12 @@ struct OnboardingView: View {
}
}
)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.tag(4)
}
.tabViewStyle(.page(indexDisplayMode: .never))
.animation(.easeInOut(duration: 0.3), value: viewModel.currentStep)
.frame(maxHeight: .infinity, alignment: .top)
}
}
.onAppear {
@@ -106,4 +112,52 @@ struct OnboardingView: View {
let plans = (try? context.fetch(FetchDescriptor<WeekPlan>())) ?? []
return !plans.isEmpty
}
private var onboardingHeader: some View {
VStack(spacing: 6) {
HStack {
if viewModel.currentStep > 0 {
Button {
viewModel.previousStep()
} label: {
Image(systemName: "chevron.left")
.font(.system(size: 16, weight: .semibold))
.foregroundColor(.mealMoodTextPrimary)
.frame(width: 32, height: 32)
.background(Color.mealMoodSurface)
.clipShape(Circle())
}
.buttonStyle(.plain)
} else {
Color.clear.frame(width: 32, height: 32)
}
Spacer()
Text(headerTitle)
.font(.mealMoodBodyBold)
.foregroundColor(.mealMoodTextPrimary)
.lineLimit(1)
Spacer()
Text(stepCounterText)
.font(.mealMoodCaption)
.foregroundColor(.mealMoodTextSecondary)
.frame(width: 70, alignment: .trailing)
}
}
.padding(.horizontal, 16)
.padding(.top, 8)
}
private var headerTitle: String {
guard viewModel.currentStep > 0 else { return String(localized: "onboarding_nav_intro") }
return String(format: String(localized: "onboarding_nav_step_short"), viewModel.currentStep)
}
private var stepCounterText: String {
guard viewModel.currentStep > 0 else { return String(localized: "onboarding_nav_setup") }
return String(format: String(localized: "onboarding_nav_step_counter"), viewModel.currentStep, viewModel.totalSteps - 1)
}
}
@@ -6,13 +6,13 @@ struct WeekendsStepView: View {
var body: some View {
VStack(spacing: 24) {
Spacer()
Text("weekends_title")
.font(.mealMoodH2)
.font(.mealMoodH1)
.foregroundColor(.mealMoodTextPrimary)
.multilineTextAlignment(.leading)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 24)
.padding(.top, 12)
VStack(spacing: 16) {
HStack {
@@ -40,11 +40,11 @@ struct WeekendsStepView: View {
.font(.mealMoodSmall)
.foregroundColor(.mealMoodTextSecondary)
Spacer()
PrimaryButton(title: String(localized: "onboarding_continue"), action: onNext)
.padding(.horizontal, 24)
.padding(.top, 8)
.padding(.bottom, 40)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
}
}
@@ -10,31 +10,18 @@ struct WelcomeStepView: View {
AppIconPlaceholder(size: 120)
VStack(spacing: 12) {
Text("onboarding_welcome_title")
.font(.mealMoodH1)
.foregroundColor(.mealMoodTextPrimary)
Text("onboarding_welcome_subtitle")
.font(.mealMoodBody)
.foregroundColor(.mealMoodTextSecondary)
.multilineTextAlignment(.center)
}
Image(systemName: "figure.2.and.child.holdinghands")
.font(.system(size: 80))
.foregroundStyle(
LinearGradient(
colors: [.mealMoodCoral, .mealMoodMint],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.padding(.vertical, 20)
.padding(.horizontal, 24)
VStack(alignment: .leading, spacing: 16) {
BenefitRow(icon: "face.smiling", text: String(localized: "onboarding_welcome_benefit1"))
BenefitRow(icon: "scalemass", text: String(localized: "onboarding_welcome_benefit2"))
BenefitRow(icon: "calendar", text: String(localized: "onboarding_welcome_benefit3"))
BenefitRow(icon: "1.circle.fill", text: String(localized: "onboarding_welcome_step_1"))
BenefitRow(icon: "2.circle.fill", text: String(localized: "onboarding_welcome_step_2"))
BenefitRow(icon: "3.circle.fill", text: String(localized: "onboarding_welcome_step_3"))
BenefitRow(icon: "4.circle.fill", text: String(localized: "onboarding_welcome_step_4"))
}
.padding(.horizontal, 40)
@@ -44,6 +31,7 @@ struct WelcomeStepView: View {
.padding(.horizontal, 24)
.padding(.bottom, 40)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
}
}
@@ -194,6 +194,20 @@ struct SettingsView: View {
Link(destination: URL(string: "mailto:support@mealmood.app")!) {
Label("settings_support", systemImage: "envelope")
}
HStack {
Text("settings_app_version")
Spacer()
Text(viewModel.appVersion)
.foregroundColor(.mealMoodTextSecondary)
}
HStack {
Text("settings_app_build")
Spacer()
Text(viewModel.appBuild)
.foregroundColor(.mealMoodTextSecondary)
}
} header: {
Label("settings_about", systemImage: "info.circle")
}
@@ -59,5 +59,25 @@ final class AutocompleteEngineTests: XCTestCase {
XCTAssertEqual(result.unfilledCount, 1)
XCTAssertNil(tuesdayDinner.dishId)
}
func testDishStableSortRemainsConsistentAfterDeletion() {
let base = Date()
let oldest = Dish(id: UUID(uuidString: "00000000-0000-0000-0000-000000000001")!, name: "Old", createdAt: base.addingTimeInterval(-60))
let newest = Dish(id: UUID(uuidString: "00000000-0000-0000-0000-000000000002")!, name: "New", createdAt: base)
let middle = Dish(id: UUID(uuidString: "00000000-0000-0000-0000-000000000003")!, name: "Middle", createdAt: base.addingTimeInterval(-30))
let initial = Dish.stableSortedForDisplay([oldest, newest, middle]).map(\.id)
XCTAssertEqual(initial, [newest.id, middle.id, oldest.id])
let afterDeletion = Dish.stableSortedForDisplay([newest, oldest]).map(\.id)
XCTAssertEqual(afterDeletion, [newest.id, oldest.id])
}
func testPreferredDuplicateSlotKeepsAssignedDish() {
let duplicateWithDish = MealSlot(dayOfWeek: 0, mealType: MealType.dinner.rawValue, dishId: UUID())
let duplicateWithoutDish = MealSlot(dayOfWeek: 0, mealType: MealType.dinner.rawValue, dishId: nil)
let preferred = MealSlot.preferredForDuplicateResolution([duplicateWithoutDish, duplicateWithDish])
XCTAssertEqual(preferred.id, duplicateWithDish.id)
}
}