Version casi lista
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user