1.0.5: Eating out slots, rule violations panel, Crashlytics, search bar fix
- Eating out: tap any empty slot → "Mark as eating out"; shows teal indicator, skipped by auto-assign, counts as complete, tap again to unmark - Rule violations panel: access via wand long-press context menu when conflicts exist; shows all isRuleOverridden slots with Fix (clear) or Ignore (acknowledge) actions - Firebase Crashlytics integrated: CrashlyticsService + dSYM upload build phase, isPremium property tracked per session - PremiumSyncService: extracted premium state machine, StoreKit Transaction.updates listener, isPremium no longer synced via iCloud to avoid stale state - StoreManager: analytics on purchase/restore, bundle ID fallback for product ID lookup - Search bar contrast bug fixed: TextField now has explicit foreground color for dark mode - WelcomeStepView: redesigned onboarding welcome screen with week preview - Version bump: 1.0.5 build 22 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -62,6 +62,8 @@ struct DishDrawerView: View {
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
TextField("home_my_dishes_search", text: $searchText)
|
||||
.font(.mealMoodBody)
|
||||
.foregroundColor(.mealMoodTextPrimary)
|
||||
.tint(.mealMoodCoral)
|
||||
if !searchText.isEmpty {
|
||||
Button {
|
||||
searchText = ""
|
||||
|
||||
@@ -29,8 +29,11 @@ struct HomeView: View {
|
||||
@State private var pendingExportPlan: WeekPlan?
|
||||
@State private var shareImageURL: URL?
|
||||
@State private var showShareSheet: Bool = false
|
||||
@State private var showViolationsPanel: Bool = false
|
||||
|
||||
private var settings: AppSettings? { allSettings.first }
|
||||
private var isRunningOnMac: Bool { ProcessInfo.processInfo.isiOSAppOnMac }
|
||||
private var contentHorizontalPadding: CGFloat { (isRunningOnMac || horizontalSizeClass == .regular) ? 14 : 0 }
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
@@ -140,6 +143,18 @@ struct HomeView: View {
|
||||
Label("home_copy_previous_week", systemImage: "doc.on.doc")
|
||||
}
|
||||
|
||||
let violationCount = AutocompleteEngine.findViolations(plan: plan, allDishes: dishes, allTags: tags).count
|
||||
if violationCount > 0 {
|
||||
Button {
|
||||
showViolationsPanel = true
|
||||
} label: {
|
||||
Label(
|
||||
String(format: String(localized: "violations_badge"), violationCount),
|
||||
systemImage: "exclamationmark.triangle.fill"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Button(role: .destructive) {
|
||||
viewModel.showResetAlert = true
|
||||
} label: {
|
||||
@@ -165,6 +180,7 @@ struct HomeView: View {
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
AnalyticsService.logScreenView("Home")
|
||||
guard let settings = settings,
|
||||
let plan = ensureCurrentWeekPlanExists(settings: settings) else { return }
|
||||
viewModel.reconcileSlotsIfNeeded(plan: plan, settings: settings, context: context)
|
||||
@@ -222,14 +238,14 @@ struct HomeView: View {
|
||||
}
|
||||
.frame(maxHeight: .infinity)
|
||||
}
|
||||
.frame(maxWidth: horizontalSizeClass == .regular ? 1160 : .infinity)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(maxWidth: .infinity, alignment: .top)
|
||||
.padding(.horizontal, horizontalSizeClass == .regular ? 14 : 0)
|
||||
.padding(.horizontal, contentHorizontalPadding)
|
||||
.padding(.bottom, 0)
|
||||
}
|
||||
.frame(maxHeight: .infinity, alignment: .top)
|
||||
.safeAreaInset(edge: .bottom, spacing: 0) {
|
||||
if !settings.isPremium {
|
||||
if !settings.isPremium && !isRunningOnMac {
|
||||
AdBannerView()
|
||||
}
|
||||
}
|
||||
@@ -366,7 +382,6 @@ struct HomeView: View {
|
||||
if isValid {
|
||||
viewModel.assignDish(dish, to: freshSlot, plan: plan, settings: settings)
|
||||
} else {
|
||||
// In picker flow, assign anyway and mark as override so the action is never lost.
|
||||
viewModel.assignDish(dish, to: freshSlot, plan: plan, settings: settings, isOverride: true)
|
||||
}
|
||||
selectedEmptySlotId = nil
|
||||
@@ -376,6 +391,14 @@ struct HomeView: View {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
|
||||
viewModel.showDishForm = true
|
||||
}
|
||||
},
|
||||
onEatingOut: {
|
||||
guard let freshSlot = plan.slots.first(where: { $0.id == slotId }) else {
|
||||
selectedEmptySlotId = nil
|
||||
return
|
||||
}
|
||||
selectedEmptySlotId = nil
|
||||
viewModel.markEatingOut(slot: freshSlot, plan: plan, settings: settings)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -405,9 +428,23 @@ struct HomeView: View {
|
||||
}
|
||||
.sheet(isPresented: $showShareSheet) {
|
||||
if let shareImageURL {
|
||||
ShareSheet(activityItems: [shareImageURL])
|
||||
SocialShareSheet(imageURL: shareImageURL)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showViolationsPanel) {
|
||||
RuleViolationsPanelSheet(
|
||||
plan: plan,
|
||||
dishes: dishes,
|
||||
tags: tags,
|
||||
settings: settings,
|
||||
onFix: { slot in
|
||||
viewModel.removeDish(from: slot, plan: plan, settings: settings)
|
||||
},
|
||||
onIgnore: { slot in
|
||||
viewModel.acknowledgeViolation(slot: slot, plan: plan)
|
||||
}
|
||||
)
|
||||
}
|
||||
.sheet(isPresented: $showMonthlyHistory) {
|
||||
MonthlyHistoryView(
|
||||
weekPlans: weekPlans,
|
||||
@@ -427,6 +464,7 @@ struct HomeView: View {
|
||||
)
|
||||
wasWeekComplete = isWeekComplete(plan: plan)
|
||||
evaluatePostOnboardingPromptsIfNeeded(plan: plan, settings: settings)
|
||||
updateWidget(settings: settings)
|
||||
}
|
||||
.onChange(of: plan.updatedAt) { _, _ in
|
||||
let nowComplete = isWeekComplete(plan: plan)
|
||||
@@ -434,6 +472,7 @@ struct HomeView: View {
|
||||
evaluateReviewPrompt()
|
||||
}
|
||||
wasWeekComplete = nowComplete
|
||||
updateWidget(settings: settings)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -442,6 +481,7 @@ struct HomeView: View {
|
||||
let tags: [Tag]
|
||||
let onPickDish: (Dish) -> Void
|
||||
let onCreateDish: () -> Void
|
||||
var onEatingOut: (() -> Void)? = nil
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var searchText: String = ""
|
||||
@@ -477,6 +517,18 @@ struct HomeView: View {
|
||||
.padding(24)
|
||||
} else {
|
||||
List {
|
||||
if let onEatingOut, searchText.isEmpty {
|
||||
Button {
|
||||
dismiss()
|
||||
onEatingOut()
|
||||
} label: {
|
||||
Label("home_mark_eating_out", systemImage: "fork.knife.circle")
|
||||
.font(.mealMoodBody)
|
||||
.foregroundColor(Color(hex: "#6B9E90"))
|
||||
}
|
||||
.listRowBackground(Color(hex: "#EEF8F5"))
|
||||
}
|
||||
|
||||
ForEach(filteredDishes, id: \.id) { dish in
|
||||
Button {
|
||||
onPickDish(dish)
|
||||
@@ -596,7 +648,12 @@ struct HomeView: View {
|
||||
}
|
||||
|
||||
private func isWeekComplete(plan: WeekPlan) -> Bool {
|
||||
plan.slots.allSatisfy { $0.dishId != nil }
|
||||
plan.slots.allSatisfy { $0.dishId != nil || $0.isEatingOut }
|
||||
}
|
||||
|
||||
private func updateWidget(settings: AppSettings) {
|
||||
let todayPlan = fetchWeekPlan(for: Date().startOfWeek())
|
||||
WidgetDataStore.update(plan: todayPlan, dishes: dishes, settings: settings)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
@@ -678,6 +735,7 @@ struct HomeView: View {
|
||||
}
|
||||
shareImageURL = url
|
||||
showShareSheet = true
|
||||
AnalyticsService.logWeekPlanShared(format: settings.weekExportStyleEnum.rawValue)
|
||||
}
|
||||
|
||||
private func renderWeekShareImage(plan: WeekPlan, settings: AppSettings) -> UIImage? {
|
||||
@@ -777,6 +835,135 @@ struct HomeView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private struct RuleViolationsPanelSheet: View {
|
||||
let plan: WeekPlan
|
||||
let dishes: [Dish]
|
||||
let tags: [Tag]
|
||||
let settings: AppSettings
|
||||
let onFix: (MealSlot) -> Void
|
||||
let onIgnore: (MealSlot) -> Void
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
private var violations: [AutocompleteEngine.RuleViolation] {
|
||||
AutocompleteEngine.findViolations(plan: plan, allDishes: dishes, allTags: tags)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Group {
|
||||
if violations.isEmpty {
|
||||
VStack(spacing: 12) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.system(size: 44))
|
||||
.foregroundColor(.mealMoodSuccess)
|
||||
Text("violations_panel_empty")
|
||||
.font(.mealMoodBody)
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.padding(24)
|
||||
} else {
|
||||
List {
|
||||
ForEach(violations, id: \.slotId) { violation in
|
||||
if let slot = plan.slots.first(where: { $0.id == violation.slotId }) {
|
||||
ViolationRow(
|
||||
violation: violation,
|
||||
settings: settings,
|
||||
onFix: {
|
||||
onFix(slot)
|
||||
if violations.count <= 1 { dismiss() }
|
||||
},
|
||||
onIgnore: {
|
||||
onIgnore(slot)
|
||||
if violations.count <= 1 { dismiss() }
|
||||
}
|
||||
)
|
||||
.listRowBackground(Color.mealMoodSurface)
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.scrollContentBackground(.hidden)
|
||||
}
|
||||
}
|
||||
.background(Color.mealMoodBackground.ignoresSafeArea())
|
||||
.navigationTitle("violations_panel_title")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("tag_selector_done") { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ViolationRow: View {
|
||||
let violation: AutocompleteEngine.RuleViolation
|
||||
let settings: AppSettings
|
||||
let onFix: () -> Void
|
||||
let onIgnore: () -> Void
|
||||
|
||||
private var dayLabel: String {
|
||||
let language = settings.languageEnum.resolved()
|
||||
return localizedString(dayKey(for: violation.dayOfWeek), language: language)
|
||||
}
|
||||
|
||||
private var mealLabel: String {
|
||||
let language = settings.languageEnum.resolved()
|
||||
return localizedString(violation.mealType, language: language)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundColor(.mealMoodWarning)
|
||||
.font(.system(size: 14))
|
||||
Text("\(dayLabel) · \(mealLabel)")
|
||||
.font(.mealMoodSmall)
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
}
|
||||
|
||||
Text(violation.dishName)
|
||||
.font(.mealMoodBodyBold)
|
||||
.foregroundColor(.mealMoodTextPrimary)
|
||||
|
||||
HStack(spacing: 10) {
|
||||
Button(action: onFix) {
|
||||
Text("violations_fix")
|
||||
.font(.mealMoodSmall)
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 6)
|
||||
.background(Color.mealMoodCoral)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
Button(action: onIgnore) {
|
||||
Text("violations_ignore")
|
||||
.font(.mealMoodSmall)
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 6)
|
||||
.background(Color(hex: "#F0F0F0"))
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
|
||||
private func dayKey(for day: Int) -> String {
|
||||
let keys = ["day_mon","day_tue","day_wed","day_thu","day_fri","day_sat","day_sun"]
|
||||
guard day >= 0 && day < keys.count else { return "" }
|
||||
return keys[day]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ShareSheet: UIViewControllerRepresentable {
|
||||
let activityItems: [Any]
|
||||
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
struct SocialShareSheet: View {
|
||||
let imageURL: URL
|
||||
@State private var showSystemShare = false
|
||||
@State private var showCopiedToast = false
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
private var image: UIImage? { UIImage(contentsOfFile: imageURL.path) }
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack(spacing: 24) {
|
||||
if let img = image {
|
||||
Image(uiImage: img)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(maxHeight: 220)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||
.shadow(color: .black.opacity(0.12), radius: 14, x: 0, y: 6)
|
||||
}
|
||||
|
||||
VStack(spacing: 10) {
|
||||
if canOpenInstagram {
|
||||
shareOptionRow(
|
||||
title: "Instagram Stories",
|
||||
subtitle: String(localized: "share_instagram_subtitle"),
|
||||
icon: "camera.fill",
|
||||
color: Color(red: 0.80, green: 0.18, blue: 0.75),
|
||||
action: shareToInstagramStories
|
||||
)
|
||||
}
|
||||
|
||||
shareOptionRow(
|
||||
title: String(localized: "share_copy_image"),
|
||||
subtitle: String(localized: "share_copy_subtitle"),
|
||||
icon: "doc.on.doc.fill",
|
||||
color: .mealMoodCoral,
|
||||
action: copyImage
|
||||
)
|
||||
|
||||
shareOptionRow(
|
||||
title: String(localized: "share_more_options"),
|
||||
subtitle: String(localized: "share_more_subtitle"),
|
||||
icon: "square.and.arrow.up.fill",
|
||||
color: Color(hex: "#4A6CF7"),
|
||||
action: { showSystemShare = true }
|
||||
)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(20)
|
||||
.navigationTitle(String(localized: "share_export_sheet_title"))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button(String(localized: "dish_cancel")) { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
.toast(isShowing: $showCopiedToast, message: String(localized: "share_copied"))
|
||||
.sheet(isPresented: $showSystemShare) {
|
||||
SystemShareSheet(activityItems: [imageURL])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Row view
|
||||
|
||||
private func shareOptionRow(title: String, subtitle: String, icon: String, color: Color, action: @escaping () -> Void) -> some View {
|
||||
Button(action: action) {
|
||||
HStack(spacing: 14) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
.foregroundColor(.white)
|
||||
.frame(width: 44, height: 44)
|
||||
.background(color)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(title)
|
||||
.font(.mealMoodBodyBold)
|
||||
.foregroundColor(.mealMoodTextPrimary)
|
||||
Text(subtitle)
|
||||
.font(.mealMoodCaption)
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
}
|
||||
.padding(12)
|
||||
.background(Color.mealMoodSurface)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
private var canOpenInstagram: Bool {
|
||||
guard let url = URL(string: "instagram-stories://share") else { return false }
|
||||
return UIApplication.shared.canOpenURL(url)
|
||||
}
|
||||
|
||||
private func shareToInstagramStories() {
|
||||
guard let img = image,
|
||||
let data = img.pngData(),
|
||||
let url = URL(string: "instagram-stories://share?source_application=com.alexandrevazquez.mealmood")
|
||||
else { return }
|
||||
UIPasteboard.general.setData(data, forPasteboardType: "com.instagram.sharedSticker.backgroundImage")
|
||||
UIApplication.shared.open(url)
|
||||
dismiss()
|
||||
}
|
||||
|
||||
private func copyImage() {
|
||||
guard let img = image else { return }
|
||||
UIPasteboard.general.image = img
|
||||
showCopiedToast = true
|
||||
HapticManager.shared.notification(type: .success)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - System share wrapper (private)
|
||||
|
||||
private struct SystemShareSheet: UIViewControllerRepresentable {
|
||||
let activityItems: [Any]
|
||||
|
||||
func makeUIViewController(context: Context) -> UIActivityViewController {
|
||||
UIActivityViewController(activityItems: activityItems, applicationActivities: nil)
|
||||
}
|
||||
|
||||
func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {}
|
||||
}
|
||||
@@ -218,7 +218,8 @@ struct WeekCalendarView: View {
|
||||
dayOfWeek: day,
|
||||
mealType: mealType.rawValue,
|
||||
dishId: preferred.dishId,
|
||||
isRuleOverridden: preferred.isRuleOverridden
|
||||
isRuleOverridden: preferred.isRuleOverridden,
|
||||
isEatingOut: preferred.isEatingOut
|
||||
)
|
||||
}
|
||||
|
||||
@@ -279,6 +280,14 @@ struct WeekCalendarView: View {
|
||||
} : nil
|
||||
)
|
||||
.draggable("slot:\(slot.slotId.uuidString)")
|
||||
} else if let slot = slot, slot.isEatingOut {
|
||||
EatingOutSlotView(mealType: mealType)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
guard viewModel.canEditCurrentWeek else { return }
|
||||
guard let live = liveSlot(for: slot) else { return }
|
||||
viewModel.unmarkEatingOut(slot: live, plan: plan, settings: settings)
|
||||
}
|
||||
} else if let slot = slot {
|
||||
EmptySlotView(mealType: mealType)
|
||||
.contentShape(Rectangle())
|
||||
@@ -309,6 +318,7 @@ struct WeekCalendarView: View {
|
||||
let mealType: String
|
||||
let dishId: UUID?
|
||||
let isRuleOverridden: Bool
|
||||
let isEatingOut: Bool
|
||||
}
|
||||
|
||||
private var dishesSnapshotKey: String {
|
||||
@@ -354,7 +364,8 @@ struct WeekCalendarView: View {
|
||||
dayOfWeek: preferred.dayOfWeek,
|
||||
mealType: preferred.mealType,
|
||||
dishId: preferred.dishId,
|
||||
isRuleOverridden: preferred.isRuleOverridden
|
||||
isRuleOverridden: preferred.isRuleOverridden,
|
||||
isEatingOut: preferred.isEatingOut
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -151,17 +151,29 @@ struct WeekPlanShareView: View {
|
||||
|
||||
private var verticalBackground: some View {
|
||||
ZStack {
|
||||
Color(hex: "#FFFDF8")
|
||||
LinearGradient(
|
||||
colors: [Color(hex: "#FFF7F4"), Color(hex: "#FFF3F7"), Color(hex: "#F6FFF8")],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
|
||||
Ellipse()
|
||||
.fill(Color.mealMoodMint.opacity(0.28))
|
||||
.frame(width: 1100, height: 900)
|
||||
.offset(x: 800, y: -1200)
|
||||
Circle()
|
||||
.fill(Color(hex: "#FFD0DF").opacity(0.6))
|
||||
.frame(width: 1100, height: 1100)
|
||||
.blur(radius: 90)
|
||||
.offset(x: 950, y: -1100)
|
||||
|
||||
Ellipse()
|
||||
.fill(Color.mealMoodCoral.opacity(0.2))
|
||||
.frame(width: 900, height: 680)
|
||||
.offset(x: -800, y: 1200)
|
||||
Circle()
|
||||
.fill(Color(hex: "#C0EAD8").opacity(0.55))
|
||||
.frame(width: 1000, height: 1000)
|
||||
.blur(radius: 80)
|
||||
.offset(x: -900, y: 1300)
|
||||
|
||||
Circle()
|
||||
.fill(Color(hex: "#FFE0C0").opacity(0.4))
|
||||
.frame(width: 700, height: 700)
|
||||
.blur(radius: 100)
|
||||
.offset(x: 100, y: 200)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -336,58 +348,91 @@ struct WeekPlanShareView: View {
|
||||
|
||||
private var verticalLayout: some View {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 30)
|
||||
.fill(Color.white.opacity(0.95))
|
||||
RoundedRectangle(cornerRadius: 32)
|
||||
.fill(Color.white.opacity(0.88))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 30)
|
||||
.stroke(Color.mealMoodMint.opacity(0.45), lineWidth: 2)
|
||||
RoundedRectangle(cornerRadius: 32)
|
||||
.stroke(
|
||||
LinearGradient(
|
||||
colors: [Color(hex: "#F0C0CC").opacity(0.7), Color(hex: "#B8E8D4").opacity(0.7)],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
),
|
||||
lineWidth: 3
|
||||
)
|
||||
)
|
||||
|
||||
VStack(spacing: 16) {
|
||||
VStack(spacing: 20) {
|
||||
ForEach(Array(dayRange), id: \.self) { day in
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text(dayHeaderTitle(for: day).uppercased(with: locale))
|
||||
.font(.custom("Didot", size: 50))
|
||||
.foregroundColor(.mealMoodTextPrimary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 10)
|
||||
.background(dayHeaderBackground(day: day))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
HStack(spacing: 0) {
|
||||
VStack(spacing: 12) {
|
||||
Text(dayLongTitle(for: day).uppercased(with: locale))
|
||||
.font(.custom("Didot", size: 68))
|
||||
.foregroundColor(Color(hex: "#2C3E35"))
|
||||
.multilineTextAlignment(.center)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.38)
|
||||
.frame(maxWidth: .infinity)
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(dayNumberOnly(for: day))
|
||||
.font(.system(size: 96, weight: .bold, design: .rounded))
|
||||
.foregroundColor(Color(hex: "#2C3E35").opacity(0.6))
|
||||
}
|
||||
.frame(width: 420)
|
||||
.frame(maxHeight: .infinity)
|
||||
.padding(.vertical, 28)
|
||||
.background(dayHeaderBackground(day: day))
|
||||
|
||||
VStack(alignment: .leading, spacing: 24) {
|
||||
ForEach(mealTypes, id: \.self) { mealType in
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
Text("\(mealTypeLabel(mealType)):")
|
||||
.font(.system(size: 34, weight: .bold, design: .serif))
|
||||
.foregroundColor(Color(hex: "#385549"))
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(spacing: 14) {
|
||||
Image(systemName: mealType.icon)
|
||||
.font(.system(size: 42, weight: .medium))
|
||||
.foregroundColor(mealType == .lunch ? Color(hex: "#C85E2A") : Color(hex: "#27604A"))
|
||||
.frame(width: 56)
|
||||
Text(mealTypeLabel(mealType).uppercased(with: locale))
|
||||
.font(.system(size: 42, weight: .bold, design: .serif))
|
||||
.foregroundColor(mealType == .lunch ? Color(hex: "#C85E2A") : Color(hex: "#27604A"))
|
||||
}
|
||||
Text(dishName(day: day, mealType: mealType))
|
||||
.font(.system(size: 35, weight: .regular, design: .serif))
|
||||
.font(.system(size: 62, weight: .medium, design: .serif))
|
||||
.foregroundColor(.mealMoodTextPrimary)
|
||||
.lineLimit(2)
|
||||
.minimumScaleFactor(0.7)
|
||||
.minimumScaleFactor(0.5)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.leading, 70)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 6)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 40)
|
||||
.padding(.vertical, 28)
|
||||
.background(Color(hex: "#FFFCF8"))
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 14)
|
||||
.background(Color.white)
|
||||
.frame(maxHeight: .infinity)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 20))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.stroke(Color(hex: "#EEE4DE"), lineWidth: 1)
|
||||
RoundedRectangle(cornerRadius: 20)
|
||||
.stroke(Color(hex: "#EAD8D0"), lineWidth: 1.5)
|
||||
)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||
.shadow(color: Color.black.opacity(0.04), radius: 10, x: 0, y: 4)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 22)
|
||||
.padding(.vertical, 20)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.padding(.horizontal, 28)
|
||||
.padding(.vertical, 26)
|
||||
}
|
||||
}
|
||||
|
||||
private func dayNumberOnly(for day: Int) -> String {
|
||||
let dayDate = plan.weekStartDate.addingDays(day)
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = locale
|
||||
formatter.dateFormat = "d"
|
||||
return formatter.string(from: dayDate)
|
||||
}
|
||||
|
||||
private var footerBlock: some View {
|
||||
HStack(alignment: .center, spacing: 20) {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
|
||||
Reference in New Issue
Block a user