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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user