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
+20 -5
View File
@@ -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)
+95 -1
View File
@@ -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)
+4 -2
View File
@@ -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
}