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")