Version casi lista
This commit is contained in:
@@ -0,0 +1,248 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
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 showDishLimitUpsell = false
|
||||
|
||||
var dish: Dish?
|
||||
|
||||
private var language: AppLanguage {
|
||||
(allSettings.first?.languageEnum ?? .system).resolved()
|
||||
}
|
||||
|
||||
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) {
|
||||
// Name field
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("dish_name_label")
|
||||
.font(.mealMoodSmall)
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
|
||||
TextField(
|
||||
text: $viewModel.name,
|
||||
prompt: Text("dish_name_placeholder").foregroundColor(.mealMoodTextSecondary.opacity(0.6))
|
||||
) {
|
||||
EmptyView()
|
||||
}
|
||||
.font(.mealMoodBody)
|
||||
.foregroundColor(.mealMoodTextPrimary)
|
||||
.tint(.mealMoodCoral)
|
||||
.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)
|
||||
)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
if showDishLimitUpsell && reachedFreeDishLimit {
|
||||
PremiumUpsellBanner(
|
||||
messageKey: "premium_limit_dishes",
|
||||
actionTitleKey: "premium_subscribe"
|
||||
) {
|
||||
showPremium = true
|
||||
}
|
||||
}
|
||||
|
||||
// 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("dish_cancel") { dismiss() }
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
}
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button {
|
||||
guard !reachedFreeDishLimit else {
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
showDishLimitUpsell = true
|
||||
}
|
||||
showPremium = true
|
||||
HapticManager.shared.notification(type: .warning)
|
||||
return
|
||||
}
|
||||
viewModel.save(context: context)
|
||||
dismiss()
|
||||
} 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) }
|
||||
}
|
||||
}
|
||||
.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Simple flow layout for tags
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
struct DishListView: View {
|
||||
@Environment(\.modelContext) private var context
|
||||
@Query(sort: \Dish.createdAt, order: .reverse) private var dishes: [Dish]
|
||||
@Query private var tags: [Tag]
|
||||
@Query private var allSettings: [AppSettings]
|
||||
@Query private var weekPlans: [WeekPlan]
|
||||
@State private var showDishForm = false
|
||||
@State private var editingDish: Dish?
|
||||
@State private var showDeleteBlockedAlert = false
|
||||
|
||||
private var language: AppLanguage {
|
||||
(allSettings.first?.languageEnum ?? .system).resolved()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color.mealMoodBackground.ignoresSafeArea()
|
||||
|
||||
if dishes.isEmpty {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "fork.knife")
|
||||
.font(.system(size: 50))
|
||||
.foregroundColor(Color(hex: "#C4C4C4"))
|
||||
Text("dish_list_empty")
|
||||
.font(.mealMoodBody)
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
PrimaryButton(title: String(localized: "home_add_dish"), action: { showDishForm = true })
|
||||
.padding(.horizontal, 60)
|
||||
}
|
||||
} else {
|
||||
List {
|
||||
ForEach(dishes) { dish in
|
||||
let dishTags = tags.filter { dish.tagIds.contains($0.id) }
|
||||
Button {
|
||||
editingDish = dish
|
||||
} label: {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(dish.name)
|
||||
.font(.mealMoodBodyBold)
|
||||
.foregroundColor(.mealMoodTextPrimary)
|
||||
if let desc = dish.descriptionText, !desc.isEmpty {
|
||||
Text(desc)
|
||||
.font(.mealMoodCaption)
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
HStack(spacing: 4) {
|
||||
ForEach(dishTags.prefix(2)) { tag in
|
||||
TagPill(name: tag.localizedName(language: language), color: tag.color)
|
||||
}
|
||||
}
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
}
|
||||
}
|
||||
.listRowBackground(Color.mealMoodSurface)
|
||||
}
|
||||
.onDelete(perform: deleteDishes)
|
||||
}
|
||||
.scrollContentBackground(.hidden)
|
||||
}
|
||||
}
|
||||
.navigationTitle("home_my_dishes")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button {
|
||||
showDishForm = true
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
.foregroundColor(.mealMoodCoral)
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showDishForm) {
|
||||
DishFormView()
|
||||
}
|
||||
.sheet(item: $editingDish) { dish in
|
||||
DishFormView(dish: dish)
|
||||
}
|
||||
.alert("dish_delete_blocked_title", isPresented: $showDeleteBlockedAlert) {
|
||||
Button("dish_delete_blocked_ok", role: .cancel) {}
|
||||
} message: {
|
||||
Text("dish_delete_blocked_message")
|
||||
}
|
||||
}
|
||||
|
||||
private func deleteDishes(at offsets: IndexSet) {
|
||||
let currentWeekStart = Date().startOfWeek()
|
||||
let currentWeekPlan = weekPlans.first { $0.weekStartDate == currentWeekStart }
|
||||
|
||||
for index in offsets {
|
||||
let dish = dishes[index]
|
||||
if currentWeekPlan?.slots.contains(where: { $0.dishId == dish.id }) == true {
|
||||
showDeleteBlockedAlert = true
|
||||
continue
|
||||
}
|
||||
context.delete(dish)
|
||||
}
|
||||
try? context.save()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
struct TagSelectorSheet: View {
|
||||
@Environment(\.modelContext) private var context
|
||||
let tags: [Tag]
|
||||
@Query(sort: \Tag.sortOrder) private var storedTags: [Tag]
|
||||
@Binding var selectedTagIds: Set<UUID>
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Query private var allSettings: [AppSettings]
|
||||
|
||||
private var language: AppLanguage {
|
||||
(allSettings.first?.languageEnum ?? .system).resolved()
|
||||
}
|
||||
|
||||
private var displayedTags: [Tag] {
|
||||
let source = tags.isEmpty ? storedTags : tags
|
||||
return source.sorted(by: { $0.sortOrder < $1.sortOrder })
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
Color.mealMoodBackground.ignoresSafeArea()
|
||||
|
||||
List {
|
||||
ForEach(displayedTags) { tag in
|
||||
Button {
|
||||
if selectedTagIds.contains(tag.id) {
|
||||
selectedTagIds.remove(tag.id)
|
||||
} else {
|
||||
selectedTagIds.insert(tag.id)
|
||||
}
|
||||
HapticManager.shared.selection()
|
||||
} label: {
|
||||
HStack {
|
||||
Circle()
|
||||
.fill(Color(hex: tag.color))
|
||||
.frame(width: 12, height: 12)
|
||||
|
||||
Text(tag.localizedName(language: language))
|
||||
.font(.mealMoodBody)
|
||||
.foregroundColor(.mealMoodTextPrimary)
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: selectedTagIds.contains(tag.id) ? "checkmark.circle.fill" : "circle")
|
||||
.foregroundColor(selectedTagIds.contains(tag.id) ? .mealMoodCoral : Color(hex: "#C4C4C4"))
|
||||
.font(.system(size: 22))
|
||||
}
|
||||
}
|
||||
.listRowBackground(Color.mealMoodSurface)
|
||||
}
|
||||
|
||||
if displayedTags.isEmpty {
|
||||
Text("tag_selector_empty")
|
||||
.font(.mealMoodSmall)
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
.listRowBackground(Color.mealMoodSurface)
|
||||
}
|
||||
}
|
||||
.scrollContentBackground(.hidden)
|
||||
}
|
||||
.navigationTitle("tag_selector_title")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("tag_selector_done") { dismiss() }
|
||||
.font(.mealMoodBodyBold)
|
||||
.foregroundColor(.mealMoodCoral)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
ensureDefaultTagsIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
private func ensureDefaultTagsIfNeeded() {
|
||||
let descriptor = FetchDescriptor<Tag>()
|
||||
if (try? context.fetch(descriptor))?.isEmpty ?? true {
|
||||
DefaultDataService.createDefaultTags(context: context)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user