Merge 1.0.1 into main
This commit is contained in:
@@ -643,7 +643,7 @@
|
|||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CODE_SIGN_ENTITLEMENTS = MealMood/Resources/MealMood.entitlements;
|
CODE_SIGN_ENTITLEMENTS = MealMood/Resources/MealMood.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 2;
|
||||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
INFOPLIST_FILE = MealMood/Resources/Info.plist;
|
INFOPLIST_FILE = MealMood/Resources/Info.plist;
|
||||||
@@ -652,7 +652,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0.0;
|
MARKETING_VERSION = 1.0.1;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.mealmood;
|
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.mealmood;
|
||||||
PRODUCT_NAME = MealMood;
|
PRODUCT_NAME = MealMood;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
@@ -747,7 +747,7 @@
|
|||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CODE_SIGN_ENTITLEMENTS = MealMood/Resources/MealMood.entitlements;
|
CODE_SIGN_ENTITLEMENTS = MealMood/Resources/MealMood.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 2;
|
||||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
INFOPLIST_FILE = MealMood/Resources/Info.plist;
|
INFOPLIST_FILE = MealMood/Resources/Info.plist;
|
||||||
@@ -756,7 +756,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0.0;
|
MARKETING_VERSION = 1.0.1;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.mealmood;
|
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.mealmood;
|
||||||
PRODUCT_NAME = MealMood;
|
PRODUCT_NAME = MealMood;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ struct ContentView: View {
|
|||||||
@State private var isReady = false
|
@State private var isReady = false
|
||||||
|
|
||||||
private var settings: AppSettings? { allSettings.first }
|
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 {
|
var body: some View {
|
||||||
Group {
|
Group {
|
||||||
@@ -72,6 +76,15 @@ struct ContentView: View {
|
|||||||
private func syncPremiumStatus() async {
|
private func syncPremiumStatus() async {
|
||||||
let descriptor = FetchDescriptor<AppSettings>()
|
let descriptor = FetchDescriptor<AppSettings>()
|
||||||
guard let settings = try? context.fetch(descriptor).first else { return }
|
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()
|
let premium = await StoreManager.hasActiveSubscription()
|
||||||
if settings.isPremium != premium {
|
if settings.isPremium != premium {
|
||||||
settings.isPremium = premium
|
settings.isPremium = premium
|
||||||
|
|||||||
@@ -23,3 +23,14 @@ final class Dish {
|
|||||||
self.createdAt = createdAt
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -33,3 +33,19 @@ final class MealSlot {
|
|||||||
set { mealType = newValue.rawValue }
|
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 */
|
||||||
"onboarding_welcome_title" = "MealMood";
|
"onboarding_welcome_title" = "Organize your meals in 1 minute";
|
||||||
"onboarding_welcome_subtitle" = "Plan your week, enjoy your meals";
|
"onboarding_welcome_subtitle" = "Answer 4 quick steps and start planning today.";
|
||||||
"onboarding_welcome_benefit1" = "No stress";
|
"onboarding_welcome_step_1" = "Choose if you want to plan lunches, dinners, or both";
|
||||||
"onboarding_welcome_benefit2" = "No calorie counting";
|
"onboarding_welcome_step_2" = "Decide whether to include weekends";
|
||||||
"onboarding_welcome_benefit3" = "Sync with your calendar";
|
"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_start" = "Get Started";
|
||||||
"onboarding_continue" = "Continue";
|
"onboarding_continue" = "Continue";
|
||||||
"onboarding_skip" = "Skip";
|
"onboarding_skip" = "Skip";
|
||||||
"onboarding_finish" = "Finish";
|
"onboarding_finish" = "Finish";
|
||||||
|
|
||||||
/* Meal Windows */
|
/* 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_dinner_only" = "Dinners only";
|
||||||
"meal_windows_lunch_only" = "Lunches only";
|
"meal_windows_lunch_only" = "Lunches only";
|
||||||
"meal_windows_both" = "Lunches and dinners";
|
"meal_windows_both" = "Lunches and dinners";
|
||||||
|
|
||||||
/* Weekends */
|
/* Weekends */
|
||||||
"weekends_title" = "Include weekends?";
|
"weekends_title" = "Step 2: Also plan weekends?";
|
||||||
"weekends_toggle" = "Also plan Saturdays and Sundays";
|
"weekends_toggle" = "Also plan Saturdays and Sundays";
|
||||||
"weekends_note" = "You can change this later in settings";
|
"weekends_note" = "You can change this later in settings";
|
||||||
|
|
||||||
/* Calendar */
|
/* Calendar */
|
||||||
"calendar_title" = "Sync with your calendar?";
|
"calendar_title" = "Step 3: Connect your calendar?";
|
||||||
"calendar_sync" = "Sync";
|
"calendar_sync" = "Sync";
|
||||||
"calendar_select" = "Calendar";
|
"calendar_select" = "Calendar";
|
||||||
"calendar_lunch_time" = "Lunch time";
|
"calendar_lunch_time" = "Lunch time";
|
||||||
@@ -32,7 +37,7 @@
|
|||||||
"calendar_permission_settings" = "Go to Settings";
|
"calendar_permission_settings" = "Go to Settings";
|
||||||
|
|
||||||
/* First Dishes */
|
/* 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_subtitle" = "Add at least 2 dishes to get started";
|
||||||
"first_dishes_name_placeholder" = "Dish name";
|
"first_dishes_name_placeholder" = "Dish name";
|
||||||
"first_dishes_add_tag" = "Add tag";
|
"first_dishes_add_tag" = "Add tag";
|
||||||
@@ -149,6 +154,8 @@
|
|||||||
"settings_website" = "Website";
|
"settings_website" = "Website";
|
||||||
"settings_support" = "Support email";
|
"settings_support" = "Support email";
|
||||||
"settings_rate_app" = "Rate MealMood";
|
"settings_rate_app" = "Rate MealMood";
|
||||||
|
"settings_app_version" = "Version";
|
||||||
|
"settings_app_build" = "Build";
|
||||||
"settings_danger_zone" = "Danger zone";
|
"settings_danger_zone" = "Danger zone";
|
||||||
"settings_reset_all_data" = "Reset all data";
|
"settings_reset_all_data" = "Reset all data";
|
||||||
"settings_reset_all_data_title" = "Reset all app data?";
|
"settings_reset_all_data_title" = "Reset all app data?";
|
||||||
@@ -215,6 +222,29 @@
|
|||||||
"first_dishes_suggestion_added" = "Added";
|
"first_dishes_suggestion_added" = "Added";
|
||||||
"first_dishes_no_tags" = "Default tags are loading, please retry in a moment";
|
"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 */
|
/* Notifications */
|
||||||
"notification_planning_title" = "Plan your next week";
|
"notification_planning_title" = "Plan your next week";
|
||||||
"notification_planning_body" = "Your new week starts tomorrow and it is still not planned.";
|
"notification_planning_body" = "Your new week starts tomorrow and it is still not planned.";
|
||||||
|
|||||||
@@ -1,27 +1,32 @@
|
|||||||
/* Onboarding */
|
/* Onboarding */
|
||||||
"onboarding_welcome_title" = "MealMood";
|
"onboarding_welcome_title" = "Organiza tus comidas en 1 minuto";
|
||||||
"onboarding_welcome_subtitle" = "Planifica tu semana, disfruta tus comidas";
|
"onboarding_welcome_subtitle" = "Responde 4 pasos rápidos y empieza a planificar hoy.";
|
||||||
"onboarding_welcome_benefit1" = "Sin estrés";
|
"onboarding_welcome_step_1" = "Elige si quieres planificar comidas, cenas o ambas";
|
||||||
"onboarding_welcome_benefit2" = "Sin contar calorías";
|
"onboarding_welcome_step_2" = "Decide si incluir fines de semana";
|
||||||
"onboarding_welcome_benefit3" = "Sincroniza con tu calendario";
|
"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_start" = "Comenzar";
|
||||||
"onboarding_continue" = "Continuar";
|
"onboarding_continue" = "Continuar";
|
||||||
"onboarding_skip" = "Omitir";
|
"onboarding_skip" = "Omitir";
|
||||||
"onboarding_finish" = "Finalizar";
|
"onboarding_finish" = "Finalizar";
|
||||||
|
|
||||||
/* Meal Windows */
|
/* 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_dinner_only" = "Solo cenas";
|
||||||
"meal_windows_lunch_only" = "Solo comidas";
|
"meal_windows_lunch_only" = "Solo comidas";
|
||||||
"meal_windows_both" = "Comidas y cenas";
|
"meal_windows_both" = "Comidas y cenas";
|
||||||
|
|
||||||
/* Weekends */
|
/* 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_toggle" = "Planificar también sábados y domingos";
|
||||||
"weekends_note" = "Puedes cambiar esto después en ajustes";
|
"weekends_note" = "Puedes cambiar esto después en ajustes";
|
||||||
|
|
||||||
/* Calendar */
|
/* Calendar */
|
||||||
"calendar_title" = "¿Sincronizar con tu calendario?";
|
"calendar_title" = "Paso 3: ¿Lo integramos con tu calendario?";
|
||||||
"calendar_sync" = "Sincronizar";
|
"calendar_sync" = "Sincronizar";
|
||||||
"calendar_select" = "Calendario";
|
"calendar_select" = "Calendario";
|
||||||
"calendar_lunch_time" = "Hora de comidas";
|
"calendar_lunch_time" = "Hora de comidas";
|
||||||
@@ -32,7 +37,7 @@
|
|||||||
"calendar_permission_settings" = "Ir a Ajustes";
|
"calendar_permission_settings" = "Ir a Ajustes";
|
||||||
|
|
||||||
/* First Dishes */
|
/* 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_subtitle" = "Añade al menos 2 platos para empezar";
|
||||||
"first_dishes_name_placeholder" = "Nombre del plato";
|
"first_dishes_name_placeholder" = "Nombre del plato";
|
||||||
"first_dishes_add_tag" = "Añadir etiqueta";
|
"first_dishes_add_tag" = "Añadir etiqueta";
|
||||||
@@ -149,6 +154,8 @@
|
|||||||
"settings_website" = "Web";
|
"settings_website" = "Web";
|
||||||
"settings_support" = "Correo de soporte";
|
"settings_support" = "Correo de soporte";
|
||||||
"settings_rate_app" = "Valorar MealMood";
|
"settings_rate_app" = "Valorar MealMood";
|
||||||
|
"settings_app_version" = "Versión";
|
||||||
|
"settings_app_build" = "Build";
|
||||||
"settings_danger_zone" = "Zona de peligro";
|
"settings_danger_zone" = "Zona de peligro";
|
||||||
"settings_reset_all_data" = "Resetear todos los datos";
|
"settings_reset_all_data" = "Resetear todos los datos";
|
||||||
"settings_reset_all_data_title" = "¿Resetear todos los datos de la app?";
|
"settings_reset_all_data_title" = "¿Resetear todos los datos de la app?";
|
||||||
@@ -215,6 +222,29 @@
|
|||||||
"first_dishes_suggestion_added" = "Añadido";
|
"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";
|
"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 */
|
/* Notifications */
|
||||||
"notification_planning_title" = "Planifica tu próxima semana";
|
"notification_planning_title" = "Planifica tu próxima semana";
|
||||||
"notification_planning_body" = "Mañana empieza la semana y aún no está planificada.";
|
"notification_planning_body" = "Mañana empieza la semana y aún no está planificada.";
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import SwiftData
|
|||||||
|
|
||||||
struct DefaultDataService {
|
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)] = [
|
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),
|
("Carne", "Meat", "#E74C3C", 3, true, true, nil, 0),
|
||||||
("Pescado", "Fish", "#3498DB", 2, true, true, nil, 1),
|
("Pescado", "Fish", "#3498DB", 2, true, true, nil, 1),
|
||||||
@@ -32,7 +32,9 @@ struct DefaultDataService {
|
|||||||
context.insert(tag)
|
context.insert(tag)
|
||||||
}
|
}
|
||||||
|
|
||||||
try? context.save()
|
if saveImmediately {
|
||||||
|
try? context.save()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static func createDefaultSettings(context: ModelContext) {
|
static func createDefaultSettings(context: ModelContext) {
|
||||||
|
|||||||
@@ -5,35 +5,39 @@ import StoreKit
|
|||||||
final class ReviewPromptService {
|
final class ReviewPromptService {
|
||||||
static let shared = ReviewPromptService()
|
static let shared = ReviewPromptService()
|
||||||
|
|
||||||
private let milestones = [1, 2, 4, 8]
|
private let reviewCompletedKey = "review_funnel_completed"
|
||||||
private let minimumDaysBetweenPrompts: Double = 30
|
private let lastPromptedCompletedWeeksKey = "review_funnel_last_prompted_completed_weeks"
|
||||||
private let promptedMilestoneKey = "review_prompted_milestone"
|
private let minimumCompletedWeeksToStart = 2
|
||||||
private let lastPromptDateKey = "review_prompt_last_date"
|
private let promptIntervalWeeks = 2
|
||||||
|
|
||||||
private init() {}
|
private init() {}
|
||||||
|
|
||||||
func considerPromptAfterWeekCompletion(completedWeeks: Int) {
|
func shouldShowFunnelAfterWeekCompletion(completedWeeks: Int) -> Bool {
|
||||||
guard let milestone = milestones.first(where: { completedWeeks >= $0 }) else { return }
|
guard !hasCompletedReviewFlow else { return false }
|
||||||
let alreadyPrompted = UserDefaults.standard.integer(forKey: promptedMilestoneKey)
|
guard completedWeeks >= minimumCompletedWeeksToStart else { return false }
|
||||||
guard milestone > alreadyPrompted else { return }
|
guard completedWeeks % promptIntervalWeeks == 0 else { return false }
|
||||||
guard canPromptNow() else { return }
|
|
||||||
|
|
||||||
requestReview()
|
let lastPrompted = UserDefaults.standard.integer(forKey: lastPromptedCompletedWeeksKey)
|
||||||
UserDefaults.standard.set(milestone, forKey: promptedMilestoneKey)
|
return completedWeeks > lastPrompted
|
||||||
UserDefaults.standard.set(Date().timeIntervalSince1970, forKey: lastPromptDateKey)
|
}
|
||||||
|
|
||||||
|
func markFunnelShown(completedWeeks: Int) {
|
||||||
|
UserDefaults.standard.set(completedWeeks, forKey: lastPromptedCompletedWeeksKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
func markReviewCompleted() {
|
||||||
|
UserDefaults.standard.set(true, forKey: reviewCompletedKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
func requestFromSettings() {
|
func requestFromSettings() {
|
||||||
requestReview()
|
requestReview()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func canPromptNow() -> Bool {
|
var hasCompletedReviewFlow: Bool {
|
||||||
let lastPrompt = UserDefaults.standard.double(forKey: lastPromptDateKey)
|
UserDefaults.standard.bool(forKey: reviewCompletedKey)
|
||||||
guard lastPrompt > 0 else { return true }
|
|
||||||
return Date().timeIntervalSince1970 - lastPrompt >= minimumDaysBetweenPrompts * 24 * 60 * 60
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func requestReview() {
|
func requestReview() {
|
||||||
guard let scene = UIApplication.shared.connectedScenes
|
guard let scene = UIApplication.shared.connectedScenes
|
||||||
.compactMap({ $0 as? UIWindowScene })
|
.compactMap({ $0 as? UIWindowScene })
|
||||||
.first(where: { $0.activationState == .foregroundActive }) else { return }
|
.first(where: { $0.activationState == .foregroundActive }) else { return }
|
||||||
|
|||||||
@@ -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>()
|
var desired = Set<SlotKey>()
|
||||||
for day in 0...maxDay {
|
for day in 0...maxDay {
|
||||||
for mealType in mealTypes {
|
for mealType in mealTypes {
|
||||||
@@ -443,7 +470,6 @@ final class HomeViewModel: ObservableObject {
|
|||||||
existingByKey[key] = slot
|
existingByKey[key] = slot
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
var changed = false
|
|
||||||
|
|
||||||
let toDelete = plan.slots.filter { slot in
|
let toDelete = plan.slots.filter { slot in
|
||||||
!desired.contains(SlotKey(dayOfWeek: slot.dayOfWeek, mealType: slot.mealType))
|
!desired.contains(SlotKey(dayOfWeek: slot.dayOfWeek, mealType: slot.mealType))
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ import SwiftData
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
final class OnboardingViewModel: ObservableObject {
|
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 currentStep: Int = 0
|
||||||
@Published var selectedMealWindows: MealWindows = .dinnerOnly
|
@Published var selectedMealWindows: MealWindows = .dinnerOnly
|
||||||
@Published var includeWeekends: Bool = true
|
@Published var includeWeekends: Bool = true
|
||||||
@@ -148,6 +151,8 @@ final class OnboardingViewModel: ObservableObject {
|
|||||||
|
|
||||||
do {
|
do {
|
||||||
try context.save()
|
try context.save()
|
||||||
|
UserDefaults.standard.set(true, forKey: Self.pendingAutoAssignPromptKey)
|
||||||
|
UserDefaults.standard.set(true, forKey: Self.pendingPremiumPromptKey)
|
||||||
return true
|
return true
|
||||||
} catch {
|
} catch {
|
||||||
print("Onboarding save failed: \(error)")
|
print("Onboarding save failed: \(error)")
|
||||||
|
|||||||
@@ -9,6 +9,14 @@ final class SettingsViewModel: ObservableObject {
|
|||||||
@Published var showToast: Bool = false
|
@Published var showToast: Bool = false
|
||||||
@Published var toastMessage: String = ""
|
@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() {
|
func loadCalendars() {
|
||||||
availableCalendars = CalendarService.shared.availableCalendars()
|
availableCalendars = CalendarService.shared.availableCalendars()
|
||||||
}
|
}
|
||||||
@@ -60,26 +68,43 @@ final class SettingsViewModel: ObservableObject {
|
|||||||
|
|
||||||
func resetAllData(context: ModelContext) {
|
func resetAllData(context: ModelContext) {
|
||||||
do {
|
do {
|
||||||
try deleteAll(of: MealSlot.self, in: context)
|
|
||||||
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>()
|
let settingsDescriptor = FetchDescriptor<AppSettings>()
|
||||||
|
let targetSettings: AppSettings
|
||||||
|
|
||||||
if let existingSettings = try context.fetch(settingsDescriptor).first {
|
if let existingSettings = try context.fetch(settingsDescriptor).first {
|
||||||
resetSettings(existingSettings)
|
targetSettings = existingSettings
|
||||||
} else {
|
} else {
|
||||||
DefaultDataService.createDefaultSettings(context: context)
|
let created = AppSettings()
|
||||||
if let createdSettings = try context.fetch(settingsDescriptor).first {
|
context.insert(created)
|
||||||
resetSettings(createdSettings)
|
targetSettings = created
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Phase 1: switch app flow out of Home before destructive deletes.
|
||||||
|
resetSettings(targetSettings)
|
||||||
try context.save()
|
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, 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 complete reset all data: \(error)")
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
print("Failed to reset all data: \(error)")
|
print("Failed to start reset all data: \(error)")
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -90,7 +115,6 @@ final class SettingsViewModel: ObservableObject {
|
|||||||
for model in models {
|
for model in models {
|
||||||
context.delete(model)
|
context.delete(model)
|
||||||
}
|
}
|
||||||
try context.save()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func showToastMessage(_ message: String) {
|
private func showToastMessage(_ message: String) {
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ struct DishListView: View {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
List {
|
List {
|
||||||
ForEach(dishes) { dish in
|
ForEach(dishes, id: \.id) { dish in
|
||||||
let dishTags = tags.filter { dish.tagIds.contains($0.id) }
|
let dishTags = tags.filter { dish.tagIds.contains($0.id) }
|
||||||
Button {
|
Button {
|
||||||
editingDish = dish
|
editingDish = dish
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ struct DishDrawerView: View {
|
|||||||
|
|
||||||
private var filteredDishes: [Dish] {
|
private var filteredDishes: [Dish] {
|
||||||
let term = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
|
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 matchesSearch = term.isEmpty || dish.name.localizedCaseInsensitiveContains(term)
|
||||||
let matchesUsedFilter = !hideUsedThisWeek || !usedDishIds.contains(dish.id)
|
let matchesUsedFilter = !hideUsedThisWeek || !usedDishIds.contains(dish.id)
|
||||||
return matchesSearch && matchesUsedFilter
|
return matchesSearch && matchesUsedFilter
|
||||||
@@ -95,7 +95,7 @@ struct DishDrawerView: View {
|
|||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
|
|
||||||
LazyVStack(spacing: 8) {
|
LazyVStack(spacing: 8) {
|
||||||
ForEach(filteredDishes) { dish in
|
ForEach(filteredDishes, id: \.id) { dish in
|
||||||
DishCardView(
|
DishCardView(
|
||||||
dish: dish,
|
dish: dish,
|
||||||
tags: tags,
|
tags: tags,
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import SwiftData
|
|||||||
struct HomeView: View {
|
struct HomeView: View {
|
||||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||||
@Environment(\.modelContext) private var context
|
@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 private var tags: [Tag]
|
||||||
@Query(sort: \WeekPlan.weekStartDate, order: .forward) private var weekPlans: [WeekPlan]
|
@Query(sort: \WeekPlan.weekStartDate, order: .forward) private var weekPlans: [WeekPlan]
|
||||||
@Query private var allSettings: [AppSettings]
|
@Query private var allSettings: [AppSettings]
|
||||||
@@ -18,6 +19,11 @@ struct HomeView: View {
|
|||||||
@State private var showWeekPicker: Bool = false
|
@State private var showWeekPicker: Bool = false
|
||||||
@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 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 }
|
private var settings: AppSettings? { allSettings.first }
|
||||||
|
|
||||||
@@ -27,7 +33,7 @@ struct HomeView: View {
|
|||||||
Color.mealMoodBackground.ignoresSafeArea()
|
Color.mealMoodBackground.ignoresSafeArea()
|
||||||
|
|
||||||
if let settings = settings {
|
if let settings = settings {
|
||||||
let plan = viewModel.getOrCreateWeekPlan(context: context, settings: settings)
|
let plan = fetchWeekPlan(for: viewModel.currentWeekStart)
|
||||||
if let plan {
|
if let plan {
|
||||||
mainContent(plan: plan, settings: settings)
|
mainContent(plan: plan, settings: settings)
|
||||||
}
|
}
|
||||||
@@ -103,7 +109,7 @@ struct HomeView: View {
|
|||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
HStack(spacing: 10) {
|
HStack(spacing: 10) {
|
||||||
if let settings = settings,
|
if let settings = settings,
|
||||||
let plan = viewModel.getOrCreateWeekPlan(context: context, settings: settings) {
|
let plan = fetchWeekPlan(for: viewModel.currentWeekStart) {
|
||||||
if viewModel.canEditCurrentWeek {
|
if viewModel.canEditCurrentWeek {
|
||||||
Button {
|
Button {
|
||||||
viewModel.autoComplete(plan: plan, dishes: dishes, tags: tags, settings: settings)
|
viewModel.autoComplete(plan: plan, dishes: dishes, tags: tags, settings: settings)
|
||||||
@@ -155,22 +161,22 @@ struct HomeView: View {
|
|||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
guard let settings = settings,
|
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)
|
viewModel.reconcileSlotsIfNeeded(plan: plan, settings: settings, context: context)
|
||||||
}
|
}
|
||||||
.onChange(of: settings?.includeWeekends) { _, _ in
|
.onChange(of: settings?.includeWeekends) { _, _ in
|
||||||
guard let settings = settings,
|
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)
|
viewModel.reconcileSlotsIfNeeded(plan: plan, settings: settings, context: context)
|
||||||
}
|
}
|
||||||
.onChange(of: settings?.mealWindows) { _, _ in
|
.onChange(of: settings?.mealWindows) { _, _ in
|
||||||
guard let settings = settings,
|
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)
|
viewModel.reconcileSlotsIfNeeded(plan: plan, settings: settings, context: context)
|
||||||
}
|
}
|
||||||
.onChange(of: viewModel.currentWeekStart) { _, _ in
|
.onChange(of: viewModel.currentWeekStart) { _, _ in
|
||||||
guard let settings = settings,
|
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)
|
viewModel.reconcileSlotsIfNeeded(plan: plan, settings: settings, context: context)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -248,6 +254,52 @@ struct HomeView: View {
|
|||||||
secondaryButton: .cancel(Text("reset_cancel"))
|
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) {
|
.sheet(isPresented: $viewModel.showDishForm) {
|
||||||
DishFormView()
|
DishFormView()
|
||||||
}
|
}
|
||||||
@@ -349,6 +401,7 @@ struct HomeView: View {
|
|||||||
language: settings.languageEnum.resolved()
|
language: settings.languageEnum.resolved()
|
||||||
)
|
)
|
||||||
wasWeekComplete = isWeekComplete(plan: plan)
|
wasWeekComplete = isWeekComplete(plan: plan)
|
||||||
|
evaluatePostOnboardingPromptsIfNeeded(plan: plan, settings: settings)
|
||||||
}
|
}
|
||||||
.onChange(of: plan.updatedAt) { _, _ in
|
.onChange(of: plan.updatedAt) { _, _ in
|
||||||
let nowComplete = isWeekComplete(plan: plan)
|
let nowComplete = isWeekComplete(plan: plan)
|
||||||
@@ -399,7 +452,7 @@ struct HomeView: View {
|
|||||||
.padding(24)
|
.padding(24)
|
||||||
} else {
|
} else {
|
||||||
List {
|
List {
|
||||||
ForEach(filteredDishes) { dish in
|
ForEach(filteredDishes, id: \.id) { dish in
|
||||||
Button {
|
Button {
|
||||||
onPickDish(dish)
|
onPickDish(dish)
|
||||||
dismiss()
|
dismiss()
|
||||||
@@ -602,19 +655,79 @@ struct HomeView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func fetchWeekPlan(for weekStartDate: Date) -> WeekPlan? {
|
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>(
|
let descriptor = FetchDescriptor<WeekPlan>(
|
||||||
predicate: #Predicate<WeekPlan> { plan in
|
predicate: #Predicate<WeekPlan> { plan in
|
||||||
plan.weekStartDate == weekStartDate
|
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() {
|
private func evaluateReviewPrompt() {
|
||||||
let descriptor = FetchDescriptor<WeekPlan>()
|
let descriptor = FetchDescriptor<WeekPlan>()
|
||||||
guard let plans = try? context.fetch(descriptor) else { return }
|
guard let plans = try? context.fetch(descriptor) else { return }
|
||||||
let completedWeeks = plans.filter { !$0.slots.isEmpty && $0.slots.allSatisfy { $0.dishId != nil } }.count
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import SwiftUI
|
|||||||
|
|
||||||
struct WeekCalendarView: View {
|
struct WeekCalendarView: View {
|
||||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||||
|
@State private var dishSnapshotsById: [UUID: DishSnapshot] = [:]
|
||||||
|
@State private var displaySlotsByKey: [String: DisplaySlot] = [:]
|
||||||
|
|
||||||
let plan: WeekPlan
|
let plan: WeekPlan
|
||||||
let settings: AppSettings
|
let settings: AppSettings
|
||||||
@@ -34,6 +36,23 @@ struct WeekCalendarView: View {
|
|||||||
compactLayout
|
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 {
|
private var compactLayout: some View {
|
||||||
@@ -55,7 +74,7 @@ struct WeekCalendarView: View {
|
|||||||
ForEach(Array(mealTypes.enumerated()), id: \.element) { index, mealType in
|
ForEach(Array(mealTypes.enumerated()), id: \.element) { index, mealType in
|
||||||
HStack(spacing: 0) {
|
HStack(spacing: 0) {
|
||||||
ForEach(Array(days.enumerated()), id: \.element) { index, day in
|
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)
|
draggableSlotView(slot: slot, mealType: mealType)
|
||||||
.frame(width: 96)
|
.frame(width: 96)
|
||||||
if index < days.count - 1 {
|
if index < days.count - 1 {
|
||||||
@@ -111,7 +130,7 @@ struct WeekCalendarView: View {
|
|||||||
.padding(.trailing, spacing)
|
.padding(.trailing, spacing)
|
||||||
|
|
||||||
ForEach(Array(days.enumerated()), id: \.element) { index, day in
|
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)
|
draggableSlotView(slot: slot, mealType: mealType)
|
||||||
.frame(width: dayWidth, height: slotRowHeight)
|
.frame(width: dayWidth, height: slotRowHeight)
|
||||||
if index < days.count - 1 {
|
if index < days.count - 1 {
|
||||||
@@ -186,16 +205,30 @@ struct WeekCalendarView: View {
|
|||||||
.cornerRadius(10)
|
.cornerRadius(10)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func slotFor(day: Int, mealType: MealType) -> MealSlot? {
|
private func displayedSlot(day: Int, mealType: MealType) -> DisplaySlot? {
|
||||||
plan.slots.first { $0.dayOfWeek == day && $0.mealType == mealType.rawValue }
|
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)
|
slotView(slot: slot, mealType: mealType)
|
||||||
.dropDestination(for: String.self) { items, _ in
|
.dropDestination(for: String.self) { items, _ in
|
||||||
guard let payloadRaw = items.first,
|
guard let payloadRaw = items.first,
|
||||||
let payload = parseDropPayload(payloadRaw),
|
let payload = parseDropPayload(payloadRaw),
|
||||||
let targetSlot = slot,
|
let targetDisplaySlot = slot,
|
||||||
|
let targetSlot = liveSlot(for: targetDisplaySlot),
|
||||||
viewModel.canEditCurrentWeek else { return false }
|
viewModel.canEditCurrentWeek else { return false }
|
||||||
|
|
||||||
switch payload {
|
switch payload {
|
||||||
@@ -230,27 +263,29 @@ struct WeekCalendarView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@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,
|
if let slot = slot, let dishId = slot.dishId,
|
||||||
let dish = dishes.first(where: { $0.id == dishId }) {
|
let dishData = dishData(for: dishId) {
|
||||||
let dishTags = tags.filter { dish.tagIds.contains($0.id) }
|
let dishTags = tags.filter { dishData.tagIds.contains($0.id) }
|
||||||
FilledSlotView(
|
FilledSlotView(
|
||||||
dishName: dish.name,
|
dishName: dishData.name,
|
||||||
dishDescription: dish.descriptionText,
|
dishDescription: dishData.descriptionText,
|
||||||
tags: dishTags.map { (name: $0.localizedName(language: settings.languageEnum.resolved()), color: $0.color) },
|
tags: dishTags.map { (name: $0.localizedName(language: settings.languageEnum.resolved()), color: $0.color) },
|
||||||
mealType: mealType,
|
mealType: mealType,
|
||||||
showsRuleWarning: slot.isRuleOverridden,
|
showsRuleWarning: slot.isRuleOverridden,
|
||||||
onRemove: viewModel.canEditCurrentWeek ? {
|
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
|
} : nil
|
||||||
)
|
)
|
||||||
.draggable("slot:\(slot.id.uuidString)")
|
.draggable("slot:\(slot.slotId.uuidString)")
|
||||||
} else if let slot = slot {
|
} else if let slot = slot {
|
||||||
EmptySlotView(mealType: mealType)
|
EmptySlotView(mealType: mealType)
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
guard viewModel.canEditCurrentWeek else { return }
|
guard viewModel.canEditCurrentWeek else { return }
|
||||||
onTapEmptySlot?(slot)
|
guard let live = liveSlot(for: slot) else { return }
|
||||||
|
onTapEmptySlot?(live)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
EmptySlotView(mealType: mealType)
|
EmptySlotView(mealType: mealType)
|
||||||
@@ -262,6 +297,90 @@ struct WeekCalendarView: View {
|
|||||||
case slot(UUID)
|
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? {
|
private func parseDropPayload(_ raw: String) -> DropPayload? {
|
||||||
let parts = raw.split(separator: ":", maxSplits: 1).map(String.init)
|
let parts = raw.split(separator: ":", maxSplits: 1).map(String.init)
|
||||||
guard parts.count == 2, let id = UUID(uuidString: parts[1]) else { return nil }
|
guard parts.count == 2, let id = UUID(uuidString: parts[1]) else { return nil }
|
||||||
|
|||||||
@@ -18,11 +18,12 @@ struct CalendarStepView: View {
|
|||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(spacing: 24) {
|
VStack(spacing: 24) {
|
||||||
Text("calendar_title")
|
Text("calendar_title")
|
||||||
.font(.mealMoodH2)
|
.font(.mealMoodH1)
|
||||||
.foregroundColor(.mealMoodTextPrimary)
|
.foregroundColor(.mealMoodTextPrimary)
|
||||||
|
.multilineTextAlignment(.leading)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.padding(.horizontal, 24)
|
.padding(.horizontal, 24)
|
||||||
.padding(.top, 32)
|
.padding(.top, 12)
|
||||||
|
|
||||||
VStack(spacing: 16) {
|
VStack(spacing: 16) {
|
||||||
// iCloud sync toggle
|
// iCloud sync toggle
|
||||||
@@ -151,5 +152,6 @@ struct CalendarStepView: View {
|
|||||||
} message: {
|
} message: {
|
||||||
Text("calendar_permission_message")
|
Text("calendar_permission_message")
|
||||||
}
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,34 +30,38 @@ struct FirstDishesStepView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private let maxVisibleSuggestions = 3
|
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 {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(spacing: 24) {
|
VStack(spacing: 0) {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(spacing: 24) {
|
||||||
Text("first_dishes_title")
|
Text("first_dishes_title")
|
||||||
.font(.mealMoodH2)
|
.font(.mealMoodH1)
|
||||||
.foregroundColor(.mealMoodTextPrimary)
|
.foregroundColor(.mealMoodTextPrimary)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(.horizontal, 24)
|
||||||
|
.padding(.top, 12)
|
||||||
|
|
||||||
Text("first_dishes_subtitle")
|
Text("first_dishes_subtitle")
|
||||||
.font(.mealMoodSmall)
|
.font(.mealMoodSmall)
|
||||||
.foregroundColor(.mealMoodTextSecondary)
|
.foregroundColor(.mealMoodTextSecondary)
|
||||||
}
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.padding(.horizontal, 24)
|
||||||
.padding(.horizontal, 24)
|
|
||||||
.padding(.top, 32)
|
|
||||||
|
|
||||||
// New dish form
|
// New dish form
|
||||||
VStack(spacing: 12) {
|
VStack(spacing: 12) {
|
||||||
TextField("first_dishes_name_placeholder", text: $viewModel.newDishName)
|
TextField("first_dishes_name_placeholder", text: $viewModel.newDishName)
|
||||||
.font(.mealMoodBody)
|
.font(.mealMoodBody)
|
||||||
.padding(14)
|
.padding(14)
|
||||||
.background(Color.mealMoodSurface)
|
.background(Color.mealMoodSurface)
|
||||||
.cornerRadius(12)
|
.cornerRadius(12)
|
||||||
.overlay(
|
.overlay(
|
||||||
RoundedRectangle(cornerRadius: 12)
|
RoundedRectangle(cornerRadius: 12)
|
||||||
.stroke(Color(hex: "#F0F0F0"), lineWidth: 1)
|
.stroke(Color(hex: "#F0F0F0"), lineWidth: 1)
|
||||||
)
|
)
|
||||||
|
|
||||||
// Selected tags
|
// Selected tags
|
||||||
if !viewModel.newDishTags.isEmpty {
|
if !viewModel.newDishTags.isEmpty {
|
||||||
@@ -117,49 +121,56 @@ struct FirstDishesStepView: View {
|
|||||||
}
|
}
|
||||||
.disabled(!viewModel.canAddDish)
|
.disabled(!viewModel.canAddDish)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 24)
|
.padding(.horizontal, 24)
|
||||||
|
|
||||||
// Added dishes list
|
// Added dishes list
|
||||||
if !viewModel.addedDishes.isEmpty {
|
if !viewModel.addedDishes.isEmpty {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
Text("first_dishes_added")
|
Text("first_dishes_added")
|
||||||
.font(.mealMoodSmall)
|
.font(.mealMoodSmall)
|
||||||
.foregroundColor(.mealMoodTextSecondary)
|
.foregroundColor(.mealMoodTextSecondary)
|
||||||
.padding(.horizontal, 24)
|
.padding(.horizontal, 24)
|
||||||
|
|
||||||
ForEach(Array(viewModel.addedDishes.enumerated()), id: \.offset) { index, dish in
|
ScrollView {
|
||||||
HStack {
|
LazyVStack(spacing: 8) {
|
||||||
Image(systemName: "checkmark.circle.fill")
|
ForEach(addedDishesReversed, id: \.index) { item in
|
||||||
.foregroundColor(.mealMoodSuccess)
|
let index = item.index
|
||||||
|
let dish = item.dish
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
.foregroundColor(.mealMoodSuccess)
|
||||||
|
|
||||||
Text(dish.name)
|
Text(dish.name)
|
||||||
.font(.mealMoodBody)
|
.font(.mealMoodBody)
|
||||||
.foregroundColor(.mealMoodTextPrimary)
|
.foregroundColor(.mealMoodTextPrimary)
|
||||||
|
|
||||||
// Show tag pills
|
// Show tag pills
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
ForEach(availableTags.filter { dish.tagIds.contains($0.id) }.prefix(2)) { tag in
|
ForEach(availableTags.filter { dish.tagIds.contains($0.id) }.prefix(2)) { tag in
|
||||||
TagPill(name: tag.localizedName(language: language), color: tag.color)
|
TagPill(name: tag.localizedName(language: language), color: tag.color)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Button {
|
||||||
|
viewModel.removeDish(at: index)
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "trash")
|
||||||
|
.font(.system(size: 14))
|
||||||
|
.foregroundColor(.mealMoodError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(12)
|
||||||
|
.background(Color.mealMoodSurface)
|
||||||
|
.cornerRadius(12)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
Button {
|
|
||||||
viewModel.removeDish(at: index)
|
|
||||||
} label: {
|
|
||||||
Image(systemName: "trash")
|
|
||||||
.font(.system(size: 14))
|
|
||||||
.foregroundColor(.mealMoodError)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.padding(12)
|
.frame(maxHeight: 190)
|
||||||
.background(Color.mealMoodSurface)
|
|
||||||
.cornerRadius(12)
|
|
||||||
.padding(.horizontal, 24)
|
.padding(.horizontal, 24)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
Text("first_dishes_suggestions")
|
Text("first_dishes_suggestions")
|
||||||
@@ -209,7 +220,9 @@ struct FirstDishesStepView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(minLength: 40)
|
Spacer(minLength: 20)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .top)
|
||||||
|
|
||||||
PrimaryButton(
|
PrimaryButton(
|
||||||
title: String(localized: "onboarding_finish"),
|
title: String(localized: "onboarding_finish"),
|
||||||
@@ -222,7 +235,8 @@ struct FirstDishesStepView: View {
|
|||||||
isEnabled: viewModel.addedDishes.count >= 2
|
isEnabled: viewModel.addedDishes.count >= 2
|
||||||
)
|
)
|
||||||
.padding(.horizontal, 24)
|
.padding(.horizontal, 24)
|
||||||
.padding(.bottom, 40)
|
.padding(.top, 12)
|
||||||
|
.padding(.bottom, 24)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showTagSelector) {
|
.sheet(isPresented: $showTagSelector) {
|
||||||
@@ -239,6 +253,7 @@ struct FirstDishesStepView: View {
|
|||||||
ensureDefaultTagsIfNeeded()
|
ensureDefaultTagsIfNeeded()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func suggestedDishes() -> [(name: String, tagHints: [String])] {
|
private func suggestedDishes() -> [(name: String, tagHints: [String])] {
|
||||||
|
|||||||
@@ -6,13 +6,13 @@ struct MealWindowsStepView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 24) {
|
VStack(spacing: 24) {
|
||||||
Spacer()
|
|
||||||
|
|
||||||
Text("meal_windows_title")
|
Text("meal_windows_title")
|
||||||
.font(.mealMoodH2)
|
.font(.mealMoodH1)
|
||||||
.foregroundColor(.mealMoodTextPrimary)
|
.foregroundColor(.mealMoodTextPrimary)
|
||||||
|
.multilineTextAlignment(.leading)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.padding(.horizontal, 24)
|
.padding(.horizontal, 24)
|
||||||
|
.padding(.top, 12)
|
||||||
|
|
||||||
VStack(spacing: 12) {
|
VStack(spacing: 12) {
|
||||||
ForEach(MealWindows.allCases, id: \.self) { option in
|
ForEach(MealWindows.allCases, id: \.self) { option in
|
||||||
@@ -30,12 +30,12 @@ struct MealWindowsStepView: View {
|
|||||||
}
|
}
|
||||||
.padding(.horizontal, 24)
|
.padding(.horizontal, 24)
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
PrimaryButton(title: String(localized: "onboarding_continue"), action: onNext)
|
PrimaryButton(title: String(localized: "onboarding_continue"), action: onNext)
|
||||||
.padding(.horizontal, 24)
|
.padding(.horizontal, 24)
|
||||||
|
.padding(.top, 8)
|
||||||
.padding(.bottom, 40)
|
.padding(.bottom, 40)
|
||||||
}
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func optionTitle(_ option: MealWindows) -> String {
|
private func optionTitle(_ option: MealWindows) -> String {
|
||||||
|
|||||||
@@ -12,33 +12,36 @@ struct OnboardingView: View {
|
|||||||
Color.mealMoodBackground.ignoresSafeArea()
|
Color.mealMoodBackground.ignoresSafeArea()
|
||||||
|
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
// Progress indicator
|
onboardingHeader
|
||||||
if viewModel.currentStep > 0 {
|
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
ForEach(1..<viewModel.totalSteps, id: \.self) { step in
|
ForEach(1..<viewModel.totalSteps, id: \.self) { step in
|
||||||
Capsule()
|
Capsule()
|
||||||
.fill(step <= viewModel.currentStep ? Color.mealMoodCoral : Color(hex: "#E0E0E0"))
|
.fill(step <= viewModel.currentStep ? Color.mealMoodCoral : Color(hex: "#E0E0E0"))
|
||||||
.frame(height: 4)
|
.frame(height: 4)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 24)
|
|
||||||
.padding(.top, 16)
|
|
||||||
}
|
}
|
||||||
|
.padding(.horizontal, 24)
|
||||||
|
.padding(.top, 8)
|
||||||
|
.padding(.bottom, 12)
|
||||||
|
|
||||||
TabView(selection: $viewModel.currentStep) {
|
TabView(selection: $viewModel.currentStep) {
|
||||||
WelcomeStepView(onNext: { viewModel.nextStep() })
|
WelcomeStepView(onNext: { viewModel.nextStep() })
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||||
.tag(0)
|
.tag(0)
|
||||||
|
|
||||||
MealWindowsStepView(
|
MealWindowsStepView(
|
||||||
selection: $viewModel.selectedMealWindows,
|
selection: $viewModel.selectedMealWindows,
|
||||||
onNext: { viewModel.nextStep() }
|
onNext: { viewModel.nextStep() }
|
||||||
)
|
)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||||
.tag(1)
|
.tag(1)
|
||||||
|
|
||||||
WeekendsStepView(
|
WeekendsStepView(
|
||||||
includeWeekends: $viewModel.includeWeekends,
|
includeWeekends: $viewModel.includeWeekends,
|
||||||
onNext: { viewModel.nextStep() }
|
onNext: { viewModel.nextStep() }
|
||||||
)
|
)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||||
.tag(2)
|
.tag(2)
|
||||||
|
|
||||||
CalendarStepView(
|
CalendarStepView(
|
||||||
@@ -53,6 +56,7 @@ struct OnboardingView: View {
|
|||||||
onNext: { viewModel.nextStep() },
|
onNext: { viewModel.nextStep() },
|
||||||
onSkip: { viewModel.nextStep() }
|
onSkip: { viewModel.nextStep() }
|
||||||
)
|
)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||||
.tag(3)
|
.tag(3)
|
||||||
|
|
||||||
FirstDishesStepView(
|
FirstDishesStepView(
|
||||||
@@ -64,10 +68,12 @@ struct OnboardingView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||||
.tag(4)
|
.tag(4)
|
||||||
}
|
}
|
||||||
.tabViewStyle(.page(indexDisplayMode: .never))
|
.tabViewStyle(.page(indexDisplayMode: .never))
|
||||||
.animation(.easeInOut(duration: 0.3), value: viewModel.currentStep)
|
.animation(.easeInOut(duration: 0.3), value: viewModel.currentStep)
|
||||||
|
.frame(maxHeight: .infinity, alignment: .top)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
@@ -106,4 +112,52 @@ struct OnboardingView: View {
|
|||||||
let plans = (try? context.fetch(FetchDescriptor<WeekPlan>())) ?? []
|
let plans = (try? context.fetch(FetchDescriptor<WeekPlan>())) ?? []
|
||||||
return !plans.isEmpty
|
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 {
|
var body: some View {
|
||||||
VStack(spacing: 24) {
|
VStack(spacing: 24) {
|
||||||
Spacer()
|
|
||||||
|
|
||||||
Text("weekends_title")
|
Text("weekends_title")
|
||||||
.font(.mealMoodH2)
|
.font(.mealMoodH1)
|
||||||
.foregroundColor(.mealMoodTextPrimary)
|
.foregroundColor(.mealMoodTextPrimary)
|
||||||
|
.multilineTextAlignment(.leading)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.padding(.horizontal, 24)
|
.padding(.horizontal, 24)
|
||||||
|
.padding(.top, 12)
|
||||||
|
|
||||||
VStack(spacing: 16) {
|
VStack(spacing: 16) {
|
||||||
HStack {
|
HStack {
|
||||||
@@ -40,11 +40,11 @@ struct WeekendsStepView: View {
|
|||||||
.font(.mealMoodSmall)
|
.font(.mealMoodSmall)
|
||||||
.foregroundColor(.mealMoodTextSecondary)
|
.foregroundColor(.mealMoodTextSecondary)
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
PrimaryButton(title: String(localized: "onboarding_continue"), action: onNext)
|
PrimaryButton(title: String(localized: "onboarding_continue"), action: onNext)
|
||||||
.padding(.horizontal, 24)
|
.padding(.horizontal, 24)
|
||||||
|
.padding(.top, 8)
|
||||||
.padding(.bottom, 40)
|
.padding(.bottom, 40)
|
||||||
}
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,31 +10,18 @@ struct WelcomeStepView: View {
|
|||||||
AppIconPlaceholder(size: 120)
|
AppIconPlaceholder(size: 120)
|
||||||
|
|
||||||
VStack(spacing: 12) {
|
VStack(spacing: 12) {
|
||||||
Text("onboarding_welcome_title")
|
|
||||||
.font(.mealMoodH1)
|
|
||||||
.foregroundColor(.mealMoodTextPrimary)
|
|
||||||
|
|
||||||
Text("onboarding_welcome_subtitle")
|
Text("onboarding_welcome_subtitle")
|
||||||
.font(.mealMoodBody)
|
.font(.mealMoodBody)
|
||||||
.foregroundColor(.mealMoodTextSecondary)
|
.foregroundColor(.mealMoodTextSecondary)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
}
|
}
|
||||||
|
.padding(.horizontal, 24)
|
||||||
Image(systemName: "figure.2.and.child.holdinghands")
|
|
||||||
.font(.system(size: 80))
|
|
||||||
.foregroundStyle(
|
|
||||||
LinearGradient(
|
|
||||||
colors: [.mealMoodCoral, .mealMoodMint],
|
|
||||||
startPoint: .topLeading,
|
|
||||||
endPoint: .bottomTrailing
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.padding(.vertical, 20)
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
BenefitRow(icon: "face.smiling", text: String(localized: "onboarding_welcome_benefit1"))
|
BenefitRow(icon: "1.circle.fill", text: String(localized: "onboarding_welcome_step_1"))
|
||||||
BenefitRow(icon: "scalemass", text: String(localized: "onboarding_welcome_benefit2"))
|
BenefitRow(icon: "2.circle.fill", text: String(localized: "onboarding_welcome_step_2"))
|
||||||
BenefitRow(icon: "calendar", text: String(localized: "onboarding_welcome_benefit3"))
|
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)
|
.padding(.horizontal, 40)
|
||||||
|
|
||||||
@@ -44,6 +31,7 @@ struct WelcomeStepView: View {
|
|||||||
.padding(.horizontal, 24)
|
.padding(.horizontal, 24)
|
||||||
.padding(.bottom, 40)
|
.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")!) {
|
Link(destination: URL(string: "mailto:support@mealmood.app")!) {
|
||||||
Label("settings_support", systemImage: "envelope")
|
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: {
|
} header: {
|
||||||
Label("settings_about", systemImage: "info.circle")
|
Label("settings_about", systemImage: "info.circle")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,5 +59,25 @@ final class AutocompleteEngineTests: XCTestCase {
|
|||||||
XCTAssertEqual(result.unfilledCount, 1)
|
XCTAssertEqual(result.unfilledCount, 1)
|
||||||
XCTAssertNil(tuesdayDinner.dishId)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user