1.0.4: Smarter auto-assign, quick dish entry, calendar fix, frictionless onboarding
- AutocompleteEngine: no-repeat rule now applies to all dishes (not just tagless ones); fallback still allows repeats when no alternative exists - CalendarService: update existing events instead of delete+create to prevent duplicates; fix weekStartDate using plan date instead of currentWeekStart - DishFormView: form stays open after saving in create mode for quick multi-dish entry; Done button to close; saved feedback banner - OnboardingViewModel/FirstDishesStepView: dishes are now optional during onboarding; Finish button always enabled - AnalyticsService: new events — onboarding_step_viewed(step) and auto_assign_used(filled, unfilled) - Localization: dish_done and first_dishes_optional_hint added in 6 languages; release notes updated in all 6 languages - Version bump: 1.0.3 → 1.0.4 (build 19) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
import FirebaseAnalytics
|
||||
|
||||
enum AnalyticsEvent {
|
||||
// Onboarding
|
||||
static let onboardingCompleted = "onboarding_completed"
|
||||
|
||||
// Dishes
|
||||
static let dishAdded = "dish_added"
|
||||
static let dishDeleted = "dish_deleted"
|
||||
|
||||
// Planner
|
||||
static let mealAssigned = "meal_assigned"
|
||||
static let mealCleared = "meal_cleared"
|
||||
static let weekReset = "week_reset"
|
||||
static let weekPlanShared = "week_plan_shared"
|
||||
|
||||
// Premium
|
||||
static let premiumUpgradeTapped = "premium_upgrade_tapped"
|
||||
static let premiumPurchased = "premium_purchased"
|
||||
static let premiumRestored = "premium_restored"
|
||||
|
||||
// Planner
|
||||
static let autoAssignUsed = "auto_assign_used"
|
||||
|
||||
// Onboarding
|
||||
static let onboardingStepViewed = "onboarding_step_viewed"
|
||||
|
||||
// Settings
|
||||
static let iCloudSyncToggled = "icloud_sync_toggled"
|
||||
static let notificationsToggled = "notifications_toggled"
|
||||
static let languageChanged = "language_changed"
|
||||
}
|
||||
|
||||
enum AnalyticsService {
|
||||
static func logEvent(_ name: String, parameters: [String: Any]? = nil) {
|
||||
Analytics.logEvent(name, parameters: parameters)
|
||||
}
|
||||
|
||||
static func logScreenView(_ screenName: String) {
|
||||
Analytics.logEvent(AnalyticsEventScreenView, parameters: [
|
||||
AnalyticsParameterScreenName: screenName,
|
||||
AnalyticsParameterScreenClass: screenName
|
||||
])
|
||||
}
|
||||
|
||||
// MARK: - Convenience methods
|
||||
|
||||
static func logDishAdded(tagCount: Int) {
|
||||
logEvent(AnalyticsEvent.dishAdded, parameters: ["tag_count": tagCount])
|
||||
}
|
||||
|
||||
static func logDishDeleted() {
|
||||
logEvent(AnalyticsEvent.dishDeleted)
|
||||
}
|
||||
|
||||
static func logMealAssigned() {
|
||||
logEvent(AnalyticsEvent.mealAssigned)
|
||||
}
|
||||
|
||||
static func logMealCleared() {
|
||||
logEvent(AnalyticsEvent.mealCleared)
|
||||
}
|
||||
|
||||
static func logWeekReset() {
|
||||
logEvent(AnalyticsEvent.weekReset)
|
||||
}
|
||||
|
||||
static func logWeekPlanShared(format: String) {
|
||||
logEvent(AnalyticsEvent.weekPlanShared, parameters: ["format": format])
|
||||
}
|
||||
|
||||
static func logPremiumUpgradeTapped(source: String) {
|
||||
logEvent(AnalyticsEvent.premiumUpgradeTapped, parameters: ["source": source])
|
||||
}
|
||||
|
||||
static func logPremiumPurchased() {
|
||||
logEvent(AnalyticsEvent.premiumPurchased)
|
||||
}
|
||||
|
||||
static func logPremiumRestored() {
|
||||
logEvent(AnalyticsEvent.premiumRestored)
|
||||
}
|
||||
|
||||
static func logOnboardingCompleted() {
|
||||
logEvent(AnalyticsEvent.onboardingCompleted)
|
||||
}
|
||||
|
||||
static func logOnboardingStepViewed(step: Int) {
|
||||
logEvent(AnalyticsEvent.onboardingStepViewed, parameters: ["step": step])
|
||||
}
|
||||
|
||||
static func logAutoAssignUsed(filledSlots: Int, unfilledSlots: Int) {
|
||||
logEvent(AnalyticsEvent.autoAssignUsed, parameters: [
|
||||
"filled_slots": filledSlots,
|
||||
"unfilled_slots": unfilledSlots
|
||||
])
|
||||
}
|
||||
|
||||
static func logICloudSyncToggled(enabled: Bool) {
|
||||
logEvent(AnalyticsEvent.iCloudSyncToggled, parameters: ["enabled": enabled])
|
||||
}
|
||||
|
||||
static func logNotificationsToggled(enabled: Bool) {
|
||||
logEvent(AnalyticsEvent.notificationsToggled, parameters: ["enabled": enabled])
|
||||
}
|
||||
}
|
||||
@@ -95,14 +95,10 @@ struct AutocompleteEngine {
|
||||
plan: WeekPlan,
|
||||
tagMap: [UUID: Tag]
|
||||
) -> Bool {
|
||||
// Default behavior: if a dish has no explicit rules, avoid repeating it in the same week.
|
||||
if !dishHasExplicitRules(dish: dish, tagMap: tagMap) {
|
||||
let alreadyAssignedThisWeek = plan.slots.contains {
|
||||
$0.id != slot.id && $0.dishId == dish.id
|
||||
}
|
||||
if alreadyAssignedThisWeek { return true }
|
||||
}
|
||||
return false
|
||||
// Default behavior: avoid assigning the same dish twice in the same week,
|
||||
// regardless of whether it has explicit tag rules. The fallback path in
|
||||
// autocomplete() still allows repeating when no alternative exists.
|
||||
return plan.slots.contains { $0.id != slot.id && $0.dishId == dish.id }
|
||||
}
|
||||
|
||||
private static func violatesExplicitRules(
|
||||
@@ -149,19 +145,6 @@ struct AutocompleteEngine {
|
||||
return false
|
||||
}
|
||||
|
||||
private static func dishHasExplicitRules(dish: Dish, tagMap: [UUID: Tag]) -> Bool {
|
||||
for tagId in dish.tagIds {
|
||||
guard let tag = tagMap[tagId] else { continue }
|
||||
if tag.maxPerWeek != nil ||
|
||||
tag.noConsecutive ||
|
||||
tag.noDuplicateInDay ||
|
||||
tag.mealTypeRestriction != nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private static func adjacentSlots(of slot: MealSlot, in plan: WeekPlan) -> [MealSlot] {
|
||||
var result: [MealSlot] = []
|
||||
if slot.dayOfWeek > 0,
|
||||
|
||||
@@ -60,6 +60,50 @@ final class CalendarService {
|
||||
}
|
||||
}
|
||||
|
||||
func updateEvent(
|
||||
eventId: String,
|
||||
slot: MealSlot,
|
||||
dishName: String,
|
||||
dishDescription: String?,
|
||||
weekStartDate: Date,
|
||||
settings: AppSettings
|
||||
) -> String? {
|
||||
guard let event = eventStore.event(withIdentifier: eventId) else {
|
||||
// Event was deleted externally — create a fresh one
|
||||
return createEvent(
|
||||
slot: slot,
|
||||
dishName: dishName,
|
||||
dishDescription: dishDescription,
|
||||
weekStartDate: weekStartDate,
|
||||
settings: settings
|
||||
)
|
||||
}
|
||||
|
||||
let prefix = settings.eventPrefix.isEmpty ? "" : "\(settings.eventPrefix) "
|
||||
let mealNameKey = slot.mealType == "lunch" ? "lunch" : "dinner"
|
||||
let mealName = localizedString(mealNameKey, language: settings.languageEnum.resolved())
|
||||
event.title = "\(prefix)\(mealName): \(dishName)"
|
||||
|
||||
let slotDate = weekStartDate.addingDays(slot.dayOfWeek)
|
||||
let slotTime = slot.mealType == "lunch" ? settings.lunchTime : settings.dinnerTime
|
||||
event.startDate = combineDateAndTime(date: slotDate, time: slotTime)
|
||||
event.endDate = event.startDate.addingMinutes(settings.eventDuration)
|
||||
event.notes = dishDescription
|
||||
|
||||
event.alarms?.forEach { event.removeAlarm($0) }
|
||||
if let reminder = settings.reminderMinutesBefore {
|
||||
event.addAlarm(EKAlarm(relativeOffset: -TimeInterval(reminder * 60)))
|
||||
}
|
||||
|
||||
do {
|
||||
try eventStore.save(event, span: .thisEvent)
|
||||
return event.eventIdentifier
|
||||
} catch {
|
||||
print("Error updating event: \(error)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func deleteEvent(eventId: String) {
|
||||
guard let event = eventStore.event(withIdentifier: eventId) else { return }
|
||||
do {
|
||||
|
||||
Reference in New Issue
Block a user