Release 1.0.0 baseline

This commit is contained in:
alexandrev-tibco
2026-02-26 10:27:09 +01:00
parent e593453abd
commit c5c7e3d2e4
24 changed files with 600 additions and 168 deletions
+49 -56
View File
@@ -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
}
+22 -11
View File
@@ -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 {
+241 -58
View File
@@ -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))]
}
}