7193234024
- Dish.fixedDayOfWeek/fixedMealType (opcionales, aditivos para CloudKit) - Toggle + pickers de dia/comida en el formulario de plato - AutocompleteEngine: pre-pasada que asigna los platos fijos a su hueco antes del bucle MRV, de modo que cuentan para el resto de reglas - Evento analytics dish_fixed_slot_set; strings en 6 idiomas Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013H6bXqGX1ygwib1Dm3n3UG
652 lines
30 KiB
Swift
652 lines
30 KiB
Swift
import SwiftUI
|
|
import SwiftData
|
|
import PhotosUI
|
|
|
|
struct DishFormView: View {
|
|
@Environment(\.modelContext) private var context
|
|
@Environment(\.dismiss) private var dismiss
|
|
@Query private var tags: [Tag]
|
|
@Query private var dishes: [Dish]
|
|
@Query private var allSettings: [AppSettings]
|
|
@StateObject private var viewModel = DishViewModel()
|
|
@State private var showPremium = false
|
|
@State private var premiumSource: String = "dish_limit"
|
|
@State private var showDishLimitModal = false
|
|
@State private var showSavedFeedback = false
|
|
@State private var photoPickerItem: PhotosPickerItem?
|
|
|
|
@StateObject private var dictation = SpeechDictationService()
|
|
@State private var dictationTarget: DictationTarget?
|
|
@State private var showDictationDeniedAlert = false
|
|
|
|
private enum DictationTarget { case name, ingredients }
|
|
|
|
var dish: Dish?
|
|
|
|
private var language: AppLanguage {
|
|
(allSettings.first?.languageEnum ?? .system).resolved()
|
|
}
|
|
|
|
/// Localized weekday name for the app's 0=Monday … 6=Sunday convention.
|
|
static func weekdayName(_ day: Int) -> String {
|
|
let symbols = Calendar.current.standaloneWeekdaySymbols // [0]=Sunday
|
|
return symbols[(day + 1) % 7].capitalized
|
|
}
|
|
|
|
private var reachedFreeDishLimit: Bool {
|
|
guard let settings = allSettings.first else { return false }
|
|
if viewModel.isEditing { return false }
|
|
return PremiumAccess.hasReachedFreeDishLimit(dishCount: dishes.count, isPremium: settings.isPremium)
|
|
}
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
ZStack {
|
|
Color.mealMoodBackground.ignoresSafeArea()
|
|
|
|
ScrollView {
|
|
VStack(spacing: 20) {
|
|
if showSavedFeedback {
|
|
HStack(spacing: 8) {
|
|
Image(systemName: "checkmark.circle.fill")
|
|
Text("toast_dish_saved")
|
|
}
|
|
.font(.mealMoodSmall)
|
|
.foregroundColor(.mealMoodSuccess)
|
|
.padding(.vertical, 10)
|
|
.frame(maxWidth: .infinity)
|
|
.background(Color.mealMoodSuccess.opacity(0.1))
|
|
.cornerRadius(10)
|
|
.transition(.opacity.combined(with: .move(edge: .top)))
|
|
}
|
|
|
|
// Name field
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text("dish_name_label")
|
|
.font(.mealMoodSmall)
|
|
.foregroundColor(.mealMoodTextSecondary)
|
|
|
|
HStack(spacing: 8) {
|
|
TextField(
|
|
text: $viewModel.name,
|
|
prompt: Text("dish_name_placeholder").foregroundColor(.mealMoodTextSecondary.opacity(0.6))
|
|
) {
|
|
EmptyView()
|
|
}
|
|
.font(.mealMoodBody)
|
|
.foregroundColor(.mealMoodTextPrimary)
|
|
.tint(.mealMoodCoral)
|
|
|
|
micButton(target: .name)
|
|
}
|
|
.padding(14)
|
|
.background(Color.mealMoodSurface)
|
|
.cornerRadius(12)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 12)
|
|
.stroke(Color(hex: "#F0F0F0"), lineWidth: 1)
|
|
)
|
|
}
|
|
|
|
// Description field
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text("dish_description_label")
|
|
.font(.mealMoodSmall)
|
|
.foregroundColor(.mealMoodTextSecondary)
|
|
|
|
TextField(
|
|
text: $viewModel.descriptionText,
|
|
prompt: Text("dish_description_placeholder").foregroundColor(.mealMoodTextSecondary.opacity(0.6)),
|
|
axis: .vertical
|
|
) {
|
|
EmptyView()
|
|
}
|
|
.font(.mealMoodBody)
|
|
.foregroundColor(.mealMoodTextPrimary)
|
|
.tint(.mealMoodCoral)
|
|
.lineLimit(2...4)
|
|
.padding(14)
|
|
.background(Color.mealMoodSurface)
|
|
.cornerRadius(12)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 12)
|
|
.stroke(Color(hex: "#F0F0F0"), lineWidth: 1)
|
|
)
|
|
}
|
|
|
|
// Photo section
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text("dish_photo_label")
|
|
.font(.mealMoodSmall)
|
|
.foregroundColor(.mealMoodTextSecondary)
|
|
|
|
if let data = viewModel.photoData, let image = UIImage(data: data) {
|
|
Image(uiImage: image)
|
|
.resizable()
|
|
.scaledToFill()
|
|
.frame(height: 160)
|
|
.frame(maxWidth: .infinity)
|
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
|
HStack(spacing: 16) {
|
|
PhotosPicker(selection: $photoPickerItem, matching: .images) {
|
|
Label("dish_photo_change", systemImage: "photo")
|
|
.font(.mealMoodSmall)
|
|
.foregroundColor(.mealMoodCoral)
|
|
}
|
|
Button {
|
|
viewModel.photoData = nil
|
|
photoPickerItem = nil
|
|
} label: {
|
|
Label("dish_photo_remove", systemImage: "trash")
|
|
.font(.mealMoodSmall)
|
|
.foregroundColor(.mealMoodError)
|
|
}
|
|
}
|
|
} else {
|
|
PhotosPicker(selection: $photoPickerItem, matching: .images) {
|
|
HStack {
|
|
Image(systemName: "photo.badge.plus")
|
|
Text("dish_photo_add")
|
|
}
|
|
.font(.mealMoodSmall)
|
|
.foregroundColor(.mealMoodCoral)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Ingredients section
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
HStack(spacing: 14) {
|
|
Text("dish_ingredients_label")
|
|
.font(.mealMoodSmall)
|
|
.foregroundColor(.mealMoodTextSecondary)
|
|
Spacer()
|
|
|
|
if viewModel.isParsingIngredients || (dictation.isRecording && dictationTarget == .ingredients) {
|
|
// stop/parse handled by the live bubble below
|
|
if viewModel.isParsingIngredients { ProgressView() }
|
|
} else {
|
|
Button {
|
|
toggleDictation(.ingredients)
|
|
} label: {
|
|
Label("dish_ingredients_dictate", systemImage: "mic.fill")
|
|
.font(.mealMoodSmall.weight(.semibold))
|
|
.foregroundColor(.mealMoodCoral)
|
|
}
|
|
}
|
|
|
|
if IngredientGenerator.isAvailable {
|
|
if viewModel.isGeneratingIngredients {
|
|
ProgressView()
|
|
} else {
|
|
Button {
|
|
requestGenerateIngredients()
|
|
} label: {
|
|
Label("shopping_generate", systemImage: "sparkles")
|
|
.font(.mealMoodSmall.weight(.semibold))
|
|
.foregroundColor(viewModel.isValid ? .mealMoodCoral : .gray)
|
|
}
|
|
.disabled(!viewModel.isValid)
|
|
}
|
|
}
|
|
}
|
|
|
|
if dictation.isRecording && dictationTarget == .ingredients {
|
|
ingredientDictationBubble
|
|
}
|
|
|
|
ForEach(viewModel.ingredients.indices, id: \.self) { index in
|
|
HStack {
|
|
TextField(
|
|
text: Binding(
|
|
get: { viewModel.ingredients.indices.contains(index) ? viewModel.ingredients[index] : "" },
|
|
set: { if viewModel.ingredients.indices.contains(index) { viewModel.ingredients[index] = $0 } }
|
|
),
|
|
prompt: Text("dish_ingredient_placeholder").foregroundColor(.mealMoodTextSecondary.opacity(0.6))
|
|
) {
|
|
EmptyView()
|
|
}
|
|
.font(.mealMoodBody)
|
|
.foregroundColor(.mealMoodTextPrimary)
|
|
.tint(.mealMoodCoral)
|
|
|
|
Button {
|
|
if viewModel.ingredients.indices.contains(index) {
|
|
viewModel.ingredients.remove(at: index)
|
|
}
|
|
} label: {
|
|
Image(systemName: "xmark.circle.fill")
|
|
.font(.system(size: 16))
|
|
.foregroundColor(.mealMoodTextSecondary)
|
|
}
|
|
}
|
|
.padding(12)
|
|
.background(Color.mealMoodSurface)
|
|
.cornerRadius(10)
|
|
}
|
|
|
|
Button {
|
|
viewModel.ingredients.append("")
|
|
} label: {
|
|
HStack {
|
|
Image(systemName: "plus.circle")
|
|
Text("dish_ingredients_add")
|
|
}
|
|
.font(.mealMoodSmall)
|
|
.foregroundColor(.mealMoodCoral)
|
|
}
|
|
}
|
|
|
|
// Tags section
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text("dish_tags_label")
|
|
.font(.mealMoodSmall)
|
|
.foregroundColor(.mealMoodTextSecondary)
|
|
|
|
if !viewModel.selectedTagIds.isEmpty {
|
|
FlowLayout(spacing: 6) {
|
|
ForEach(tags.filter { viewModel.selectedTagIds.contains($0.id) }) { tag in
|
|
HStack(spacing: 4) {
|
|
TagPill(name: tag.localizedName(language: language), color: tag.color)
|
|
Button {
|
|
viewModel.selectedTagIds.remove(tag.id)
|
|
} label: {
|
|
Image(systemName: "xmark.circle.fill")
|
|
.font(.system(size: 14))
|
|
.foregroundColor(.mealMoodTextSecondary)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Button {
|
|
viewModel.showTagSelector = true
|
|
} label: {
|
|
HStack {
|
|
Image(systemName: "plus.circle")
|
|
Text("dish_add_tag")
|
|
}
|
|
.font(.mealMoodSmall)
|
|
.foregroundColor(.mealMoodCoral)
|
|
}
|
|
}
|
|
|
|
// Priority toggle
|
|
Toggle(isOn: Binding(
|
|
get: { viewModel.isPriority },
|
|
set: { viewModel.isPriority = $0 }
|
|
)) {
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
HStack(spacing: 6) {
|
|
Image(systemName: "star.fill")
|
|
.foregroundColor(.mealMoodCoral)
|
|
.font(.system(size: 13))
|
|
Text("dish_priority")
|
|
.font(.mealMoodBodyBold)
|
|
.foregroundColor(.mealMoodTextPrimary)
|
|
}
|
|
Text("dish_priority_desc")
|
|
.font(.mealMoodCaption)
|
|
.foregroundColor(.mealMoodTextSecondary)
|
|
}
|
|
}
|
|
.tint(.mealMoodCoral)
|
|
.padding(16)
|
|
.background(Color.mealMoodSurface)
|
|
.cornerRadius(14)
|
|
|
|
// Fixed-slot rule ("tortilla every Friday dinner")
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
Toggle(isOn: Binding(
|
|
get: { viewModel.hasFixedSlot },
|
|
set: { viewModel.hasFixedSlot = $0 }
|
|
)) {
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
HStack(spacing: 6) {
|
|
Image(systemName: "pin.fill")
|
|
.foregroundColor(.mealMoodCoral)
|
|
.font(.system(size: 13))
|
|
Text("dish_fixed_slot")
|
|
.font(.mealMoodBodyBold)
|
|
.foregroundColor(.mealMoodTextPrimary)
|
|
}
|
|
Text("dish_fixed_slot_desc")
|
|
.font(.mealMoodCaption)
|
|
.foregroundColor(.mealMoodTextSecondary)
|
|
}
|
|
}
|
|
.tint(.mealMoodCoral)
|
|
|
|
if viewModel.hasFixedSlot {
|
|
HStack(spacing: 12) {
|
|
Picker("dish_fixed_slot_day", selection: Binding(
|
|
get: { viewModel.fixedDayOfWeek },
|
|
set: { viewModel.fixedDayOfWeek = $0 }
|
|
)) {
|
|
ForEach(0..<7, id: \.self) { day in
|
|
Text(Self.weekdayName(day)).tag(day)
|
|
}
|
|
}
|
|
Picker("dish_fixed_slot_meal", selection: Binding(
|
|
get: { viewModel.fixedMealType },
|
|
set: { viewModel.fixedMealType = $0 }
|
|
)) {
|
|
ForEach(allSettings.first?.activeMealTypes ?? MealType.allCases, id: \.self) { meal in
|
|
Text(LocalizedStringKey(meal.localizedKey)).tag(meal)
|
|
}
|
|
}
|
|
}
|
|
.pickerStyle(.menu)
|
|
.tint(.mealMoodCoral)
|
|
}
|
|
}
|
|
.padding(16)
|
|
.background(Color.mealMoodSurface)
|
|
.cornerRadius(14)
|
|
|
|
// Delete button (edit mode only)
|
|
if viewModel.isEditing {
|
|
Button {
|
|
viewModel.showDeleteAlert = true
|
|
} label: {
|
|
HStack {
|
|
Image(systemName: "trash")
|
|
Text("dish_delete")
|
|
}
|
|
.font(.mealMoodBodyBold)
|
|
.foregroundColor(.mealMoodError)
|
|
.padding(.vertical, 14)
|
|
.frame(maxWidth: .infinity)
|
|
.background(Color.mealMoodError.opacity(0.1))
|
|
.cornerRadius(14)
|
|
}
|
|
.padding(.top, 20)
|
|
}
|
|
}
|
|
.padding(24)
|
|
}
|
|
}
|
|
.navigationTitle(viewModel.isEditing ? String(localized: "dish_edit_title") : String(localized: "dish_new_title"))
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .navigationBarLeading) {
|
|
Button(viewModel.isEditing ? "dish_cancel" : "dish_done") { dismiss() }
|
|
.foregroundColor(.mealMoodTextSecondary)
|
|
}
|
|
ToolbarItem(placement: .navigationBarTrailing) {
|
|
Button {
|
|
guard !reachedFreeDishLimit else {
|
|
AnalyticsService.logDishLimitHit()
|
|
showDishLimitModal = true
|
|
HapticManager.shared.notification(type: .warning)
|
|
return
|
|
}
|
|
let wasEditing = viewModel.isEditing
|
|
viewModel.save(context: context)
|
|
if wasEditing {
|
|
dismiss()
|
|
} else {
|
|
withAnimation { showSavedFeedback = true }
|
|
HapticManager.shared.notification(type: .success)
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
|
|
withAnimation { showSavedFeedback = false }
|
|
}
|
|
}
|
|
} label: {
|
|
Image(systemName: "checkmark")
|
|
.fontWeight(.semibold)
|
|
.foregroundColor(viewModel.isValid ? .mealMoodCoral : .gray)
|
|
}
|
|
.disabled(!viewModel.isValid)
|
|
}
|
|
}
|
|
.sheet(isPresented: $viewModel.showTagSelector) {
|
|
TagSelectorSheet(tags: tags, selectedTagIds: $viewModel.selectedTagIds)
|
|
}
|
|
.sheet(isPresented: $showPremium) {
|
|
if let settings = allSettings.first {
|
|
NavigationStack { PremiumView(settings: settings, source: premiumSource) }
|
|
}
|
|
}
|
|
.sheet(isPresented: $showDishLimitModal) {
|
|
DishLimitModal(
|
|
onSeePremium: {
|
|
showDishLimitModal = false
|
|
premiumSource = "dish_limit"
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
|
|
showPremium = true
|
|
}
|
|
},
|
|
onDismiss: { showDishLimitModal = false }
|
|
)
|
|
.presentationDetents([.medium])
|
|
.presentationDragIndicator(.visible)
|
|
}
|
|
.alert("dish_delete_title", isPresented: $viewModel.showDeleteAlert) {
|
|
Button("dish_cancel", role: .cancel) {}
|
|
Button("dish_delete", role: .destructive) {
|
|
let deleted = viewModel.deleteDish(context: context)
|
|
if deleted {
|
|
dismiss()
|
|
}
|
|
}
|
|
} message: {
|
|
Text("dish_delete_message")
|
|
}
|
|
.alert("dish_delete_blocked_title", isPresented: $viewModel.showAssignedDeleteAlert) {
|
|
Button("dish_delete_blocked_ok", role: .cancel) {}
|
|
} message: {
|
|
Text("dish_delete_blocked_message")
|
|
}
|
|
.onAppear {
|
|
if let dish = dish {
|
|
viewModel.loadDish(dish)
|
|
}
|
|
}
|
|
.onChange(of: photoPickerItem) { _, newItem in
|
|
guard let newItem else { return }
|
|
Task {
|
|
if let data = try? await newItem.loadTransferable(type: Data.self) {
|
|
viewModel.photoData = Self.downscaledJPEG(from: data)
|
|
}
|
|
}
|
|
}
|
|
.onChange(of: dictation.transcript) { _, newValue in
|
|
guard dictation.isRecording, dictationTarget == .name else { return }
|
|
viewModel.name = newValue
|
|
}
|
|
.onChange(of: dictation.state) { _, newState in
|
|
if newState == .denied || newState == .unavailable {
|
|
dictationTarget = nil
|
|
if newState == .denied { showDictationDeniedAlert = true }
|
|
}
|
|
}
|
|
.onDisappear { dictation.stop() }
|
|
.alert("dictation_denied_title", isPresented: $showDictationDeniedAlert) {
|
|
Button("dish_cancel", role: .cancel) {}
|
|
Button("dictation_open_settings") {
|
|
if let url = URL(string: UIApplication.openSettingsURLString) {
|
|
UIApplication.shared.open(url)
|
|
}
|
|
}
|
|
} message: {
|
|
Text("dictation_denied_message")
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Dictation
|
|
|
|
/// A microphone toggle that dictates into `target`. Shows an active waveform
|
|
/// while this field is recording.
|
|
@ViewBuilder
|
|
private func micButton(target: DictationTarget) -> some View {
|
|
let isActive = dictation.isRecording && dictationTarget == target
|
|
Button {
|
|
toggleDictation(target)
|
|
} label: {
|
|
Image(systemName: isActive ? "waveform" : "mic.fill")
|
|
.font(.system(size: 16, weight: .semibold))
|
|
.foregroundColor(isActive ? .mealMoodError : .mealMoodCoral)
|
|
.symbolEffect(.variableColor.iterative, isActive: isActive)
|
|
.frame(width: 28, height: 28)
|
|
}
|
|
.accessibilityLabel(Text("dish_dictate"))
|
|
}
|
|
|
|
/// Live transcript shown while dictating a full ingredient list, with a
|
|
/// button to stop and split the phrase into individual ingredients.
|
|
private var ingredientDictationBubble: some View {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
HStack(spacing: 8) {
|
|
Image(systemName: "waveform")
|
|
.foregroundColor(.mealMoodError)
|
|
.symbolEffect(.variableColor.iterative, isActive: true)
|
|
Text("dictation_listening")
|
|
.font(.mealMoodSmall.weight(.semibold))
|
|
.foregroundColor(.mealMoodTextSecondary)
|
|
Spacer()
|
|
Button {
|
|
toggleDictation(.ingredients)
|
|
} label: {
|
|
Text("dictation_stop")
|
|
.font(.mealMoodSmall.weight(.semibold))
|
|
.foregroundColor(.mealMoodCoral)
|
|
}
|
|
}
|
|
|
|
Text(dictation.transcript.isEmpty ? String(localized: "dictation_ingredients_hint") : dictation.transcript)
|
|
.font(.mealMoodBody)
|
|
.foregroundColor(dictation.transcript.isEmpty ? .mealMoodTextSecondary : .mealMoodTextPrimary)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
.padding(14)
|
|
.background(Color.mealMoodCoral.opacity(0.08))
|
|
.cornerRadius(12)
|
|
}
|
|
|
|
/// Starts dictation for `target`, or stops the current one — routing the
|
|
/// final text to the name field or the ingredient parser.
|
|
private func toggleDictation(_ target: DictationTarget) {
|
|
if dictation.isRecording {
|
|
let text = dictation.stop()
|
|
let finishedTarget = dictationTarget
|
|
dictationTarget = nil
|
|
if finishedTarget == .ingredients {
|
|
viewModel.addDictatedIngredients(text, language: language)
|
|
}
|
|
} else {
|
|
dictationTarget = target
|
|
dictation.start(localeIdentifier: language.localeIdentifier)
|
|
}
|
|
}
|
|
|
|
private func requestGenerateIngredients() {
|
|
let isPremium = allSettings.first?.isPremium ?? false
|
|
guard PremiumAccess.canGenerateIngredients(isPremium: isPremium) else {
|
|
premiumSource = "ingredient_generation"
|
|
showPremium = true
|
|
return
|
|
}
|
|
viewModel.generateIngredients(language: language)
|
|
}
|
|
|
|
/// Downscales the picked photo (max 1024pt long edge, JPEG 0.8) so dish
|
|
/// photos stay small in the store and cheap to sync.
|
|
private static func downscaledJPEG(from data: Data, maxDimension: CGFloat = 1024) -> Data? {
|
|
guard let image = UIImage(data: data) else { return nil }
|
|
let size = image.size
|
|
let scale = min(1, maxDimension / max(size.width, size.height))
|
|
guard scale < 1 else { return image.jpegData(compressionQuality: 0.8) }
|
|
let newSize = CGSize(width: size.width * scale, height: size.height * scale)
|
|
let renderer = UIGraphicsImageRenderer(size: newSize)
|
|
let resized = renderer.image { _ in
|
|
image.draw(in: CGRect(origin: .zero, size: newSize))
|
|
}
|
|
return resized.jpegData(compressionQuality: 0.8)
|
|
}
|
|
}
|
|
|
|
// Simple flow layout for tags
|
|
struct DishLimitModal: View {
|
|
let onSeePremium: () -> Void
|
|
let onDismiss: () -> Void
|
|
|
|
var body: some View {
|
|
VStack(spacing: 20) {
|
|
Image(systemName: "sparkles")
|
|
.font(.system(size: 44))
|
|
.foregroundColor(.mealMoodCoral)
|
|
.padding(.top, 24)
|
|
|
|
VStack(spacing: 8) {
|
|
Text("dish_limit_modal_title")
|
|
.font(.mealMoodH2)
|
|
.foregroundColor(.mealMoodTextPrimary)
|
|
.multilineTextAlignment(.center)
|
|
Text("dish_limit_modal_message")
|
|
.font(.mealMoodBody)
|
|
.foregroundColor(.mealMoodTextSecondary)
|
|
.multilineTextAlignment(.center)
|
|
}
|
|
.padding(.horizontal, 24)
|
|
|
|
Spacer()
|
|
|
|
VStack(spacing: 10) {
|
|
PrimaryButton(title: String(localized: "dish_limit_modal_cta"), action: onSeePremium)
|
|
Button(action: onDismiss) {
|
|
Text("dish_limit_modal_dismiss")
|
|
.font(.mealMoodSmall)
|
|
.foregroundColor(.mealMoodTextSecondary)
|
|
}
|
|
}
|
|
.padding(.horizontal, 24)
|
|
.padding(.bottom, 24)
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
.background(Color.mealMoodBackground.ignoresSafeArea())
|
|
}
|
|
}
|
|
|
|
struct FlowLayout: Layout {
|
|
var spacing: CGFloat = 8
|
|
|
|
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
|
|
let result = arrange(proposal: proposal, subviews: subviews)
|
|
return result.size
|
|
}
|
|
|
|
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
|
|
let result = arrange(proposal: proposal, subviews: subviews)
|
|
for (index, position) in result.positions.enumerated() {
|
|
subviews[index].place(at: CGPoint(x: bounds.minX + position.x, y: bounds.minY + position.y), proposal: .unspecified)
|
|
}
|
|
}
|
|
|
|
private func arrange(proposal: ProposedViewSize, subviews: Subviews) -> (size: CGSize, positions: [CGPoint]) {
|
|
let maxWidth = proposal.width ?? .infinity
|
|
var positions: [CGPoint] = []
|
|
var x: CGFloat = 0
|
|
var y: CGFloat = 0
|
|
var maxHeight: CGFloat = 0
|
|
var rowHeight: CGFloat = 0
|
|
|
|
for subview in subviews {
|
|
let size = subview.sizeThatFits(.unspecified)
|
|
if x + size.width > maxWidth && x > 0 {
|
|
x = 0
|
|
y += rowHeight + spacing
|
|
rowHeight = 0
|
|
}
|
|
positions.append(CGPoint(x: x, y: y))
|
|
rowHeight = max(rowHeight, size.height)
|
|
x += size.width + spacing
|
|
maxHeight = max(maxHeight, y + rowHeight)
|
|
}
|
|
|
|
return (CGSize(width: maxWidth, height: maxHeight), positions)
|
|
}
|
|
}
|