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
+29 -3
View File
@@ -7,6 +7,14 @@ struct AutocompleteEngine {
let unfilledCount: Int
}
struct RuleViolation {
let slotId: UUID
let dayOfWeek: Int
let mealType: String
let dishId: UUID
let dishName: String
}
static func autocomplete(
emptySlots: [MealSlot],
currentPlan: WeekPlan,
@@ -18,9 +26,9 @@ struct AutocompleteEngine {
var filledSlots: [(slotId: UUID, dishId: UUID)] = []
var unfilledCount = 0
let sortedEmpty = emptySlots.sorted {
($0.dayOfWeek, $0.mealType) < ($1.dayOfWeek, $1.mealType)
}
let sortedEmpty = emptySlots
.filter { !$0.isEatingOut }
.sorted { ($0.dayOfWeek, $0.mealType) < ($1.dayOfWeek, $1.mealType) }
for slot in sortedEmpty {
let strictCandidates = allDishes.filter { dish in
@@ -66,6 +74,24 @@ struct AutocompleteEngine {
return AutocompleteResult(filledSlots: filledSlots, unfilledCount: unfilledCount)
}
static func findViolations(plan: WeekPlan, allDishes: [Dish], allTags: [Tag]) -> [RuleViolation] {
let tagMap = Dictionary(uniqueKeysWithValues: allTags.map { ($0.id, $0) })
let dishMap = Dictionary(uniqueKeysWithValues: allDishes.map { ($0.id, $0) })
return plan.slots.compactMap { slot in
guard let dishId = slot.dishId,
let dish = dishMap[dishId],
slot.isRuleOverridden,
!slot.isEatingOut else { return nil }
return RuleViolation(
slotId: slot.id,
dayOfWeek: slot.dayOfWeek,
mealType: slot.mealType,
dishId: dishId,
dishName: dish.name
)
}
}
static func validateDrop(
dish: Dish,
slot: MealSlot,
@@ -0,0 +1,26 @@
import FirebaseCrashlytics
enum CrashlyticsService {
static func setUserProperties(isPremium: Bool) {
Crashlytics.crashlytics().setCustomValue(isPremium, forKey: "is_premium")
}
static func log(_ message: String) {
Crashlytics.crashlytics().log(message)
}
static func record(_ error: Error, context: String? = nil) {
var userInfo: [String: Any] = [:]
if let context { userInfo["context"] = context }
Crashlytics.crashlytics().record(error: error, userInfo: userInfo)
}
static func record(message: String, context: String? = nil) {
let userInfo: [String: Any] = context.map { ["context": $0] } ?? [:]
Crashlytics.crashlytics().record(
exceptionModel: ExceptionModel(name: "AppError", reason: message),
userInfo: userInfo
)
}
}
+5 -3
View File
@@ -71,7 +71,6 @@ final class ICloudSyncService {
reminderMinutesBefore: $0.reminderMinutesBefore,
iCloudSyncEnabled: $0.iCloudSyncEnabled,
weekExportStyle: $0.weekExportStyle,
isPremium: $0.isPremium,
onboardingCompleted: $0.onboardingCompleted
)
},
@@ -153,7 +152,9 @@ final class ICloudSyncService {
settings.reminderMinutesBefore = settingsPayload.reminderMinutesBefore
settings.iCloudSyncEnabled = settingsPayload.iCloudSyncEnabled
settings.weekExportStyle = settingsPayload.weekExportStyle
settings.isPremium = settingsPayload.isPremium
// isPremium is intentionally NOT synced via iCloud.
// It is determined exclusively by StoreKit to avoid stale state
// overwriting a freshly completed purchase.
settings.onboardingCompleted = settingsPayload.onboardingCompleted
context.insert(settings)
}
@@ -236,8 +237,9 @@ private struct SettingsPayload: Codable {
let reminderMinutesBefore: Int?
let iCloudSyncEnabled: Bool?
let weekExportStyle: String?
let isPremium: Bool
let onboardingCompleted: Bool
// isPremium is intentionally omitted determined by StoreKit only, never by sync.
// Old payloads in iCloud may contain this key; it is safely ignored by the decoder.
}
private struct TagPayload: Codable {
@@ -0,0 +1,36 @@
import Foundation
/// Encapsulates the premium status sync state machine.
/// Extracted from ContentView to be independently testable.
@MainActor
final class PremiumSyncService {
/// Injectable for testing defaults to real StoreKit check.
var hasActiveSubscription: () async -> Bool = {
await StoreManager.hasActiveSubscription()
}
/// Determine whether the user should be premium after a sync.
///
/// Rules:
/// - Active subscription found always return `true` (upgrade).
/// - No subscription, currently free return `false`.
/// - No subscription, currently premium, **cold launch** return `false`
/// (subscription likely expired between sessions).
/// - No subscription, currently premium, **foreground activation** return `true`
/// (StoreKit `currentEntitlements` can return empty for a few seconds immediately
/// after a purchase while the receipt propagates; never downgrade mid-session).
func sync(currentIsPremium: Bool, isColdLaunch: Bool) async -> Bool {
let active = await hasActiveSubscription()
switch (active, currentIsPremium, isColdLaunch) {
case (true, _, _):
return true
case (false, false, _):
return false
case (false, true, true):
return false // Subscription expired downgrade on cold launch
case (false, true, false):
return true // Keep premium on foreground (timing protection)
}
}
}
+16 -5
View File
@@ -77,6 +77,7 @@ final class StoreManager: ObservableObject {
if case .verified(let transaction) = verification {
await transaction.finish()
isPremium = true
AnalyticsService.logPremiumPurchased()
return .success
}
return .failed
@@ -99,14 +100,18 @@ final class StoreManager: ObservableObject {
do {
try await AppStore.sync()
let wasNotPremium = !isPremium
await checkPremiumStatus()
if wasNotPremium && isPremium {
AnalyticsService.logPremiumRestored()
}
} catch {
print("Restore failed: \(error)")
}
}
private func checkPremiumStatus() async {
isPremium = await Self.hasActiveSubscription(productIds: productIds)
isPremium = await Self.hasActiveSubscription()
}
var monthlyProduct: Product? {
@@ -133,12 +138,18 @@ final class StoreManager: ObservableObject {
var debugProductIds: [String] { productIds }
static func hasActiveSubscription(productIds: [String] = [
StoreManager.monthlyProductId
]) async -> Bool {
static func hasActiveSubscription() async -> Bool {
// Check all known product ID variants to handle both the canonical ID
// and any bundle-prefixed IDs that may have been used at purchase time.
var knownIds: [String] = [monthlyProductId]
if let bundleId = Bundle.main.bundleIdentifier {
knownIds.append("\(bundleId).premium.monthly")
}
let ids = Set(knownIds)
for await result in Transaction.currentEntitlements {
if case .verified(let transaction) = result,
productIds.contains(transaction.productID),
ids.contains(transaction.productID),
transaction.revocationDate == nil {
if let expirationDate = transaction.expirationDate, expirationDate < Date() {
continue
+55
View File
@@ -0,0 +1,55 @@
import Foundation
import WidgetKit
struct TodayMealData: Codable {
let lunch: String?
let dinner: String?
let lunchLabel: String
let dinnerLabel: String
let weekdayName: String
let updatedAt: Date
}
enum WidgetDataStore {
static let appGroupID = "group.com.alexandrevazquez.mealmood"
private static let key = "mealmood.today_meals"
static func update(plan: WeekPlan?, dishes: [Dish], settings: AppSettings) {
let today = Date()
let weekday = Calendar.current.component(.weekday, from: today)
// weekday: 1=Sun,2=Mon..7=Sat appDayOfWeek: 0=Mon..6=Sun
let appDayOfWeek = (weekday + 5) % 7
let lunchId = plan?.slots.first { $0.dayOfWeek == appDayOfWeek && $0.mealType == MealType.lunch.rawValue }?.dishId
let dinnerId = plan?.slots.first { $0.dayOfWeek == appDayOfWeek && $0.mealType == MealType.dinner.rawValue }?.dishId
let lunch = lunchId.flatMap { id in dishes.first { $0.id == id }?.name }
let dinner = dinnerId.flatMap { id in dishes.first { $0.id == id }?.name }
let locale = Locale(identifier: settings.languageEnum.resolved().localeIdentifier)
let formatter = DateFormatter()
formatter.locale = locale
formatter.dateFormat = "EEEE"
let weekdayName = formatter.string(from: today).capitalized(with: locale)
let data = TodayMealData(
lunch: lunch,
dinner: dinner,
lunchLabel: String(localized: "lunch"),
dinnerLabel: String(localized: "dinner"),
weekdayName: weekdayName,
updatedAt: today
)
guard let defaults = UserDefaults(suiteName: appGroupID),
let encoded = try? JSONEncoder().encode(data) else { return }
defaults.set(encoded, forKey: key)
WidgetCenter.shared.reloadAllTimelines()
}
static func read() -> TodayMealData? {
guard let defaults = UserDefaults(suiteName: appGroupID),
let data = defaults.data(forKey: key) else { return nil }
return try? JSONDecoder().decode(TodayMealData.self, from: data)
}
}