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:
@@ -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;
|
||||
|
||||
BIN
Binary file not shown.
+12
@@ -8,6 +8,18 @@
|
||||
<dict>
|
||||
<key>isShown</key>
|
||||
<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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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: ", ")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,9 +15,9 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0.5</string>
|
||||
<string>1.0.6</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>24</string>
|
||||
<string>32</string>
|
||||
<key>GADApplicationIdentifier</key>
|
||||
<string>ca-app-pub-1549720748100858~9985112590</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
|
||||
@@ -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.";
|
||||
|
||||
@@ -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.";
|
||||
|
||||
@@ -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.";
|
||||
|
||||
@@ -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.";
|
||||
|
||||
@@ -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.";
|
||||
|
||||
@@ -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.";
|
||||
|
||||
@@ -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, +)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -6,6 +6,7 @@ final class DishViewModel: ObservableObject {
|
||||
@Published var name: String = ""
|
||||
@Published var descriptionText: String = ""
|
||||
@Published var selectedTagIds: Set<UUID> = []
|
||||
@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)
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -5,6 +5,7 @@ struct DishDrawerView: View {
|
||||
let tags: [Tag]
|
||||
let language: AppLanguage
|
||||
let usedDishIds: Set<UUID>
|
||||
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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<Int> {
|
||||
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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user