Release 1.0.0 baseline
This commit is contained in:
@@ -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 = "<group>"; };
|
||||
050EA827CA951AB9E847E2CA /* AdBannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdBannerView.swift; sourceTree = "<group>"; };
|
||||
065982B284538632951F1C3A /* MealMoodLogo.svg */ = {isa = PBXFileReference; lastKnownFileType = text; path = MealMoodLogo.svg; sourceTree = "<group>"; };
|
||||
0EC555FD2F4498780005513D /* aaaa.storekit */ = {isa = PBXFileReference; lastKnownFileType = text; path = aaaa.storekit; sourceTree = "<group>"; };
|
||||
0F46C32F97EAD52F8F3BC0B7 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
|
||||
103374A7D91B4F884278DA72 /* StoreManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreManager.swift; sourceTree = "<group>"; };
|
||||
10594EA55F6BEFF4241D364C /* FirstDishesStepView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirstDishesStepView.swift; sourceTree = "<group>"; };
|
||||
@@ -193,6 +195,7 @@
|
||||
4B4ACE1D0F2968B284A1FA7E /* Configuration */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
0EC555FD2F4498780005513D /* aaaa.storekit */,
|
||||
);
|
||||
path = Configuration;
|
||||
sourceTree = "<group>";
|
||||
@@ -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 */,
|
||||
|
||||
BIN
Binary file not shown.
@@ -71,7 +71,7 @@
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
<StoreKitConfigurationFileReference
|
||||
identifier = "../../../MealMood/Configuration/MealMoodV4.storekit">
|
||||
identifier = "../../../MealMood/Configuration/MealMood.storekit">
|
||||
</StoreKitConfigurationFileReference>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
|
||||
@@ -71,7 +71,7 @@
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
<StoreKitConfigurationFileReference
|
||||
identifier = "../../MealMood/Configuration/MealMood.storekit">
|
||||
identifier = "../../MealMood/Configuration/aaaa.storekit">
|
||||
</StoreKitConfigurationFileReference>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
|
||||
Vendored
BIN
Binary file not shown.
@@ -29,6 +29,7 @@ struct AdBannerView: View {
|
||||
}
|
||||
}
|
||||
.frame(height: 56)
|
||||
.clipped()
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(Color.mealMoodSurface)
|
||||
@@ -52,6 +53,7 @@ private struct BannerBridge: UIViewRepresentable {
|
||||
func makeUIView(context: Context) -> UIView {
|
||||
let container = UIView()
|
||||
container.backgroundColor = UIColor(Color.mealMoodSurface)
|
||||
container.clipsToBounds = true
|
||||
|
||||
let width = max(UIScreen.main.bounds.width, 320)
|
||||
let adSize = GADCurrentOrientationAnchoredAdaptiveBannerAdSizeWithWidth(width)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.";
|
||||
|
||||
@@ -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.";
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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<UUID> = []
|
||||
@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
|
||||
|
||||
@@ -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<AppSettings>()
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<Int> {
|
||||
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))]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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<Tag>()
|
||||
if (try? context.fetch(descriptor))?.isEmpty ?? true {
|
||||
|
||||
@@ -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<AppSettings>()
|
||||
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<Dish>())) ?? []
|
||||
if !dishes.isEmpty { return true }
|
||||
|
||||
let plans = (try? context.fetch(FetchDescriptor<WeekPlan>())) ?? []
|
||||
return !plans.isEmpty
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user