320 lines
12 KiB
Swift
320 lines
12 KiB
Swift
import SwiftUI
|
|
import CoreImage.CIFilterBuiltins
|
|
|
|
struct WeekPlanShareView: View {
|
|
let plan: WeekPlan
|
|
let settings: AppSettings
|
|
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
|
|
}
|
|
|
|
private var mealTypes: [MealType] {
|
|
switch settings.mealWindowsEnum {
|
|
case .dinnerOnly: return [.dinner]
|
|
case .lunchOnly: return [.lunch]
|
|
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 {
|
|
backgroundLayer
|
|
|
|
VStack(alignment: .leading, spacing: 34) {
|
|
headerBlock
|
|
|
|
ZStack {
|
|
RoundedRectangle(cornerRadius: 30)
|
|
.fill(Color.white.opacity(0.62))
|
|
RoundedRectangle(cornerRadius: 30)
|
|
.stroke(Color.white.opacity(0.75), lineWidth: 2)
|
|
|
|
Grid(horizontalSpacing: 10, verticalSpacing: 10) {
|
|
GridRow {
|
|
gridHeaderCell("", day: nil)
|
|
.frame(minWidth: 220)
|
|
ForEach(Array(dayRange), id: \.self) { day in
|
|
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()
|
|
|
|
footerBlock
|
|
}
|
|
.padding(.horizontal, 84)
|
|
.padding(.vertical, 72)
|
|
}
|
|
.frame(width: Self.a4LandscapeWidth, height: Self.a4LandscapeHeight)
|
|
}
|
|
|
|
private func dayTitle(for day: Int) -> String {
|
|
let keys = ["day_mon", "day_tue", "day_wed", "day_thu", "day_fri", "day_sat", "day_sun"]
|
|
let localizedDay = day >= 0 && day < keys.count ? NSLocalizedString(keys[day], comment: "") : ""
|
|
let dayDate = plan.weekStartDate.addingDays(day)
|
|
let dayNumber = Calendar.current.component(.day, from: dayDate)
|
|
return "\(localizedDay) \(dayNumber)"
|
|
}
|
|
|
|
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 {
|
|
return "—"
|
|
}
|
|
return dish.name
|
|
}
|
|
|
|
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(background)
|
|
.clipShape(RoundedRectangle(cornerRadius: 14))
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 14)
|
|
.stroke(Color.white.opacity(0.6), lineWidth: 1)
|
|
)
|
|
}
|
|
|
|
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: 25, weight: .bold, design: .rounded))
|
|
.foregroundColor(Color(hex: "#365548"))
|
|
.frame(maxWidth: .infinity, minHeight: 130)
|
|
.padding(.horizontal, 14)
|
|
.background(background)
|
|
.clipShape(RoundedRectangle(cornerRadius: 14))
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 14)
|
|
.stroke(Color.white.opacity(0.6), lineWidth: 1)
|
|
)
|
|
}
|
|
|
|
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)
|
|
.lineLimit(3)
|
|
.minimumScaleFactor(0.6)
|
|
.frame(maxWidth: .infinity, minHeight: 130)
|
|
.padding(.horizontal, 10)
|
|
.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))]
|
|
}
|
|
}
|