diff --git a/MealMood.xcodeproj/project.pbxproj b/MealMood.xcodeproj/project.pbxproj index 84a2798..4f75ac7 100644 --- a/MealMood.xcodeproj/project.pbxproj +++ b/MealMood.xcodeproj/project.pbxproj @@ -860,7 +860,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = MealMood/Resources/MealMood.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; - CURRENT_PROJECT_VERSION = 24; + CURRENT_PROJECT_VERSION = 31; DEVELOPMENT_TEAM = 2825Q76T7H; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = MealMood/Resources/Info.plist; @@ -965,7 +965,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = MealMood/Resources/MealMood.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; - CURRENT_PROJECT_VERSION = 24; + CURRENT_PROJECT_VERSION = 31; DEVELOPMENT_TEAM = 2825Q76T7H; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = MealMood/Resources/Info.plist; diff --git a/MealMood.xcodeproj/project.xcworkspace/xcuserdata/alexandrev.xcuserdatad/UserInterfaceState.xcuserstate b/MealMood.xcodeproj/project.xcworkspace/xcuserdata/alexandrev.xcuserdatad/UserInterfaceState.xcuserstate index 6d01036..dd15f39 100644 Binary files a/MealMood.xcodeproj/project.xcworkspace/xcuserdata/alexandrev.xcuserdatad/UserInterfaceState.xcuserstate and b/MealMood.xcodeproj/project.xcworkspace/xcuserdata/alexandrev.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/MealMood.xcodeproj/xcuserdata/alexandrev.xcuserdatad/xcschemes/xcschememanagement.plist b/MealMood.xcodeproj/xcuserdata/alexandrev.xcuserdatad/xcschemes/xcschememanagement.plist index 8688c62..3a0cbb5 100644 --- a/MealMood.xcodeproj/xcuserdata/alexandrev.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/MealMood.xcodeproj/xcuserdata/alexandrev.xcuserdatad/xcschemes/xcschememanagement.plist @@ -8,6 +8,18 @@ isShown + orderHint + 1 + + MealMood.xcscheme_^#shared#^_ + + orderHint + 0 + + MealMoodWidget.xcscheme_^#shared#^_ + + orderHint + 2 diff --git a/MealMood/Models/Dish.swift b/MealMood/Models/Dish.swift index 75808df..6ffc44e 100644 --- a/MealMood/Models/Dish.swift +++ b/MealMood/Models/Dish.swift @@ -8,19 +8,22 @@ final class Dish { var descriptionText: String? var tagIds: [UUID] var createdAt: Date + var isPriority: Bool init( id: UUID = UUID(), name: String, descriptionText: String? = nil, tagIds: [UUID] = [], - createdAt: Date = Date() + createdAt: Date = Date(), + isPriority: Bool = false ) { self.id = id self.name = name self.descriptionText = descriptionText self.tagIds = tagIds self.createdAt = createdAt + self.isPriority = isPriority } } diff --git a/MealMood/Models/Tag.swift b/MealMood/Models/Tag.swift index 0012b1c..5e6ca2d 100644 --- a/MealMood/Models/Tag.swift +++ b/MealMood/Models/Tag.swift @@ -11,6 +11,7 @@ final class Tag { var noConsecutive: Bool var noDuplicateInDay: Bool var mealTypeRestriction: String? // "lunch", "dinner", nil + var dayRestriction: String? // nil = any day, "weekdays" = Mon-Fri, "weekend" = Sat-Sun var isDefault: Bool var sortOrder: Int @@ -23,6 +24,7 @@ final class Tag { noConsecutive: Bool = false, noDuplicateInDay: Bool = false, mealTypeRestriction: String? = nil, + dayRestriction: String? = nil, isDefault: Bool = true, sortOrder: Int = 0 ) { @@ -34,6 +36,7 @@ final class Tag { self.noConsecutive = noConsecutive self.noDuplicateInDay = noDuplicateInDay self.mealTypeRestriction = mealTypeRestriction + self.dayRestriction = dayRestriction self.isDefault = isDefault self.sortOrder = sortOrder } @@ -76,6 +79,15 @@ final class Tag { parts.append(restriction == "lunch" ? "lunch only" : "dinner only") } } + if let day = dayRestriction { + switch (day, resolvedLanguage) { + case ("weekdays", .spanish): parts.append("entre semana") + case ("weekdays", _): parts.append("weekdays") + case ("weekend", .spanish): parts.append("fin de semana") + case ("weekend", _): parts.append("weekend") + default: break + } + } return parts.isEmpty ? (resolvedLanguage == .spanish ? "Sin límite" : "No limit") : parts.joined(separator: ", ") } } diff --git a/MealMood/Resources/Info.plist b/MealMood/Resources/Info.plist index f4ab0fc..f006633 100644 --- a/MealMood/Resources/Info.plist +++ b/MealMood/Resources/Info.plist @@ -15,9 +15,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.0.5 + 1.0.6 CFBundleVersion - 24 + 32 GADApplicationIdentifier ca-app-pub-1549720748100858~9985112590 LSRequiresIPhoneOS diff --git a/MealMood/Resources/de.lproj/Localizable.strings b/MealMood/Resources/de.lproj/Localizable.strings index a13b378..06a548c 100644 --- a/MealMood/Resources/de.lproj/Localizable.strings +++ b/MealMood/Resources/de.lproj/Localizable.strings @@ -310,3 +310,17 @@ "violations_fix" = "Slot leeren"; "violations_ignore" = "Ignorieren"; "violations_badge" = "%d Konflikt(e)"; + +// 1.0.6 +"dish_priority" = "Prioritätsgericht"; +"dish_priority_desc" = "Wird bei der Auto-Vervollständigung bevorzugt"; +"tags_day_restriction" = "Tageseinschränkung"; +"tags_day_any" = "Beliebiger Tag"; +"tags_day_weekdays" = "Nur Wochentage"; +"tags_day_weekend" = "Nur Wochenende"; +"share_slot_empty" = "Noch offen"; +"home_auto_assign_cta_title" = "Woche automatisch füllen"; +"home_auto_assign_cta_slots" = "%d freie Slots"; +"home_auto_assign_cta_button" = "Auto-ausfüllen"; +"notification_sunday_title" = "Plane deine nächste Woche"; +"notification_sunday_body" = "Es ist Sonntag — nimm dir 2 Minuten, um die Mahlzeiten der nächsten Woche zu planen."; diff --git a/MealMood/Resources/en.lproj/Localizable.strings b/MealMood/Resources/en.lproj/Localizable.strings index a8023d3..97e360c 100644 --- a/MealMood/Resources/en.lproj/Localizable.strings +++ b/MealMood/Resources/en.lproj/Localizable.strings @@ -310,3 +310,17 @@ "violations_fix" = "Clear slot"; "violations_ignore" = "Ignore"; "violations_badge" = "%d conflict(s)"; + +// 1.0.6 +"dish_priority" = "Priority dish"; +"dish_priority_desc" = "Will be prioritized in auto-assign"; +"tags_day_restriction" = "Day restriction"; +"tags_day_any" = "Any day"; +"tags_day_weekdays" = "Weekdays only"; +"tags_day_weekend" = "Weekend only"; +"share_slot_empty" = "TBD"; +"home_auto_assign_cta_title" = "Fill your week automatically"; +"home_auto_assign_cta_slots" = "%d empty slots"; +"home_auto_assign_cta_button" = "Auto-assign"; +"notification_sunday_title" = "Plan your next week"; +"notification_sunday_body" = "Sunday is here — take 2 minutes to plan next week's meals."; diff --git a/MealMood/Resources/es.lproj/Localizable.strings b/MealMood/Resources/es.lproj/Localizable.strings index ea72431..fd3fb89 100644 --- a/MealMood/Resources/es.lproj/Localizable.strings +++ b/MealMood/Resources/es.lproj/Localizable.strings @@ -310,3 +310,17 @@ "violations_fix" = "Vaciar hueco"; "violations_ignore" = "Ignorar"; "violations_badge" = "%d conflicto(s)"; + +// 1.0.6 +"dish_priority" = "Plato prioritario"; +"dish_priority_desc" = "Se priorizará en la asignación automática"; +"tags_day_restriction" = "Restricción de días"; +"tags_day_any" = "Cualquier día"; +"tags_day_weekdays" = "Solo entre semana"; +"tags_day_weekend" = "Solo fin de semana"; +"share_slot_empty" = "Por definir"; +"home_auto_assign_cta_title" = "Rellena tu semana automáticamente"; +"home_auto_assign_cta_slots" = "%d huecos vacíos"; +"home_auto_assign_cta_button" = "Auto-rellenar"; +"notification_sunday_title" = "Planifica la semana que viene"; +"notification_sunday_body" = "Es domingo — tómate 2 minutos para planificar las comidas de la próxima semana."; diff --git a/MealMood/Resources/fr.lproj/Localizable.strings b/MealMood/Resources/fr.lproj/Localizable.strings index c77d3fd..59f81e3 100644 --- a/MealMood/Resources/fr.lproj/Localizable.strings +++ b/MealMood/Resources/fr.lproj/Localizable.strings @@ -310,3 +310,17 @@ "violations_fix" = "Vider le créneau"; "violations_ignore" = "Ignorer"; "violations_badge" = "%d conflit(s)"; + +// 1.0.6 +"dish_priority" = "Plat prioritaire"; +"dish_priority_desc" = "Sera priorisé dans l'auto-complétion"; +"tags_day_restriction" = "Restriction de jours"; +"tags_day_any" = "N'importe quel jour"; +"tags_day_weekdays" = "Jours de semaine seulement"; +"tags_day_weekend" = "Week-end seulement"; +"share_slot_empty" = "À définir"; +"home_auto_assign_cta_title" = "Remplir la semaine automatiquement"; +"home_auto_assign_cta_slots" = "%d créneaux vides"; +"home_auto_assign_cta_button" = "Auto-remplir"; +"notification_sunday_title" = "Planifiez votre prochaine semaine"; +"notification_sunday_body" = "C'est dimanche — prenez 2 minutes pour planifier vos repas de la semaine prochaine."; diff --git a/MealMood/Resources/it.lproj/Localizable.strings b/MealMood/Resources/it.lproj/Localizable.strings index 9aa0a53..e7bfdf9 100644 --- a/MealMood/Resources/it.lproj/Localizable.strings +++ b/MealMood/Resources/it.lproj/Localizable.strings @@ -310,3 +310,17 @@ "violations_fix" = "Svuota slot"; "violations_ignore" = "Ignora"; "violations_badge" = "%d conflitto/i"; + +// 1.0.6 +"dish_priority" = "Piatto prioritario"; +"dish_priority_desc" = "Verrà prioritizzato nell'auto-completamento"; +"tags_day_restriction" = "Restrizione giorni"; +"tags_day_any" = "Qualsiasi giorno"; +"tags_day_weekdays" = "Solo giorni feriali"; +"tags_day_weekend" = "Solo weekend"; +"share_slot_empty" = "Da definire"; +"home_auto_assign_cta_title" = "Riempi la settimana automaticamente"; +"home_auto_assign_cta_slots" = "%d slot vuoti"; +"home_auto_assign_cta_button" = "Auto-assegna"; +"notification_sunday_title" = "Pianifica la prossima settimana"; +"notification_sunday_body" = "È domenica — prenditi 2 minuti per pianificare i pasti della prossima settimana."; diff --git a/MealMood/Resources/pt-BR.lproj/Localizable.strings b/MealMood/Resources/pt-BR.lproj/Localizable.strings index 139de64..5a332ae 100644 --- a/MealMood/Resources/pt-BR.lproj/Localizable.strings +++ b/MealMood/Resources/pt-BR.lproj/Localizable.strings @@ -310,3 +310,17 @@ "violations_fix" = "Limpar slot"; "violations_ignore" = "Ignorar"; "violations_badge" = "%d conflito(s)"; + +// 1.0.6 +"dish_priority" = "Prato prioritário"; +"dish_priority_desc" = "Será priorizado na atribuição automática"; +"tags_day_restriction" = "Restrição de dias"; +"tags_day_any" = "Qualquer dia"; +"tags_day_weekdays" = "Apenas dias úteis"; +"tags_day_weekend" = "Apenas fim de semana"; +"share_slot_empty" = "A definir"; +"home_auto_assign_cta_title" = "Preencher a semana automaticamente"; +"home_auto_assign_cta_slots" = "%d slots vazios"; +"home_auto_assign_cta_button" = "Auto-atribuir"; +"notification_sunday_title" = "Planeje a próxima semana"; +"notification_sunday_body" = "É domingo — reserve 2 minutos para planejar as refeições da próxima semana."; diff --git a/MealMood/Services/AutocompleteEngine.swift b/MealMood/Services/AutocompleteEngine.swift index 330886d..a425d6f 100644 --- a/MealMood/Services/AutocompleteEngine.swift +++ b/MealMood/Services/AutocompleteEngine.swift @@ -166,6 +166,15 @@ struct AutocompleteEngine { if let restriction = tag.mealTypeRestriction, slot.mealType != restriction { return true } + + // Day restriction + if let dayRestriction = tag.dayRestriction { + switch dayRestriction { + case "weekdays" where slot.dayOfWeek > 4: return true + case "weekend" where slot.dayOfWeek < 5: return true + default: break + } + } } return false } @@ -187,10 +196,11 @@ struct AutocompleteEngine { guard !candidates.isEmpty else { return nil } let weights: [Double] = candidates.map { dish in let usage = plan.slots.filter { $0.dishId == dish.id }.count + let priorityBoost: Double = dish.isPriority ? 2.5 : 1.0 switch usage { - case 0: return 3.0 - case 1: return 2.0 - default: return 1.0 + case 0: return 3.0 * priorityBoost + case 1: return 2.0 * priorityBoost + default: return 1.0 * priorityBoost } } let total = weights.reduce(0, +) diff --git a/MealMood/Services/NotificationService.swift b/MealMood/Services/NotificationService.swift index c252899..5d977d3 100644 --- a/MealMood/Services/NotificationService.swift +++ b/MealMood/Services/NotificationService.swift @@ -18,23 +18,52 @@ final class NotificationService { let center = UNUserNotificationCenter.current() let identifier = "mealmood.next-week-planning" - let isComplete = nextWeekPlan?.slots.allSatisfy { $0.dishId != nil } ?? false + let isComplete = nextWeekPlan?.slots.allSatisfy { $0.dishId != nil || $0.isEatingOut } ?? false if isComplete { center.removePendingNotificationRequests(withIdentifiers: [identifier]) + } else { + let nextWeekStart = Date().startOfWeek().addingDays(7) + let reminderDay = nextWeekStart.addingDays(-1) + let calendar = Calendar.current + var components = calendar.dateComponents([.year, .month, .day], from: reminderDay) + components.hour = 19 + components.minute = 0 + + let trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: false) + let content = UNMutableNotificationContent() + content.title = localizedString("notification_planning_title", language: language) + content.body = localizedString("notification_planning_body", language: language) + content.sound = .default + + let request = UNNotificationRequest(identifier: identifier, content: content, trigger: trigger) + center.removePendingNotificationRequests(withIdentifiers: [identifier]) + center.add(request) + } + + scheduleSundayReminderIfNeeded(nextWeekPlan: nextWeekPlan, language: language) + } + + private func scheduleSundayReminderIfNeeded(nextWeekPlan: WeekPlan?, language: AppLanguage) { + let center = UNUserNotificationCenter.current() + let identifier = "mealmood.sunday-planning" + + // Cancel if next week is already planned + let hasAnySlot = nextWeekPlan?.slots.contains { $0.dishId != nil || $0.isEatingOut } ?? false + if hasAnySlot { + center.removePendingNotificationRequests(withIdentifiers: [identifier]) return } - let nextWeekStart = Date().startOfWeek().addingDays(7) - let reminderDay = nextWeekStart.addingDays(-1) - let calendar = Calendar.current - var components = calendar.dateComponents([.year, .month, .day], from: reminderDay) - components.hour = 19 + // Schedule for next Sunday at 17:00 + var components = DateComponents() + components.weekday = 1 // Sunday + components.hour = 17 components.minute = 0 - let trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: false) + let trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: true) let content = UNMutableNotificationContent() - content.title = localizedString("notification_planning_title", language: language) - content.body = localizedString("notification_planning_body", language: language) + content.title = localizedString("notification_sunday_title", language: language) + content.body = localizedString("notification_sunday_body", language: language) content.sound = .default let request = UNNotificationRequest(identifier: identifier, content: content, trigger: trigger) diff --git a/MealMood/ViewModels/DishViewModel.swift b/MealMood/ViewModels/DishViewModel.swift index 4c345da..0e60188 100644 --- a/MealMood/ViewModels/DishViewModel.swift +++ b/MealMood/ViewModels/DishViewModel.swift @@ -6,6 +6,7 @@ final class DishViewModel: ObservableObject { @Published var name: String = "" @Published var descriptionText: String = "" @Published var selectedTagIds: Set = [] + @Published var isPriority: Bool = false @Published var showTagSelector: Bool = false @Published var showDeleteAlert: Bool = false @Published var showAssignedDeleteAlert: Bool = false @@ -23,6 +24,7 @@ final class DishViewModel: ObservableObject { name = dish.name descriptionText = dish.descriptionText ?? "" selectedTagIds = Set(dish.tagIds) + isPriority = dish.isPriority } func reset() { @@ -30,6 +32,7 @@ final class DishViewModel: ObservableObject { name = "" descriptionText = "" selectedTagIds = [] + isPriority = false } func save(context: ModelContext) { @@ -40,11 +43,13 @@ final class DishViewModel: ObservableObject { dish.name = trimmedName dish.descriptionText = descriptionText.isEmpty ? nil : descriptionText dish.tagIds = Array(selectedTagIds) + dish.isPriority = isPriority } else { let dish = Dish( name: trimmedName, descriptionText: descriptionText.isEmpty ? nil : descriptionText, - tagIds: Array(selectedTagIds) + tagIds: Array(selectedTagIds), + isPriority: isPriority ) context.insert(dish) AnalyticsService.logDishAdded(tagCount: selectedTagIds.count) diff --git a/MealMood/ViewModels/TagViewModel.swift b/MealMood/ViewModels/TagViewModel.swift index 2cca259..69104f9 100644 --- a/MealMood/ViewModels/TagViewModel.swift +++ b/MealMood/ViewModels/TagViewModel.swift @@ -8,6 +8,7 @@ final class TagViewModel: ObservableObject { @Published var noConsecutive: Bool = false @Published var noDuplicateInDay: Bool = false @Published var mealTypeRestriction: String? = nil + @Published var dayRestriction: String? = nil @Published var useMaxLimit: Bool = true func loadTag(_ tag: Tag) { @@ -16,6 +17,7 @@ final class TagViewModel: ObservableObject { noConsecutive = tag.noConsecutive noDuplicateInDay = tag.noDuplicateInDay mealTypeRestriction = tag.mealTypeRestriction + dayRestriction = tag.dayRestriction useMaxLimit = tag.maxPerWeek != nil } @@ -25,6 +27,7 @@ final class TagViewModel: ObservableObject { tag.noConsecutive = noConsecutive tag.noDuplicateInDay = noDuplicateInDay tag.mealTypeRestriction = mealTypeRestriction + tag.dayRestriction = dayRestriction } func reset() { diff --git a/MealMood/Views/Dishes/DishFormView.swift b/MealMood/Views/Dishes/DishFormView.swift index 623d529..66348d2 100644 --- a/MealMood/Views/Dishes/DishFormView.swift +++ b/MealMood/Views/Dishes/DishFormView.swift @@ -130,6 +130,30 @@ struct DishFormView: View { } } + // Priority toggle + Toggle(isOn: Binding( + get: { viewModel.isPriority }, + set: { viewModel.isPriority = $0 } + )) { + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 6) { + Image(systemName: "star.fill") + .foregroundColor(.mealMoodCoral) + .font(.system(size: 13)) + Text("dish_priority") + .font(.mealMoodBodyBold) + .foregroundColor(.mealMoodTextPrimary) + } + Text("dish_priority_desc") + .font(.mealMoodCaption) + .foregroundColor(.mealMoodTextSecondary) + } + } + .tint(.mealMoodCoral) + .padding(16) + .background(Color.mealMoodSurface) + .cornerRadius(14) + if showDishLimitUpsell && reachedFreeDishLimit { PremiumUpsellBanner( messageKey: "premium_limit_dishes", diff --git a/MealMood/Views/Home/DishDrawerView.swift b/MealMood/Views/Home/DishDrawerView.swift index 623ad29..3b0e919 100644 --- a/MealMood/Views/Home/DishDrawerView.swift +++ b/MealMood/Views/Home/DishDrawerView.swift @@ -5,6 +5,7 @@ struct DishDrawerView: View { let tags: [Tag] let language: AppLanguage let usedDishIds: Set + var usageRanking: [UUID: Int] = [:] var onAddDish: () -> Void var onQuickAssignDish: (Dish) -> Void var onEditDish: (Dish) -> Void @@ -12,10 +13,17 @@ struct DishDrawerView: View { @Binding var draggedDish: Dish? @State private var searchText: String = "" @State private var hideUsedThisWeek: Bool = false - + private var filteredDishes: [Dish] { let term = searchText.trimmingCharacters(in: .whitespacesAndNewlines) - return Dish.stableSortedForDisplay(dishes).filter { dish in + let sorted = dishes.sorted { lhs, rhs in + if lhs.isPriority != rhs.isPriority { return lhs.isPriority } + let lhsUsage = usageRanking[lhs.id] ?? 0 + let rhsUsage = usageRanking[rhs.id] ?? 0 + if lhsUsage != rhsUsage { return lhsUsage > rhsUsage } + return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending + } + return sorted.filter { dish in let matchesSearch = term.isEmpty || dish.name.localizedCaseInsensitiveContains(term) let matchesUsedFilter = !hideUsedThisWeek || !usedDishIds.contains(dish.id) return matchesSearch && matchesUsedFilter @@ -159,9 +167,16 @@ struct DishCardView: View { var body: some View { HStack { VStack(alignment: .leading, spacing: 4) { - Text(dish.name) - .font(.mealMoodBodyBold) - .foregroundColor(.mealMoodTextPrimary) + HStack(spacing: 4) { + if dish.isPriority { + Image(systemName: "star.fill") + .font(.system(size: 11)) + .foregroundColor(.mealMoodCoral) + } + Text(dish.name) + .font(.mealMoodBodyBold) + .foregroundColor(.mealMoodTextPrimary) + } if let desc = dish.descriptionText, !desc.isEmpty { Text(desc) diff --git a/MealMood/Views/Home/HomeView.swift b/MealMood/Views/Home/HomeView.swift index b688fe9..2aa0130 100644 --- a/MealMood/Views/Home/HomeView.swift +++ b/MealMood/Views/Home/HomeView.swift @@ -17,6 +17,7 @@ struct HomeView: View { @State private var showWeekLimitUpsell: Bool = false @State private var wasWeekComplete: Bool = false @State private var selectedEmptySlotId: UUID? + @State private var selectedFilledSlotId: UUID? @State private var showWeekPicker: Bool = false @State private var weekPickerDate: Date = Date() @State private var showCopyPreviousConfirm: Bool = false @@ -225,13 +226,22 @@ struct HomeView: View { viewModel: viewModel, onTapEmptySlot: { slot in selectedEmptySlotId = slot.id + }, + onTapFilledSlot: { slot in + selectedFilledSlotId = slot.id } ) - if isWeekComplete(plan: plan) { + let filledCount = plan.slots.filter { $0.dishId != nil || $0.isEatingOut }.count + if filledCount > 0 { exportCallout(plan: plan, settings: settings) } + let emptyCount = plan.slots.filter { $0.dishId == nil && !$0.isEatingOut }.count + if viewModel.canEditCurrentWeek && !dishes.isEmpty && emptyCount > 0 { + autoAssignBanner(emptyCount: emptyCount, plan: plan, settings: settings) + } + ScrollView(showsIndicators: true) { dishDrawer(plan: plan, settings: settings) .padding(.bottom, 0) @@ -403,6 +413,36 @@ struct HomeView: View { ) } } + .sheet( + isPresented: Binding( + get: { selectedFilledSlotId != nil }, + set: { isPresented in + if !isPresented { selectedFilledSlotId = nil } + } + ) + ) { + if let slotId = selectedFilledSlotId, + plan.slots.contains(where: { $0.id == slotId }) { + SlotDishPickerSheet( + dishes: dishes, + tags: tags, + onPickDish: { dish in + guard let freshSlot = plan.slots.first(where: { $0.id == slotId }) else { + selectedFilledSlotId = nil + return + } + viewModel.assignDish(dish, to: freshSlot, plan: plan, settings: settings, isOverride: true) + selectedFilledSlotId = nil + }, + onCreateDish: { + selectedFilledSlotId = nil + DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { + viewModel.showDishForm = true + } + } + ) + } + } .sheet(item: $editingDish) { dish in DishFormView(dish: dish) } @@ -591,6 +631,7 @@ struct HomeView: View { tags: tags, language: settings.languageEnum.resolved(), usedDishIds: Set(plan.slots.compactMap(\.dishId)), + usageRanking: dishUsageCounts, onAddDish: { viewModel.showDishForm = true }, onQuickAssignDish: { dish in viewModel.assignDishToFirstFreeSlot( @@ -651,6 +692,59 @@ struct HomeView: View { plan.slots.allSatisfy { $0.dishId != nil || $0.isEatingOut } } + private var dishUsageCounts: [UUID: Int] { + var counts: [UUID: Int] = [:] + for plan in weekPlans { + for slot in plan.slots { + if let dishId = slot.dishId { + counts[dishId, default: 0] += 1 + } + } + } + return counts + } + + @ViewBuilder + private func autoAssignBanner(emptyCount: Int, plan: WeekPlan, settings: AppSettings) -> some View { + HStack(spacing: 12) { + VStack(alignment: .leading, spacing: 2) { + Text("home_auto_assign_cta_title") + .font(.mealMoodBodyBold) + .foregroundColor(.mealMoodTextPrimary) + Text(String(format: String(localized: "home_auto_assign_cta_slots"), emptyCount)) + .font(.mealMoodCaption) + .foregroundColor(.mealMoodTextSecondary) + } + Spacer() + Button { + viewModel.autoComplete(plan: plan, dishes: dishes, tags: tags, settings: settings) + } label: { + HStack(spacing: 6) { + Image(systemName: "wand.and.stars") + Text("home_auto_assign_cta_button") + .font(.mealMoodSmall.weight(.semibold)) + } + .foregroundColor(.white) + .padding(.horizontal, 14) + .padding(.vertical, 8) + .background(Color.mealMoodCoral) + .clipShape(Capsule()) + } + .buttonStyle(.plain) + .disabled(viewModel.isAutoCompleting) + } + .padding(12) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(Color.mealMoodCoral.opacity(0.08)) + ) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(Color.mealMoodCoral.opacity(0.25), lineWidth: 1) + ) + .padding(.horizontal, 16) + } + private func updateWidget(settings: AppSettings) { let todayPlan = fetchWeekPlan(for: Date().startOfWeek()) WidgetDataStore.update(plan: todayPlan, dishes: dishes, settings: settings) diff --git a/MealMood/Views/Home/WeekCalendarView.swift b/MealMood/Views/Home/WeekCalendarView.swift index b40589d..8003faf 100644 --- a/MealMood/Views/Home/WeekCalendarView.swift +++ b/MealMood/Views/Home/WeekCalendarView.swift @@ -11,6 +11,7 @@ struct WeekCalendarView: View { let tags: [Tag] @ObservedObject var viewModel: HomeViewModel var onTapEmptySlot: ((MealSlot) -> Void)? + var onTapFilledSlot: ((MealSlot) -> Void)? private var dayRange: ClosedRange { settings.includeWeekends ? 0...6 : 0...4 @@ -279,6 +280,12 @@ struct WeekCalendarView: View { viewModel.removeDish(from: live, plan: plan, settings: settings) } : nil ) + .contentShape(Rectangle()) + .onTapGesture { + guard viewModel.canEditCurrentWeek, onTapFilledSlot != nil else { return } + guard let live = liveSlot(for: slot) else { return } + onTapFilledSlot?(live) + } .draggable("slot:\(slot.slotId.uuidString)") } else if let slot = slot, slot.isEatingOut { EatingOutSlotView(mealType: mealType) diff --git a/MealMood/Views/Home/WeekPlanShareView.swift b/MealMood/Views/Home/WeekPlanShareView.swift index f6d3e23..21178bd 100644 --- a/MealMood/Views/Home/WeekPlanShareView.swift +++ b/MealMood/Views/Home/WeekPlanShareView.swift @@ -502,8 +502,10 @@ struct WeekPlanShareView: View { private func dishName(day: Int, mealType: MealType) -> String { let slot = plan.slots.first { $0.dayOfWeek == day && $0.mealType == mealType.rawValue } - guard let slot, let dishId = slot.dishId, let dish = dishes.first(where: { $0.id == dishId }) else { - return "—" + guard let slot else { return String(localized: "share_slot_empty") } + if slot.isEatingOut { return String(localized: "slot_eating_out") } + guard let dishId = slot.dishId, let dish = dishes.first(where: { $0.id == dishId }) else { + return String(localized: "share_slot_empty") } return dish.name } diff --git a/MealMood/Views/Tags/TagRulesEditView.swift b/MealMood/Views/Tags/TagRulesEditView.swift index 7f73f0a..640a93d 100644 --- a/MealMood/Views/Tags/TagRulesEditView.swift +++ b/MealMood/Views/Tags/TagRulesEditView.swift @@ -147,6 +147,24 @@ struct TagRulesEditView: View { .background(Color.mealMoodSurface) .cornerRadius(14) + // Day restriction + VStack(alignment: .leading, spacing: 12) { + Text("tags_day_restriction") + .font(.mealMoodBodyBold) + .foregroundColor(.mealMoodTextPrimary) + + Picker("Días", selection: $viewModel.dayRestriction) { + Text("tags_day_any").tag(nil as String?) + Text("tags_day_weekdays").tag("weekdays" as String?) + Text("tags_day_weekend").tag("weekend" as String?) + } + .pickerStyle(.segmented) + .disabled(!isPremium) + } + .padding(16) + .background(Color.mealMoodSurface) + .cornerRadius(14) + if !isPremium { HStack(spacing: 8) { Image(systemName: "star.circle")