Autocomplete feedback loop: implicit rejection + weekly rating
A) Implicit: HomeViewModel tracks auto-assigned slot IDs after each autocomplete run. When the user manually replaces one of those dishes, the rejected dish ID is recorded in FeedbackStore (UserDefaults). AutocompleteEngine applies a 25% penalty per rejection (floor 0.30). B) Explicit: WeekPlan gains userRating (0/1/-1). Past weeks with dishes show a compact 👍/👎 row (Premium only). Liked weeks boost their dishes (+0.20 each, max +0.50); disliked weeks penalise them (-0.25 each, max -0.50). Signal is intentionally modest so the period cycle score remains dominant. Also adds Stats + week-rating localization strings to all 6 languages (were missing from the previous commit). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -86,6 +86,7 @@
|
|||||||
FB0011111111111111111108 /* FirebaseCrashlytics in Frameworks */ = {isa = PBXBuildFile; productRef = FB001111111111111111110B /* FirebaseCrashlytics */; };
|
FB0011111111111111111108 /* FirebaseCrashlytics in Frameworks */ = {isa = PBXBuildFile; productRef = FB001111111111111111110B /* FirebaseCrashlytics */; };
|
||||||
FB0011111111111111111109 /* CrashlyticsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB001111111111111111110A /* CrashlyticsService.swift */; };
|
FB0011111111111111111109 /* CrashlyticsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB001111111111111111110A /* CrashlyticsService.swift */; };
|
||||||
443CC3997A9A4818B51066FB /* StatsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E8AD94FC874467D863D464C /* StatsView.swift */; };
|
443CC3997A9A4818B51066FB /* StatsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E8AD94FC874467D863D464C /* StatsView.swift */; };
|
||||||
|
B4D6F8012C3E5F7A91B3D5F7 /* FeedbackStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3C5F7891B2D4E6F80A2C4E6 /* FeedbackStore.swift */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy section */
|
/* Begin PBXContainerItemProxy section */
|
||||||
@@ -200,6 +201,7 @@
|
|||||||
FE73D5B7AF8B5E2D63105977 /* Font+MealMood.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Font+MealMood.swift"; sourceTree = "<group>"; };
|
FE73D5B7AF8B5E2D63105977 /* Font+MealMood.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Font+MealMood.swift"; sourceTree = "<group>"; };
|
||||||
FF2DE77AD57DB6DAB188CD9E /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; };
|
FF2DE77AD57DB6DAB188CD9E /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||||
1E8AD94FC874467D863D464C /* StatsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatsView.swift; sourceTree = "<group>"; };
|
1E8AD94FC874467D863D464C /* StatsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatsView.swift; sourceTree = "<group>"; };
|
||||||
|
A3C5F7891B2D4E6F80A2C4E6 /* FeedbackStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbackStore.swift; sourceTree = "<group>"; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
@@ -487,6 +489,7 @@
|
|||||||
103374A7D91B4F884278DA72 /* StoreManager.swift */,
|
103374A7D91B4F884278DA72 /* StoreManager.swift */,
|
||||||
A6FC2153490D53B80DBDC034 /* WidgetDataStore.swift */,
|
A6FC2153490D53B80DBDC034 /* WidgetDataStore.swift */,
|
||||||
928E6D4F6D78AD1840245FB4 /* PremiumSyncService.swift */,
|
928E6D4F6D78AD1840245FB4 /* PremiumSyncService.swift */,
|
||||||
|
A3C5F7891B2D4E6F80A2C4E6 /* FeedbackStore.swift */,
|
||||||
);
|
);
|
||||||
path = Services;
|
path = Services;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -697,6 +700,7 @@
|
|||||||
25B3C328E088A403CABAF20A /* PremiumSyncService.swift in Sources */,
|
25B3C328E088A403CABAF20A /* PremiumSyncService.swift in Sources */,
|
||||||
FB0011111111111111111109 /* CrashlyticsService.swift in Sources */,
|
FB0011111111111111111109 /* CrashlyticsService.swift in Sources */,
|
||||||
443CC3997A9A4818B51066FB /* StatsView.swift in Sources */,
|
443CC3997A9A4818B51066FB /* StatsView.swift in Sources */,
|
||||||
|
B4D6F8012C3E5F7A91B3D5F7 /* FeedbackStore.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ final class WeekPlan {
|
|||||||
var weekStartDate: Date
|
var weekStartDate: Date
|
||||||
var createdAt: Date
|
var createdAt: Date
|
||||||
var updatedAt: Date
|
var updatedAt: Date
|
||||||
|
var userRating: Int = 0 // 0 = unrated, 1 = liked, -1 = disliked
|
||||||
|
|
||||||
@Relationship(deleteRule: .cascade)
|
@Relationship(deleteRule: .cascade)
|
||||||
var slots: [MealSlot]
|
var slots: [MealSlot]
|
||||||
@@ -23,5 +24,6 @@ final class WeekPlan {
|
|||||||
self.slots = slots
|
self.slots = slots
|
||||||
self.createdAt = createdAt
|
self.createdAt = createdAt
|
||||||
self.updatedAt = updatedAt
|
self.updatedAt = updatedAt
|
||||||
|
self.userRating = 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -325,3 +325,16 @@
|
|||||||
"notification_sunday_title" = "Plane deine nächste Woche";
|
"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.";
|
"notification_sunday_body" = "Es ist Sonntag — nimm dir 2 Minuten, um die Mahlzeiten der nächsten Woche zu planen.";
|
||||||
|
|
||||||
|
/* Stats */
|
||||||
|
"stats_title" = "Statistiken";
|
||||||
|
"stats_streak_label" = "Wochen-Serie";
|
||||||
|
"stats_weeks_planned_label" = "Geplante Wochen";
|
||||||
|
"stats_complete_weeks_label" = "Vollständige Wochen";
|
||||||
|
"stats_completion_rate_label" = "Abschlussrate";
|
||||||
|
"stats_top_dishes_title" = "Meistgenutzte Gerichte";
|
||||||
|
"stats_tags_title" = "Nach Kategorie";
|
||||||
|
"stats_no_data" = "Noch keine Daten";
|
||||||
|
|
||||||
|
/* Week rating */
|
||||||
|
"week_rating_prompt" = "Wie war diese Woche?";
|
||||||
|
|
||||||
|
|||||||
@@ -325,3 +325,16 @@
|
|||||||
"notification_sunday_title" = "Plan your next week";
|
"notification_sunday_title" = "Plan your next week";
|
||||||
"notification_sunday_body" = "Sunday is here — take 2 minutes to plan next week's meals.";
|
"notification_sunday_body" = "Sunday is here — take 2 minutes to plan next week's meals.";
|
||||||
|
|
||||||
|
/* Stats */
|
||||||
|
"stats_title" = "Statistics";
|
||||||
|
"stats_streak_label" = "Week streak";
|
||||||
|
"stats_weeks_planned_label" = "Weeks planned";
|
||||||
|
"stats_complete_weeks_label" = "Complete weeks";
|
||||||
|
"stats_completion_rate_label" = "Completion rate";
|
||||||
|
"stats_top_dishes_title" = "Top dishes";
|
||||||
|
"stats_tags_title" = "By category";
|
||||||
|
"stats_no_data" = "No data yet";
|
||||||
|
|
||||||
|
/* Week rating */
|
||||||
|
"week_rating_prompt" = "How was this week?";
|
||||||
|
|
||||||
|
|||||||
@@ -325,3 +325,16 @@
|
|||||||
"notification_sunday_title" = "Planifica la semana que viene";
|
"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.";
|
"notification_sunday_body" = "Es domingo — tómate 2 minutos para planificar las comidas de la próxima semana.";
|
||||||
|
|
||||||
|
/* Stats */
|
||||||
|
"stats_title" = "Estadísticas";
|
||||||
|
"stats_streak_label" = "Racha de semanas";
|
||||||
|
"stats_weeks_planned_label" = "Semanas planificadas";
|
||||||
|
"stats_complete_weeks_label" = "Semanas completas";
|
||||||
|
"stats_completion_rate_label" = "Tasa de completado";
|
||||||
|
"stats_top_dishes_title" = "Platos más usados";
|
||||||
|
"stats_tags_title" = "Por categoría";
|
||||||
|
"stats_no_data" = "Sin datos todavía";
|
||||||
|
|
||||||
|
/* Week rating */
|
||||||
|
"week_rating_prompt" = "¿Qué tal fue la semana?";
|
||||||
|
|
||||||
|
|||||||
@@ -325,3 +325,16 @@
|
|||||||
"notification_sunday_title" = "Planifiez votre prochaine semaine";
|
"notification_sunday_title" = "Planifiez votre prochaine semaine";
|
||||||
"notification_sunday_body" = "C'est dimanche — prenez 2 minutes pour planifier vos repas de la semaine prochaine.";
|
"notification_sunday_body" = "C'est dimanche — prenez 2 minutes pour planifier vos repas de la semaine prochaine.";
|
||||||
|
|
||||||
|
/* Stats */
|
||||||
|
"stats_title" = "Statistiques";
|
||||||
|
"stats_streak_label" = "Série de semaines";
|
||||||
|
"stats_weeks_planned_label" = "Semaines planifiées";
|
||||||
|
"stats_complete_weeks_label" = "Semaines complètes";
|
||||||
|
"stats_completion_rate_label" = "Taux de complétion";
|
||||||
|
"stats_top_dishes_title" = "Plats les plus utilisés";
|
||||||
|
"stats_tags_title" = "Par catégorie";
|
||||||
|
"stats_no_data" = "Pas encore de données";
|
||||||
|
|
||||||
|
/* Week rating */
|
||||||
|
"week_rating_prompt" = "Comment s'est passée cette semaine ?";
|
||||||
|
|
||||||
|
|||||||
@@ -325,3 +325,16 @@
|
|||||||
"notification_sunday_title" = "Pianifica la prossima settimana";
|
"notification_sunday_title" = "Pianifica la prossima settimana";
|
||||||
"notification_sunday_body" = "È domenica — prenditi 2 minuti per pianificare i pasti della prossima settimana.";
|
"notification_sunday_body" = "È domenica — prenditi 2 minuti per pianificare i pasti della prossima settimana.";
|
||||||
|
|
||||||
|
/* Stats */
|
||||||
|
"stats_title" = "Statistiche";
|
||||||
|
"stats_streak_label" = "Serie di settimane";
|
||||||
|
"stats_weeks_planned_label" = "Settimane pianificate";
|
||||||
|
"stats_complete_weeks_label" = "Settimane complete";
|
||||||
|
"stats_completion_rate_label" = "Tasso di completamento";
|
||||||
|
"stats_top_dishes_title" = "Piatti più utilizzati";
|
||||||
|
"stats_tags_title" = "Per categoria";
|
||||||
|
"stats_no_data" = "Nessun dato ancora";
|
||||||
|
|
||||||
|
/* Week rating */
|
||||||
|
"week_rating_prompt" = "Com'è andata questa settimana?";
|
||||||
|
|
||||||
|
|||||||
@@ -325,3 +325,16 @@
|
|||||||
"notification_sunday_title" = "Planeje a próxima semana";
|
"notification_sunday_title" = "Planeje a próxima semana";
|
||||||
"notification_sunday_body" = "É domingo — reserve 2 minutos para planejar as refeições da próxima semana.";
|
"notification_sunday_body" = "É domingo — reserve 2 minutos para planejar as refeições da próxima semana.";
|
||||||
|
|
||||||
|
/* Stats */
|
||||||
|
"stats_title" = "Estatísticas";
|
||||||
|
"stats_streak_label" = "Sequência de semanas";
|
||||||
|
"stats_weeks_planned_label" = "Semanas planejadas";
|
||||||
|
"stats_complete_weeks_label" = "Semanas completas";
|
||||||
|
"stats_completion_rate_label" = "Taxa de conclusão";
|
||||||
|
"stats_top_dishes_title" = "Pratos mais usados";
|
||||||
|
"stats_tags_title" = "Por categoria";
|
||||||
|
"stats_no_data" = "Sem dados ainda";
|
||||||
|
|
||||||
|
/* Week rating */
|
||||||
|
"week_rating_prompt" = "Como foi esta semana?";
|
||||||
|
|
||||||
|
|||||||
@@ -21,11 +21,13 @@ struct AutocompleteEngine {
|
|||||||
currentPlan: WeekPlan,
|
currentPlan: WeekPlan,
|
||||||
allDishes: [Dish],
|
allDishes: [Dish],
|
||||||
allTags: [Tag],
|
allTags: [Tag],
|
||||||
recentPlans: [WeekPlan] = []
|
recentPlans: [WeekPlan] = [],
|
||||||
|
rejectionCounts: [UUID: Int] = [:]
|
||||||
) -> AutocompleteResult {
|
) -> AutocompleteResult {
|
||||||
let tagMap = Dictionary(uniqueKeysWithValues: allTags.map { ($0.id, $0) })
|
let tagMap = Dictionary(uniqueKeysWithValues: allTags.map { ($0.id, $0) })
|
||||||
let dishMap = Dictionary(uniqueKeysWithValues: allDishes.map { ($0.id, $0) })
|
let dishMap = Dictionary(uniqueKeysWithValues: allDishes.map { ($0.id, $0) })
|
||||||
let historyScores = periodScores(dishes: allDishes, recentPlans: recentPlans, currentWeekStart: currentPlan.weekStartDate)
|
let historyScores = periodScores(dishes: allDishes, recentPlans: recentPlans, currentWeekStart: currentPlan.weekStartDate)
|
||||||
|
let ratingScores = Self.ratingScores(dishes: allDishes, recentPlans: recentPlans)
|
||||||
|
|
||||||
var filledSlots: [(slotId: UUID, dishId: UUID)] = []
|
var filledSlots: [(slotId: UUID, dishId: UUID)] = []
|
||||||
var unfilledCount = 0
|
var unfilledCount = 0
|
||||||
@@ -63,7 +65,7 @@ struct AutocompleteEngine {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if let picked = weightedRandomPick(candidates: candidates, plan: currentPlan, historyScores: historyScores) {
|
if let picked = weightedRandomPick(candidates: candidates, plan: currentPlan, historyScores: historyScores, ratingScores: ratingScores, rejectionCounts: rejectionCounts) {
|
||||||
slot.dishId = picked.id
|
slot.dishId = picked.id
|
||||||
filledSlots.append((slotId: slot.id, dishId: picked.id))
|
filledSlots.append((slotId: slot.id, dishId: picked.id))
|
||||||
} else {
|
} else {
|
||||||
@@ -248,25 +250,51 @@ struct AutocompleteEngine {
|
|||||||
return n % 2 == 1 ? Double(s[n / 2]) : Double(s[n / 2 - 1] + s[n / 2]) / 2.0
|
return n % 2 == 1 ? Double(s[n / 2]) : Double(s[n / 2 - 1] + s[n / 2]) / 2.0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Rating-based scoring
|
||||||
|
|
||||||
|
// Boost dishes that appeared in liked weeks; penalise those from disliked weeks.
|
||||||
|
// Signal is intentionally modest (±0.5 max) so period scoring dominates.
|
||||||
|
private static func ratingScores(dishes: [Dish], recentPlans: [WeekPlan]) -> [UUID: Double] {
|
||||||
|
let ratedPlans = recentPlans.filter { $0.userRating != 0 }
|
||||||
|
guard !ratedPlans.isEmpty else { return [:] }
|
||||||
|
var scores: [UUID: Double] = [:]
|
||||||
|
for dish in dishes {
|
||||||
|
var liked = 0, disliked = 0
|
||||||
|
for plan in ratedPlans {
|
||||||
|
guard plan.slots.contains(where: { $0.dishId == dish.id }) else { continue }
|
||||||
|
if plan.userRating > 0 { liked += 1 } else { disliked += 1 }
|
||||||
|
}
|
||||||
|
guard liked + disliked > 0 else { continue }
|
||||||
|
let boost = min(0.50, Double(liked) * 0.20)
|
||||||
|
let penalty = min(0.50, Double(disliked) * 0.25)
|
||||||
|
scores[dish.id] = max(0.50, 1.0 + boost - penalty)
|
||||||
|
}
|
||||||
|
return scores
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Weighted pick
|
// MARK: - Weighted pick
|
||||||
|
|
||||||
private static func weightedRandomPick(
|
private static func weightedRandomPick(
|
||||||
candidates: [Dish],
|
candidates: [Dish],
|
||||||
plan: WeekPlan,
|
plan: WeekPlan,
|
||||||
historyScores: [UUID: Double] = [:]
|
historyScores: [UUID: Double] = [:],
|
||||||
|
ratingScores: [UUID: Double] = [:],
|
||||||
|
rejectionCounts: [UUID: Int] = [:]
|
||||||
) -> Dish? {
|
) -> Dish? {
|
||||||
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
|
let priorityBoost: Double = dish.isPriority ? 2.5 : 1.0
|
||||||
let recencyMultiplier = historyScores[dish.id] ?? 1.0
|
let patternScore = historyScores[dish.id] ?? 1.0
|
||||||
|
let ratingScore = ratingScores[dish.id] ?? 1.0
|
||||||
|
let rejectionScore = rejectionCounts[dish.id].map { max(0.30, 1.0 - 0.25 * Double($0)) } ?? 1.0
|
||||||
let baseWeight: Double
|
let baseWeight: Double
|
||||||
switch usage {
|
switch usage {
|
||||||
case 0: baseWeight = 3.0
|
case 0: baseWeight = 3.0
|
||||||
case 1: baseWeight = 2.0
|
case 1: baseWeight = 2.0
|
||||||
default: baseWeight = 1.0
|
default: baseWeight = 1.0
|
||||||
}
|
}
|
||||||
return baseWeight * priorityBoost * recencyMultiplier
|
return baseWeight * priorityBoost * patternScore * ratingScore * rejectionScore
|
||||||
}
|
}
|
||||||
let total = weights.reduce(0, +)
|
let total = weights.reduce(0, +)
|
||||||
var r = Double.random(in: 0..<total)
|
var r = Double.random(in: 0..<total)
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct FeedbackStore {
|
||||||
|
private static let rejectionKey = "com.mealmood.dishRejections"
|
||||||
|
|
||||||
|
static func rejectionCounts() -> [UUID: Int] {
|
||||||
|
guard let data = UserDefaults.standard.data(forKey: rejectionKey),
|
||||||
|
let raw = try? JSONDecoder().decode([String: Int].self, from: data)
|
||||||
|
else { return [:] }
|
||||||
|
return Dictionary(uniqueKeysWithValues: raw.compactMap { k, v in
|
||||||
|
UUID(uuidString: k).map { ($0, v) }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
static func recordRejection(for dishId: UUID) {
|
||||||
|
var raw: [String: Int]
|
||||||
|
if let data = UserDefaults.standard.data(forKey: rejectionKey),
|
||||||
|
let decoded = try? JSONDecoder().decode([String: Int].self, from: data) {
|
||||||
|
raw = decoded
|
||||||
|
} else {
|
||||||
|
raw = [:]
|
||||||
|
}
|
||||||
|
raw[dishId.uuidString, default: 0] += 1
|
||||||
|
if let encoded = try? JSONEncoder().encode(raw) {
|
||||||
|
UserDefaults.standard.set(encoded, forKey: rejectionKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ final class HomeViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Published var currentWeekStart: Date
|
@Published var currentWeekStart: Date
|
||||||
|
private var autoAssignedSlotIds: Set<UUID> = []
|
||||||
@Published var isAutoCompleting: Bool = false
|
@Published var isAutoCompleting: Bool = false
|
||||||
@Published var showConfetti: Bool = false
|
@Published var showConfetti: Bool = false
|
||||||
@Published var showToast: Bool = false
|
@Published var showToast: Bool = false
|
||||||
@@ -80,6 +81,11 @@ final class HomeViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func assignDish(_ dish: Dish, to slot: MealSlot, plan: WeekPlan, settings: AppSettings, isOverride: Bool = false) {
|
func assignDish(_ dish: Dish, to slot: MealSlot, plan: WeekPlan, settings: AppSettings, isOverride: Bool = false) {
|
||||||
|
if autoAssignedSlotIds.contains(slot.id),
|
||||||
|
let previous = slot.dishId, previous != dish.id {
|
||||||
|
FeedbackStore.recordRejection(for: previous)
|
||||||
|
autoAssignedSlotIds.remove(slot.id)
|
||||||
|
}
|
||||||
captureUndoSnapshot(plan: plan)
|
captureUndoSnapshot(plan: plan)
|
||||||
|
|
||||||
if let oldEventId = slot.calendarEventId {
|
if let oldEventId = slot.calendarEventId {
|
||||||
@@ -128,8 +134,10 @@ final class HomeViewModel: ObservableObject {
|
|||||||
currentPlan: plan,
|
currentPlan: plan,
|
||||||
allDishes: dishes,
|
allDishes: dishes,
|
||||||
allTags: tags,
|
allTags: tags,
|
||||||
recentPlans: Array(recentPlans)
|
recentPlans: Array(recentPlans),
|
||||||
|
rejectionCounts: FeedbackStore.rejectionCounts()
|
||||||
)
|
)
|
||||||
|
autoAssignedSlotIds = Set(result.filledSlots.map(\.slotId))
|
||||||
|
|
||||||
plan.updatedAt = Date()
|
plan.updatedAt = Date()
|
||||||
applyCalendarSyncPolicy(plan: plan, settings: settings, shouldNotify: false)
|
applyCalendarSyncPolicy(plan: plan, settings: settings, shouldNotify: false)
|
||||||
@@ -157,7 +165,14 @@ final class HomeViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func rateWeek(plan: WeekPlan, rating: Int) {
|
||||||
|
plan.userRating = rating
|
||||||
|
plan.updatedAt = Date()
|
||||||
|
HapticManager.shared.impact(style: .light)
|
||||||
|
}
|
||||||
|
|
||||||
func resetWeek(plan: WeekPlan, settings: AppSettings) {
|
func resetWeek(plan: WeekPlan, settings: AppSettings) {
|
||||||
|
autoAssignedSlotIds = []
|
||||||
captureUndoSnapshot(plan: plan)
|
captureUndoSnapshot(plan: plan)
|
||||||
|
|
||||||
for slot in plan.slots {
|
for slot in plan.slots {
|
||||||
|
|||||||
@@ -244,6 +244,10 @@ struct HomeView: View {
|
|||||||
exportCallout(plan: plan, settings: settings)
|
exportCallout(plan: plan, settings: settings)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !viewModel.canEditCurrentWeek && filledCount > 0 && settings.isPremium {
|
||||||
|
weekRatingRow(plan: plan)
|
||||||
|
}
|
||||||
|
|
||||||
let emptyCount = plan.slots.filter { $0.dishId == nil && !$0.isEatingOut }.count
|
let emptyCount = plan.slots.filter { $0.dishId == nil && !$0.isEatingOut }.count
|
||||||
if viewModel.canEditCurrentWeek && !dishes.isEmpty && emptyCount > 0 {
|
if viewModel.canEditCurrentWeek && !dishes.isEmpty && emptyCount > 0 {
|
||||||
autoAssignBanner(emptyCount: emptyCount, plan: plan, settings: settings)
|
autoAssignBanner(emptyCount: emptyCount, plan: plan, settings: settings)
|
||||||
@@ -635,6 +639,36 @@ struct HomeView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func weekRatingRow(plan: WeekPlan) -> some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Text("week_rating_prompt")
|
||||||
|
.font(.mealMoodSmall)
|
||||||
|
.foregroundColor(.mealMoodTextSecondary)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Button {
|
||||||
|
viewModel.rateWeek(plan: plan, rating: plan.userRating == 1 ? 0 : 1)
|
||||||
|
} label: {
|
||||||
|
Image(systemName: plan.userRating == 1 ? "hand.thumbsup.fill" : "hand.thumbsup")
|
||||||
|
.font(.system(size: 17))
|
||||||
|
.foregroundColor(plan.userRating == 1 ? .mealMoodSuccess : .mealMoodTextSecondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
viewModel.rateWeek(plan: plan, rating: plan.userRating == -1 ? 0 : -1)
|
||||||
|
} label: {
|
||||||
|
Image(systemName: plan.userRating == -1 ? "hand.thumbsdown.fill" : "hand.thumbsdown")
|
||||||
|
.font(.system(size: 17))
|
||||||
|
.foregroundColor(plan.userRating == -1 ? .mealMoodCoral : .mealMoodTextSecondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 14)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.background(Color.mealMoodSurface)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
}
|
||||||
|
|
||||||
private func dishDrawer(plan: WeekPlan, settings: AppSettings) -> some View {
|
private func dishDrawer(plan: WeekPlan, settings: AppSettings) -> some View {
|
||||||
DishDrawerView(
|
DishDrawerView(
|
||||||
dishes: dishes,
|
dishes: dishes,
|
||||||
|
|||||||
Reference in New Issue
Block a user