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:
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user