diff --git a/.DS_Store b/.DS_Store index 30d7846..94eb701 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/MealMood.xcodeproj/project.pbxproj b/MealMood.xcodeproj/project.pbxproj index 7810967..9b7e9f5 100644 --- a/MealMood.xcodeproj/project.pbxproj +++ b/MealMood.xcodeproj/project.pbxproj @@ -8,6 +8,7 @@ /* Begin PBXBuildFile section */ 016D78C0DC1B1F6E671F36F7 /* OnboardingViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 168A6CA95246E1BB72797D4E /* OnboardingViewModelTests.swift */; }; + 0EC555FE2F4498780005513D /* aaaa.storekit in Resources */ = {isa = PBXBuildFile; fileRef = 0EC555FD2F4498780005513D /* aaaa.storekit */; }; 0F91C8611D3BBE1FBD51D1E6 /* MealSlot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 797A41D292142240FBDF9F2A /* MealSlot.swift */; }; 19330C6ECE7A60110A3A679D /* TagListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13DB00DA865F6FE86952BFC3 /* TagListView.swift */; }; 203DDFF14C0688DEDB6EE83E /* Localization+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0164A33496A304E40685EA6D /* Localization+Helpers.swift */; }; @@ -88,6 +89,7 @@ 049AE68D5B577514ADD6BACB /* Tag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tag.swift; sourceTree = ""; }; 050EA827CA951AB9E847E2CA /* AdBannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdBannerView.swift; sourceTree = ""; }; 065982B284538632951F1C3A /* MealMoodLogo.svg */ = {isa = PBXFileReference; lastKnownFileType = text; path = MealMoodLogo.svg; sourceTree = ""; }; + 0EC555FD2F4498780005513D /* aaaa.storekit */ = {isa = PBXFileReference; lastKnownFileType = text; path = aaaa.storekit; sourceTree = ""; }; 0F46C32F97EAD52F8F3BC0B7 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; 103374A7D91B4F884278DA72 /* StoreManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreManager.swift; sourceTree = ""; }; 10594EA55F6BEFF4241D364C /* FirstDishesStepView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirstDishesStepView.swift; sourceTree = ""; }; @@ -193,6 +195,7 @@ 4B4ACE1D0F2968B284A1FA7E /* Configuration */ = { isa = PBXGroup; children = ( + 0EC555FD2F4498780005513D /* aaaa.storekit */, ); path = Configuration; sourceTree = ""; @@ -455,6 +458,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + 0EC555FE2F4498780005513D /* aaaa.storekit in Resources */, EA4EC6244EEBE6D568BCA5E5 /* Assets.xcassets in Resources */, 87EB371FD3B6C241AC721C55 /* Localizable.strings in Resources */, 7CBE113B28462C6C41C35533 /* MealMoodLogo.svg in Resources */, diff --git a/MealMood.xcodeproj/project.xcworkspace/xcuserdata/alexandrev.xcuserdatad/UserInterfaceState.xcuserstate b/MealMood.xcodeproj/project.xcworkspace/xcuserdata/alexandrev.xcuserdatad/UserInterfaceState.xcuserstate index 9387c09..6d01036 100644 Binary files a/MealMood.xcodeproj/project.xcworkspace/xcuserdata/alexandrev.xcuserdatad/UserInterfaceState.xcuserstate and b/MealMood.xcodeproj/project.xcworkspace/xcuserdata/alexandrev.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/MealMood.xcodeproj/xcshareddata/xcschemes/MealMood-IAP.xcscheme b/MealMood.xcodeproj/xcshareddata/xcschemes/MealMood-IAP.xcscheme index 52cffa8..27268f6 100644 --- a/MealMood.xcodeproj/xcshareddata/xcschemes/MealMood-IAP.xcscheme +++ b/MealMood.xcodeproj/xcshareddata/xcschemes/MealMood-IAP.xcscheme @@ -71,7 +71,7 @@ + identifier = "../../../MealMood/Configuration/MealMood.storekit"> + identifier = "../../MealMood/Configuration/aaaa.storekit"> UIView { let container = UIView() container.backgroundColor = UIColor(Color.mealMoodSurface) + container.clipsToBounds = true let width = max(UIScreen.main.bounds.width, 320) let adSize = GADCurrentOrientationAnchoredAdaptiveBannerAdSizeWithWidth(width) diff --git a/MealMood/Configuration/aaaa.storekit b/MealMood/Configuration/aaaa.storekit new file mode 100644 index 0000000..41fbd7d --- /dev/null +++ b/MealMood/Configuration/aaaa.storekit @@ -0,0 +1,76 @@ +{ + "appPolicies" : { + "eula" : "", + "policies" : [ + { + "locale" : "en_US", + "policyText" : "", + "policyURL" : "" + } + ] + }, + "identifier" : "8A126AD4", + "nonRenewingSubscriptions" : [ + + ], + "products" : [ + + ], + "settings" : { + "_askToBuyEnabled" : false, + "_billingGracePeriodEnabled" : false, + "_billingIssuesEnabled" : false, + "_disableDialogs" : false, + "_failTransactionsEnabled" : false, + "_locale" : "en_US", + "_renewalBillingIssuesEnabled" : false, + "_storefront" : "USA", + "_storeKitErrors" : [ + + ], + "_timeRate" : 0 + }, + "subscriptionGroups" : [ + { + "id" : "6E6036BC", + "localizations" : [ + + ], + "name" : "Test", + "subscriptions" : [ + { + "adHocOffers" : [ + + ], + "codeOffers" : [ + + ], + "displayPrice" : "2.99", + "familyShareable" : true, + "groupNumber" : 1, + "internalID" : "375D53F5", + "introductoryOffer" : null, + "localizations" : [ + { + "description" : "", + "displayName" : "", + "locale" : "en_US" + } + ], + "productID" : "com.mealmood.premium.monthly", + "recurringSubscriptionPeriod" : "P1M", + "referenceName" : "com.mealmood.premium.monthly", + "subscriptionGroupID" : "6E6036BC", + "type" : "RecurringSubscription", + "winbackOffers" : [ + + ] + } + ] + } + ], + "version" : { + "major" : 4, + "minor" : 0 + } +} diff --git a/MealMood/ContentView.swift b/MealMood/ContentView.swift index 6988a5d..2684c40 100644 --- a/MealMood/ContentView.swift +++ b/MealMood/ContentView.swift @@ -44,9 +44,6 @@ struct ContentView: View { Task { switch phase { case .active: - if settings?.iCloudSyncEnabledResolved ?? true { - await ICloudSyncService.shared.pullRemoteIfNeeded(context: context) - } await syncPremiumStatus() case .inactive, .background: if settings?.iCloudSyncEnabledResolved ?? true { diff --git a/MealMood/Resources/en.lproj/Localizable.strings b/MealMood/Resources/en.lproj/Localizable.strings index 8c61f72..aee7272 100644 --- a/MealMood/Resources/en.lproj/Localizable.strings +++ b/MealMood/Resources/en.lproj/Localizable.strings @@ -242,3 +242,5 @@ "reminder_30" = "30 min before"; "reminder_60" = "1 hour before"; "reminder_120" = "2 hours before"; + +"first_dishes_limit_reached" = "You reached 10 dishes. Tap Finish when you are ready."; diff --git a/MealMood/Resources/es.lproj/Localizable.strings b/MealMood/Resources/es.lproj/Localizable.strings index afa23b5..beb1c91 100644 --- a/MealMood/Resources/es.lproj/Localizable.strings +++ b/MealMood/Resources/es.lproj/Localizable.strings @@ -242,3 +242,5 @@ "reminder_30" = "30 min antes"; "reminder_60" = "1 hora antes"; "reminder_120" = "2 horas antes"; + +"first_dishes_limit_reached" = "Has llegado a 10 platos. Pulsa Finalizar cuando quieras."; diff --git a/MealMood/Services/CalendarService.swift b/MealMood/Services/CalendarService.swift index 0b305cd..57a54b4 100644 --- a/MealMood/Services/CalendarService.swift +++ b/MealMood/Services/CalendarService.swift @@ -28,8 +28,10 @@ final class CalendarService { weekStartDate: Date, settings: AppSettings ) -> String? { - guard let calendarId = settings.calendarId, - let calendar = eventStore.calendar(withIdentifier: calendarId) else { return nil } + guard let calendar = resolvedCalendar(for: settings) else { + print("Calendar sync skipped: no writable calendar available") + return nil + } let event = EKEvent(eventStore: eventStore) @@ -81,4 +83,17 @@ final class CalendarService { try? eventStore.save(event, span: .thisEvent) } } + + private func resolvedCalendar(for settings: AppSettings) -> EKCalendar? { + if let calendarId = settings.calendarId, + let calendar = eventStore.calendar(withIdentifier: calendarId) { + return calendar + } + + if let defaultCalendar = eventStore.defaultCalendarForNewEvents { + return defaultCalendar + } + + return eventStore.calendars(for: .event).first + } } diff --git a/MealMood/Services/StoreManager.swift b/MealMood/Services/StoreManager.swift index bcb5794..18b2042 100644 --- a/MealMood/Services/StoreManager.swift +++ b/MealMood/Services/StoreManager.swift @@ -28,10 +28,6 @@ final class StoreManager: ObservableObject { @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] @@ -114,20 +110,13 @@ final class StoreManager: ObservableObject { } 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 + product.id == Self.monthlyProductId && product.type == .autoRenewable }) { return match } - if let match = products.first(where: { prioritizedIds.contains($0.id) }) { + if let match = products.first(where: { $0.id == Self.monthlyProductId }) { return match } @@ -145,10 +134,7 @@ final class StoreManager: ObservableObject { var debugProductIds: [String] { productIds } static func hasActiveSubscription(productIds: [String] = [ - StoreManager.monthlyProductId, - StoreManager.monthlyProductIdAlt, - StoreManager.monthlyProductIdLegacy, - StoreManager.monthlyProductIdShort + StoreManager.monthlyProductId ]) async -> Bool { for await result in Transaction.currentEntitlements { if case .verified(let transaction) = result, diff --git a/MealMood/ViewModels/HomeViewModel.swift b/MealMood/ViewModels/HomeViewModel.swift index c038b18..55400fd 100644 --- a/MealMood/ViewModels/HomeViewModel.swift +++ b/MealMood/ViewModels/HomeViewModel.swift @@ -68,13 +68,16 @@ final class HomeViewModel: ObservableObject { ) if let existing = try? context.fetch(descriptor).first { - syncSlotsIfNeeded(plan: existing, settings: settings, context: context) return existing } return DefaultDataService.createWeekPlan(for: currentWeekStart, settings: settings, context: context) } + func reconcileSlotsIfNeeded(plan: WeekPlan, settings: AppSettings, context: ModelContext) { + syncSlotsIfNeeded(plan: plan, settings: settings, context: context) + } + func assignDish(_ dish: Dish, to slot: MealSlot, plan: WeekPlan, settings: AppSettings, isOverride: Bool = false) { captureUndoSnapshot(plan: plan) diff --git a/MealMood/ViewModels/OnboardingViewModel.swift b/MealMood/ViewModels/OnboardingViewModel.swift index fe62488..66499b2 100644 --- a/MealMood/ViewModels/OnboardingViewModel.swift +++ b/MealMood/ViewModels/OnboardingViewModel.swift @@ -6,6 +6,7 @@ final class OnboardingViewModel: ObservableObject { @Published var currentStep: Int = 0 @Published var selectedMealWindows: MealWindows = .dinnerOnly @Published var includeWeekends: Bool = true + @Published var syncICloud: Bool = true @Published var syncCalendar: Bool = false @Published var selectedCalendarId: String? @Published var lunchTime: Date = { @@ -21,6 +22,7 @@ final class OnboardingViewModel: ObservableObject { @Published var newDishName: String = "" @Published var newDishTags: Set = [] @Published var addedDishes: [(name: String, tagIds: [UUID])] = [] + let maxOnboardingDishes = 10 let totalSteps = 5 @@ -36,10 +38,15 @@ final class OnboardingViewModel: ObservableObject { } var canAddDish: Bool { - !newDishName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + !newDishName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && canAddMoreDishes + } + + var canAddMoreDishes: Bool { + addedDishes.count < maxOnboardingDishes } func addDish(defaultTagId: UUID? = nil) { + guard canAddMoreDishes else { return } guard canAddDish else { return } let normalizedName = newDishName.trimmingCharacters(in: .whitespacesAndNewlines) guard !addedDishes.contains(where: { $0.name.lowercased() == normalizedName.lowercased() }) else { return } @@ -64,6 +71,7 @@ final class OnboardingViewModel: ObservableObject { } func addSuggestedDish(name: String, preferredTagNames: [String], availableTags: [Tag]) { + guard canAddMoreDishes else { return } let normalizedName = name.trimmingCharacters(in: .whitespacesAndNewlines) guard !normalizedName.isEmpty else { return } guard !addedDishes.contains(where: { $0.name.lowercased() == normalizedName.lowercased() }) else { return } @@ -115,6 +123,7 @@ final class OnboardingViewModel: ObservableObject { settings.mealWindowsEnum = selectedMealWindows settings.includeWeekends = includeWeekends + settings.iCloudSyncEnabled = syncICloud settings.syncEnabled = syncCalendar settings.calendarId = selectedCalendarId settings.lunchTime = lunchTime diff --git a/MealMood/ViewModels/SettingsViewModel.swift b/MealMood/ViewModels/SettingsViewModel.swift index c3b7e3e..7095ac5 100644 --- a/MealMood/ViewModels/SettingsViewModel.swift +++ b/MealMood/ViewModels/SettingsViewModel.swift @@ -6,11 +6,33 @@ import EventKit final class SettingsViewModel: ObservableObject { @Published var availableCalendars: [EKCalendar] = [] @Published var showCalendarPermissionAlert: Bool = false + @Published var showToast: Bool = false + @Published var toastMessage: String = "" func loadCalendars() { availableCalendars = CalendarService.shared.availableCalendars() } + @discardableResult + func ensureValidCalendarSelection(settings: AppSettings) -> Bool { + loadCalendars() + + guard !availableCalendars.isEmpty else { + settings.calendarId = nil + settings.syncEnabled = false + showToastMessage(String(localized: "toast_calendar_unavailable")) + return false + } + + if let selected = settings.calendarId, + availableCalendars.contains(where: { $0.calendarIdentifier == selected }) { + return true + } + + settings.calendarId = availableCalendars.first?.calendarIdentifier + return true + } + func requestCalendarAccess() async -> Bool { let granted = await CalendarService.shared.requestAccess() if !granted { @@ -42,10 +64,19 @@ final class SettingsViewModel: ObservableObject { try deleteAll(of: WeekPlan.self, in: context) try deleteAll(of: Dish.self, in: context) try deleteAll(of: Tag.self, in: context) - try deleteAll(of: AppSettings.self, in: context) - - DefaultDataService.createDefaultSettings(context: context) DefaultDataService.createDefaultTags(context: context) + + let settingsDescriptor = FetchDescriptor() + if let existingSettings = try context.fetch(settingsDescriptor).first { + resetSettings(existingSettings) + } else { + DefaultDataService.createDefaultSettings(context: context) + if let createdSettings = try context.fetch(settingsDescriptor).first { + resetSettings(createdSettings) + } + } + + try context.save() } catch { #if DEBUG print("Failed to reset all data: \(error)") @@ -61,4 +92,28 @@ final class SettingsViewModel: ObservableObject { } try context.save() } + + private func showToastMessage(_ message: String) { + toastMessage = message + withAnimation(.easeInOut(duration: 0.2)) { + showToast = true + } + } + + private func resetSettings(_ settings: AppSettings) { + let defaults = AppSettings() + settings.mealWindows = defaults.mealWindows + settings.includeWeekends = defaults.includeWeekends + settings.language = defaults.language + settings.calendarId = defaults.calendarId + settings.syncEnabled = defaults.syncEnabled + settings.syncMode = defaults.syncMode + settings.lunchTime = defaults.lunchTime + settings.dinnerTime = defaults.dinnerTime + settings.eventDuration = defaults.eventDuration + settings.eventPrefix = defaults.eventPrefix + settings.reminderMinutesBefore = defaults.reminderMinutesBefore + settings.iCloudSyncEnabled = false + settings.onboardingCompleted = false + } } diff --git a/MealMood/Views/Home/HomeView.swift b/MealMood/Views/Home/HomeView.swift index 680c831..9bc9392 100644 --- a/MealMood/Views/Home/HomeView.swift +++ b/MealMood/Views/Home/HomeView.swift @@ -26,9 +26,11 @@ struct HomeView: View { ZStack { Color.mealMoodBackground.ignoresSafeArea() - if let settings = settings, - let plan = viewModel.getOrCreateWeekPlan(context: context, settings: settings) { - mainContent(plan: plan, settings: settings) + if let settings = settings { + let plan = viewModel.getOrCreateWeekPlan(context: context, settings: settings) + if let plan { + mainContent(plan: plan, settings: settings) + } } } .toast(isShowing: $viewModel.showToast, message: viewModel.toastMessage) @@ -151,6 +153,26 @@ struct HomeView: View { } } } + .onAppear { + guard let settings = settings, + let plan = viewModel.getOrCreateWeekPlan(context: context, settings: settings) else { return } + viewModel.reconcileSlotsIfNeeded(plan: plan, settings: settings, context: context) + } + .onChange(of: settings?.includeWeekends) { _, _ in + guard let settings = settings, + let plan = viewModel.getOrCreateWeekPlan(context: context, settings: settings) else { return } + viewModel.reconcileSlotsIfNeeded(plan: plan, settings: settings, context: context) + } + .onChange(of: settings?.mealWindows) { _, _ in + guard let settings = settings, + let plan = viewModel.getOrCreateWeekPlan(context: context, settings: settings) else { return } + viewModel.reconcileSlotsIfNeeded(plan: plan, settings: settings, context: context) + } + .onChange(of: viewModel.currentWeekStart) { _, _ in + guard let settings = settings, + let plan = viewModel.getOrCreateWeekPlan(context: context, settings: settings) else { return } + viewModel.reconcileSlotsIfNeeded(plan: plan, settings: settings, context: context) + } } @ViewBuilder @@ -167,69 +189,37 @@ struct HomeView: View { .padding(.bottom, 8) } - if horizontalSizeClass == .regular { - HStack(alignment: .top, spacing: 20) { - VStack(spacing: 10) { - WeekCalendarView( - plan: plan, - settings: settings, - dishes: dishes, - tags: tags, - viewModel: viewModel, - onTapEmptySlot: { slot in - selectedEmptySlotId = slot.id - } - ) - - if isWeekComplete(plan: plan) { - exportCallout(plan: plan, settings: settings) - } - + VStack(spacing: 10) { + WeekCalendarView( + plan: plan, + settings: settings, + dishes: dishes, + tags: tags, + viewModel: viewModel, + onTapEmptySlot: { slot in + selectedEmptySlotId = slot.id } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + ) - VStack(spacing: 12) { - ScrollView(showsIndicators: true) { - dishDrawer(plan: plan, settings: settings) - .padding(.bottom, 6) - } - } - .frame(width: 360) - .frame(maxHeight: .infinity, alignment: .top) + if isWeekComplete(plan: plan) { + exportCallout(plan: plan, settings: settings) } - .padding(.horizontal, 16) - .padding(.bottom, 0) - } else { - VStack(spacing: 10) { - WeekCalendarView( - plan: plan, - settings: settings, - dishes: dishes, - tags: tags, - viewModel: viewModel, - onTapEmptySlot: { slot in - selectedEmptySlotId = slot.id - } - ) - if isWeekComplete(plan: plan) { - exportCallout(plan: plan, settings: settings) - } - - ScrollView(showsIndicators: true) { - dishDrawer(plan: plan, settings: settings) - .padding(.bottom, 0) - } - .frame(maxHeight: .infinity) + ScrollView(showsIndicators: true) { + dishDrawer(plan: plan, settings: settings) + .padding(.bottom, 0) } - .padding(.bottom, 0) + .frame(maxHeight: .infinity) } + .frame(maxWidth: horizontalSizeClass == .regular ? 1160 : .infinity) + .frame(maxWidth: .infinity, alignment: .top) + .padding(.horizontal, horizontalSizeClass == .regular ? 14 : 0) + .padding(.bottom, 0) } .frame(maxHeight: .infinity, alignment: .top) .safeAreaInset(edge: .bottom, spacing: 0) { if !settings.isPremium { AdBannerView() - .ignoresSafeArea(.container, edges: .bottom) } } .alert("reset_title", isPresented: $viewModel.showResetAlert) { @@ -596,7 +586,10 @@ struct HomeView: View { private func renderWeekShareImage(plan: WeekPlan, settings: AppSettings) -> UIImage? { let renderer = ImageRenderer(content: WeekPlanShareView(plan: plan, settings: settings, dishes: dishes, tags: tags)) - renderer.proposedSize = ProposedViewSize(width: 2400, height: 1700) + renderer.proposedSize = ProposedViewSize( + width: WeekPlanShareView.a4LandscapeWidth, + height: WeekPlanShareView.a4LandscapeHeight + ) renderer.scale = 1 return renderer.uiImage } diff --git a/MealMood/Views/Home/WeekCalendarView.swift b/MealMood/Views/Home/WeekCalendarView.swift index d52ed1c..8619508 100644 --- a/MealMood/Views/Home/WeekCalendarView.swift +++ b/MealMood/Views/Home/WeekCalendarView.swift @@ -44,7 +44,7 @@ struct WeekCalendarView: View { ForEach(Array(days.enumerated()), id: \.element) { index, day in dayHeaderCell(day: day, width: 96, height: 24) if index < days.count - 1 { - calendarDivider + calendarDivider() } } } @@ -59,7 +59,7 @@ struct WeekCalendarView: View { draggableSlotView(slot: slot, mealType: mealType) .frame(width: 96) if index < days.count - 1 { - calendarDivider + calendarDivider() } } } @@ -78,16 +78,25 @@ struct WeekCalendarView: View { let dayCount = CGFloat(dayRange.count) let spacing: CGFloat = 10 let rowLabelWidth: CGFloat = 86 + let headerHeight: CGFloat = 26 + let slotRowHeight: CGFloat = 96 + let verticalInset: CGFloat = 2 let contentWidth = proxy.size.width - rowLabelWidth - (dayCount * spacing) let dayWidth = max(92, contentWidth / max(dayCount, 1)) + let totalHeight = + (verticalInset * 2) + + headerHeight + + 1 + + (CGFloat(mealTypes.count) * slotRowHeight) + + CGFloat(max(0, mealTypes.count - 1)) VStack(spacing: 0) { HStack(spacing: 0) { - Color.clear.frame(width: rowLabelWidth, height: 24) + Color.clear.frame(width: rowLabelWidth, height: headerHeight) ForEach(Array(days.enumerated()), id: \.element) { index, day in - dayHeaderCell(day: day, width: dayWidth, height: 24) + dayHeaderCell(day: day, width: dayWidth, height: headerHeight) if index < days.count - 1 { - calendarDivider + calendarDivider(height: headerHeight) .padding(.horizontal, spacing / 2) } } @@ -98,15 +107,15 @@ struct WeekCalendarView: View { ForEach(Array(mealTypes.enumerated()), id: \.element) { rowIndex, mealType in HStack(spacing: 0) { mealTypeLabel(mealType) - .frame(width: rowLabelWidth) + .frame(width: rowLabelWidth, height: slotRowHeight) .padding(.trailing, spacing) ForEach(Array(days.enumerated()), id: \.element) { index, day in let slot = slotFor(day: day, mealType: mealType) draggableSlotView(slot: slot, mealType: mealType) - .frame(width: dayWidth) + .frame(width: dayWidth, height: slotRowHeight) if index < days.count - 1 { - calendarDivider + calendarDivider(height: slotRowHeight) .padding(.horizontal, spacing / 2) } } @@ -118,15 +127,17 @@ struct WeekCalendarView: View { } } .padding(.horizontal, 16) - .padding(.vertical, 6) + .padding(.vertical, verticalInset) + .frame(height: totalHeight, alignment: .top) } - .frame(minHeight: CGFloat(mealTypes.count) * 142 + 54) + .frame(height: CGFloat(mealTypes.count) * 98 + 44) } - private var calendarDivider: some View { + private func calendarDivider(height: CGFloat? = nil) -> some View { Rectangle() .fill(Color.mealMoodTextSecondary.opacity(0.22)) .frame(width: 1) + .frame(height: height) } private var calendarHorizontalDivider: some View { diff --git a/MealMood/Views/Home/WeekPlanShareView.swift b/MealMood/Views/Home/WeekPlanShareView.swift index abbe198..fcc2ce1 100644 --- a/MealMood/Views/Home/WeekPlanShareView.swift +++ b/MealMood/Views/Home/WeekPlanShareView.swift @@ -1,4 +1,5 @@ import SwiftUI +import CoreImage.CIFilterBuiltins struct WeekPlanShareView: View { let plan: WeekPlan @@ -6,6 +7,13 @@ struct WeekPlanShareView: View { let dishes: [Dish] let tags: [Tag] + static let a4LandscapeWidth: CGFloat = 3508 + static let a4LandscapeHeight: CGFloat = 2480 + + private let shareURL = "https://mealmood.app" + private let qrContext = CIContext() + private let qrFilter = CIFilter.qrCodeGenerator() + private var dayRange: ClosedRange { settings.includeWeekends ? 0...6 : 0...4 } @@ -17,65 +25,64 @@ struct WeekPlanShareView: View { case .both: return [.lunch, .dinner] } } - + + private var exportDateString: String { + Date.now.formatted(date: .abbreviated, time: .omitted) + } + + private var qrImage: UIImage? { + qrFilter.setValue(Data(shareURL.utf8), forKey: "inputMessage") + qrFilter.setValue("M", forKey: "inputCorrectionLevel") + guard let outputImage = qrFilter.outputImage else { return nil } + let transformed = outputImage.transformed(by: CGAffineTransform(scaleX: 10, y: 10)) + guard let cgImage = qrContext.createCGImage(transformed, from: transformed.extent) else { return nil } + return UIImage(cgImage: cgImage) + } + var body: some View { ZStack { - LinearGradient( - colors: [ - Color(hex: "#FFF6F0"), - Color(hex: "#F6FCFA") - ], - startPoint: .topLeading, - endPoint: .bottomTrailing - ) - .ignoresSafeArea() + backgroundLayer - VStack(alignment: .leading, spacing: 28) { - HStack(spacing: 16) { - AppIconPlaceholder(size: 72) - VStack(alignment: .leading, spacing: 6) { - Text("MealMood") - .font(.system(size: 44, weight: .bold)) - .foregroundColor(.mealMoodTextPrimary) - Text(plan.weekStartDate.formattedWeekRange()) - .font(.system(size: 26, weight: .medium)) - .foregroundColor(.mealMoodTextSecondary) - } - Spacer() - Text(Date.now.formatted(date: .abbreviated, time: .omitted)) - .font(.system(size: 20, weight: .medium)) - .foregroundColor(.mealMoodTextSecondary) - } + VStack(alignment: .leading, spacing: 34) { + headerBlock - Grid(horizontalSpacing: 10, verticalSpacing: 10) { - GridRow { - gridHeaderCell("") - .frame(minWidth: 220) - ForEach(Array(dayRange), id: \.self) { day in - gridHeaderCell(dayTitle(for: day)) - } - } + ZStack { + RoundedRectangle(cornerRadius: 30) + .fill(Color.white.opacity(0.62)) + RoundedRectangle(cornerRadius: 30) + .stroke(Color.white.opacity(0.75), lineWidth: 2) - ForEach(mealTypes, id: \.self) { mealType in + Grid(horizontalSpacing: 10, verticalSpacing: 10) { GridRow { - gridMealCell(mealTypeLabel(mealType), icon: mealType.icon) + gridHeaderCell("", day: nil) .frame(minWidth: 220) ForEach(Array(dayRange), id: \.self) { day in - gridDishCell(dishName(day: day, mealType: mealType)) + gridHeaderCell(dayTitle(for: day), day: day) + } + } + + ForEach(mealTypes, id: \.self) { mealType in + GridRow { + gridMealCell(mealTypeLabel(mealType), icon: mealType.icon, mealType: mealType) + .frame(minWidth: 220) + ForEach(Array(dayRange), id: \.self) { day in + gridDishCell(dishName(day: day, mealType: mealType), day: day) + } } } } + .padding(18) } + .shadow(color: Color(hex: "#DDAA99").opacity(0.18), radius: 26, x: 0, y: 16) Spacer() - Text("share_week_title") - .font(.system(size: 18, weight: .medium)) - .foregroundColor(.mealMoodTextSecondary) + footerBlock } - .padding(56) + .padding(.horizontal, 84) + .padding(.vertical, 72) } - .frame(width: 2400, height: 1700) + .frame(width: Self.a4LandscapeWidth, height: Self.a4LandscapeHeight) } private func dayTitle(for day: Int) -> String { @@ -89,7 +96,7 @@ struct WeekPlanShareView: View { private func mealTypeLabel(_ mealType: MealType) -> String { mealType == .lunch ? String(localized: "lunch") : String(localized: "dinner") } - + private func dishName(day: Int, mealType: MealType) -> String { let slot = plan.slots.first { $0.dayOfWeek == day && $0.mealType == mealType.rawValue } guard let slot, let dishId = slot.dishId, let dish = dishes.first(where: { $0.id == dishId }) else { @@ -97,32 +104,160 @@ struct WeekPlanShareView: View { } return dish.name } - - private func gridHeaderCell(_ title: String) -> some View { - Text(title) - .font(.system(size: 24, weight: .semibold)) + + private var backgroundLayer: some View { + ZStack { + LinearGradient( + colors: [ + Color(hex: "#FFEFE6"), + Color(hex: "#F1FBF5") + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + + Circle() + .fill(Color(hex: "#FFD6E2").opacity(0.55)) + .frame(width: 760, height: 760) + .blur(radius: 20) + .offset(x: -980, y: -760) + + Circle() + .fill(Color(hex: "#CFF2E8").opacity(0.55)) + .frame(width: 680, height: 680) + .blur(radius: 20) + .offset(x: 1050, y: 760) + + GeometryReader { geo in + Canvas { context, size in + let spacing: CGFloat = 80 + var x: CGFloat = -size.height + while x < size.width { + var path = Path() + path.move(to: CGPoint(x: x, y: 0)) + path.addLine(to: CGPoint(x: x + size.height, y: size.height)) + context.stroke(path, with: .color(Color(hex: "#FFFFFF").opacity(0.26)), lineWidth: 1.2) + x += spacing + } + } + .frame(width: geo.size.width, height: geo.size.height) + } + } + } + + private var headerBlock: some View { + HStack(alignment: .center, spacing: 24) { + AppIconPlaceholder(size: 84) + + VStack(alignment: .leading, spacing: 8) { + Text("MealMood Premium") + .font(.system(size: 50, weight: .bold, design: .rounded)) + .foregroundColor(.mealMoodTextPrimary) + Text(String(localized: "share_week_title")) + .font(.system(size: 28, weight: .semibold, design: .rounded)) + .foregroundColor(Color(hex: "#5D645F")) + } + + Spacer() + + VStack(alignment: .trailing, spacing: 8) { + Text(plan.weekStartDate.formattedWeekRange()) + .font(.system(size: 36, weight: .bold, design: .rounded)) + .foregroundColor(Color(hex: "#4A4F4B")) + Text(exportDateString) + .font(.system(size: 24, weight: .medium, design: .rounded)) + .foregroundColor(.mealMoodTextSecondary) + } + } + .padding(.horizontal, 30) + .padding(.vertical, 24) + .background(Color.white.opacity(0.76)) + .clipShape(RoundedRectangle(cornerRadius: 24)) + .overlay( + RoundedRectangle(cornerRadius: 24) + .stroke(Color(hex: "#F3C4B5").opacity(0.7), lineWidth: 1) + ) + .shadow(color: Color(hex: "#DDAA99").opacity(0.15), radius: 20, x: 0, y: 12) + } + + private var footerBlock: some View { + HStack(alignment: .center, spacing: 20) { + VStack(alignment: .leading, spacing: 6) { + Text("Powered by MealMood") + .font(.system(size: 30, weight: .bold, design: .rounded)) + .foregroundColor(Color(hex: "#3A3A3A")) + Text(shareURL) + .font(.system(size: 24, weight: .semibold, design: .rounded)) + .foregroundColor(Color(hex: "#588A77")) + } + + Spacer() + + if let qrImage { + VStack(spacing: 6) { + Image(uiImage: qrImage) + .interpolation(.none) + .resizable() + .scaledToFit() + .frame(width: 162, height: 162) + .padding(10) + .background(Color.white) + .clipShape(RoundedRectangle(cornerRadius: 18)) + Text("Scan me") + .font(.system(size: 18, weight: .semibold, design: .rounded)) + .foregroundColor(.mealMoodTextSecondary) + } + } + } + .padding(.horizontal, 26) + .padding(.vertical, 18) + .background(Color.white.opacity(0.8)) + .clipShape(RoundedRectangle(cornerRadius: 22)) + .overlay( + RoundedRectangle(cornerRadius: 22) + .stroke(Color(hex: "#DDEEE6"), lineWidth: 1) + ) + .shadow(color: Color(hex: "#7F9E90").opacity(0.14), radius: 16, x: 0, y: 8) + } + + private func gridHeaderCell(_ title: String, day: Int?) -> some View { + let background = dayHeaderBackground(day: day) + return Text(title) + .font(.system(size: 25, weight: .bold, design: .rounded)) .foregroundColor(.mealMoodTextPrimary) .frame(maxWidth: .infinity, minHeight: 92) .padding(.horizontal, 10) - .background(Color.white.opacity(0.95)) + .background(background) .clipShape(RoundedRectangle(cornerRadius: 14)) + .overlay( + RoundedRectangle(cornerRadius: 14) + .stroke(Color.white.opacity(0.6), lineWidth: 1) + ) } - - private func gridMealCell(_ title: String, icon: String) -> some View { - HStack(spacing: 8) { + + private func gridMealCell(_ title: String, icon: String, mealType: MealType) -> some View { + let background = mealType == .lunch ? + LinearGradient(colors: [Color(hex: "#FFE2C8"), Color(hex: "#FFD4B2")], startPoint: .topLeading, endPoint: .bottomTrailing) : + LinearGradient(colors: [Color(hex: "#D7EFE7"), Color(hex: "#C2E7DB")], startPoint: .topLeading, endPoint: .bottomTrailing) + return HStack(spacing: 8) { Image(systemName: icon) Text(title) } - .font(.system(size: 24, weight: .semibold)) - .foregroundColor(.mealMoodTextPrimary) + .font(.system(size: 25, weight: .bold, design: .rounded)) + .foregroundColor(Color(hex: "#365548")) .frame(maxWidth: .infinity, minHeight: 130) .padding(.horizontal, 14) - .background(Color.white.opacity(0.95)) + .background(background) .clipShape(RoundedRectangle(cornerRadius: 14)) + .overlay( + RoundedRectangle(cornerRadius: 14) + .stroke(Color.white.opacity(0.6), lineWidth: 1) + ) } - - private func gridDishCell(_ title: String) -> some View { - Text(title) + + private func gridDishCell(_ title: String, day: Int) -> some View { + let tint = dayCellTint(day: day) + return Text(title) .font(.system(size: 26, weight: .medium)) .foregroundColor(.mealMoodTextPrimary) .multilineTextAlignment(.center) @@ -130,7 +265,55 @@ struct WeekPlanShareView: View { .minimumScaleFactor(0.6) .frame(maxWidth: .infinity, minHeight: 130) .padding(.horizontal, 10) - .background(Color.white.opacity(0.92)) + .background( + LinearGradient( + colors: [Color.white.opacity(0.95), tint.opacity(0.32)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) .clipShape(RoundedRectangle(cornerRadius: 14)) + .overlay( + RoundedRectangle(cornerRadius: 14) + .stroke(Color(hex: "#F2E5DE"), lineWidth: 1) + ) + } + + private func dayHeaderBackground(day: Int?) -> LinearGradient { + guard let day else { + return LinearGradient( + colors: [Color(hex: "#F6E7DE"), Color(hex: "#F2DED2")], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + } + let palettes: [(String, String)] = [ + ("#FFD5D9", "#FFC8D2"), + ("#FFE1C8", "#FFD3AF"), + ("#FFF0C8", "#FFE6A8"), + ("#DFF2CC", "#CFEAB6"), + ("#D3F0E7", "#BFE6DA"), + ("#D9E8FF", "#C9DDFF"), + ("#E6D9FF", "#D9C8FF") + ] + let index = max(0, min(day, palettes.count - 1)) + return LinearGradient( + colors: [Color(hex: palettes[index].0), Color(hex: palettes[index].1)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + } + + private func dayCellTint(day: Int) -> Color { + let colors = [ + Color(hex: "#FFD0D8"), + Color(hex: "#FFE1C6"), + Color(hex: "#FFF0C3"), + Color(hex: "#DFF1CA"), + Color(hex: "#D5EFE6"), + Color(hex: "#D7E7FF"), + Color(hex: "#E7DCFF") + ] + return colors[max(0, min(day, colors.count - 1))] } } diff --git a/MealMood/Views/Onboarding/CalendarStepView.swift b/MealMood/Views/Onboarding/CalendarStepView.swift index b786c5f..2a6908f 100644 --- a/MealMood/Views/Onboarding/CalendarStepView.swift +++ b/MealMood/Views/Onboarding/CalendarStepView.swift @@ -2,10 +2,12 @@ import SwiftUI import EventKit struct CalendarStepView: View { + @Binding var iCloudSyncEnabled: Bool @Binding var syncEnabled: Bool @Binding var selectedCalendarId: String? @Binding var lunchTime: Date @Binding var dinnerTime: Date + var onEnableICloud: () -> Void var onNext: () -> Void var onSkip: () -> Void @@ -23,6 +25,29 @@ struct CalendarStepView: View { .padding(.top, 32) VStack(spacing: 16) { + // iCloud sync toggle + HStack { + Text("settings_icloud_sync") + .font(.mealMoodBody) + .foregroundColor(.mealMoodTextPrimary) + Spacer() + Toggle("", isOn: $iCloudSyncEnabled) + .tint(.mealMoodCoral) + .labelsHidden() + .onChange(of: iCloudSyncEnabled) { _, newValue in + if newValue { + onEnableICloud() + } + } + } + .padding(16) + .background(Color.mealMoodSurface) + .cornerRadius(14) + .overlay( + RoundedRectangle(cornerRadius: 14) + .stroke(Color(hex: "#F0F0F0"), lineWidth: 1) + ) + // Sync toggle HStack { Text("calendar_sync") diff --git a/MealMood/Views/Onboarding/FirstDishesStepView.swift b/MealMood/Views/Onboarding/FirstDishesStepView.swift index 985f399..1749700 100644 --- a/MealMood/Views/Onboarding/FirstDishesStepView.swift +++ b/MealMood/Views/Onboarding/FirstDishesStepView.swift @@ -29,6 +29,8 @@ struct FirstDishesStepView: View { ?? availableTags.sorted(by: { $0.sortOrder < $1.sortOrder }).first?.id } + private let maxVisibleSuggestions = 3 + var body: some View { ScrollView { VStack(spacing: 24) { @@ -165,12 +167,13 @@ struct FirstDishesStepView: View { .foregroundColor(.mealMoodTextSecondary) .padding(.horizontal, 24) - ForEach(suggestedDishes(), id: \.name) { suggestion in - let isAdded = viewModel.addedDishes.contains(where: { - $0.name.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == suggestion.name.lowercased() - }) + Text("Added \(viewModel.addedDishes.count)/\(viewModel.maxOnboardingDishes)") + .font(.mealMoodCaption) + .foregroundColor(.mealMoodTextSecondary) + .padding(.horizontal, 24) + + ForEach(visibleSuggestions(), id: \.name) { suggestion in Button { - guard !isAdded else { return } viewModel.addSuggestedDish( name: suggestion.name, preferredTagNames: suggestion.tagHints, @@ -182,21 +185,28 @@ struct FirstDishesStepView: View { Text(suggestion.name) .font(.mealMoodBodyBold) .foregroundColor(.mealMoodTextPrimary) - Text(isAdded ? "first_dishes_suggestion_added" : "first_dishes_suggestion_add") + Text("first_dishes_suggestion_add") .font(.mealMoodCaption) .foregroundColor(.mealMoodTextSecondary) } Spacer() - Image(systemName: isAdded ? "checkmark.circle.fill" : "plus.circle.fill") - .foregroundColor(isAdded ? .mealMoodSuccess : .mealMoodCoral) + Image(systemName: "plus.circle.fill") + .foregroundColor(.mealMoodCoral) } .padding(12) .background(Color.mealMoodSurface) .cornerRadius(12) } - .disabled(isAdded) + .disabled(!viewModel.canAddMoreDishes) .padding(.horizontal, 24) } + + if !viewModel.canAddMoreDishes { + Text("first_dishes_limit_reached") + .font(.mealMoodCaption) + .foregroundColor(.mealMoodTextSecondary) + .padding(.horizontal, 24) + } } Spacer(minLength: 40) @@ -236,17 +246,41 @@ struct FirstDishesStepView: View { return [ (name: "Roast chicken", tagHints: ["Meat"]), (name: "Lentil stew", tagHints: ["Legumes"]), - (name: "Veggie pasta", tagHints: ["Pasta/Rice", "Vegetables"]) + (name: "Veggie pasta", tagHints: ["Pasta/Rice", "Vegetables"]), + (name: "Chicken curry", tagHints: ["Meat"]), + (name: "Salmon with rice", tagHints: ["Fish", "Pasta/Rice"]), + (name: "Turkey meatballs", tagHints: ["Meat"]), + (name: "Chickpea salad", tagHints: ["Legumes", "Vegetables"]), + (name: "Beef tacos", tagHints: ["Meat"]), + (name: "Vegetable soup", tagHints: ["Vegetables"]), + (name: "Baked cod", tagHints: ["Fish"]) ] } return [ (name: "Pollo asado", tagHints: ["Carne"]), (name: "Lentejas estofadas", tagHints: ["Legumbres"]), - (name: "Pasta con verduras", tagHints: ["Pasta/Arroz", "Verduras"]) + (name: "Pasta con verduras", tagHints: ["Pasta/Arroz", "Verduras"]), + (name: "Pollo al curry", tagHints: ["Carne"]), + (name: "Salmón con arroz", tagHints: ["Pescado", "Pasta/Arroz"]), + (name: "Albóndigas de pavo", tagHints: ["Carne"]), + (name: "Ensalada de garbanzos", tagHints: ["Legumbres", "Verduras"]), + (name: "Tacos de ternera", tagHints: ["Carne"]), + (name: "Crema de verduras", tagHints: ["Verduras"]), + (name: "Merluza al horno", tagHints: ["Pescado"]) ] } + private func visibleSuggestions() -> [(name: String, tagHints: [String])] { + let addedNames = Set( + viewModel.addedDishes.map { + $0.name.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + } + ) + let remaining = suggestedDishes().filter { !addedNames.contains($0.name.lowercased()) } + return Array(remaining.prefix(maxVisibleSuggestions)) + } + private func ensureDefaultTagsIfNeeded() { let descriptor = FetchDescriptor() if (try? context.fetch(descriptor))?.isEmpty ?? true { diff --git a/MealMood/Views/Onboarding/OnboardingView.swift b/MealMood/Views/Onboarding/OnboardingView.swift index ca2d5dd..bab6574 100644 --- a/MealMood/Views/Onboarding/OnboardingView.swift +++ b/MealMood/Views/Onboarding/OnboardingView.swift @@ -42,10 +42,14 @@ struct OnboardingView: View { .tag(2) CalendarStepView( + iCloudSyncEnabled: $viewModel.syncICloud, syncEnabled: $viewModel.syncCalendar, selectedCalendarId: $viewModel.selectedCalendarId, lunchTime: $viewModel.lunchTime, dinnerTime: $viewModel.dinnerTime, + onEnableICloud: { + Task { await restoreFromICloudAndFinishIfNeeded() } + }, onNext: { viewModel.nextStep() }, onSkip: { viewModel.nextStep() } ) @@ -68,6 +72,7 @@ struct OnboardingView: View { } .onAppear { ensureDefaultTagsIfNeeded() + Task { await restoreFromICloudAndFinishIfNeeded() } } } @@ -77,4 +82,28 @@ struct OnboardingView: View { DefaultDataService.createDefaultTags(context: context) } } + + private func restoreFromICloudAndFinishIfNeeded() async { + let settingsDescriptor = FetchDescriptor() + let currentSettings = (try? context.fetch(settingsDescriptor))?.first + let iCloudEnabled = currentSettings?.iCloudSyncEnabledResolved ?? viewModel.syncICloud + guard iCloudEnabled else { return } + + await ICloudSyncService.shared.pullRemoteIfNeeded(context: context) + + guard shouldAutoCompleteOnboarding() else { return } + if let settings = (try? context.fetch(settingsDescriptor))?.first, !settings.onboardingCompleted { + settings.onboardingCompleted = true + try? context.save() + } + onComplete() + } + + private func shouldAutoCompleteOnboarding() -> Bool { + let dishes = (try? context.fetch(FetchDescriptor())) ?? [] + if !dishes.isEmpty { return true } + + let plans = (try? context.fetch(FetchDescriptor())) ?? [] + return !plans.isEmpty + } } diff --git a/MealMood/Views/Premium/PremiumView.swift b/MealMood/Views/Premium/PremiumView.swift index 680b314..2c73390 100644 --- a/MealMood/Views/Premium/PremiumView.swift +++ b/MealMood/Views/Premium/PremiumView.swift @@ -116,12 +116,15 @@ struct PremiumView: View { .foregroundColor(.mealMoodTextSecondary) .multilineTextAlignment(.center) .padding(.horizontal, 24) - .padding(.bottom, 40) + .padding(.bottom, 24) } + .safeAreaPadding(.bottom, 20) } } .navigationTitle("settings_premium") .navigationBarTitleDisplayMode(.inline) + .presentationDetents([.large]) + .presentationDragIndicator(.visible) .task { await storeManager.loadProducts() } diff --git a/MealMood/Views/Settings/SettingsView.swift b/MealMood/Views/Settings/SettingsView.swift index a3d1bd4..7bb9447 100644 --- a/MealMood/Views/Settings/SettingsView.swift +++ b/MealMood/Views/Settings/SettingsView.swift @@ -23,6 +23,7 @@ struct SettingsView: View { .toolbarBackground(Color.mealMoodBackground, for: .navigationBar) .toolbarBackground(.visible, for: .navigationBar) .environment(\.isEnabled, true) + .toast(isShowing: $viewModel.showToast, message: viewModel.toastMessage) } @ViewBuilder @@ -64,7 +65,8 @@ struct SettingsView: View { Task { let granted = await viewModel.requestCalendarAccess() if granted { - settings.syncEnabled = true + let hasCalendar = viewModel.ensureValidCalendarSelection(settings: settings) + settings.syncEnabled = hasCalendar } } } else { @@ -214,7 +216,8 @@ struct SettingsView: View { .tint(.mealMoodCoral) .onAppear { if settings.syncEnabled { - viewModel.loadCalendars() + let hasCalendar = viewModel.ensureValidCalendarSelection(settings: settings) + settings.syncEnabled = hasCalendar } } .alert("calendar_permission_title", isPresented: $viewModel.showCalendarPermissionAlert) {