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:
alexandrev-tibco
2026-04-19 11:35:50 +02:00
parent c66132f494
commit 6c7e12b41f
25 changed files with 659 additions and 49 deletions
+2 -2
View File
@@ -15,9 +15,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<string>1.0.4</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<string>21</string>
<key>GADApplicationIdentifier</key>
<string>ca-app-pub-1549720748100858~9985112590</string>
<key>LSRequiresIPhoneOS</key>
@@ -38,7 +38,8 @@
/* First Dishes */
"first_dishes_title" = "Schritt 4: Füge deine Lieblingsgerichte hinzu";
"first_dishes_subtitle" = "Füge mindestens 2 Gerichte hinzu, um zu starten";
"first_dishes_subtitle" = "Füge einige Gerichte hinzu, um schneller zu starten.";
"first_dishes_optional_hint" = "Optional — du kannst jederzeit weitere Gerichte in der App hinzufügen.";
"first_dishes_name_placeholder" = "Gerichtsname";
"first_dishes_add_tag" = "Tag hinzufügen";
"first_dishes_add_dish" = "Gericht hinzufügen";
@@ -104,6 +105,7 @@
"dish_delete_title" = "Dieses Gericht löschen?";
"dish_delete_message" = "Diese Aktion kann nicht rückgängig gemacht werden";
"dish_cancel" = "Abbrechen";
"dish_done" = "Fertig";
"dish_delete_blocked_title" = "Löschen nicht möglich";
"dish_delete_blocked_message" = "Du kannst kein Gericht löschen, das der aktuellen Woche zugewiesen ist.";
"dish_delete_blocked_ok" = "OK";
@@ -284,3 +286,15 @@
"reminder_120" = "2 Stunden vorher";
"first_dishes_limit_reached" = "Du hast 10 Gerichte erreicht. Tippe auf Fertig, wenn du bereit bist.";
// Social share
"share_instagram_subtitle" = "Als Story-Hintergrund teilen";
"share_copy_image" = "Bild kopieren";
"share_copy_subtitle" = "In WhatsApp, Telegram einfügen…";
"share_more_options" = "Mehr Optionen";
"share_more_subtitle" = "In jeder App teilen";
"share_export_sheet_title" = "Teilen";
"share_copied" = "Bild kopiert!";
// Onboarding preview
"onboarding_welcome_preview_label" = "So könnte deine Woche aussehen ✨";
@@ -38,7 +38,8 @@
/* First Dishes */
"first_dishes_title" = "Step 4: Add your go-to dishes";
"first_dishes_subtitle" = "Add at least 2 dishes to get started";
"first_dishes_subtitle" = "Add a few dishes to get started faster.";
"first_dishes_optional_hint" = "Optional — you can add more dishes anytime from the app.";
"first_dishes_name_placeholder" = "Dish name";
"first_dishes_add_tag" = "Add tag";
"first_dishes_add_dish" = "Add dish";
@@ -104,6 +105,7 @@
"dish_delete_title" = "Delete this dish?";
"dish_delete_message" = "This action cannot be undone";
"dish_cancel" = "Cancel";
"dish_done" = "Done";
"dish_delete_blocked_title" = "Cannot delete";
"dish_delete_blocked_message" = "You cannot delete a dish assigned in the current week.";
"dish_delete_blocked_ok" = "OK";
@@ -284,3 +286,15 @@
"reminder_120" = "2 hours before";
"first_dishes_limit_reached" = "You reached 10 dishes. Tap Finish when you are ready.";
// Social share
"share_instagram_subtitle" = "Share as a Story background";
"share_copy_image" = "Copy Image";
"share_copy_subtitle" = "Paste in WhatsApp, Telegram…";
"share_more_options" = "More options";
"share_more_subtitle" = "Share to any app";
"share_export_sheet_title" = "Share";
"share_copied" = "Image copied!";
// Onboarding preview
"onboarding_welcome_preview_label" = "Your week could look like this ✨";
@@ -38,7 +38,8 @@
/* First Dishes */
"first_dishes_title" = "Paso 4: Añade tus platos habituales";
"first_dishes_subtitle" = "Añade al menos 2 platos para empezar";
"first_dishes_subtitle" = "Añade algunos platos para empezar más rápido.";
"first_dishes_optional_hint" = "Opcional — puedes añadir más platos desde la app en cualquier momento.";
"first_dishes_name_placeholder" = "Nombre del plato";
"first_dishes_add_tag" = "Añadir etiqueta";
"first_dishes_add_dish" = "Añadir plato";
@@ -104,6 +105,7 @@
"dish_delete_title" = "¿Eliminar este plato?";
"dish_delete_message" = "Esta acción no se puede deshacer";
"dish_cancel" = "Cancelar";
"dish_done" = "Hecho";
"dish_delete_blocked_title" = "No se puede eliminar";
"dish_delete_blocked_message" = "No puedes eliminar un plato asignado en la semana actual.";
"dish_delete_blocked_ok" = "Entendido";
@@ -284,3 +286,15 @@
"reminder_120" = "2 horas antes";
"first_dishes_limit_reached" = "Has llegado a 10 platos. Pulsa Finalizar cuando quieras.";
// Social share
"share_instagram_subtitle" = "Compartir como fondo de historia";
"share_copy_image" = "Copiar imagen";
"share_copy_subtitle" = "Pega en WhatsApp, Telegram…";
"share_more_options" = "Más opciones";
"share_more_subtitle" = "Compartir con cualquier app";
"share_export_sheet_title" = "Compartir";
"share_copied" = "¡Imagen copiada!";
// Onboarding preview
"onboarding_welcome_preview_label" = "Tu semana puede quedar así ✨";
@@ -38,7 +38,8 @@
/* First Dishes */
"first_dishes_title" = "Étape 4 : Ajoutez vos plats favoris";
"first_dishes_subtitle" = "Ajoutez au moins 2 plats pour commencer";
"first_dishes_subtitle" = "Ajoutez quelques plats pour démarrer plus vite.";
"first_dishes_optional_hint" = "Optionnel — vous pouvez en ajouter d'autres depuis l'app.";
"first_dishes_name_placeholder" = "Nom du plat";
"first_dishes_add_tag" = "Ajouter un tag";
"first_dishes_add_dish" = "Ajouter un plat";
@@ -104,6 +105,7 @@
"dish_delete_title" = "Supprimer ce plat ?";
"dish_delete_message" = "Cette action est irréversible";
"dish_cancel" = "Annuler";
"dish_done" = "Terminé";
"dish_delete_blocked_title" = "Suppression impossible";
"dish_delete_blocked_message" = "Vous ne pouvez pas supprimer un plat assigné à la semaine en cours.";
"dish_delete_blocked_ok" = "OK";
@@ -284,3 +286,15 @@
"reminder_120" = "2 heures avant";
"first_dishes_limit_reached" = "Vous avez atteint 10 plats. Touchez Terminer quand vous êtes prêt.";
// Social share
"share_instagram_subtitle" = "Partager comme fond de Story";
"share_copy_image" = "Copier l'image";
"share_copy_subtitle" = "Coller dans WhatsApp, Telegram…";
"share_more_options" = "Plus d'options";
"share_more_subtitle" = "Partager dans n'importe quelle app";
"share_export_sheet_title" = "Partager";
"share_copied" = "Image copiée !";
// Onboarding preview
"onboarding_welcome_preview_label" = "Votre semaine pourrait ressembler à ça ✨";
@@ -38,7 +38,8 @@
/* First Dishes */
"first_dishes_title" = "Passo 4: Aggiungi i tuoi piatti preferiti";
"first_dishes_subtitle" = "Aggiungi almeno 2 piatti per iniziare";
"first_dishes_subtitle" = "Aggiungi alcuni piatti per iniziare più velocemente.";
"first_dishes_optional_hint" = "Opzionale — puoi aggiungerne altri dall'app in qualsiasi momento.";
"first_dishes_name_placeholder" = "Nome del piatto";
"first_dishes_add_tag" = "Aggiungi tag";
"first_dishes_add_dish" = "Aggiungi piatto";
@@ -104,6 +105,7 @@
"dish_delete_title" = "Eliminare questo piatto?";
"dish_delete_message" = "Questa azione non può essere annullata";
"dish_cancel" = "Annulla";
"dish_done" = "Fatto";
"dish_delete_blocked_title" = "Impossibile eliminare";
"dish_delete_blocked_message" = "Non puoi eliminare un piatto assegnato alla settimana corrente.";
"dish_delete_blocked_ok" = "OK";
@@ -284,3 +286,15 @@
"reminder_120" = "2 ore prima";
"first_dishes_limit_reached" = "Hai raggiunto 10 piatti. Tocca Fine quando sei pronto.";
// Social share
"share_instagram_subtitle" = "Condividi come sfondo di una Story";
"share_copy_image" = "Copia immagine";
"share_copy_subtitle" = "Incolla in WhatsApp, Telegram…";
"share_more_options" = "Altre opzioni";
"share_more_subtitle" = "Condividi con qualsiasi app";
"share_export_sheet_title" = "Condividi";
"share_copied" = "Immagine copiata!";
// Onboarding preview
"onboarding_welcome_preview_label" = "La tua settimana potrebbe essere così ✨";
@@ -38,7 +38,8 @@
/* First Dishes */
"first_dishes_title" = "Etapa 4: Adicione seus pratos favoritos";
"first_dishes_subtitle" = "Adicione pelo menos 2 pratos para começar";
"first_dishes_subtitle" = "Adicione alguns pratos para começar mais rápido.";
"first_dishes_optional_hint" = "Opcional — você pode adicionar mais pratos pelo app a qualquer momento.";
"first_dishes_name_placeholder" = "Nome do prato";
"first_dishes_add_tag" = "Adicionar tag";
"first_dishes_add_dish" = "Adicionar prato";
@@ -104,6 +105,7 @@
"dish_delete_title" = "Excluir este prato?";
"dish_delete_message" = "Essa ação não pode ser desfeita";
"dish_cancel" = "Cancelar";
"dish_done" = "Concluído";
"dish_delete_blocked_title" = "Não é possível excluir";
"dish_delete_blocked_message" = "Você não pode excluir um prato atribuído à semana atual.";
"dish_delete_blocked_ok" = "OK";
@@ -284,3 +286,15 @@
"reminder_120" = "2 horas antes";
"first_dishes_limit_reached" = "Você chegou a 10 pratos. Toque em Concluir quando estiver pronto.";
// Social share
"share_instagram_subtitle" = "Compartilhar como fundo de Story";
"share_copy_image" = "Copiar imagem";
"share_copy_subtitle" = "Cole no WhatsApp, Telegram…";
"share_more_options" = "Mais opções";
"share_more_subtitle" = "Compartilhar com qualquer app";
"share_export_sheet_title" = "Compartilhar";
"share_copied" = "Imagem copiada!";
// Onboarding preview
"onboarding_welcome_preview_label" = "Sua semana pode ficar assim ✨";
+106
View File
@@ -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])
}
}
+4 -21
View File
@@ -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,
+44
View File
@@ -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 {
+2
View File
@@ -47,6 +47,7 @@ final class DishViewModel: ObservableObject {
tagIds: Array(selectedTagIds)
)
context.insert(dish)
AnalyticsService.logDishAdded(tagCount: selectedTagIds.count)
}
try? context.save()
@@ -63,6 +64,7 @@ final class DishViewModel: ObservableObject {
context.delete(dish)
try? context.save()
AnalyticsService.logDishDeleted()
reset()
return true
}
+28 -11
View File
@@ -91,6 +91,7 @@ final class HomeViewModel: ObservableObject {
plan.updatedAt = Date()
applyCalendarSyncPolicy(plan: plan, settings: settings)
AnalyticsService.logMealAssigned()
HapticManager.shared.notification(type: .success)
showToastMessage(localizedString("toast_dish_assigned", language: settings.languageEnum.resolved()))
@@ -107,6 +108,7 @@ final class HomeViewModel: ObservableObject {
slot.isRuleOverridden = false
plan.updatedAt = Date()
applyCalendarSyncPolicy(plan: plan, settings: settings, shouldNotify: false)
AnalyticsService.logMealCleared()
HapticManager.shared.notification(type: .warning)
}
@@ -124,6 +126,7 @@ final class HomeViewModel: ObservableObject {
plan.updatedAt = Date()
applyCalendarSyncPolicy(plan: plan, settings: settings, shouldNotify: false)
AnalyticsService.logAutoAssignUsed(filledSlots: result.filledSlots.count, unfilledSlots: result.unfilledCount)
Task {
for _ in result.filledSlots {
@@ -159,6 +162,7 @@ final class HomeViewModel: ObservableObject {
slot.isRuleOverridden = false
}
plan.updatedAt = Date()
AnalyticsService.logWeekReset()
HapticManager.shared.notification(type: .warning)
showToastMessage(localizedString("toast_week_reset", language: settings.languageEnum.resolved()))
}
@@ -378,19 +382,32 @@ final class HomeViewModel: ObservableObject {
private func syncAllAssignedSlotsToCalendar(plan: WeekPlan, dishes: [Dish], settings: AppSettings) {
let dishById = Dictionary(uniqueKeysWithValues: dishes.map { ($0.id, $0) })
for slot in plan.slots {
if let oldEventId = slot.calendarEventId {
CalendarService.shared.deleteEvent(eventId: oldEventId)
slot.calendarEventId = nil
guard let dishId = slot.dishId, let dish = dishById[dishId] else {
if let eventId = slot.calendarEventId {
CalendarService.shared.deleteEvent(eventId: eventId)
slot.calendarEventId = nil
}
continue
}
guard let dishId = slot.dishId, let dish = dishById[dishId] else { continue }
slot.calendarEventId = CalendarService.shared.createEvent(
slot: slot,
dishName: dish.name,
dishDescription: dish.descriptionText,
weekStartDate: currentWeekStart,
settings: settings
)
if let existingEventId = slot.calendarEventId {
slot.calendarEventId = CalendarService.shared.updateEvent(
eventId: existingEventId,
slot: slot,
dishName: dish.name,
dishDescription: dish.descriptionText,
weekStartDate: plan.weekStartDate,
settings: settings
)
} else {
slot.calendarEventId = CalendarService.shared.createEvent(
slot: slot,
dishName: dish.name,
dishDescription: dish.descriptionText,
weekStartDate: plan.weekStartDate,
settings: settings
)
}
}
}
@@ -35,7 +35,7 @@ final class OnboardingViewModel: ObservableObject {
case 1: return true // Meal windows (always has selection)
case 2: return true // Weekends (toggle)
case 3: return true // Calendar (optional)
case 4: return addedDishes.count >= 2 // Need at least 2 dishes
case 4: return true // Dishes are optional
default: return false
}
}
@@ -151,6 +151,7 @@ final class OnboardingViewModel: ObservableObject {
do {
try context.save()
AnalyticsService.logOnboardingCompleted()
UserDefaults.standard.set(true, forKey: Self.pendingAutoAssignPromptKey)
UserDefaults.standard.set(true, forKey: Self.pendingPremiumPromptKey)
return true
+26 -2
View File
@@ -10,6 +10,7 @@ struct DishFormView: View {
@StateObject private var viewModel = DishViewModel()
@State private var showPremium = false
@State private var showDishLimitUpsell = false
@State private var showSavedFeedback = false
var dish: Dish?
@@ -30,6 +31,20 @@ struct DishFormView: View {
ScrollView {
VStack(spacing: 20) {
if showSavedFeedback {
HStack(spacing: 8) {
Image(systemName: "checkmark.circle.fill")
Text("toast_dish_saved")
}
.font(.mealMoodSmall)
.foregroundColor(.mealMoodSuccess)
.padding(.vertical, 10)
.frame(maxWidth: .infinity)
.background(Color.mealMoodSuccess.opacity(0.1))
.cornerRadius(10)
.transition(.opacity.combined(with: .move(edge: .top)))
}
// Name field
VStack(alignment: .leading, spacing: 8) {
Text("dish_name_label")
@@ -150,7 +165,7 @@ struct DishFormView: View {
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("dish_cancel") { dismiss() }
Button(viewModel.isEditing ? "dish_cancel" : "dish_done") { dismiss() }
.foregroundColor(.mealMoodTextSecondary)
}
ToolbarItem(placement: .navigationBarTrailing) {
@@ -163,8 +178,17 @@ struct DishFormView: View {
HapticManager.shared.notification(type: .warning)
return
}
let wasEditing = viewModel.isEditing
viewModel.save(context: context)
dismiss()
if wasEditing {
dismiss()
} else {
withAnimation { showSavedFeedback = true }
HapticManager.shared.notification(type: .success)
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
withAnimation { showSavedFeedback = false }
}
}
} label: {
Image(systemName: "checkmark")
.fontWeight(.semibold)
@@ -51,6 +51,13 @@ struct FirstDishesStepView: View {
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 24)
Text("first_dishes_optional_hint")
.font(.mealMoodCaption)
.foregroundColor(.mealMoodCoral.opacity(0.8))
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 24)
.padding(.top, -16)
// New dish form
VStack(spacing: 12) {
TextField("first_dishes_name_placeholder", text: $viewModel.newDishName)
@@ -232,7 +239,7 @@ struct FirstDishesStepView: View {
HapticManager.shared.notification(type: .success)
onFinish()
},
isEnabled: viewModel.addedDishes.count >= 2
isEnabled: true
)
.padding(.horizontal, 24)
.padding(.top, 12)
@@ -79,6 +79,10 @@ struct OnboardingView: View {
.onAppear {
ensureDefaultTagsIfNeeded()
Task { await restoreFromICloudAndFinishIfNeeded() }
AnalyticsService.logOnboardingStepViewed(step: 0)
}
.onChange(of: viewModel.currentStep) { _, newStep in
AnalyticsService.logOnboardingStepViewed(step: newStep)
}
}