1.0.2: Add multilingual support, redesign share view, and iCloud sync improvements
- Add localisations for German, French, Italian, and Brazilian Portuguese - Redesign WeekPlanShareView with richer layout and sharing options - Improve HomeView with various UI enhancements - Update AppSettings and Tag models for new features - Minor iCloud sync and Settings fixes - Add archive & upload script for App Store submissions Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -6,14 +6,40 @@ struct WeekPlanShareView: View {
|
||||
let settings: AppSettings
|
||||
let dishes: [Dish]
|
||||
let tags: [Tag]
|
||||
let exportStyleOverride: WeekExportStyle?
|
||||
|
||||
static let a4LandscapeWidth: CGFloat = 3508
|
||||
static let a4LandscapeHeight: CGFloat = 2480
|
||||
static let a4PortraitWidth: CGFloat = 2480
|
||||
static let a4PortraitHeight: CGFloat = 3508
|
||||
|
||||
private let shareURL = "https://mealmood.app"
|
||||
private let qrContext = CIContext()
|
||||
private let qrFilter = CIFilter.qrCodeGenerator()
|
||||
|
||||
init(
|
||||
plan: WeekPlan,
|
||||
settings: AppSettings,
|
||||
dishes: [Dish],
|
||||
tags: [Tag],
|
||||
exportStyleOverride: WeekExportStyle? = nil
|
||||
) {
|
||||
self.plan = plan
|
||||
self.settings = settings
|
||||
self.dishes = dishes
|
||||
self.tags = tags
|
||||
self.exportStyleOverride = exportStyleOverride
|
||||
}
|
||||
|
||||
static func canvasSize(for style: WeekExportStyle) -> CGSize {
|
||||
switch style {
|
||||
case .vertical:
|
||||
return CGSize(width: a4PortraitWidth, height: a4PortraitHeight)
|
||||
case .defaultStyle, .schoolTimetable:
|
||||
return CGSize(width: a4LandscapeWidth, height: a4LandscapeHeight)
|
||||
}
|
||||
}
|
||||
|
||||
private var dayRange: ClosedRange<Int> {
|
||||
settings.includeWeekends ? 0...6 : 0...4
|
||||
}
|
||||
@@ -26,6 +52,18 @@ struct WeekPlanShareView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var exportStyle: WeekExportStyle {
|
||||
exportStyleOverride ?? settings.weekExportStyleEnum
|
||||
}
|
||||
|
||||
private var canvasSize: CGSize {
|
||||
Self.canvasSize(for: exportStyle)
|
||||
}
|
||||
|
||||
private var locale: Locale {
|
||||
Locale(identifier: settings.languageEnum.resolved().localeIdentifier)
|
||||
}
|
||||
|
||||
private var exportDateString: String {
|
||||
Date.now.formatted(date: .abbreviated, time: .omitted)
|
||||
}
|
||||
@@ -41,77 +79,42 @@ struct WeekPlanShareView: View {
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
backgroundLayer
|
||||
switch exportStyle {
|
||||
case .defaultStyle:
|
||||
defaultBackground
|
||||
case .schoolTimetable:
|
||||
schoolBackground
|
||||
case .vertical:
|
||||
verticalBackground
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 34) {
|
||||
VStack(spacing: 24) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Group {
|
||||
switch exportStyle {
|
||||
case .defaultStyle:
|
||||
defaultGridLayout
|
||||
case .schoolTimetable:
|
||||
schoolTimetableLayout
|
||||
case .vertical:
|
||||
verticalLayout
|
||||
}
|
||||
.padding(18)
|
||||
}
|
||||
.shadow(color: Color(hex: "#DDAA99").opacity(0.18), radius: 26, x: 0, y: 16)
|
||||
|
||||
Spacer()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
|
||||
footerBlock
|
||||
}
|
||||
.padding(.horizontal, 84)
|
||||
.padding(.vertical, 72)
|
||||
.padding(.horizontal, exportStyle == .vertical ? 72 : 84)
|
||||
.padding(.vertical, exportStyle == .vertical ? 58 : 70)
|
||||
}
|
||||
.frame(width: Self.a4LandscapeWidth, height: Self.a4LandscapeHeight)
|
||||
.frame(width: canvasSize.width, height: canvasSize.height)
|
||||
}
|
||||
|
||||
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 {
|
||||
private var defaultBackground: some View {
|
||||
ZStack {
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Color(hex: "#FFEFE6"),
|
||||
Color(hex: "#F1FBF5")
|
||||
],
|
||||
colors: [Color(hex: "#FFEFE6"), Color(hex: "#F1FBF5")],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
@@ -127,57 +130,262 @@ struct WeekPlanShareView: View {
|
||||
.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 schoolBackground: some View {
|
||||
ZStack {
|
||||
Color(hex: "#FFF9F5")
|
||||
|
||||
RoundedRectangle(cornerRadius: 220)
|
||||
.fill(Color.mealMoodMint.opacity(0.22))
|
||||
.frame(width: 1500, height: 520)
|
||||
.offset(x: 920, y: -920)
|
||||
|
||||
RoundedRectangle(cornerRadius: 220)
|
||||
.fill(Color.mealMoodCoral.opacity(0.18))
|
||||
.frame(width: 1600, height: 560)
|
||||
.offset(x: -920, y: 940)
|
||||
}
|
||||
}
|
||||
|
||||
private var verticalBackground: some View {
|
||||
ZStack {
|
||||
Color(hex: "#FFFDF8")
|
||||
|
||||
Ellipse()
|
||||
.fill(Color.mealMoodMint.opacity(0.28))
|
||||
.frame(width: 1100, height: 900)
|
||||
.offset(x: 800, y: -1200)
|
||||
|
||||
Ellipse()
|
||||
.fill(Color.mealMoodCoral.opacity(0.2))
|
||||
.frame(width: 900, height: 680)
|
||||
.offset(x: -800, y: 1200)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var headerBlock: some View {
|
||||
switch exportStyle {
|
||||
case .defaultStyle:
|
||||
defaultHeader
|
||||
case .schoolTimetable, .vertical:
|
||||
elegantHeader
|
||||
}
|
||||
}
|
||||
|
||||
private var defaultHeader: 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))
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(exportHeaderTitle)
|
||||
.font(.system(size: 52, weight: .bold, design: .rounded))
|
||||
.foregroundColor(.mealMoodTextPrimary)
|
||||
Text(String(localized: "share_week_title"))
|
||||
Text(plan.weekStartDate.formattedWeekRange())
|
||||
.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)
|
||||
}
|
||||
Text(exportDateString)
|
||||
.font(.system(size: 24, weight: .medium, design: .rounded))
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
}
|
||||
.padding(.horizontal, 30)
|
||||
.padding(.vertical, 24)
|
||||
.background(Color.white.opacity(0.76))
|
||||
.background(Color.white.opacity(0.8))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 24))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 24)
|
||||
.stroke(Color(hex: "#F3C4B5").opacity(0.7), lineWidth: 1)
|
||||
.stroke(Color.mealMoodCoral.opacity(0.5), lineWidth: 1)
|
||||
)
|
||||
.shadow(color: Color(hex: "#DDAA99").opacity(0.15), radius: 20, x: 0, y: 12)
|
||||
}
|
||||
|
||||
private var elegantHeader: some View {
|
||||
VStack(spacing: 8) {
|
||||
Text(plan.weekStartDate.formattedWeekRange())
|
||||
.font(.custom("Didot", size: exportStyle == .vertical ? 126 : 108))
|
||||
.foregroundColor(Color(hex: "#30473F"))
|
||||
.multilineTextAlignment(.center)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.45)
|
||||
.frame(maxWidth: .infinity)
|
||||
|
||||
Text(exportHeaderTitle)
|
||||
.font(.system(size: exportStyle == .vertical ? 58 : 54, weight: .semibold, design: .serif))
|
||||
.foregroundColor(.mealMoodTextPrimary)
|
||||
|
||||
Text(exportDateString)
|
||||
.font(.system(size: 34, weight: .medium, design: .serif))
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.vertical, 24)
|
||||
.background(Color.white.opacity(0.88))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 24))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 24)
|
||||
.stroke(Color.mealMoodCoral.opacity(0.45), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
|
||||
private var exportHeaderTitle: String {
|
||||
switch exportStyle {
|
||||
case .defaultStyle:
|
||||
return String(localized: "share_week_title")
|
||||
case .schoolTimetable:
|
||||
return String(localized: "share_export_school_title")
|
||||
case .vertical:
|
||||
return String(localized: "share_export_vertical_title")
|
||||
}
|
||||
}
|
||||
|
||||
private var defaultGridLayout: some View {
|
||||
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(dayShortTitle(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)
|
||||
}
|
||||
|
||||
private var schoolTimetableLayout: some View {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 28)
|
||||
.fill(Color.white)
|
||||
.shadow(color: Color.black.opacity(0.06), radius: 20, x: 0, y: 10)
|
||||
|
||||
VStack(spacing: 18) {
|
||||
Text(String(localized: "share_export_school_subtitle"))
|
||||
.font(.custom("Didot", size: 48))
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
|
||||
Grid(horizontalSpacing: 12, verticalSpacing: 12) {
|
||||
GridRow {
|
||||
Text(" ")
|
||||
.frame(width: 280)
|
||||
ForEach(mealTypes, id: \.self) { mealType in
|
||||
Text(mealTypeLabel(mealType).uppercased(with: locale))
|
||||
.font(.system(size: 34, weight: .bold, design: .serif))
|
||||
.foregroundColor(Color(hex: "#355548"))
|
||||
.frame(maxWidth: .infinity, minHeight: 88)
|
||||
.background(Color.mealMoodMint.opacity(0.3))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
}
|
||||
|
||||
ForEach(Array(dayRange), id: \.self) { day in
|
||||
GridRow {
|
||||
Text(dayHeaderTitle(for: day).uppercased(with: locale))
|
||||
.font(.system(size: 30, weight: .bold, design: .serif))
|
||||
.foregroundColor(.mealMoodTextPrimary)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(width: 280)
|
||||
.frame(minHeight: 138)
|
||||
.background(dayHeaderBackground(day: day))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
|
||||
ForEach(mealTypes, id: \.self) { mealType in
|
||||
Text(dishName(day: day, mealType: mealType))
|
||||
.font(.system(size: 31, weight: .medium, design: .serif))
|
||||
.foregroundColor(.mealMoodTextPrimary)
|
||||
.multilineTextAlignment(.leading)
|
||||
.lineLimit(3)
|
||||
.frame(maxWidth: .infinity, minHeight: 138, alignment: .leading)
|
||||
.padding(.horizontal, 16)
|
||||
.background(Color(hex: "#FFFDFB"))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.stroke(Color(hex: "#ECDDD5"), lineWidth: 1)
|
||||
)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.vertical, 22)
|
||||
}
|
||||
}
|
||||
|
||||
private var verticalLayout: some View {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 30)
|
||||
.fill(Color.white.opacity(0.95))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 30)
|
||||
.stroke(Color.mealMoodMint.opacity(0.45), lineWidth: 2)
|
||||
)
|
||||
|
||||
VStack(spacing: 16) {
|
||||
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))
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
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"))
|
||||
|
||||
Text(dishName(day: day, mealType: mealType))
|
||||
.font(.system(size: 35, weight: .regular, design: .serif))
|
||||
.foregroundColor(.mealMoodTextPrimary)
|
||||
.lineLimit(2)
|
||||
.minimumScaleFactor(0.7)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 6)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 14)
|
||||
.background(Color.white)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.stroke(Color(hex: "#EEE4DE"), lineWidth: 1)
|
||||
)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 22)
|
||||
.padding(.vertical, 20)
|
||||
}
|
||||
}
|
||||
|
||||
private var footerBlock: some View {
|
||||
@@ -211,13 +419,48 @@ struct WeekPlanShareView: View {
|
||||
}
|
||||
.padding(.horizontal, 26)
|
||||
.padding(.vertical, 18)
|
||||
.background(Color.white.opacity(0.8))
|
||||
.background(Color.white.opacity(0.82))
|
||||
.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 dayShortTitle(for day: Int) -> String {
|
||||
let dayDate = plan.weekStartDate.addingDays(day)
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = locale
|
||||
formatter.setLocalizedDateFormatFromTemplate("EEE d")
|
||||
return formatter.string(from: dayDate).capitalized(with: locale)
|
||||
}
|
||||
|
||||
private func dayLongTitle(for day: Int) -> String {
|
||||
let dayDate = plan.weekStartDate.addingDays(day)
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = locale
|
||||
formatter.setLocalizedDateFormatFromTemplate("EEEE")
|
||||
return formatter.string(from: dayDate).capitalized(with: locale)
|
||||
}
|
||||
|
||||
private func dayHeaderTitle(for day: Int) -> String {
|
||||
let dayDate = plan.weekStartDate.addingDays(day)
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = locale
|
||||
formatter.setLocalizedDateFormatFromTemplate("EEEE d")
|
||||
return formatter.string(from: dayDate).capitalized(with: locale)
|
||||
}
|
||||
|
||||
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 func gridHeaderCell(_ title: String, day: Int?) -> some View {
|
||||
|
||||
Reference in New Issue
Block a user