Version casi lista

This commit is contained in:
alexandrev-tibco
2026-02-17 13:34:02 +01:00
commit e593453abd
99 changed files with 10867 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
enum AdMobConfig {
static let appId = "ca-app-pub-1549720748100858~9985112590"
static let bannerHomeUnitId = "ca-app-pub-1549720748100858/7693991173"
static let testBannerUnitId = "ca-app-pub-3940256099942544/2435281174"
static var resolvedBannerHomeUnitId: String {
#if DEBUG
// Always use Google's official test unit while debugging (simulator and device).
return testBannerUnitId
#else
return bannerHomeUnitId
#endif
}
}
+196
View File
@@ -0,0 +1,196 @@
import Foundation
struct AutocompleteEngine {
struct AutocompleteResult {
let filledSlots: [(slotId: UUID, dishId: UUID)]
let unfilledCount: Int
}
static func autocomplete(
emptySlots: [MealSlot],
currentPlan: WeekPlan,
allDishes: [Dish],
allTags: [Tag]
) -> AutocompleteResult {
let tagMap = Dictionary(uniqueKeysWithValues: allTags.map { ($0.id, $0) })
let dishMap = Dictionary(uniqueKeysWithValues: allDishes.map { ($0.id, $0) })
var filledSlots: [(slotId: UUID, dishId: UUID)] = []
var unfilledCount = 0
let sortedEmpty = emptySlots.sorted {
($0.dayOfWeek, $0.mealType) < ($1.dayOfWeek, $1.mealType)
}
for slot in sortedEmpty {
let strictCandidates = allDishes.filter { dish in
!violatesRules(
dish: dish,
slot: slot,
plan: currentPlan,
tagMap: tagMap,
dishMap: dishMap
)
}
// Fallback: if strict rules cannot fill a slot, allow repeating dishes
// that only fail the implicit "no repeat this week" rule.
let candidates: [Dish]
if strictCandidates.isEmpty {
candidates = allDishes.filter { dish in
!violatesExplicitRules(
dish: dish,
slot: slot,
plan: currentPlan,
tagMap: tagMap,
dishMap: dishMap
)
}
} else {
candidates = strictCandidates
}
if candidates.isEmpty {
unfilledCount += 1
continue
}
if let picked = weightedRandomPick(candidates: candidates, plan: currentPlan) {
slot.dishId = picked.id
filledSlots.append((slotId: slot.id, dishId: picked.id))
} else {
unfilledCount += 1
}
}
return AutocompleteResult(filledSlots: filledSlots, unfilledCount: unfilledCount)
}
static func validateDrop(
dish: Dish,
slot: MealSlot,
plan: WeekPlan,
allTags: [Tag],
allDishes: [Dish]
) -> Bool {
let tagMap = Dictionary(uniqueKeysWithValues: allTags.map { ($0.id, $0) })
let dishMap = Dictionary(uniqueKeysWithValues: allDishes.map { ($0.id, $0) })
return !violatesRules(dish: dish, slot: slot, plan: plan, tagMap: tagMap, dishMap: dishMap)
}
private static func violatesRules(
dish: Dish,
slot: MealSlot,
plan: WeekPlan,
tagMap: [UUID: Tag],
dishMap: [UUID: Dish]
) -> Bool {
violatesDefaultNoRepeatRule(dish: dish, slot: slot, plan: plan, tagMap: tagMap) ||
violatesExplicitRules(dish: dish, slot: slot, plan: plan, tagMap: tagMap, dishMap: dishMap)
}
private static func violatesDefaultNoRepeatRule(
dish: Dish,
slot: MealSlot,
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
}
private static func violatesExplicitRules(
dish: Dish,
slot: MealSlot,
plan: WeekPlan,
tagMap: [UUID: Tag],
dishMap: [UUID: Dish]
) -> Bool {
for tagId in dish.tagIds {
guard let tag = tagMap[tagId] else { continue }
// Max per week
if let maxPerWeek = tag.maxPerWeek {
var count = 0
for s in plan.slots {
guard let did = s.dishId, let d = dishMap[did] else { continue }
if d.tagIds.contains(tagId) { count += 1 }
}
if count >= maxPerWeek { return true }
}
// No consecutive
if tag.noConsecutive {
for adjSlot in adjacentSlots(of: slot, in: plan) {
guard let did = adjSlot.dishId, let d = dishMap[did] else { continue }
if d.tagIds.contains(tagId) { return true }
}
}
// No duplicate in day
if tag.noDuplicateInDay {
for sdSlot in plan.slots where sdSlot.dayOfWeek == slot.dayOfWeek && sdSlot.id != slot.id {
guard let did = sdSlot.dishId, let d = dishMap[did] else { continue }
if d.tagIds.contains(tagId) { return true }
}
}
// Meal type restriction
if let restriction = tag.mealTypeRestriction, slot.mealType != restriction {
return true
}
}
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,
let prev = plan.slots.first(where: { $0.dayOfWeek == slot.dayOfWeek - 1 && $0.mealType == slot.mealType }) {
result.append(prev)
}
if slot.dayOfWeek < 6,
let next = plan.slots.first(where: { $0.dayOfWeek == slot.dayOfWeek + 1 && $0.mealType == slot.mealType }) {
result.append(next)
}
return result
}
private static func weightedRandomPick(candidates: [Dish], plan: WeekPlan) -> Dish? {
guard !candidates.isEmpty else { return nil }
let weights: [Double] = candidates.map { dish in
let usage = plan.slots.filter { $0.dishId == dish.id }.count
switch usage {
case 0: return 3.0
case 1: return 2.0
default: return 1.0
}
}
let total = weights.reduce(0, +)
var r = Double.random(in: 0..<total)
for (i, w) in weights.enumerated() {
r -= w
if r <= 0 { return candidates[i] }
}
return candidates.last
}
}
+84
View File
@@ -0,0 +1,84 @@
import EventKit
import Foundation
@MainActor
final class CalendarService {
static let shared = CalendarService()
private let eventStore = EKEventStore()
private init() {}
func requestAccess() async -> Bool {
do {
return try await eventStore.requestFullAccessToEvents()
} catch {
print("Calendar access error: \(error)")
return false
}
}
func availableCalendars() -> [EKCalendar] {
eventStore.calendars(for: .event)
}
func createEvent(
slot: MealSlot,
dishName: String,
dishDescription: String?,
weekStartDate: Date,
settings: AppSettings
) -> String? {
guard let calendarId = settings.calendarId,
let calendar = eventStore.calendar(withIdentifier: calendarId) else { return nil }
let event = EKEvent(eventStore: eventStore)
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.calendar = calendar
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 saving event: \(error)")
return nil
}
}
func deleteEvent(eventId: String) {
guard let event = eventStore.event(withIdentifier: eventId) else { return }
do {
try eventStore.remove(event, span: .thisEvent)
} catch {
print("Error deleting event: \(error)")
}
}
func updateEventsTime(slots: [MealSlot], weekStartDate: Date, settings: AppSettings) {
for slot in slots {
guard let eventId = slot.calendarEventId,
let event = eventStore.event(withIdentifier: eventId),
event.startDate >= Date() else { continue }
let slotDate = weekStartDate.addingDays(slot.dayOfWeek)
let newTime = slot.mealType == "lunch" ? settings.lunchTime : settings.dinnerTime
event.startDate = combineDateAndTime(date: slotDate, time: newTime)
event.endDate = event.startDate.addingMinutes(settings.eventDuration)
try? eventStore.save(event, span: .thisEvent)
}
}
}
@@ -0,0 +1,69 @@
import Foundation
import SwiftData
struct DefaultDataService {
static func createDefaultTags(context: ModelContext) {
let defaults: [(name: String, nameEN: String, color: String, maxPerWeek: Int?, noConsecutive: Bool, noDuplicateInDay: Bool, mealTypeRestriction: String?, sortOrder: Int)] = [
("Carne", "Meat", "#E74C3C", 3, true, true, nil, 0),
("Pescado", "Fish", "#3498DB", 2, true, true, nil, 1),
("Legumbres", "Legumes", "#95A5A6", 2, false, true, nil, 2),
("Verduras", "Vegetables", "#2ECC71", nil, false, false, nil, 3),
("Huevos", "Eggs", "#F1C40F", 2, false, true, nil, 4),
("Pasta/Arroz", "Pasta/Rice", "#D4AC6E", 3, true, false, nil, 5),
("Solo Cena", "Dinner Only", "#9B59B6", nil, false, false, "dinner", 6),
("Solo Comida", "Lunch Only", "#E67E22", nil, false, false, "lunch", 7),
("Con Gluten", "Contains Gluten", "#8B4513", nil, false, false, nil, 8),
("Sin Gluten", "Gluten Free", "#1ABC9C", nil, false, false, nil, 9)
]
for d in defaults {
let tag = Tag(
name: d.name,
nameEN: d.nameEN,
color: d.color,
maxPerWeek: d.maxPerWeek,
noConsecutive: d.noConsecutive,
noDuplicateInDay: d.noDuplicateInDay,
mealTypeRestriction: d.mealTypeRestriction,
isDefault: true,
sortOrder: d.sortOrder
)
context.insert(tag)
}
try? context.save()
}
static func createDefaultSettings(context: ModelContext) {
let settings = AppSettings()
context.insert(settings)
try? context.save()
}
static func createWeekPlan(for weekStart: Date, settings: AppSettings, context: ModelContext) -> WeekPlan {
let plan = WeekPlan(weekStartDate: weekStart)
context.insert(plan)
let maxDay = settings.includeWeekends ? 6 : 4
let mealTypes: [String] = {
switch settings.mealWindowsEnum {
case .dinnerOnly: return ["dinner"]
case .lunchOnly: return ["lunch"]
case .both: return ["lunch", "dinner"]
}
}()
for day in 0...maxDay {
for mealType in mealTypes {
let slot = MealSlot(dayOfWeek: day, mealType: mealType)
slot.weekPlan = plan
plan.slots.append(slot)
context.insert(slot)
}
}
try? context.save()
return plan
}
}
+25
View File
@@ -0,0 +1,25 @@
import UIKit
@MainActor
final class HapticManager {
static let shared = HapticManager()
private init() {}
func impact(style: UIImpactFeedbackGenerator.FeedbackStyle) {
let generator = UIImpactFeedbackGenerator(style: style)
generator.prepare()
generator.impactOccurred()
}
func notification(type: UINotificationFeedbackGenerator.FeedbackType) {
let generator = UINotificationFeedbackGenerator()
generator.prepare()
generator.notificationOccurred(type)
}
func selection() {
let generator = UISelectionFeedbackGenerator()
generator.prepare()
generator.selectionChanged()
}
}
+276
View File
@@ -0,0 +1,276 @@
import Foundation
import SwiftData
@MainActor
final class ICloudSyncService {
static let shared = ICloudSyncService()
private let store = NSUbiquitousKeyValueStore.default
private let payloadKey = "mealmood_sync_payload_v1"
private let lastAppliedKey = "mealmood_sync_last_applied"
private var isApplyingRemote = false
private init() {}
func pullRemoteIfNeeded(context: ModelContext) async {
store.synchronize()
guard let data = store.data(forKey: payloadKey),
let snapshot = try? JSONDecoder().decode(SyncSnapshot.self, from: data) else {
return
}
let lastApplied = UserDefaults.standard.double(forKey: lastAppliedKey)
let remoteTs = snapshot.updatedAt.timeIntervalSince1970
if remoteTs <= lastApplied {
return
}
isApplyingRemote = true
defer { isApplyingRemote = false }
apply(snapshot: snapshot, context: context)
UserDefaults.standard.set(remoteTs, forKey: lastAppliedKey)
}
func pushLocalSnapshot(context: ModelContext) async {
if isApplyingRemote { return }
guard let snapshot = makeSnapshot(context: context) else { return }
guard let data = try? JSONEncoder().encode(snapshot) else { return }
store.set(data, forKey: payloadKey)
store.synchronize()
UserDefaults.standard.set(snapshot.updatedAt.timeIntervalSince1970, forKey: lastAppliedKey)
}
private func makeSnapshot(context: ModelContext) -> SyncSnapshot? {
let settings = (try? context.fetch(FetchDescriptor<AppSettings>()))?.first
let tags = (try? context.fetch(FetchDescriptor<Tag>())) ?? []
let dishes = (try? context.fetch(FetchDescriptor<Dish>())) ?? []
let plans = (try? context.fetch(FetchDescriptor<WeekPlan>())) ?? []
guard settings != nil || !tags.isEmpty || !dishes.isEmpty || !plans.isEmpty else {
return nil
}
let snapshot = SyncSnapshot(
updatedAt: Date(),
settings: settings.map {
SettingsPayload(
mealWindows: $0.mealWindows,
includeWeekends: $0.includeWeekends,
language: $0.language,
calendarId: $0.calendarId,
syncEnabled: $0.syncEnabled,
syncMode: $0.syncMode,
lunchTime: $0.lunchTime,
dinnerTime: $0.dinnerTime,
eventDuration: $0.eventDuration,
eventPrefix: $0.eventPrefix,
reminderMinutesBefore: $0.reminderMinutesBefore,
iCloudSyncEnabled: $0.iCloudSyncEnabled,
isPremium: $0.isPremium,
onboardingCompleted: $0.onboardingCompleted
)
},
tags: tags.map {
TagPayload(
id: $0.id,
name: $0.name,
nameEN: $0.nameEN,
color: $0.color,
maxPerWeek: $0.maxPerWeek,
noConsecutive: $0.noConsecutive,
noDuplicateInDay: $0.noDuplicateInDay,
mealTypeRestriction: $0.mealTypeRestriction,
isDefault: $0.isDefault,
sortOrder: $0.sortOrder
)
},
dishes: dishes.map {
DishPayload(
id: $0.id,
name: $0.name,
descriptionText: $0.descriptionText,
tagIds: $0.tagIds,
createdAt: $0.createdAt
)
},
weekPlans: plans.map { plan in
WeekPlanPayload(
id: plan.id,
weekStartDate: plan.weekStartDate,
createdAt: plan.createdAt,
updatedAt: plan.updatedAt,
slots: plan.slots.map {
MealSlotPayload(
id: $0.id,
dayOfWeek: $0.dayOfWeek,
mealType: $0.mealType,
dishId: $0.dishId,
calendarEventId: $0.calendarEventId,
isRuleOverridden: $0.isRuleOverridden
)
}
)
}
)
return snapshot
}
private func apply(snapshot: SyncSnapshot, context: ModelContext) {
guard snapshot.settings != nil || !snapshot.tags.isEmpty || !snapshot.dishes.isEmpty || !snapshot.weekPlans.isEmpty else {
return
}
let existingSlots = (try? context.fetch(FetchDescriptor<MealSlot>())) ?? []
let existingPlans = (try? context.fetch(FetchDescriptor<WeekPlan>())) ?? []
let existingDishes = (try? context.fetch(FetchDescriptor<Dish>())) ?? []
let existingTags = (try? context.fetch(FetchDescriptor<Tag>())) ?? []
let existingSettings = (try? context.fetch(FetchDescriptor<AppSettings>())) ?? []
existingSlots.forEach { context.delete($0) }
existingPlans.forEach { context.delete($0) }
existingDishes.forEach { context.delete($0) }
existingTags.forEach { context.delete($0) }
existingSettings.forEach { context.delete($0) }
if let settingsPayload = snapshot.settings {
let settings = AppSettings()
settings.mealWindows = settingsPayload.mealWindows
settings.includeWeekends = settingsPayload.includeWeekends
settings.language = settingsPayload.language
settings.calendarId = settingsPayload.calendarId
settings.syncEnabled = settingsPayload.syncEnabled
settings.syncMode = settingsPayload.syncMode
settings.lunchTime = settingsPayload.lunchTime
settings.dinnerTime = settingsPayload.dinnerTime
settings.eventDuration = settingsPayload.eventDuration
settings.eventPrefix = settingsPayload.eventPrefix
settings.reminderMinutesBefore = settingsPayload.reminderMinutesBefore
settings.iCloudSyncEnabled = settingsPayload.iCloudSyncEnabled
settings.isPremium = settingsPayload.isPremium
settings.onboardingCompleted = settingsPayload.onboardingCompleted
context.insert(settings)
}
snapshot.tags.forEach { payload in
let tag = Tag(
id: payload.id,
name: payload.name,
nameEN: payload.nameEN,
color: payload.color,
maxPerWeek: payload.maxPerWeek,
noConsecutive: payload.noConsecutive,
noDuplicateInDay: payload.noDuplicateInDay,
mealTypeRestriction: payload.mealTypeRestriction,
isDefault: payload.isDefault,
sortOrder: payload.sortOrder
)
context.insert(tag)
}
snapshot.dishes.forEach { payload in
let dish = Dish(
id: payload.id,
name: payload.name,
descriptionText: payload.descriptionText,
tagIds: payload.tagIds,
createdAt: payload.createdAt
)
context.insert(dish)
}
snapshot.weekPlans.forEach { payload in
let plan = WeekPlan(
id: payload.id,
weekStartDate: payload.weekStartDate,
slots: [],
createdAt: payload.createdAt,
updatedAt: payload.updatedAt
)
context.insert(plan)
payload.slots.forEach { slotPayload in
let slot = MealSlot(
id: slotPayload.id,
dayOfWeek: slotPayload.dayOfWeek,
mealType: slotPayload.mealType,
dishId: slotPayload.dishId,
calendarEventId: slotPayload.calendarEventId,
isRuleOverridden: slotPayload.isRuleOverridden
)
slot.weekPlan = plan
plan.slots.append(slot)
context.insert(slot)
}
}
try? context.save()
}
}
private struct SyncSnapshot: Codable {
let updatedAt: Date
let settings: SettingsPayload?
let tags: [TagPayload]
let dishes: [DishPayload]
let weekPlans: [WeekPlanPayload]
}
private struct SettingsPayload: Codable {
let mealWindows: String
let includeWeekends: Bool
let language: String
let calendarId: String?
let syncEnabled: Bool
let syncMode: String?
let lunchTime: Date
let dinnerTime: Date
let eventDuration: Int
let eventPrefix: String
let reminderMinutesBefore: Int?
let iCloudSyncEnabled: Bool?
let isPremium: Bool
let onboardingCompleted: Bool
}
private struct TagPayload: Codable {
let id: UUID
let name: String
let nameEN: String
let color: String
let maxPerWeek: Int?
let noConsecutive: Bool
let noDuplicateInDay: Bool
let mealTypeRestriction: String?
let isDefault: Bool
let sortOrder: Int
}
private struct DishPayload: Codable {
let id: UUID
let name: String
let descriptionText: String?
let tagIds: [UUID]
let createdAt: Date
}
private struct WeekPlanPayload: Codable {
let id: UUID
let weekStartDate: Date
let createdAt: Date
let updatedAt: Date
let slots: [MealSlotPayload]
}
private struct MealSlotPayload: Codable {
let id: UUID
let dayOfWeek: Int
let mealType: String
let dishId: UUID?
let calendarEventId: String?
let isRuleOverridden: Bool
}
@@ -0,0 +1,45 @@
import Foundation
import UserNotifications
@MainActor
final class NotificationService {
static let shared = NotificationService()
private init() {}
func requestPermissionIfNeeded() async {
let center = UNUserNotificationCenter.current()
let settings = await center.notificationSettings()
guard settings.authorizationStatus == .notDetermined else { return }
_ = try? await center.requestAuthorization(options: [.alert, .sound, .badge])
}
func schedulePlanningReminderIfNeeded(nextWeekPlan: WeekPlan?, language: AppLanguage) {
let center = UNUserNotificationCenter.current()
let identifier = "mealmood.next-week-planning"
let isComplete = nextWeekPlan?.slots.allSatisfy { $0.dishId != nil } ?? false
if isComplete {
center.removePendingNotificationRequests(withIdentifiers: [identifier])
return
}
let nextWeekStart = Date().startOfWeek().addingDays(7)
let reminderDay = nextWeekStart.addingDays(-1)
let calendar = Calendar.current
var components = calendar.dateComponents([.year, .month, .day], from: reminderDay)
components.hour = 19
components.minute = 0
let trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: false)
let content = UNMutableNotificationContent()
content.title = localizedString("notification_planning_title", language: language)
content.body = localizedString("notification_planning_body", language: language)
content.sound = .default
let request = UNNotificationRequest(identifier: identifier, content: content, trigger: trigger)
center.removePendingNotificationRequests(withIdentifiers: [identifier])
center.add(request)
}
}
+10
View File
@@ -0,0 +1,10 @@
import Foundation
enum PremiumAccess {
static let freeDishLimit = 20
static let freeFutureWeeks = 1
static func hasReachedFreeDishLimit(dishCount: Int, isPremium: Bool) -> Bool {
!isPremium && dishCount >= freeDishLimit
}
}
@@ -0,0 +1,43 @@
import UIKit
import StoreKit
@MainActor
final class ReviewPromptService {
static let shared = ReviewPromptService()
private let milestones = [1, 2, 4, 8]
private let minimumDaysBetweenPrompts: Double = 30
private let promptedMilestoneKey = "review_prompted_milestone"
private let lastPromptDateKey = "review_prompt_last_date"
private init() {}
func considerPromptAfterWeekCompletion(completedWeeks: Int) {
guard let milestone = milestones.first(where: { completedWeeks >= $0 }) else { return }
let alreadyPrompted = UserDefaults.standard.integer(forKey: promptedMilestoneKey)
guard milestone > alreadyPrompted else { return }
guard canPromptNow() else { return }
requestReview()
UserDefaults.standard.set(milestone, forKey: promptedMilestoneKey)
UserDefaults.standard.set(Date().timeIntervalSince1970, forKey: lastPromptDateKey)
}
func requestFromSettings() {
requestReview()
}
private func canPromptNow() -> Bool {
let lastPrompt = UserDefaults.standard.double(forKey: lastPromptDateKey)
guard lastPrompt > 0 else { return true }
return Date().timeIntervalSince1970 - lastPrompt >= minimumDaysBetweenPrompts * 24 * 60 * 60
}
private func requestReview() {
guard let scene = UIApplication.shared.connectedScenes
.compactMap({ $0 as? UIWindowScene })
.first(where: { $0.activationState == .foregroundActive }) else { return }
SKStoreReviewController.requestReview(in: scene)
}
}
+224
View File
@@ -0,0 +1,224 @@
import StoreKit
import SwiftUI
@MainActor
final class StoreManager: ObservableObject {
enum ProductLoadState {
case idle
case loading
case loaded
case timedOut
case notFound
case failed
}
enum PurchaseResult {
case success
case cancelled
case pending
case failed
}
@Published var products: [Product] = []
@Published var isPremium: Bool = false
@Published var isLoading: Bool = false
@Published var isLoadingProducts: Bool = false
@Published var productLoadState: ProductLoadState = .idle
@Published var debugLoadedProductIds: [String] = []
@Published var debugLoadedProducts: [String] = []
static let monthlyProductId = "com.mealmood.premium.monthly"
static let monthlyProductIdAlt = "com.alexandrevazquez.mealmood.premium.monthly"
static let monthlyProductIdLegacy = "com.alexandrev.mealmood.premium.monthly"
static let monthlyProductIdShort = "com.alexandrevazquez.mealmood.premium"
private var productIds: [String] {
var ids = [Self.monthlyProductId]
if let bundleId = Bundle.main.bundleIdentifier {
ids.append("\(bundleId).premium.monthly")
}
var seen = Set<String>()
return ids.filter { seen.insert($0).inserted }
}
init() {
Task {
await loadProducts()
await checkPremiumStatus()
}
}
func loadProducts() async {
isLoadingProducts = true
productLoadState = .loading
defer { isLoadingProducts = false }
do {
let fetchedProducts = try await loadProductsWithRetries()
products = fetchedProducts
debugLoadedProductIds = fetchedProducts.map(\.id)
debugLoadedProducts = fetchedProducts.map { "\($0.id) [\($0.type)]" }
productLoadState = fetchedProducts.isEmpty ? .notFound : .loaded
} catch {
if error is TimeoutError {
productLoadState = .timedOut
} else {
productLoadState = .failed
}
print("Failed to load products: \(error)")
}
}
func purchase(_ product: Product) async -> PurchaseResult {
isLoading = true
defer { isLoading = false }
do {
let result = try await product.purchase()
switch result {
case .success(let verification):
if case .verified(let transaction) = verification {
await transaction.finish()
isPremium = true
return .success
}
return .failed
case .userCancelled:
return .cancelled
case .pending:
return .pending
@unknown default:
return .failed
}
} catch {
print("Purchase failed: \(error)")
return .failed
}
}
func restorePurchases() async {
isLoading = true
defer { isLoading = false }
do {
try await AppStore.sync()
await checkPremiumStatus()
} catch {
print("Restore failed: \(error)")
}
}
private func checkPremiumStatus() async {
isPremium = await Self.hasActiveSubscription(productIds: productIds)
}
var monthlyProduct: Product? {
let prioritizedIds = [
Self.monthlyProductIdAlt,
Self.monthlyProductId,
Self.monthlyProductIdLegacy,
Self.monthlyProductIdShort
]
if let match = products.first(where: { product in
prioritizedIds.contains(product.id) && product.type == .autoRenewable
}) {
return match
}
if let match = products.first(where: { prioritizedIds.contains($0.id) }) {
return match
}
if let match = products.first(where: { $0.id.contains("monthly") && $0.type == .autoRenewable }) {
return match
}
if let match = products.first(where: { $0.type == .autoRenewable }) {
return match
}
return products.first
}
var debugProductIds: [String] { productIds }
static func hasActiveSubscription(productIds: [String] = [
StoreManager.monthlyProductId,
StoreManager.monthlyProductIdAlt,
StoreManager.monthlyProductIdLegacy,
StoreManager.monthlyProductIdShort
]) async -> Bool {
for await result in Transaction.currentEntitlements {
if case .verified(let transaction) = result,
productIds.contains(transaction.productID),
transaction.revocationDate == nil {
if let expirationDate = transaction.expirationDate, expirationDate < Date() {
continue
}
return true
}
}
return false
}
private struct TimeoutError: Error {}
private func loadProductsWithRetries(maxAttempts: Int = 3) async throws -> [Product] {
var lastProducts: [Product] = []
for attempt in 1...maxAttempts {
let products = try await loadProductsWithTimeout(seconds: 12)
if !products.isEmpty {
return products
}
lastProducts = products
if attempt < maxAttempts {
try await Task.sleep(nanoseconds: 800_000_000)
}
}
return lastProducts
}
private func loadProductsWithTimeout(seconds: UInt64) async throws -> [Product] {
let ids = productIds
return try await withThrowingTaskGroup(of: [Product].self) { group in
group.addTask {
try await self.loadProductsBySingleID(ids: ids)
}
group.addTask {
try await Task.sleep(nanoseconds: seconds * 1_000_000_000)
throw TimeoutError()
}
let firstResult = try await group.next() ?? []
group.cancelAll()
return firstResult
}
}
private func loadProductsBySingleID(ids: [String]) async throws -> [Product] {
var merged: [String: Product] = [:]
// First, attempt a single batch request with all IDs.
let batchItems = try await Product.products(for: ids)
for item in batchItems {
merged[item.id] = item
}
if !merged.isEmpty {
return Array(merged.values)
}
// Fallback to one-by-one requests for better resilience/debugging.
for id in ids {
let items = try await Product.products(for: [id])
for item in items {
merged[item.id] = item
}
}
return Array(merged.values)
}
}