1.0.5: Eating out slots, rule violations panel, Crashlytics, search bar fix

- Eating out: tap any empty slot → "Mark as eating out"; shows teal indicator,
  skipped by auto-assign, counts as complete, tap again to unmark
- Rule violations panel: access via wand long-press context menu when conflicts exist;
  shows all isRuleOverridden slots with Fix (clear) or Ignore (acknowledge) actions
- Firebase Crashlytics integrated: CrashlyticsService + dSYM upload build phase,
  isPremium property tracked per session
- PremiumSyncService: extracted premium state machine, StoreKit Transaction.updates
  listener, isPremium no longer synced via iCloud to avoid stale state
- StoreManager: analytics on purchase/restore, bundle ID fallback for product ID lookup
- Search bar contrast bug fixed: TextField now has explicit foreground color for dark mode
- WelcomeStepView: redesigned onboarding welcome screen with week preview
- Version bump: 1.0.5 build 22

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
alexandrev-tibco
2026-04-27 09:48:20 +02:00
parent 6c7e12b41f
commit 110807d239
111 changed files with 3116 additions and 154 deletions
+193 -6
View File
@@ -29,8 +29,11 @@ struct HomeView: View {
@State private var pendingExportPlan: WeekPlan?
@State private var shareImageURL: URL?
@State private var showShareSheet: Bool = false
@State private var showViolationsPanel: Bool = false
private var settings: AppSettings? { allSettings.first }
private var isRunningOnMac: Bool { ProcessInfo.processInfo.isiOSAppOnMac }
private var contentHorizontalPadding: CGFloat { (isRunningOnMac || horizontalSizeClass == .regular) ? 14 : 0 }
var body: some View {
NavigationStack {
@@ -140,6 +143,18 @@ struct HomeView: View {
Label("home_copy_previous_week", systemImage: "doc.on.doc")
}
let violationCount = AutocompleteEngine.findViolations(plan: plan, allDishes: dishes, allTags: tags).count
if violationCount > 0 {
Button {
showViolationsPanel = true
} label: {
Label(
String(format: String(localized: "violations_badge"), violationCount),
systemImage: "exclamationmark.triangle.fill"
)
}
}
Button(role: .destructive) {
viewModel.showResetAlert = true
} label: {
@@ -165,6 +180,7 @@ struct HomeView: View {
}
}
.onAppear {
AnalyticsService.logScreenView("Home")
guard let settings = settings,
let plan = ensureCurrentWeekPlanExists(settings: settings) else { return }
viewModel.reconcileSlotsIfNeeded(plan: plan, settings: settings, context: context)
@@ -222,14 +238,14 @@ struct HomeView: View {
}
.frame(maxHeight: .infinity)
}
.frame(maxWidth: horizontalSizeClass == .regular ? 1160 : .infinity)
.frame(maxWidth: .infinity)
.frame(maxWidth: .infinity, alignment: .top)
.padding(.horizontal, horizontalSizeClass == .regular ? 14 : 0)
.padding(.horizontal, contentHorizontalPadding)
.padding(.bottom, 0)
}
.frame(maxHeight: .infinity, alignment: .top)
.safeAreaInset(edge: .bottom, spacing: 0) {
if !settings.isPremium {
if !settings.isPremium && !isRunningOnMac {
AdBannerView()
}
}
@@ -366,7 +382,6 @@ struct HomeView: View {
if isValid {
viewModel.assignDish(dish, to: freshSlot, plan: plan, settings: settings)
} else {
// In picker flow, assign anyway and mark as override so the action is never lost.
viewModel.assignDish(dish, to: freshSlot, plan: plan, settings: settings, isOverride: true)
}
selectedEmptySlotId = nil
@@ -376,6 +391,14 @@ struct HomeView: View {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
viewModel.showDishForm = true
}
},
onEatingOut: {
guard let freshSlot = plan.slots.first(where: { $0.id == slotId }) else {
selectedEmptySlotId = nil
return
}
selectedEmptySlotId = nil
viewModel.markEatingOut(slot: freshSlot, plan: plan, settings: settings)
}
)
}
@@ -405,9 +428,23 @@ struct HomeView: View {
}
.sheet(isPresented: $showShareSheet) {
if let shareImageURL {
ShareSheet(activityItems: [shareImageURL])
SocialShareSheet(imageURL: shareImageURL)
}
}
.sheet(isPresented: $showViolationsPanel) {
RuleViolationsPanelSheet(
plan: plan,
dishes: dishes,
tags: tags,
settings: settings,
onFix: { slot in
viewModel.removeDish(from: slot, plan: plan, settings: settings)
},
onIgnore: { slot in
viewModel.acknowledgeViolation(slot: slot, plan: plan)
}
)
}
.sheet(isPresented: $showMonthlyHistory) {
MonthlyHistoryView(
weekPlans: weekPlans,
@@ -427,6 +464,7 @@ struct HomeView: View {
)
wasWeekComplete = isWeekComplete(plan: plan)
evaluatePostOnboardingPromptsIfNeeded(plan: plan, settings: settings)
updateWidget(settings: settings)
}
.onChange(of: plan.updatedAt) { _, _ in
let nowComplete = isWeekComplete(plan: plan)
@@ -434,6 +472,7 @@ struct HomeView: View {
evaluateReviewPrompt()
}
wasWeekComplete = nowComplete
updateWidget(settings: settings)
}
}
@@ -442,6 +481,7 @@ struct HomeView: View {
let tags: [Tag]
let onPickDish: (Dish) -> Void
let onCreateDish: () -> Void
var onEatingOut: (() -> Void)? = nil
@Environment(\.dismiss) private var dismiss
@State private var searchText: String = ""
@@ -477,6 +517,18 @@ struct HomeView: View {
.padding(24)
} else {
List {
if let onEatingOut, searchText.isEmpty {
Button {
dismiss()
onEatingOut()
} label: {
Label("home_mark_eating_out", systemImage: "fork.knife.circle")
.font(.mealMoodBody)
.foregroundColor(Color(hex: "#6B9E90"))
}
.listRowBackground(Color(hex: "#EEF8F5"))
}
ForEach(filteredDishes, id: \.id) { dish in
Button {
onPickDish(dish)
@@ -596,7 +648,12 @@ struct HomeView: View {
}
private func isWeekComplete(plan: WeekPlan) -> Bool {
plan.slots.allSatisfy { $0.dishId != nil }
plan.slots.allSatisfy { $0.dishId != nil || $0.isEatingOut }
}
private func updateWidget(settings: AppSettings) {
let todayPlan = fetchWeekPlan(for: Date().startOfWeek())
WidgetDataStore.update(plan: todayPlan, dishes: dishes, settings: settings)
}
@ViewBuilder
@@ -678,6 +735,7 @@ struct HomeView: View {
}
shareImageURL = url
showShareSheet = true
AnalyticsService.logWeekPlanShared(format: settings.weekExportStyleEnum.rawValue)
}
private func renderWeekShareImage(plan: WeekPlan, settings: AppSettings) -> UIImage? {
@@ -777,6 +835,135 @@ struct HomeView: View {
}
}
private struct RuleViolationsPanelSheet: View {
let plan: WeekPlan
let dishes: [Dish]
let tags: [Tag]
let settings: AppSettings
let onFix: (MealSlot) -> Void
let onIgnore: (MealSlot) -> Void
@Environment(\.dismiss) private var dismiss
private var violations: [AutocompleteEngine.RuleViolation] {
AutocompleteEngine.findViolations(plan: plan, allDishes: dishes, allTags: tags)
}
var body: some View {
NavigationStack {
Group {
if violations.isEmpty {
VStack(spacing: 12) {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 44))
.foregroundColor(.mealMoodSuccess)
Text("violations_panel_empty")
.font(.mealMoodBody)
.foregroundColor(.mealMoodTextSecondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(24)
} else {
List {
ForEach(violations, id: \.slotId) { violation in
if let slot = plan.slots.first(where: { $0.id == violation.slotId }) {
ViolationRow(
violation: violation,
settings: settings,
onFix: {
onFix(slot)
if violations.count <= 1 { dismiss() }
},
onIgnore: {
onIgnore(slot)
if violations.count <= 1 { dismiss() }
}
)
.listRowBackground(Color.mealMoodSurface)
}
}
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
}
}
.background(Color.mealMoodBackground.ignoresSafeArea())
.navigationTitle("violations_panel_title")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("tag_selector_done") { dismiss() }
}
}
}
}
private struct ViolationRow: View {
let violation: AutocompleteEngine.RuleViolation
let settings: AppSettings
let onFix: () -> Void
let onIgnore: () -> Void
private var dayLabel: String {
let language = settings.languageEnum.resolved()
return localizedString(dayKey(for: violation.dayOfWeek), language: language)
}
private var mealLabel: String {
let language = settings.languageEnum.resolved()
return localizedString(violation.mealType, language: language)
}
var body: some View {
VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 6) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(.mealMoodWarning)
.font(.system(size: 14))
Text("\(dayLabel) · \(mealLabel)")
.font(.mealMoodSmall)
.foregroundColor(.mealMoodTextSecondary)
}
Text(violation.dishName)
.font(.mealMoodBodyBold)
.foregroundColor(.mealMoodTextPrimary)
HStack(spacing: 10) {
Button(action: onFix) {
Text("violations_fix")
.font(.mealMoodSmall)
.foregroundColor(.white)
.padding(.horizontal, 14)
.padding(.vertical, 6)
.background(Color.mealMoodCoral)
.clipShape(Capsule())
}
.buttonStyle(.plain)
Button(action: onIgnore) {
Text("violations_ignore")
.font(.mealMoodSmall)
.foregroundColor(.mealMoodTextSecondary)
.padding(.horizontal, 14)
.padding(.vertical, 6)
.background(Color(hex: "#F0F0F0"))
.clipShape(Capsule())
}
.buttonStyle(.plain)
}
}
.padding(.vertical, 8)
}
private func dayKey(for day: Int) -> String {
let keys = ["day_mon","day_tue","day_wed","day_thu","day_fri","day_sat","day_sun"]
guard day >= 0 && day < keys.count else { return "" }
return keys[day]
}
}
}
private struct ShareSheet: UIViewControllerRepresentable {
let activityItems: [Any]