Merge 1.0.1 into main
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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()
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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)")
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user