1.0.6: Priority dishes, day rules, auto-assign CTA, tap-to-replace, export incomplete weeks, Sunday notification

- Dish.isPriority: mark dishes as priority; auto-assign gives them 2.5x weight
- Tag.dayRestriction: restrict tags to weekdays or weekend only
- Auto-assign CTA banner shown when there are empty slots (was hidden in context menu)
- Tap on filled slot opens picker to replace dish directly (no need to remove first)
- Export callout visible as soon as ≥1 slot is filled (not only when week is complete)
- WeekPlanShareView shows localized "TBD" / "Eating out" for empty/eating-out slots
- Second Sunday 17:00 push notification to plan next week
- DishDrawer sorts: priority first, then by historical usage, then alphabetically
- Search in SlotDishPickerSheet already shipped in 1.0.5 (verified ✓)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
alexandrev-tibco
2026-04-30 00:00:53 +02:00
parent 92f97f855d
commit a4dfb38feb
22 changed files with 344 additions and 26 deletions
+2 -2
View File
@@ -860,7 +860,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = MealMood/Resources/MealMood.entitlements; CODE_SIGN_ENTITLEMENTS = MealMood/Resources/MealMood.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_IDENTITY = "iPhone Developer";
CURRENT_PROJECT_VERSION = 24; CURRENT_PROJECT_VERSION = 31;
DEVELOPMENT_TEAM = 2825Q76T7H; DEVELOPMENT_TEAM = 2825Q76T7H;
GENERATE_INFOPLIST_FILE = NO; GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = MealMood/Resources/Info.plist; INFOPLIST_FILE = MealMood/Resources/Info.plist;
@@ -965,7 +965,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = MealMood/Resources/MealMood.entitlements; CODE_SIGN_ENTITLEMENTS = MealMood/Resources/MealMood.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_IDENTITY = "iPhone Developer";
CURRENT_PROJECT_VERSION = 24; CURRENT_PROJECT_VERSION = 31;
DEVELOPMENT_TEAM = 2825Q76T7H; DEVELOPMENT_TEAM = 2825Q76T7H;
GENERATE_INFOPLIST_FILE = NO; GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = MealMood/Resources/Info.plist; INFOPLIST_FILE = MealMood/Resources/Info.plist;
@@ -8,6 +8,18 @@
<dict> <dict>
<key>isShown</key> <key>isShown</key>
<false/> <false/>
<key>orderHint</key>
<integer>1</integer>
</dict>
<key>MealMood.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>0</integer>
</dict>
<key>MealMoodWidget.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>2</integer>
</dict> </dict>
</dict> </dict>
</dict> </dict>
+4 -1
View File
@@ -8,19 +8,22 @@ final class Dish {
var descriptionText: String? var descriptionText: String?
var tagIds: [UUID] var tagIds: [UUID]
var createdAt: Date var createdAt: Date
var isPriority: Bool
init( init(
id: UUID = UUID(), id: UUID = UUID(),
name: String, name: String,
descriptionText: String? = nil, descriptionText: String? = nil,
tagIds: [UUID] = [], tagIds: [UUID] = [],
createdAt: Date = Date() createdAt: Date = Date(),
isPriority: Bool = false
) { ) {
self.id = id self.id = id
self.name = name self.name = name
self.descriptionText = descriptionText self.descriptionText = descriptionText
self.tagIds = tagIds self.tagIds = tagIds
self.createdAt = createdAt self.createdAt = createdAt
self.isPriority = isPriority
} }
} }
+12
View File
@@ -11,6 +11,7 @@ final class Tag {
var noConsecutive: Bool var noConsecutive: Bool
var noDuplicateInDay: Bool var noDuplicateInDay: Bool
var mealTypeRestriction: String? // "lunch", "dinner", nil var mealTypeRestriction: String? // "lunch", "dinner", nil
var dayRestriction: String? // nil = any day, "weekdays" = Mon-Fri, "weekend" = Sat-Sun
var isDefault: Bool var isDefault: Bool
var sortOrder: Int var sortOrder: Int
@@ -23,6 +24,7 @@ final class Tag {
noConsecutive: Bool = false, noConsecutive: Bool = false,
noDuplicateInDay: Bool = false, noDuplicateInDay: Bool = false,
mealTypeRestriction: String? = nil, mealTypeRestriction: String? = nil,
dayRestriction: String? = nil,
isDefault: Bool = true, isDefault: Bool = true,
sortOrder: Int = 0 sortOrder: Int = 0
) { ) {
@@ -34,6 +36,7 @@ final class Tag {
self.noConsecutive = noConsecutive self.noConsecutive = noConsecutive
self.noDuplicateInDay = noDuplicateInDay self.noDuplicateInDay = noDuplicateInDay
self.mealTypeRestriction = mealTypeRestriction self.mealTypeRestriction = mealTypeRestriction
self.dayRestriction = dayRestriction
self.isDefault = isDefault self.isDefault = isDefault
self.sortOrder = sortOrder self.sortOrder = sortOrder
} }
@@ -76,6 +79,15 @@ final class Tag {
parts.append(restriction == "lunch" ? "lunch only" : "dinner only") 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: ", ") return parts.isEmpty ? (resolvedLanguage == .spanish ? "Sin límite" : "No limit") : parts.joined(separator: ", ")
} }
} }
+2 -2
View File
@@ -15,9 +15,9 @@
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>APPL</string> <string>APPL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>1.0.5</string> <string>1.0.6</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>24</string> <string>32</string>
<key>GADApplicationIdentifier</key> <key>GADApplicationIdentifier</key>
<string>ca-app-pub-1549720748100858~9985112590</string> <string>ca-app-pub-1549720748100858~9985112590</string>
<key>LSRequiresIPhoneOS</key> <key>LSRequiresIPhoneOS</key>
@@ -310,3 +310,17 @@
"violations_fix" = "Slot leeren"; "violations_fix" = "Slot leeren";
"violations_ignore" = "Ignorieren"; "violations_ignore" = "Ignorieren";
"violations_badge" = "%d Konflikt(e)"; "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.";
@@ -310,3 +310,17 @@
"violations_fix" = "Clear slot"; "violations_fix" = "Clear slot";
"violations_ignore" = "Ignore"; "violations_ignore" = "Ignore";
"violations_badge" = "%d conflict(s)"; "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.";
@@ -310,3 +310,17 @@
"violations_fix" = "Vaciar hueco"; "violations_fix" = "Vaciar hueco";
"violations_ignore" = "Ignorar"; "violations_ignore" = "Ignorar";
"violations_badge" = "%d conflicto(s)"; "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.";
@@ -310,3 +310,17 @@
"violations_fix" = "Vider le créneau"; "violations_fix" = "Vider le créneau";
"violations_ignore" = "Ignorer"; "violations_ignore" = "Ignorer";
"violations_badge" = "%d conflit(s)"; "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.";
@@ -310,3 +310,17 @@
"violations_fix" = "Svuota slot"; "violations_fix" = "Svuota slot";
"violations_ignore" = "Ignora"; "violations_ignore" = "Ignora";
"violations_badge" = "%d conflitto/i"; "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.";
@@ -310,3 +310,17 @@
"violations_fix" = "Limpar slot"; "violations_fix" = "Limpar slot";
"violations_ignore" = "Ignorar"; "violations_ignore" = "Ignorar";
"violations_badge" = "%d conflito(s)"; "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.";
+13 -3
View File
@@ -166,6 +166,15 @@ struct AutocompleteEngine {
if let restriction = tag.mealTypeRestriction, slot.mealType != restriction { if let restriction = tag.mealTypeRestriction, slot.mealType != restriction {
return true 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 return false
} }
@@ -187,10 +196,11 @@ struct AutocompleteEngine {
guard !candidates.isEmpty else { return nil } guard !candidates.isEmpty else { return nil }
let weights: [Double] = candidates.map { dish in let weights: [Double] = candidates.map { dish in
let usage = plan.slots.filter { $0.dishId == dish.id }.count let usage = plan.slots.filter { $0.dishId == dish.id }.count
let priorityBoost: Double = dish.isPriority ? 2.5 : 1.0
switch usage { switch usage {
case 0: return 3.0 case 0: return 3.0 * priorityBoost
case 1: return 2.0 case 1: return 2.0 * priorityBoost
default: return 1.0 default: return 1.0 * priorityBoost
} }
} }
let total = weights.reduce(0, +) let total = weights.reduce(0, +)
+38 -9
View File
@@ -18,23 +18,52 @@ final class NotificationService {
let center = UNUserNotificationCenter.current() let center = UNUserNotificationCenter.current()
let identifier = "mealmood.next-week-planning" 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 { if isComplete {
center.removePendingNotificationRequests(withIdentifiers: [identifier]) 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 return
} }
let nextWeekStart = Date().startOfWeek().addingDays(7) // Schedule for next Sunday at 17:00
let reminderDay = nextWeekStart.addingDays(-1) var components = DateComponents()
let calendar = Calendar.current components.weekday = 1 // Sunday
var components = calendar.dateComponents([.year, .month, .day], from: reminderDay) components.hour = 17
components.hour = 19
components.minute = 0 components.minute = 0
let trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: false) let trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: true)
let content = UNMutableNotificationContent() let content = UNMutableNotificationContent()
content.title = localizedString("notification_planning_title", language: language) content.title = localizedString("notification_sunday_title", language: language)
content.body = localizedString("notification_planning_body", language: language) content.body = localizedString("notification_sunday_body", language: language)
content.sound = .default content.sound = .default
let request = UNNotificationRequest(identifier: identifier, content: content, trigger: trigger) let request = UNNotificationRequest(identifier: identifier, content: content, trigger: trigger)
+6 -1
View File
@@ -6,6 +6,7 @@ final class DishViewModel: ObservableObject {
@Published var name: String = "" @Published var name: String = ""
@Published var descriptionText: String = "" @Published var descriptionText: String = ""
@Published var selectedTagIds: Set<UUID> = [] @Published var selectedTagIds: Set<UUID> = []
@Published var isPriority: Bool = false
@Published var showTagSelector: Bool = false @Published var showTagSelector: Bool = false
@Published var showDeleteAlert: Bool = false @Published var showDeleteAlert: Bool = false
@Published var showAssignedDeleteAlert: Bool = false @Published var showAssignedDeleteAlert: Bool = false
@@ -23,6 +24,7 @@ final class DishViewModel: ObservableObject {
name = dish.name name = dish.name
descriptionText = dish.descriptionText ?? "" descriptionText = dish.descriptionText ?? ""
selectedTagIds = Set(dish.tagIds) selectedTagIds = Set(dish.tagIds)
isPriority = dish.isPriority
} }
func reset() { func reset() {
@@ -30,6 +32,7 @@ final class DishViewModel: ObservableObject {
name = "" name = ""
descriptionText = "" descriptionText = ""
selectedTagIds = [] selectedTagIds = []
isPriority = false
} }
func save(context: ModelContext) { func save(context: ModelContext) {
@@ -40,11 +43,13 @@ final class DishViewModel: ObservableObject {
dish.name = trimmedName dish.name = trimmedName
dish.descriptionText = descriptionText.isEmpty ? nil : descriptionText dish.descriptionText = descriptionText.isEmpty ? nil : descriptionText
dish.tagIds = Array(selectedTagIds) dish.tagIds = Array(selectedTagIds)
dish.isPriority = isPriority
} else { } else {
let dish = Dish( let dish = Dish(
name: trimmedName, name: trimmedName,
descriptionText: descriptionText.isEmpty ? nil : descriptionText, descriptionText: descriptionText.isEmpty ? nil : descriptionText,
tagIds: Array(selectedTagIds) tagIds: Array(selectedTagIds),
isPriority: isPriority
) )
context.insert(dish) context.insert(dish)
AnalyticsService.logDishAdded(tagCount: selectedTagIds.count) AnalyticsService.logDishAdded(tagCount: selectedTagIds.count)
+3
View File
@@ -8,6 +8,7 @@ final class TagViewModel: ObservableObject {
@Published var noConsecutive: Bool = false @Published var noConsecutive: Bool = false
@Published var noDuplicateInDay: Bool = false @Published var noDuplicateInDay: Bool = false
@Published var mealTypeRestriction: String? = nil @Published var mealTypeRestriction: String? = nil
@Published var dayRestriction: String? = nil
@Published var useMaxLimit: Bool = true @Published var useMaxLimit: Bool = true
func loadTag(_ tag: Tag) { func loadTag(_ tag: Tag) {
@@ -16,6 +17,7 @@ final class TagViewModel: ObservableObject {
noConsecutive = tag.noConsecutive noConsecutive = tag.noConsecutive
noDuplicateInDay = tag.noDuplicateInDay noDuplicateInDay = tag.noDuplicateInDay
mealTypeRestriction = tag.mealTypeRestriction mealTypeRestriction = tag.mealTypeRestriction
dayRestriction = tag.dayRestriction
useMaxLimit = tag.maxPerWeek != nil useMaxLimit = tag.maxPerWeek != nil
} }
@@ -25,6 +27,7 @@ final class TagViewModel: ObservableObject {
tag.noConsecutive = noConsecutive tag.noConsecutive = noConsecutive
tag.noDuplicateInDay = noDuplicateInDay tag.noDuplicateInDay = noDuplicateInDay
tag.mealTypeRestriction = mealTypeRestriction tag.mealTypeRestriction = mealTypeRestriction
tag.dayRestriction = dayRestriction
} }
func reset() { func reset() {
+24
View File
@@ -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 { if showDishLimitUpsell && reachedFreeDishLimit {
PremiumUpsellBanner( PremiumUpsellBanner(
messageKey: "premium_limit_dishes", messageKey: "premium_limit_dishes",
+20 -5
View File
@@ -5,6 +5,7 @@ struct DishDrawerView: View {
let tags: [Tag] let tags: [Tag]
let language: AppLanguage let language: AppLanguage
let usedDishIds: Set<UUID> let usedDishIds: Set<UUID>
var usageRanking: [UUID: Int] = [:]
var onAddDish: () -> Void var onAddDish: () -> Void
var onQuickAssignDish: (Dish) -> Void var onQuickAssignDish: (Dish) -> Void
var onEditDish: (Dish) -> Void var onEditDish: (Dish) -> Void
@@ -12,10 +13,17 @@ struct DishDrawerView: View {
@Binding var draggedDish: Dish? @Binding var draggedDish: Dish?
@State private var searchText: String = "" @State private var searchText: String = ""
@State private var hideUsedThisWeek: Bool = false @State private var hideUsedThisWeek: Bool = false
private var filteredDishes: [Dish] { private var filteredDishes: [Dish] {
let term = searchText.trimmingCharacters(in: .whitespacesAndNewlines) 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 matchesSearch = term.isEmpty || dish.name.localizedCaseInsensitiveContains(term)
let matchesUsedFilter = !hideUsedThisWeek || !usedDishIds.contains(dish.id) let matchesUsedFilter = !hideUsedThisWeek || !usedDishIds.contains(dish.id)
return matchesSearch && matchesUsedFilter return matchesSearch && matchesUsedFilter
@@ -159,9 +167,16 @@ struct DishCardView: View {
var body: some View { var body: some View {
HStack { HStack {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Text(dish.name) HStack(spacing: 4) {
.font(.mealMoodBodyBold) if dish.isPriority {
.foregroundColor(.mealMoodTextPrimary) Image(systemName: "star.fill")
.font(.system(size: 11))
.foregroundColor(.mealMoodCoral)
}
Text(dish.name)
.font(.mealMoodBodyBold)
.foregroundColor(.mealMoodTextPrimary)
}
if let desc = dish.descriptionText, !desc.isEmpty { if let desc = dish.descriptionText, !desc.isEmpty {
Text(desc) Text(desc)
+95 -1
View File
@@ -17,6 +17,7 @@ struct HomeView: View {
@State private var showWeekLimitUpsell: Bool = false @State private var showWeekLimitUpsell: Bool = false
@State private var wasWeekComplete: Bool = false @State private var wasWeekComplete: Bool = false
@State private var selectedEmptySlotId: UUID? @State private var selectedEmptySlotId: UUID?
@State private var selectedFilledSlotId: UUID?
@State private var showWeekPicker: Bool = false @State private var showWeekPicker: Bool = false
@State private var weekPickerDate: Date = Date() @State private var weekPickerDate: Date = Date()
@State private var showCopyPreviousConfirm: Bool = false @State private var showCopyPreviousConfirm: Bool = false
@@ -225,13 +226,22 @@ struct HomeView: View {
viewModel: viewModel, viewModel: viewModel,
onTapEmptySlot: { slot in onTapEmptySlot: { slot in
selectedEmptySlotId = slot.id 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) 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) { ScrollView(showsIndicators: true) {
dishDrawer(plan: plan, settings: settings) dishDrawer(plan: plan, settings: settings)
.padding(.bottom, 0) .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 .sheet(item: $editingDish) { dish in
DishFormView(dish: dish) DishFormView(dish: dish)
} }
@@ -591,6 +631,7 @@ struct HomeView: View {
tags: tags, tags: tags,
language: settings.languageEnum.resolved(), language: settings.languageEnum.resolved(),
usedDishIds: Set(plan.slots.compactMap(\.dishId)), usedDishIds: Set(plan.slots.compactMap(\.dishId)),
usageRanking: dishUsageCounts,
onAddDish: { viewModel.showDishForm = true }, onAddDish: { viewModel.showDishForm = true },
onQuickAssignDish: { dish in onQuickAssignDish: { dish in
viewModel.assignDishToFirstFreeSlot( viewModel.assignDishToFirstFreeSlot(
@@ -651,6 +692,59 @@ struct HomeView: View {
plan.slots.allSatisfy { $0.dishId != nil || $0.isEatingOut } 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) { private func updateWidget(settings: AppSettings) {
let todayPlan = fetchWeekPlan(for: Date().startOfWeek()) let todayPlan = fetchWeekPlan(for: Date().startOfWeek())
WidgetDataStore.update(plan: todayPlan, dishes: dishes, settings: settings) WidgetDataStore.update(plan: todayPlan, dishes: dishes, settings: settings)
@@ -11,6 +11,7 @@ struct WeekCalendarView: View {
let tags: [Tag] let tags: [Tag]
@ObservedObject var viewModel: HomeViewModel @ObservedObject var viewModel: HomeViewModel
var onTapEmptySlot: ((MealSlot) -> Void)? var onTapEmptySlot: ((MealSlot) -> Void)?
var onTapFilledSlot: ((MealSlot) -> Void)?
private var dayRange: ClosedRange<Int> { private var dayRange: ClosedRange<Int> {
settings.includeWeekends ? 0...6 : 0...4 settings.includeWeekends ? 0...6 : 0...4
@@ -279,6 +280,12 @@ struct WeekCalendarView: View {
viewModel.removeDish(from: live, plan: plan, settings: settings) viewModel.removeDish(from: live, plan: plan, settings: settings)
} : nil } : 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)") .draggable("slot:\(slot.slotId.uuidString)")
} else if let slot = slot, slot.isEatingOut { } else if let slot = slot, slot.isEatingOut {
EatingOutSlotView(mealType: mealType) EatingOutSlotView(mealType: mealType)
+4 -2
View File
@@ -502,8 +502,10 @@ struct WeekPlanShareView: View {
private func dishName(day: Int, mealType: MealType) -> String { private func dishName(day: Int, mealType: MealType) -> String {
let slot = plan.slots.first { $0.dayOfWeek == day && $0.mealType == mealType.rawValue } 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 { guard let slot else { return String(localized: "share_slot_empty") }
return "" 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 return dish.name
} }
@@ -147,6 +147,24 @@ struct TagRulesEditView: View {
.background(Color.mealMoodSurface) .background(Color.mealMoodSurface)
.cornerRadius(14) .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 { if !isPremium {
HStack(spacing: 8) { HStack(spacing: 8) {
Image(systemName: "star.circle") Image(systemName: "star.circle")