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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import SwiftUI
|
||||
|
||||
struct DishDrawerView: View {
|
||||
let dishes: [Dish]
|
||||
let tags: [Tag]
|
||||
let language: AppLanguage
|
||||
let usedDishIds: Set<UUID>
|
||||
var onAddDish: () -> Void
|
||||
var onQuickAssignDish: (Dish) -> Void
|
||||
var onEditDish: (Dish) -> Void
|
||||
var onDeleteDish: (Dish) -> Void
|
||||
@Binding var draggedDish: Dish?
|
||||
@State private var searchText: String = ""
|
||||
@State private var hideUsedThisWeek: Bool = false
|
||||
|
||||
private var filteredDishes: [Dish] {
|
||||
let term = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return dishes.filter { dish in
|
||||
let matchesSearch = term.isEmpty || dish.name.localizedCaseInsensitiveContains(term)
|
||||
let matchesUsedFilter = !hideUsedThisWeek || !usedDishIds.contains(dish.id)
|
||||
return matchesSearch && matchesUsedFilter
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 12) {
|
||||
HStack {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "list.clipboard")
|
||||
Text("home_my_dishes")
|
||||
.font(.mealMoodH3)
|
||||
}
|
||||
.foregroundColor(.mealMoodTextPrimary)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button(action: onAddDish) {
|
||||
Image(systemName: "plus.circle.fill")
|
||||
.font(.system(size: 28))
|
||||
.foregroundColor(.mealMoodCoral)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
|
||||
if dishes.isEmpty {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "fork.knife")
|
||||
.font(.system(size: 40))
|
||||
.foregroundColor(Color(hex: "#C4C4C4"))
|
||||
|
||||
Text("home_add_first_dish")
|
||||
.font(.mealMoodBody)
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
|
||||
PrimaryButton(title: String(localized: "home_add_dish"), icon: nil, action: onAddDish)
|
||||
.padding(.horizontal, 40)
|
||||
}
|
||||
.padding(.vertical, 32)
|
||||
} else {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
TextField("home_my_dishes_search", text: $searchText)
|
||||
.font(.mealMoodBody)
|
||||
if !searchText.isEmpty {
|
||||
Button {
|
||||
searchText = ""
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 10)
|
||||
.background(Color.mealMoodSurface)
|
||||
.cornerRadius(12)
|
||||
.padding(.horizontal, 16)
|
||||
|
||||
Button {
|
||||
hideUsedThisWeek.toggle()
|
||||
} label: {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: hideUsedThisWeek ? "checkmark.square.fill" : "square")
|
||||
.foregroundColor(hideUsedThisWeek ? .mealMoodCoral : .mealMoodTextSecondary)
|
||||
Text("home_my_dishes_hide_used")
|
||||
.font(.mealMoodSmall)
|
||||
.foregroundColor(.mealMoodTextPrimary)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
LazyVStack(spacing: 8) {
|
||||
ForEach(filteredDishes) { dish in
|
||||
DishCardView(
|
||||
dish: dish,
|
||||
tags: tags,
|
||||
language: language,
|
||||
onEdit: { onEditDish(dish) },
|
||||
onDelete: { onDeleteDish(dish) }
|
||||
)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
onQuickAssignDish(dish)
|
||||
}
|
||||
.swipeActions(edge: .leading, allowsFullSwipe: false) {
|
||||
Button {
|
||||
onEditDish(dish)
|
||||
} label: {
|
||||
Label("dish_edit_title", systemImage: "pencil")
|
||||
}
|
||||
.tint(.mealMoodCoral)
|
||||
}
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||
Button(role: .destructive) {
|
||||
onDeleteDish(dish)
|
||||
} label: {
|
||||
Label("dish_delete", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
.draggable("dish:\(dish.id.uuidString)") {
|
||||
DishCardView(dish: dish, tags: tags, language: language)
|
||||
.frame(width: 200)
|
||||
.opacity(0.8)
|
||||
}
|
||||
}
|
||||
|
||||
if filteredDishes.isEmpty {
|
||||
Text("home_my_dishes_search_empty")
|
||||
.font(.mealMoodBody)
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
}
|
||||
.padding(.top, 16)
|
||||
}
|
||||
}
|
||||
|
||||
struct DishCardView: View {
|
||||
let dish: Dish
|
||||
let tags: [Tag]
|
||||
let language: AppLanguage
|
||||
var onEdit: (() -> Void)? = nil
|
||||
var onDelete: (() -> Void)? = nil
|
||||
|
||||
private var dishTags: [Tag] {
|
||||
tags.filter { dish.tagIds.contains($0.id) }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
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(3)) { tag in
|
||||
TagDot(color: tag.color, size: 10)
|
||||
}
|
||||
}
|
||||
|
||||
if onEdit != nil || onDelete != nil {
|
||||
HStack(spacing: 8) {
|
||||
if let onEdit {
|
||||
Button(action: onEdit) {
|
||||
Image(systemName: "pencil.circle")
|
||||
.foregroundColor(.mealMoodCoral)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
if let onDelete {
|
||||
Button(action: onDelete) {
|
||||
Image(systemName: "trash.circle")
|
||||
.foregroundColor(.mealMoodError)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(14)
|
||||
.mealCardStyle()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,752 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
struct HomeView: View {
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
@Environment(\.modelContext) private var context
|
||||
@Query private var dishes: [Dish]
|
||||
@Query private var tags: [Tag]
|
||||
@Query(sort: \WeekPlan.weekStartDate, order: .forward) private var weekPlans: [WeekPlan]
|
||||
@Query private var allSettings: [AppSettings]
|
||||
@StateObject private var viewModel = HomeViewModel()
|
||||
@State private var editingDish: Dish?
|
||||
@State private var showPremiumFromExport: Bool = false
|
||||
@State private var showMonthlyHistory: Bool = false
|
||||
@State private var showWeekLimitUpsell: Bool = false
|
||||
@State private var wasWeekComplete: Bool = false
|
||||
@State private var selectedEmptySlotId: UUID?
|
||||
@State private var showWeekPicker: Bool = false
|
||||
@State private var weekPickerDate: Date = Date()
|
||||
@State private var showCopyPreviousConfirm: Bool = false
|
||||
|
||||
private var settings: AppSettings? { allSettings.first }
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
Color.mealMoodBackground.ignoresSafeArea()
|
||||
|
||||
if let settings = settings,
|
||||
let plan = viewModel.getOrCreateWeekPlan(context: context, settings: settings) {
|
||||
mainContent(plan: plan, settings: settings)
|
||||
}
|
||||
}
|
||||
.toast(isShowing: $viewModel.showToast, message: viewModel.toastMessage)
|
||||
.navigationTitle("")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbarBackground(Color.mealMoodCoral.opacity(0.22), for: .navigationBar)
|
||||
.toolbarBackground(.visible, for: .navigationBar)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
HStack(spacing: 12) {
|
||||
NavigationLink(destination: SettingsView()) {
|
||||
Image(systemName: "gearshape")
|
||||
.foregroundColor(.mealMoodTextPrimary)
|
||||
}
|
||||
|
||||
if settings?.isPremium == true {
|
||||
Button {
|
||||
showMonthlyHistory = true
|
||||
} label: {
|
||||
Image(systemName: "calendar")
|
||||
.foregroundColor(.mealMoodTextPrimary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .principal) {
|
||||
HStack(spacing: 10) {
|
||||
if let settings = settings {
|
||||
Button {
|
||||
viewModel.goToPreviousWeek()
|
||||
showWeekLimitUpsell = false
|
||||
} label: {
|
||||
Image(systemName: "chevron.left")
|
||||
.foregroundColor(.mealMoodCoral)
|
||||
}
|
||||
.accessibilityLabel(Text("home_previous"))
|
||||
|
||||
Button {
|
||||
weekPickerDate = viewModel.currentWeekStart
|
||||
showWeekPicker = true
|
||||
} label: {
|
||||
VStack(spacing: 2) {
|
||||
Text(viewModel.weekRangeText)
|
||||
.font(.mealMoodH2)
|
||||
.foregroundColor(.mealMoodTextPrimary)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(Text("home_select_week"))
|
||||
|
||||
Button {
|
||||
if canNavigateToNextWeek(settings: settings) {
|
||||
viewModel.goToNextWeek()
|
||||
showWeekLimitUpsell = false
|
||||
} else {
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
showWeekLimitUpsell = true
|
||||
}
|
||||
showPremiumFromExport = true
|
||||
HapticManager.shared.notification(type: .warning)
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "chevron.right")
|
||||
.foregroundColor(.mealMoodCoral)
|
||||
}
|
||||
.accessibilityLabel(Text("home_next"))
|
||||
}
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
HStack(spacing: 10) {
|
||||
if let settings = settings,
|
||||
let plan = viewModel.getOrCreateWeekPlan(context: context, settings: settings) {
|
||||
if viewModel.canEditCurrentWeek {
|
||||
Button {
|
||||
viewModel.autoComplete(plan: plan, dishes: dishes, tags: tags, settings: settings)
|
||||
} label: {
|
||||
Image(systemName: "wand.and.stars")
|
||||
.foregroundColor(.mealMoodTextPrimary)
|
||||
}
|
||||
.contextMenu {
|
||||
Button {
|
||||
viewModel.undoLastAction(plan: plan, settings: settings)
|
||||
} label: {
|
||||
Label("home_undo_last_action", systemImage: "arrow.uturn.backward")
|
||||
}
|
||||
.disabled(!viewModel.canUndo(for: plan))
|
||||
|
||||
Button {
|
||||
if plan.slots.contains(where: { $0.dishId != nil }) {
|
||||
showCopyPreviousConfirm = true
|
||||
} else {
|
||||
copyFromPreviousWeek(currentPlan: plan, settings: settings)
|
||||
}
|
||||
} label: {
|
||||
Label("home_copy_previous_week", systemImage: "doc.on.doc")
|
||||
}
|
||||
|
||||
Button(role: .destructive) {
|
||||
viewModel.showResetAlert = true
|
||||
} label: {
|
||||
Label("home_reset", systemImage: "arrow.counterclockwise")
|
||||
}
|
||||
}
|
||||
.accessibilityLabel(Text("home_complete"))
|
||||
.disabled(viewModel.isAutoCompleting || dishes.isEmpty)
|
||||
|
||||
if settings.syncEnabled && settings.syncModeEnum == .manual {
|
||||
Button {
|
||||
viewModel.syncWeekToCalendar(plan: plan, dishes: dishes, settings: settings)
|
||||
} label: {
|
||||
Image(systemName: "arrow.triangle.2.circlepath")
|
||||
.foregroundColor(.mealMoodTextPrimary)
|
||||
}
|
||||
.accessibilityLabel(Text("settings_sync_now"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func mainContent(plan: WeekPlan, settings: AppSettings) -> some View {
|
||||
VStack(spacing: 0) {
|
||||
if showWeekLimitUpsell && !settings.isPremium {
|
||||
PremiumUpsellBanner(
|
||||
messageKey: "premium_limit_future_weeks",
|
||||
actionTitleKey: "premium_subscribe"
|
||||
) {
|
||||
showPremiumFromExport = true
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.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)
|
||||
}
|
||||
|
||||
}
|
||||
.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)
|
||||
}
|
||||
.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)
|
||||
}
|
||||
.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) {
|
||||
Button(String(localized: "reset_cancel"), role: .cancel) {}
|
||||
Button(String(localized: "reset_confirm"), role: .destructive) {
|
||||
viewModel.resetWeek(plan: plan, settings: settings)
|
||||
}
|
||||
} message: {
|
||||
Text("reset_message")
|
||||
}
|
||||
.alert("copy_previous_confirm_title", isPresented: $showCopyPreviousConfirm) {
|
||||
Button("reset_cancel", role: .cancel) {}
|
||||
Button("copy_previous_confirm_confirm", role: .destructive) {
|
||||
copyFromPreviousWeek(currentPlan: plan, settings: settings)
|
||||
}
|
||||
} message: {
|
||||
Text("copy_previous_confirm_message")
|
||||
}
|
||||
.alert(item: $viewModel.invalidDropContext) { context in
|
||||
Alert(
|
||||
title: Text("rule_override_title"),
|
||||
message: Text("rule_override_message"),
|
||||
primaryButton: .destructive(Text("rule_override_confirm")) {
|
||||
viewModel.assignDish(context.dish, to: context.slot, plan: plan, settings: settings, isOverride: true)
|
||||
},
|
||||
secondaryButton: .cancel(Text("reset_cancel"))
|
||||
)
|
||||
}
|
||||
.sheet(isPresented: $viewModel.showDishForm) {
|
||||
DishFormView()
|
||||
}
|
||||
.sheet(isPresented: $showWeekPicker) {
|
||||
NavigationStack {
|
||||
Form {
|
||||
DatePicker(
|
||||
"home_week_picker_date",
|
||||
selection: $weekPickerDate,
|
||||
displayedComponents: .date
|
||||
)
|
||||
.datePickerStyle(.graphical)
|
||||
}
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(Color.mealMoodBackground)
|
||||
.navigationTitle("home_week_picker_title")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("reset_cancel") {
|
||||
showWeekPicker = false
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("home_week_picker_go") {
|
||||
jumpToSelectedWeek()
|
||||
showWeekPicker = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.presentationDetents([.medium, .large])
|
||||
}
|
||||
.sheet(
|
||||
isPresented: Binding(
|
||||
get: { selectedEmptySlotId != nil },
|
||||
set: { isPresented in
|
||||
if !isPresented { selectedEmptySlotId = nil }
|
||||
}
|
||||
)
|
||||
) {
|
||||
if let slotId = selectedEmptySlotId,
|
||||
plan.slots.contains(where: { $0.id == slotId }) {
|
||||
SlotDishPickerSheet(
|
||||
dishes: dishes,
|
||||
tags: tags,
|
||||
onPickDish: { dish in
|
||||
guard let freshSlot = plan.slots.first(where: { $0.id == slotId }) else {
|
||||
selectedEmptySlotId = nil
|
||||
return
|
||||
}
|
||||
let isValid = AutocompleteEngine.validateDrop(
|
||||
dish: dish,
|
||||
slot: freshSlot,
|
||||
plan: plan,
|
||||
allTags: tags,
|
||||
allDishes: dishes
|
||||
)
|
||||
if isValid {
|
||||
viewModel.assignDish(dish, to: freshSlot, plan: plan, settings: settings)
|
||||
} else {
|
||||
// In picker flow, assign anyway and mark as override so the action is never lost.
|
||||
viewModel.assignDish(dish, to: freshSlot, plan: plan, settings: settings, isOverride: true)
|
||||
}
|
||||
selectedEmptySlotId = nil
|
||||
},
|
||||
onCreateDish: {
|
||||
selectedEmptySlotId = nil
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
|
||||
viewModel.showDishForm = true
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
.sheet(item: $editingDish) { dish in
|
||||
DishFormView(dish: dish)
|
||||
}
|
||||
.sheet(isPresented: $showPremiumFromExport) {
|
||||
NavigationStack {
|
||||
PremiumView(settings: settings)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showMonthlyHistory) {
|
||||
MonthlyHistoryView(
|
||||
weekPlans: weekPlans,
|
||||
currentWeekStart: viewModel.currentWeekStart,
|
||||
language: settings.languageEnum.resolved()
|
||||
) { weekStart in
|
||||
viewModel.jumpToWeek(startDate: weekStart)
|
||||
showMonthlyHistory = false
|
||||
}
|
||||
}
|
||||
.task {
|
||||
await NotificationService.shared.requestPermissionIfNeeded()
|
||||
let nextPlan = fetchWeekPlan(for: Date().startOfWeek().addingDays(7))
|
||||
NotificationService.shared.schedulePlanningReminderIfNeeded(
|
||||
nextWeekPlan: nextPlan,
|
||||
language: settings.languageEnum.resolved()
|
||||
)
|
||||
wasWeekComplete = isWeekComplete(plan: plan)
|
||||
}
|
||||
.onChange(of: plan.updatedAt) { _, _ in
|
||||
let nowComplete = isWeekComplete(plan: plan)
|
||||
if nowComplete && !wasWeekComplete {
|
||||
evaluateReviewPrompt()
|
||||
}
|
||||
wasWeekComplete = nowComplete
|
||||
}
|
||||
}
|
||||
|
||||
private struct SlotDishPickerSheet: View {
|
||||
let dishes: [Dish]
|
||||
let tags: [Tag]
|
||||
let onPickDish: (Dish) -> Void
|
||||
let onCreateDish: () -> Void
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var searchText: String = ""
|
||||
|
||||
private var filteredDishes: [Dish] {
|
||||
let trimmed = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else {
|
||||
return dishes.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
|
||||
}
|
||||
return dishes
|
||||
.filter { $0.name.localizedCaseInsensitiveContains(trimmed) }
|
||||
.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Group {
|
||||
if dishes.isEmpty {
|
||||
VStack(spacing: 16) {
|
||||
Text("home_pick_dish_no_dishes")
|
||||
.font(.mealMoodBody)
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
Button {
|
||||
dismiss()
|
||||
onCreateDish()
|
||||
} label: {
|
||||
Label("home_pick_dish_add_new", systemImage: "plus")
|
||||
.font(.mealMoodBodyBold)
|
||||
.foregroundColor(.mealMoodCoral)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.padding(24)
|
||||
} else {
|
||||
List {
|
||||
ForEach(filteredDishes) { dish in
|
||||
Button {
|
||||
onPickDish(dish)
|
||||
dismiss()
|
||||
} label: {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(dish.name)
|
||||
.font(.mealMoodBodyBold)
|
||||
.foregroundColor(.mealMoodTextPrimary)
|
||||
HStack(spacing: 4) {
|
||||
let dishTags = tags.filter { dish.tagIds.contains($0.id) }
|
||||
ForEach(dishTags.prefix(3), id: \.id) { tag in
|
||||
TagDot(color: tag.color, size: 8)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
.listRowBackground(Color.mealMoodSurface)
|
||||
}
|
||||
|
||||
if filteredDishes.isEmpty {
|
||||
Text("home_pick_dish_empty")
|
||||
.font(.mealMoodBody)
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
.listRowBackground(Color.mealMoodSurface)
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(Color.mealMoodBackground)
|
||||
.searchable(text: $searchText, prompt: Text("home_pick_dish_search"))
|
||||
}
|
||||
}
|
||||
.background(Color.mealMoodBackground.ignoresSafeArea())
|
||||
.environment(\.colorScheme, .light)
|
||||
.navigationTitle("home_pick_dish_title")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button("dish_cancel") { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button {
|
||||
dismiss()
|
||||
onCreateDish()
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
.accessibilityLabel(Text("home_pick_dish_add_new"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func dishDrawer(plan: WeekPlan, settings: AppSettings) -> some View {
|
||||
DishDrawerView(
|
||||
dishes: dishes,
|
||||
tags: tags,
|
||||
language: settings.languageEnum.resolved(),
|
||||
usedDishIds: Set(plan.slots.compactMap(\.dishId)),
|
||||
onAddDish: { viewModel.showDishForm = true },
|
||||
onQuickAssignDish: { dish in
|
||||
viewModel.assignDishToFirstFreeSlot(
|
||||
dish,
|
||||
plan: plan,
|
||||
settings: settings,
|
||||
allTags: tags,
|
||||
allDishes: dishes
|
||||
)
|
||||
},
|
||||
onEditDish: { dish in
|
||||
editingDish = dish
|
||||
},
|
||||
onDeleteDish: { dish in
|
||||
let isAssignedInCurrentWeek = plan.slots.contains { $0.dishId == dish.id }
|
||||
if isAssignedInCurrentWeek {
|
||||
viewModel.toastMessage = localizedString("dish_delete_blocked_message", language: settings.languageEnum.resolved())
|
||||
viewModel.showToast = true
|
||||
return
|
||||
}
|
||||
context.delete(dish)
|
||||
try? context.save()
|
||||
viewModel.toastMessage = localizedString("toast_dish_deleted", language: settings.languageEnum.resolved())
|
||||
viewModel.showToast = true
|
||||
},
|
||||
draggedDish: $viewModel.draggedDish
|
||||
)
|
||||
}
|
||||
|
||||
private func canNavigateToNextWeek(settings: AppSettings) -> Bool {
|
||||
if settings.isPremium { return true }
|
||||
let maxFreeWeek = Date().startOfWeek().addingDays(7)
|
||||
return viewModel.currentWeekStart < maxFreeWeek
|
||||
}
|
||||
|
||||
private func canNavigateToWeek(_ startDate: Date, settings: AppSettings) -> Bool {
|
||||
if settings.isPremium { return true }
|
||||
let maxFreeWeek = Date().startOfWeek().addingDays(7)
|
||||
return startDate <= maxFreeWeek
|
||||
}
|
||||
|
||||
private func jumpToSelectedWeek() {
|
||||
guard let settings else { return }
|
||||
let selectedWeekStart = weekPickerDate.startOfWeek()
|
||||
if canNavigateToWeek(selectedWeekStart, settings: settings) {
|
||||
viewModel.jumpToWeek(startDate: selectedWeekStart)
|
||||
showWeekLimitUpsell = false
|
||||
} else {
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
showWeekLimitUpsell = true
|
||||
}
|
||||
showPremiumFromExport = true
|
||||
HapticManager.shared.notification(type: .warning)
|
||||
}
|
||||
}
|
||||
|
||||
private func isWeekComplete(plan: WeekPlan) -> Bool {
|
||||
plan.slots.allSatisfy { $0.dishId != nil }
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func exportCallout(plan: WeekPlan, settings: AppSettings) -> some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("share_week_callout_title")
|
||||
.font(.mealMoodBodyBold)
|
||||
.foregroundColor(.mealMoodTextPrimary)
|
||||
Text("share_week_callout_subtitle")
|
||||
.font(.mealMoodCaption)
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
|
||||
if settings.isPremium,
|
||||
let image = renderWeekShareImage(plan: plan, settings: settings),
|
||||
let shareURL = persistShareImage(image) {
|
||||
ShareLink(
|
||||
item: shareURL,
|
||||
preview: SharePreview(String(localized: "share_week_title"), image: Image(uiImage: image))
|
||||
) {
|
||||
Label("share_week_button", systemImage: "square.and.arrow.up")
|
||||
.font(.mealMoodBodyBold)
|
||||
.foregroundColor(.mealMoodTextPrimary)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 10)
|
||||
.background(Color.white.opacity(0.75))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
}
|
||||
} else {
|
||||
Button {
|
||||
showPremiumFromExport = true
|
||||
} label: {
|
||||
Label("share_week_button", systemImage: "star.fill")
|
||||
.font(.mealMoodBodyBold)
|
||||
.foregroundColor(.mealMoodTextPrimary)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 10)
|
||||
.background(Color.white.opacity(0.75))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(Color.mealMoodMint.opacity(0.55))
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.stroke(Color.mealMoodCoral.opacity(0.4), lineWidth: 1)
|
||||
)
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
|
||||
private func copyFromPreviousWeek(currentPlan: WeekPlan, settings: AppSettings) {
|
||||
let previousPlan = fetchWeekPlan(for: viewModel.currentWeekStart.addingDays(-7))
|
||||
viewModel.copyFromPreviousWeek(
|
||||
currentPlan: currentPlan,
|
||||
previousPlan: previousPlan,
|
||||
settings: settings,
|
||||
allTags: tags,
|
||||
allDishes: dishes
|
||||
)
|
||||
}
|
||||
|
||||
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.scale = 1
|
||||
return renderer.uiImage
|
||||
}
|
||||
|
||||
private func persistShareImage(_ image: UIImage) -> URL? {
|
||||
guard let data = image.pngData() else { return nil }
|
||||
let url = FileManager.default.temporaryDirectory.appendingPathComponent("mealmood-week-plan.png")
|
||||
try? data.write(to: url, options: .atomic)
|
||||
return url
|
||||
}
|
||||
|
||||
private func fetchWeekPlan(for weekStartDate: Date) -> WeekPlan? {
|
||||
let descriptor = FetchDescriptor<WeekPlan>(
|
||||
predicate: #Predicate<WeekPlan> { plan in
|
||||
plan.weekStartDate == weekStartDate
|
||||
}
|
||||
)
|
||||
return try? context.fetch(descriptor).first
|
||||
}
|
||||
|
||||
private func evaluateReviewPrompt() {
|
||||
let descriptor = FetchDescriptor<WeekPlan>()
|
||||
guard let plans = try? context.fetch(descriptor) else { return }
|
||||
let completedWeeks = plans.filter { !$0.slots.isEmpty && $0.slots.allSatisfy { $0.dishId != nil } }.count
|
||||
ReviewPromptService.shared.considerPromptAfterWeekCompletion(completedWeeks: completedWeeks)
|
||||
}
|
||||
}
|
||||
|
||||
private struct MonthlyHistoryView: View {
|
||||
let weekPlans: [WeekPlan]
|
||||
let currentWeekStart: Date
|
||||
let language: AppLanguage
|
||||
let onSelectWeek: (Date) -> Void
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var monthCursor: Date
|
||||
|
||||
init(
|
||||
weekPlans: [WeekPlan],
|
||||
currentWeekStart: Date,
|
||||
language: AppLanguage,
|
||||
onSelectWeek: @escaping (Date) -> Void
|
||||
) {
|
||||
self.weekPlans = weekPlans
|
||||
self.currentWeekStart = currentWeekStart
|
||||
self.language = language
|
||||
self.onSelectWeek = onSelectWeek
|
||||
_monthCursor = State(initialValue: currentWeekStart.startOfMonth())
|
||||
}
|
||||
|
||||
private var locale: Locale {
|
||||
Locale(identifier: language.localeIdentifier)
|
||||
}
|
||||
|
||||
private var monthPlans: [WeekPlan] {
|
||||
let calendar = Calendar.current
|
||||
return weekPlans
|
||||
.filter {
|
||||
calendar.component(.year, from: $0.weekStartDate) == calendar.component(.year, from: monthCursor) &&
|
||||
calendar.component(.month, from: $0.weekStartDate) == calendar.component(.month, from: monthCursor)
|
||||
}
|
||||
.sorted { $0.weekStartDate > $1.weekStartDate }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack(spacing: 14) {
|
||||
monthHeader
|
||||
|
||||
if monthPlans.isEmpty {
|
||||
Text("history_month_empty")
|
||||
.font(.mealMoodBody)
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
.padding(.top, 24)
|
||||
} else {
|
||||
List(monthPlans, id: \.id) { plan in
|
||||
Button {
|
||||
onSelectWeek(plan.weekStartDate)
|
||||
dismiss()
|
||||
} label: {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(weekLabel(for: plan.weekStartDate))
|
||||
.font(.mealMoodBodyBold)
|
||||
.foregroundColor(.mealMoodTextPrimary)
|
||||
Text(plan.slots.allSatisfy { $0.dishId != nil } ? String(localized: "history_week_complete") : String(localized: "history_week_incomplete"))
|
||||
.font(.mealMoodCaption)
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
}
|
||||
Spacer()
|
||||
Image(systemName: "chevron.right")
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
.listRowBackground(Color.mealMoodSurface)
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.scrollContentBackground(.hidden)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 10)
|
||||
.background(Color.mealMoodBackground.ignoresSafeArea())
|
||||
.navigationTitle("history_title")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("tag_selector_done") { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var monthHeader: some View {
|
||||
HStack {
|
||||
Button {
|
||||
monthCursor = monthCursor.addingMonths(-1)
|
||||
} label: {
|
||||
Image(systemName: "chevron.left")
|
||||
.foregroundColor(.mealMoodCoral)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(monthCursor.monthYearLabel(locale: locale))
|
||||
.font(.mealMoodH3)
|
||||
.foregroundColor(.mealMoodTextPrimary)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
monthCursor = monthCursor.addingMonths(1)
|
||||
} label: {
|
||||
Image(systemName: "chevron.right")
|
||||
.foregroundColor(.mealMoodCoral)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func weekLabel(for startDate: Date) -> String {
|
||||
let endDate = startDate.addingDays(6)
|
||||
let dayFormatter = DateFormatter()
|
||||
dayFormatter.locale = locale
|
||||
dayFormatter.setLocalizedDateFormatFromTemplate("d")
|
||||
|
||||
let monthFormatter = DateFormatter()
|
||||
monthFormatter.locale = locale
|
||||
monthFormatter.setLocalizedDateFormatFromTemplate("MMM")
|
||||
|
||||
return "\(dayFormatter.string(from: startDate))-\(dayFormatter.string(from: endDate)) \(monthFormatter.string(from: endDate))"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
import SwiftUI
|
||||
|
||||
struct WeekCalendarView: View {
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
|
||||
let plan: WeekPlan
|
||||
let settings: AppSettings
|
||||
let dishes: [Dish]
|
||||
let tags: [Tag]
|
||||
@ObservedObject var viewModel: HomeViewModel
|
||||
var onTapEmptySlot: ((MealSlot) -> Void)?
|
||||
|
||||
private var dayRange: ClosedRange<Int> {
|
||||
settings.includeWeekends ? 0...6 : 0...4
|
||||
}
|
||||
|
||||
private var days: [Int] {
|
||||
Array(dayRange)
|
||||
}
|
||||
|
||||
private var mealTypes: [MealType] {
|
||||
switch settings.mealWindowsEnum {
|
||||
case .dinnerOnly: return [.dinner]
|
||||
case .lunchOnly: return [.lunch]
|
||||
case .both: return [.lunch, .dinner]
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if horizontalSizeClass == .regular {
|
||||
regularGridLayout
|
||||
} else {
|
||||
compactLayout
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var compactLayout: some View {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
VStack(spacing: 0) {
|
||||
HStack(spacing: 0) {
|
||||
HStack(spacing: 0) {
|
||||
ForEach(Array(days.enumerated()), id: \.element) { index, day in
|
||||
dayHeaderCell(day: day, width: 96, height: 24)
|
||||
if index < days.count - 1 {
|
||||
calendarDivider
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
calendarHorizontalDivider
|
||||
|
||||
ForEach(Array(mealTypes.enumerated()), id: \.element) { index, mealType in
|
||||
HStack(spacing: 0) {
|
||||
ForEach(Array(days.enumerated()), id: \.element) { index, day in
|
||||
let slot = slotFor(day: day, mealType: mealType)
|
||||
draggableSlotView(slot: slot, mealType: mealType)
|
||||
.frame(width: 96)
|
||||
if index < days.count - 1 {
|
||||
calendarDivider
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if index < mealTypes.count - 1 {
|
||||
calendarHorizontalDivider
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
}
|
||||
|
||||
private var regularGridLayout: some View {
|
||||
GeometryReader { proxy in
|
||||
let dayCount = CGFloat(dayRange.count)
|
||||
let spacing: CGFloat = 10
|
||||
let rowLabelWidth: CGFloat = 86
|
||||
let contentWidth = proxy.size.width - rowLabelWidth - (dayCount * spacing)
|
||||
let dayWidth = max(92, contentWidth / max(dayCount, 1))
|
||||
|
||||
VStack(spacing: 0) {
|
||||
HStack(spacing: 0) {
|
||||
Color.clear.frame(width: rowLabelWidth, height: 24)
|
||||
ForEach(Array(days.enumerated()), id: \.element) { index, day in
|
||||
dayHeaderCell(day: day, width: dayWidth, height: 24)
|
||||
if index < days.count - 1 {
|
||||
calendarDivider
|
||||
.padding(.horizontal, spacing / 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
calendarHorizontalDivider
|
||||
|
||||
ForEach(Array(mealTypes.enumerated()), id: \.element) { rowIndex, mealType in
|
||||
HStack(spacing: 0) {
|
||||
mealTypeLabel(mealType)
|
||||
.frame(width: rowLabelWidth)
|
||||
.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)
|
||||
if index < days.count - 1 {
|
||||
calendarDivider
|
||||
.padding(.horizontal, spacing / 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if rowIndex < mealTypes.count - 1 {
|
||||
calendarHorizontalDivider
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
.frame(minHeight: CGFloat(mealTypes.count) * 142 + 54)
|
||||
}
|
||||
|
||||
private var calendarDivider: some View {
|
||||
Rectangle()
|
||||
.fill(Color.mealMoodTextSecondary.opacity(0.22))
|
||||
.frame(width: 1)
|
||||
}
|
||||
|
||||
private var calendarHorizontalDivider: some View {
|
||||
Rectangle()
|
||||
.fill(Color.mealMoodTextSecondary.opacity(0.22))
|
||||
.frame(height: 1)
|
||||
}
|
||||
|
||||
private func dayHeader(day: Int) -> some View {
|
||||
HStack(spacing: 4) {
|
||||
Text(LocalizedStringKey(dayKey(for: day)))
|
||||
.font(.mealMoodCaption)
|
||||
.foregroundColor(.mealMoodTextPrimary)
|
||||
.fontWeight(.semibold)
|
||||
|
||||
let dayDate = plan.weekStartDate.addingDays(day)
|
||||
Text("\(Calendar.current.component(.day, from: dayDate))")
|
||||
.font(.mealMoodSmall)
|
||||
.foregroundColor(dayDate.isToday ? .mealMoodCoral : .mealMoodTextPrimary)
|
||||
.fontWeight(dayDate.isToday ? .bold : .regular)
|
||||
}
|
||||
}
|
||||
|
||||
private func dayHeaderCell(day: Int, width: CGFloat, height: CGFloat) -> some View {
|
||||
dayHeader(day: day)
|
||||
.frame(width: width, height: height)
|
||||
.background(Color.mealMoodCoral.opacity(0.28))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.stroke(Color.mealMoodCoral.opacity(0.45), lineWidth: 1)
|
||||
)
|
||||
.cornerRadius(8)
|
||||
}
|
||||
|
||||
private func mealTypeLabel(_ mealType: MealType) -> some View {
|
||||
VStack(spacing: 6) {
|
||||
Image(systemName: mealType.icon)
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundColor(.mealMoodCoral)
|
||||
Text(LocalizedStringKey(mealType.rawValue))
|
||||
.font(.mealMoodCaption)
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
.background(Color.mealMoodSurface)
|
||||
.cornerRadius(10)
|
||||
}
|
||||
|
||||
private func slotFor(day: Int, mealType: MealType) -> MealSlot? {
|
||||
plan.slots.first { $0.dayOfWeek == day && $0.mealType == mealType.rawValue }
|
||||
}
|
||||
|
||||
private func draggableSlotView(slot: MealSlot?, mealType: MealType) -> some View {
|
||||
slotView(slot: slot, mealType: mealType)
|
||||
.dropDestination(for: String.self) { items, _ in
|
||||
guard let payloadRaw = items.first,
|
||||
let payload = parseDropPayload(payloadRaw),
|
||||
let targetSlot = slot,
|
||||
viewModel.canEditCurrentWeek else { return false }
|
||||
|
||||
switch payload {
|
||||
case .dish(let dishId):
|
||||
guard let dish = dishes.first(where: { $0.id == dishId }) else { return false }
|
||||
|
||||
let isValid = AutocompleteEngine.validateDrop(
|
||||
dish: dish, slot: targetSlot, plan: plan,
|
||||
allTags: tags, allDishes: dishes
|
||||
)
|
||||
|
||||
if isValid {
|
||||
viewModel.assignDish(dish, to: targetSlot, plan: plan, settings: settings)
|
||||
return true
|
||||
}
|
||||
|
||||
viewModel.confirmInvalidDrop(dish, to: targetSlot, plan: plan, settings: settings)
|
||||
return true
|
||||
case .slot(let sourceSlotId):
|
||||
guard let sourceSlot = plan.slots.first(where: { $0.id == sourceSlotId }) else { return false }
|
||||
viewModel.moveOrSwapDish(
|
||||
from: sourceSlot,
|
||||
to: targetSlot,
|
||||
plan: plan,
|
||||
settings: settings,
|
||||
allTags: tags,
|
||||
allDishes: dishes
|
||||
)
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func slotView(slot: MealSlot?, mealType: MealType) -> some View {
|
||||
if let slot = slot, let dishId = slot.dishId,
|
||||
let dish = dishes.first(where: { $0.id == dishId }) {
|
||||
let dishTags = tags.filter { dish.tagIds.contains($0.id) }
|
||||
FilledSlotView(
|
||||
dishName: dish.name,
|
||||
dishDescription: dish.descriptionText,
|
||||
tags: dishTags.map { (name: $0.localizedName(language: settings.languageEnum.resolved()), color: $0.color) },
|
||||
mealType: mealType,
|
||||
showsRuleWarning: slot.isRuleOverridden,
|
||||
onRemove: viewModel.canEditCurrentWeek ? {
|
||||
viewModel.removeDish(from: slot, plan: plan, settings: settings)
|
||||
} : nil
|
||||
)
|
||||
.draggable("slot:\(slot.id.uuidString)")
|
||||
} else if let slot = slot {
|
||||
EmptySlotView(mealType: mealType)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
guard viewModel.canEditCurrentWeek else { return }
|
||||
onTapEmptySlot?(slot)
|
||||
}
|
||||
} else {
|
||||
EmptySlotView(mealType: mealType)
|
||||
}
|
||||
}
|
||||
|
||||
private enum DropPayload {
|
||||
case dish(UUID)
|
||||
case slot(UUID)
|
||||
}
|
||||
|
||||
private func parseDropPayload(_ raw: String) -> DropPayload? {
|
||||
let parts = raw.split(separator: ":", maxSplits: 1).map(String.init)
|
||||
guard parts.count == 2, let id = UUID(uuidString: parts[1]) else { return nil }
|
||||
switch parts[0] {
|
||||
case "dish":
|
||||
return .dish(id)
|
||||
case "slot":
|
||||
return .slot(id)
|
||||
default:
|
||||
// Backward compatibility for older draggable payloads that only sent dish UUID.
|
||||
if let fallbackDishId = UUID(uuidString: raw) {
|
||||
return .dish(fallbackDishId)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private func dayKey(for dayIndex: Int) -> String {
|
||||
switch dayIndex {
|
||||
case 0: return "day_mon"
|
||||
case 1: return "day_tue"
|
||||
case 2: return "day_wed"
|
||||
case 3: return "day_thu"
|
||||
case 4: return "day_fri"
|
||||
case 5: return "day_sat"
|
||||
case 6: return "day_sun"
|
||||
default: return ""
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import SwiftUI
|
||||
|
||||
struct WeekPlanShareView: View {
|
||||
let plan: WeekPlan
|
||||
let settings: AppSettings
|
||||
let dishes: [Dish]
|
||||
let tags: [Tag]
|
||||
|
||||
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]
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Color(hex: "#FFF6F0"),
|
||||
Color(hex: "#F6FCFA")
|
||||
],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
.ignoresSafeArea()
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
Grid(horizontalSpacing: 10, verticalSpacing: 10) {
|
||||
GridRow {
|
||||
gridHeaderCell("")
|
||||
.frame(minWidth: 220)
|
||||
ForEach(Array(dayRange), id: \.self) { day in
|
||||
gridHeaderCell(dayTitle(for: day))
|
||||
}
|
||||
}
|
||||
|
||||
ForEach(mealTypes, id: \.self) { mealType in
|
||||
GridRow {
|
||||
gridMealCell(mealTypeLabel(mealType), icon: mealType.icon)
|
||||
.frame(minWidth: 220)
|
||||
ForEach(Array(dayRange), id: \.self) { day in
|
||||
gridDishCell(dishName(day: day, mealType: mealType))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Text("share_week_title")
|
||||
.font(.system(size: 18, weight: .medium))
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
}
|
||||
.padding(56)
|
||||
}
|
||||
.frame(width: 2400, height: 1700)
|
||||
}
|
||||
|
||||
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 func gridHeaderCell(_ title: String) -> some View {
|
||||
Text(title)
|
||||
.font(.system(size: 24, weight: .semibold))
|
||||
.foregroundColor(.mealMoodTextPrimary)
|
||||
.frame(maxWidth: .infinity, minHeight: 92)
|
||||
.padding(.horizontal, 10)
|
||||
.background(Color.white.opacity(0.95))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||
}
|
||||
|
||||
private func gridMealCell(_ title: String, icon: String) -> some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: icon)
|
||||
Text(title)
|
||||
}
|
||||
.font(.system(size: 24, weight: .semibold))
|
||||
.foregroundColor(.mealMoodTextPrimary)
|
||||
.frame(maxWidth: .infinity, minHeight: 130)
|
||||
.padding(.horizontal, 14)
|
||||
.background(Color.white.opacity(0.95))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||
}
|
||||
|
||||
private func gridDishCell(_ title: String) -> some View {
|
||||
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(Color.white.opacity(0.92))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import SwiftUI
|
||||
import EventKit
|
||||
|
||||
struct CalendarStepView: View {
|
||||
@Binding var syncEnabled: Bool
|
||||
@Binding var selectedCalendarId: String?
|
||||
@Binding var lunchTime: Date
|
||||
@Binding var dinnerTime: Date
|
||||
var onNext: () -> Void
|
||||
var onSkip: () -> Void
|
||||
|
||||
@State private var availableCalendars: [EKCalendar] = []
|
||||
@State private var showPermissionAlert = false
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 24) {
|
||||
Text("calendar_title")
|
||||
.font(.mealMoodH2)
|
||||
.foregroundColor(.mealMoodTextPrimary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.top, 32)
|
||||
|
||||
VStack(spacing: 16) {
|
||||
// Sync toggle
|
||||
HStack {
|
||||
Text("calendar_sync")
|
||||
.font(.mealMoodBody)
|
||||
.foregroundColor(.mealMoodTextPrimary)
|
||||
Spacer()
|
||||
Toggle("", isOn: $syncEnabled)
|
||||
.tint(.mealMoodCoral)
|
||||
.labelsHidden()
|
||||
.onChange(of: syncEnabled) { _, newValue in
|
||||
if newValue {
|
||||
Task {
|
||||
let granted = await CalendarService.shared.requestAccess()
|
||||
if granted {
|
||||
availableCalendars = CalendarService.shared.availableCalendars()
|
||||
} else {
|
||||
syncEnabled = false
|
||||
showPermissionAlert = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.background(Color.mealMoodSurface)
|
||||
.cornerRadius(14)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.stroke(Color(hex: "#F0F0F0"), lineWidth: 1)
|
||||
)
|
||||
|
||||
if syncEnabled {
|
||||
// Calendar picker
|
||||
if !availableCalendars.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("calendar_select")
|
||||
.font(.mealMoodSmall)
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
|
||||
Picker("calendar_select", selection: $selectedCalendarId) {
|
||||
Text("calendar_select").tag(nil as String?)
|
||||
ForEach(availableCalendars, id: \.calendarIdentifier) { cal in
|
||||
Text(cal.title).tag(cal.calendarIdentifier as String?)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
.padding(12)
|
||||
.background(Color.mealMoodSurface)
|
||||
.cornerRadius(12)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.stroke(Color(hex: "#F0F0F0"), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Time pickers
|
||||
VStack(spacing: 12) {
|
||||
DatePicker("calendar_lunch_time", selection: $lunchTime, displayedComponents: .hourAndMinute)
|
||||
.font(.mealMoodBody)
|
||||
|
||||
DatePicker("calendar_dinner_time", selection: $dinnerTime, displayedComponents: .hourAndMinute)
|
||||
.font(.mealMoodBody)
|
||||
}
|
||||
.padding(16)
|
||||
.background(Color.mealMoodSurface)
|
||||
.cornerRadius(14)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.stroke(Color(hex: "#F0F0F0"), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
|
||||
Text("calendar_optional")
|
||||
.font(.mealMoodSmall)
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
|
||||
Spacer(minLength: 40)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
SecondaryButton(title: String(localized: "onboarding_skip"), action: {
|
||||
syncEnabled = false
|
||||
onSkip()
|
||||
})
|
||||
|
||||
PrimaryButton(title: String(localized: "onboarding_continue"), action: onNext)
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.bottom, 40)
|
||||
}
|
||||
}
|
||||
.alert("calendar_permission_title", isPresented: $showPermissionAlert) {
|
||||
Button("calendar_permission_settings") {
|
||||
if let url = URL(string: UIApplication.openSettingsURLString) {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
}
|
||||
Button("reset_cancel", role: .cancel) {}
|
||||
} message: {
|
||||
Text("calendar_permission_message")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
struct FirstDishesStepView: View {
|
||||
@Environment(\.modelContext) private var context
|
||||
@ObservedObject var viewModel: OnboardingViewModel
|
||||
let tags: [Tag]
|
||||
var onFinish: () -> Void
|
||||
|
||||
@State private var showTagSelector = false
|
||||
@Query(sort: \Tag.sortOrder) private var storedTags: [Tag]
|
||||
@Query private var allSettings: [AppSettings]
|
||||
|
||||
private var language: AppLanguage {
|
||||
(allSettings.first?.languageEnum ?? .system).resolved()
|
||||
}
|
||||
|
||||
private var availableTags: [Tag] {
|
||||
var seen = Set<UUID>()
|
||||
let merged = (tags + storedTags).filter { tag in
|
||||
seen.insert(tag.id).inserted
|
||||
}
|
||||
return merged.sorted(by: { $0.sortOrder < $1.sortOrder })
|
||||
}
|
||||
|
||||
private var defaultTagId: UUID? {
|
||||
availableTags.first(where: { $0.name.caseInsensitiveCompare("Verduras") == .orderedSame })?.id
|
||||
?? availableTags.first(where: { $0.nameEN.caseInsensitiveCompare("Vegetables") == .orderedSame })?.id
|
||||
?? availableTags.sorted(by: { $0.sortOrder < $1.sortOrder }).first?.id
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 24) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("first_dishes_title")
|
||||
.font(.mealMoodH2)
|
||||
.foregroundColor(.mealMoodTextPrimary)
|
||||
|
||||
Text("first_dishes_subtitle")
|
||||
.font(.mealMoodSmall)
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.top, 32)
|
||||
|
||||
// New dish form
|
||||
VStack(spacing: 12) {
|
||||
TextField("first_dishes_name_placeholder", text: $viewModel.newDishName)
|
||||
.font(.mealMoodBody)
|
||||
.padding(14)
|
||||
.background(Color.mealMoodSurface)
|
||||
.cornerRadius(12)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.stroke(Color(hex: "#F0F0F0"), lineWidth: 1)
|
||||
)
|
||||
|
||||
// Selected tags
|
||||
if !viewModel.newDishTags.isEmpty {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 6) {
|
||||
ForEach(availableTags.filter { viewModel.newDishTags.contains($0.id) }) { tag in
|
||||
HStack(spacing: 4) {
|
||||
TagPill(name: tag.localizedName(language: language), color: tag.color)
|
||||
Button {
|
||||
viewModel.newDishTags.remove(tag.id)
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.font(.system(size: 14))
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
ensureDefaultTagsIfNeeded()
|
||||
showTagSelector = true
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "plus.circle")
|
||||
Text("first_dishes_add_tag")
|
||||
}
|
||||
.font(.mealMoodSmall)
|
||||
.foregroundColor(.mealMoodCoral)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
if availableTags.isEmpty {
|
||||
Text("first_dishes_no_tags")
|
||||
.font(.mealMoodCaption)
|
||||
.foregroundColor(.mealMoodError)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
Button {
|
||||
ensureDefaultTagsIfNeeded()
|
||||
viewModel.addDish(defaultTagId: defaultTagId)
|
||||
HapticManager.shared.notification(type: .success)
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "plus")
|
||||
Text("first_dishes_add_dish")
|
||||
}
|
||||
.font(.mealMoodBodyBold)
|
||||
.foregroundColor(.white)
|
||||
.padding(.vertical, 12)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(viewModel.canAddDish ? Color.mealMoodCoral : Color.gray.opacity(0.3))
|
||||
.cornerRadius(12)
|
||||
}
|
||||
.disabled(!viewModel.canAddDish)
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
|
||||
// Added dishes list
|
||||
if !viewModel.addedDishes.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("first_dishes_added")
|
||||
.font(.mealMoodSmall)
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
.padding(.horizontal, 24)
|
||||
|
||||
ForEach(Array(viewModel.addedDishes.enumerated()), id: \.offset) { index, dish in
|
||||
HStack {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundColor(.mealMoodSuccess)
|
||||
|
||||
Text(dish.name)
|
||||
.font(.mealMoodBody)
|
||||
.foregroundColor(.mealMoodTextPrimary)
|
||||
|
||||
// Show tag pills
|
||||
HStack(spacing: 4) {
|
||||
ForEach(availableTags.filter { dish.tagIds.contains($0.id) }.prefix(2)) { tag in
|
||||
TagPill(name: tag.localizedName(language: language), color: tag.color)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
viewModel.removeDish(at: index)
|
||||
} label: {
|
||||
Image(systemName: "trash")
|
||||
.font(.system(size: 14))
|
||||
.foregroundColor(.mealMoodError)
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.background(Color.mealMoodSurface)
|
||||
.cornerRadius(12)
|
||||
.padding(.horizontal, 24)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("first_dishes_suggestions")
|
||||
.font(.mealMoodSmall)
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
.padding(.horizontal, 24)
|
||||
|
||||
ForEach(suggestedDishes(), id: \.name) { suggestion in
|
||||
let isAdded = viewModel.addedDishes.contains(where: {
|
||||
$0.name.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == suggestion.name.lowercased()
|
||||
})
|
||||
Button {
|
||||
guard !isAdded else { return }
|
||||
viewModel.addSuggestedDish(
|
||||
name: suggestion.name,
|
||||
preferredTagNames: suggestion.tagHints,
|
||||
availableTags: availableTags
|
||||
)
|
||||
} label: {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(suggestion.name)
|
||||
.font(.mealMoodBodyBold)
|
||||
.foregroundColor(.mealMoodTextPrimary)
|
||||
Text(isAdded ? "first_dishes_suggestion_added" : "first_dishes_suggestion_add")
|
||||
.font(.mealMoodCaption)
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
}
|
||||
Spacer()
|
||||
Image(systemName: isAdded ? "checkmark.circle.fill" : "plus.circle.fill")
|
||||
.foregroundColor(isAdded ? .mealMoodSuccess : .mealMoodCoral)
|
||||
}
|
||||
.padding(12)
|
||||
.background(Color.mealMoodSurface)
|
||||
.cornerRadius(12)
|
||||
}
|
||||
.disabled(isAdded)
|
||||
.padding(.horizontal, 24)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(minLength: 40)
|
||||
|
||||
PrimaryButton(
|
||||
title: String(localized: "onboarding_finish"),
|
||||
icon: "🎉",
|
||||
action: {
|
||||
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
|
||||
HapticManager.shared.notification(type: .success)
|
||||
onFinish()
|
||||
},
|
||||
isEnabled: viewModel.addedDishes.count >= 2
|
||||
)
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.bottom, 40)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showTagSelector) {
|
||||
TagSelectorSheet(
|
||||
tags: availableTags,
|
||||
selectedTagIds: $viewModel.newDishTags
|
||||
)
|
||||
}
|
||||
.onAppear {
|
||||
ensureDefaultTagsIfNeeded()
|
||||
}
|
||||
.onChange(of: storedTags.count) { _, newCount in
|
||||
if newCount == 0 {
|
||||
ensureDefaultTagsIfNeeded()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func suggestedDishes() -> [(name: String, tagHints: [String])] {
|
||||
if language == .english {
|
||||
return [
|
||||
(name: "Roast chicken", tagHints: ["Meat"]),
|
||||
(name: "Lentil stew", tagHints: ["Legumes"]),
|
||||
(name: "Veggie pasta", tagHints: ["Pasta/Rice", "Vegetables"])
|
||||
]
|
||||
}
|
||||
|
||||
return [
|
||||
(name: "Pollo asado", tagHints: ["Carne"]),
|
||||
(name: "Lentejas estofadas", tagHints: ["Legumbres"]),
|
||||
(name: "Pasta con verduras", tagHints: ["Pasta/Arroz", "Verduras"])
|
||||
]
|
||||
}
|
||||
|
||||
private func ensureDefaultTagsIfNeeded() {
|
||||
let descriptor = FetchDescriptor<Tag>()
|
||||
if (try? context.fetch(descriptor))?.isEmpty ?? true {
|
||||
DefaultDataService.createDefaultTags(context: context)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import SwiftUI
|
||||
|
||||
struct MealWindowsStepView: View {
|
||||
@Binding var selection: MealWindows
|
||||
var onNext: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 24) {
|
||||
Spacer()
|
||||
|
||||
Text("meal_windows_title")
|
||||
.font(.mealMoodH2)
|
||||
.foregroundColor(.mealMoodTextPrimary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 24)
|
||||
|
||||
VStack(spacing: 12) {
|
||||
ForEach(MealWindows.allCases, id: \.self) { option in
|
||||
SelectionCard(
|
||||
icon: option.icon,
|
||||
title: optionTitle(option),
|
||||
isSelected: selection == option
|
||||
) {
|
||||
withAnimation(.spring(response: 0.3)) {
|
||||
selection = option
|
||||
}
|
||||
HapticManager.shared.selection()
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
|
||||
Spacer()
|
||||
|
||||
PrimaryButton(title: String(localized: "onboarding_continue"), action: onNext)
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.bottom, 40)
|
||||
}
|
||||
}
|
||||
|
||||
private func optionTitle(_ option: MealWindows) -> String {
|
||||
switch option {
|
||||
case .dinnerOnly: return "🌙 \(String(localized: "meal_windows_dinner_only"))"
|
||||
case .lunchOnly: return "☀️ \(String(localized: "meal_windows_lunch_only"))"
|
||||
case .both: return "🌞🌙 \(String(localized: "meal_windows_both"))"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SelectionCard: View {
|
||||
let icon: String
|
||||
let title: String
|
||||
let isSelected: Bool
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
HStack {
|
||||
Text(title)
|
||||
.font(.mealMoodBody)
|
||||
.foregroundColor(.mealMoodTextPrimary)
|
||||
Spacer()
|
||||
Image(systemName: isSelected ? "checkmark.circle.fill" : "circle")
|
||||
.foregroundColor(isSelected ? .mealMoodCoral : Color(hex: "#C4C4C4"))
|
||||
.font(.system(size: 22))
|
||||
}
|
||||
.padding(16)
|
||||
.background(Color.mealMoodSurface)
|
||||
.cornerRadius(14)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.stroke(isSelected ? Color.mealMoodCoral : Color(hex: "#F0F0F0"), lineWidth: isSelected ? 2 : 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
struct OnboardingView: View {
|
||||
@Environment(\.modelContext) private var context
|
||||
@StateObject private var viewModel = OnboardingViewModel()
|
||||
@Query(sort: \Tag.sortOrder) private var tags: [Tag]
|
||||
var onComplete: () -> Void
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color.mealMoodBackground.ignoresSafeArea()
|
||||
|
||||
VStack(spacing: 0) {
|
||||
// Progress indicator
|
||||
if viewModel.currentStep > 0 {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(1..<viewModel.totalSteps, id: \.self) { step in
|
||||
Capsule()
|
||||
.fill(step <= viewModel.currentStep ? Color.mealMoodCoral : Color(hex: "#E0E0E0"))
|
||||
.frame(height: 4)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.top, 16)
|
||||
}
|
||||
|
||||
TabView(selection: $viewModel.currentStep) {
|
||||
WelcomeStepView(onNext: { viewModel.nextStep() })
|
||||
.tag(0)
|
||||
|
||||
MealWindowsStepView(
|
||||
selection: $viewModel.selectedMealWindows,
|
||||
onNext: { viewModel.nextStep() }
|
||||
)
|
||||
.tag(1)
|
||||
|
||||
WeekendsStepView(
|
||||
includeWeekends: $viewModel.includeWeekends,
|
||||
onNext: { viewModel.nextStep() }
|
||||
)
|
||||
.tag(2)
|
||||
|
||||
CalendarStepView(
|
||||
syncEnabled: $viewModel.syncCalendar,
|
||||
selectedCalendarId: $viewModel.selectedCalendarId,
|
||||
lunchTime: $viewModel.lunchTime,
|
||||
dinnerTime: $viewModel.dinnerTime,
|
||||
onNext: { viewModel.nextStep() },
|
||||
onSkip: { viewModel.nextStep() }
|
||||
)
|
||||
.tag(3)
|
||||
|
||||
FirstDishesStepView(
|
||||
viewModel: viewModel,
|
||||
tags: tags,
|
||||
onFinish: {
|
||||
if viewModel.completeOnboarding(context: context) {
|
||||
onComplete()
|
||||
}
|
||||
}
|
||||
)
|
||||
.tag(4)
|
||||
}
|
||||
.tabViewStyle(.page(indexDisplayMode: .never))
|
||||
.animation(.easeInOut(duration: 0.3), value: viewModel.currentStep)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
ensureDefaultTagsIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
private func ensureDefaultTagsIfNeeded() {
|
||||
let descriptor = FetchDescriptor<Tag>()
|
||||
if (try? context.fetch(descriptor))?.isEmpty ?? true {
|
||||
DefaultDataService.createDefaultTags(context: context)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import SwiftUI
|
||||
|
||||
struct WeekendsStepView: View {
|
||||
@Binding var includeWeekends: Bool
|
||||
var onNext: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 24) {
|
||||
Spacer()
|
||||
|
||||
Text("weekends_title")
|
||||
.font(.mealMoodH2)
|
||||
.foregroundColor(.mealMoodTextPrimary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 24)
|
||||
|
||||
VStack(spacing: 16) {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("weekends_toggle")
|
||||
.font(.mealMoodBody)
|
||||
.foregroundColor(.mealMoodTextPrimary)
|
||||
}
|
||||
Spacer()
|
||||
Toggle("", isOn: $includeWeekends)
|
||||
.tint(.mealMoodCoral)
|
||||
.labelsHidden()
|
||||
}
|
||||
.padding(16)
|
||||
.background(Color.mealMoodSurface)
|
||||
.cornerRadius(14)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.stroke(Color(hex: "#F0F0F0"), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
|
||||
Text("weekends_note")
|
||||
.font(.mealMoodSmall)
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
|
||||
Spacer()
|
||||
|
||||
PrimaryButton(title: String(localized: "onboarding_continue"), action: onNext)
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.bottom, 40)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import SwiftUI
|
||||
|
||||
struct WelcomeStepView: View {
|
||||
var onNext: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 32) {
|
||||
Spacer()
|
||||
|
||||
AppIconPlaceholder(size: 120)
|
||||
|
||||
VStack(spacing: 12) {
|
||||
Text("onboarding_welcome_title")
|
||||
.font(.mealMoodH1)
|
||||
.foregroundColor(.mealMoodTextPrimary)
|
||||
|
||||
Text("onboarding_welcome_subtitle")
|
||||
.font(.mealMoodBody)
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
|
||||
Image(systemName: "figure.2.and.child.holdinghands")
|
||||
.font(.system(size: 80))
|
||||
.foregroundStyle(
|
||||
LinearGradient(
|
||||
colors: [.mealMoodCoral, .mealMoodMint],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
)
|
||||
.padding(.vertical, 20)
|
||||
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
BenefitRow(icon: "face.smiling", text: String(localized: "onboarding_welcome_benefit1"))
|
||||
BenefitRow(icon: "scalemass", text: String(localized: "onboarding_welcome_benefit2"))
|
||||
BenefitRow(icon: "calendar", text: String(localized: "onboarding_welcome_benefit3"))
|
||||
}
|
||||
.padding(.horizontal, 40)
|
||||
|
||||
Spacer()
|
||||
|
||||
PrimaryButton(title: String(localized: "onboarding_start"), icon: "✨", action: onNext)
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.bottom, 40)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct BenefitRow: View {
|
||||
let icon: String
|
||||
let text: String
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 20))
|
||||
.foregroundColor(.mealMoodCoral)
|
||||
.frame(width: 28)
|
||||
|
||||
Text(text)
|
||||
.font(.mealMoodBody)
|
||||
.foregroundColor(.mealMoodTextPrimary)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
import SwiftUI
|
||||
import StoreKit
|
||||
|
||||
struct PremiumView: View {
|
||||
@Environment(\.modelContext) private var context
|
||||
@StateObject private var storeManager = StoreManager()
|
||||
@State private var purchaseStatusMessageKey: String?
|
||||
|
||||
let settings: AppSettings
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color.mealMoodBackground.ignoresSafeArea()
|
||||
|
||||
ScrollView {
|
||||
VStack(spacing: 24) {
|
||||
VStack(spacing: 16) {
|
||||
AppIconPlaceholder(size: 84)
|
||||
|
||||
Text("premium_title")
|
||||
.font(.mealMoodH1)
|
||||
.foregroundColor(.mealMoodTextPrimary)
|
||||
|
||||
Text("premium_subtitle")
|
||||
.font(.mealMoodBody)
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.padding(.top, 28)
|
||||
.padding(.horizontal, 24)
|
||||
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
BenefitItem(text: String(localized: "premium_no_ads"))
|
||||
BenefitItem(text: String(localized: "premium_unlimited_dishes"))
|
||||
BenefitItem(text: String(localized: "premium_advanced_rules"))
|
||||
BenefitItem(text: String(localized: "premium_future_weeks"))
|
||||
BenefitItem(text: String(localized: "premium_share_week"))
|
||||
BenefitItem(text: String(localized: "premium_family_sharing"))
|
||||
}
|
||||
.padding(20)
|
||||
.background(Color.mealMoodSurface)
|
||||
.cornerRadius(16)
|
||||
.padding(.horizontal, 24)
|
||||
|
||||
if settings.isPremium {
|
||||
Label("premium_active", systemImage: "checkmark.seal.fill")
|
||||
.font(.mealMoodBodyBold)
|
||||
.foregroundColor(.mealMoodSuccess)
|
||||
.padding(.horizontal, 24)
|
||||
} else if let monthly = storeManager.monthlyProduct {
|
||||
PlanCard(
|
||||
title: String(localized: "premium_monthly"),
|
||||
price: monthly.displayPrice + " / " + String(localized: "premium_month"),
|
||||
caption: String(localized: "premium_price_note"),
|
||||
isLoading: storeManager.isLoading
|
||||
) {
|
||||
Task { await purchase(monthly) }
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
} else {
|
||||
let fallbackText = fallbackContent(for: storeManager.productLoadState)
|
||||
|
||||
PlanCard(
|
||||
title: String(localized: "premium_monthly"),
|
||||
price: fallbackText.price,
|
||||
caption: fallbackText.hint,
|
||||
buttonTitle: String(localized: "premium_retry_products"),
|
||||
isLoading: storeManager.isLoadingProducts
|
||||
) {
|
||||
Task { await storeManager.loadProducts() }
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
|
||||
#if DEBUG
|
||||
let hasStoreKitFileInBundle = Bundle.main.url(forResource: "MealMood", withExtension: "storekit") != nil
|
||||
let hasStoreKitLaunchArgument = ProcessInfo.processInfo.arguments
|
||||
.contains { $0.localizedCaseInsensitiveContains("storekit") }
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Debug")
|
||||
.font(.mealMoodCaption)
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
Text("StoreKit file in bundle: \(hasStoreKitFileInBundle ? "yes" : "no")")
|
||||
.font(.mealMoodCaption)
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
Text("StoreKit launch arg: \(hasStoreKitLaunchArgument ? "yes" : "no")")
|
||||
.font(.mealMoodCaption)
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
Text("Bundle: \(Bundle.main.bundleIdentifier ?? "-")")
|
||||
.font(.mealMoodCaption)
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
Text("IDs: \(storeManager.debugProductIds.joined(separator: ", "))")
|
||||
.font(.mealMoodCaption)
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
Text("Loaded: \(storeManager.debugLoadedProductIds.joined(separator: ", "))")
|
||||
.font(.mealMoodCaption)
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
Text("Loaded detail: \(storeManager.debugLoadedProducts.joined(separator: ", "))")
|
||||
.font(.mealMoodCaption)
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
#endif
|
||||
}
|
||||
|
||||
Button {
|
||||
Task { await restorePurchases() }
|
||||
} label: {
|
||||
Text("premium_restore")
|
||||
.font(.mealMoodSmall)
|
||||
.foregroundColor(.mealMoodCoral)
|
||||
}
|
||||
.padding(.top, 4)
|
||||
|
||||
Text("premium_terms_note")
|
||||
.font(.mealMoodCaption)
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.bottom, 40)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("settings_premium")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.task {
|
||||
await storeManager.loadProducts()
|
||||
}
|
||||
.onChange(of: storeManager.isPremium) { _, isPremium in
|
||||
if settings.isPremium != isPremium {
|
||||
settings.isPremium = isPremium
|
||||
try? context.save()
|
||||
}
|
||||
}
|
||||
.alert("premium_title", isPresented: Binding(
|
||||
get: { purchaseStatusMessageKey != nil },
|
||||
set: { isPresented in
|
||||
if !isPresented { purchaseStatusMessageKey = nil }
|
||||
}
|
||||
)) {
|
||||
Button("dish_delete_blocked_ok", role: .cancel) {}
|
||||
} message: {
|
||||
Text(LocalizedStringKey(purchaseStatusMessageKey ?? ""))
|
||||
}
|
||||
}
|
||||
|
||||
private func purchase(_ product: Product) async {
|
||||
let result = await storeManager.purchase(product)
|
||||
switch result {
|
||||
case .success:
|
||||
settings.isPremium = true
|
||||
try? context.save()
|
||||
case .pending:
|
||||
purchaseStatusMessageKey = "premium_purchase_pending"
|
||||
case .cancelled:
|
||||
purchaseStatusMessageKey = "premium_purchase_cancelled"
|
||||
case .failed:
|
||||
purchaseStatusMessageKey = "premium_purchase_failed"
|
||||
}
|
||||
}
|
||||
|
||||
private func restorePurchases() async {
|
||||
await storeManager.restorePurchases()
|
||||
settings.isPremium = storeManager.isPremium
|
||||
try? context.save()
|
||||
}
|
||||
|
||||
private func fallbackContent(for state: StoreManager.ProductLoadState) -> (price: String, hint: String) {
|
||||
switch state {
|
||||
case .timedOut:
|
||||
return (
|
||||
String(localized: "premium_loading_timeout"),
|
||||
String(localized: "premium_loading_timeout_hint")
|
||||
)
|
||||
case .notFound:
|
||||
return (
|
||||
String(localized: "premium_products_not_found"),
|
||||
String(localized: "premium_products_not_found_hint")
|
||||
)
|
||||
case .failed:
|
||||
return (
|
||||
String(localized: "premium_loading_failed"),
|
||||
String(localized: "premium_loading_failed_hint")
|
||||
)
|
||||
case .idle, .loading, .loaded:
|
||||
return (
|
||||
String(localized: "premium_loading_products"),
|
||||
String(localized: "premium_loading_products_hint")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct BenefitItem: View {
|
||||
let text: String
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundColor(.mealMoodSuccess)
|
||||
.font(.system(size: 18))
|
||||
Text(text)
|
||||
.font(.mealMoodBody)
|
||||
.foregroundColor(.mealMoodTextPrimary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct PlanCard: View {
|
||||
let title: String
|
||||
let price: String
|
||||
let caption: String
|
||||
var buttonTitle: String? = nil
|
||||
var isLoading: Bool = false
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 12) {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(title)
|
||||
.font(.mealMoodH3)
|
||||
.foregroundColor(.mealMoodTextPrimary)
|
||||
Text(price)
|
||||
.font(.mealMoodBody)
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
Text(caption)
|
||||
.font(.mealMoodCaption)
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
|
||||
PrimaryButton(
|
||||
title: isLoading
|
||||
? String(localized: "premium_processing")
|
||||
: (buttonTitle ?? String(localized: "premium_subscribe")),
|
||||
action: action,
|
||||
isEnabled: !isLoading
|
||||
)
|
||||
}
|
||||
.padding(16)
|
||||
.background(Color.mealMoodSurface)
|
||||
.cornerRadius(16)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.stroke(Color(hex: "#F0F0F0"), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
import EventKit
|
||||
|
||||
struct SettingsView: View {
|
||||
@Environment(\.modelContext) private var context
|
||||
@Query private var allSettings: [AppSettings]
|
||||
@StateObject private var viewModel = SettingsViewModel()
|
||||
@State private var showResetAllDataAlert = false
|
||||
|
||||
private var settings: AppSettings? { allSettings.first }
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color.mealMoodBackground.ignoresSafeArea()
|
||||
|
||||
if let settings = settings {
|
||||
settingsContent(settings: settings)
|
||||
}
|
||||
}
|
||||
.navigationTitle("settings_title")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbarBackground(Color.mealMoodBackground, for: .navigationBar)
|
||||
.toolbarBackground(.visible, for: .navigationBar)
|
||||
.environment(\.isEnabled, true)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func settingsContent(settings: AppSettings) -> some View {
|
||||
List {
|
||||
// Planning section
|
||||
Section {
|
||||
Picker("settings_meal_windows", selection: Binding(
|
||||
get: { settings.mealWindowsEnum },
|
||||
set: { settings.mealWindowsEnum = $0 }
|
||||
)) {
|
||||
Text("meal_windows_dinner_only").tag(MealWindows.dinnerOnly)
|
||||
Text("meal_windows_lunch_only").tag(MealWindows.lunchOnly)
|
||||
Text("meal_windows_both").tag(MealWindows.both)
|
||||
}
|
||||
|
||||
Toggle("settings_include_weekends", isOn: Binding(
|
||||
get: { settings.includeWeekends },
|
||||
set: { settings.includeWeekends = $0 }
|
||||
))
|
||||
.tint(.mealMoodCoral)
|
||||
} header: {
|
||||
Label("settings_planning", systemImage: "fork.knife")
|
||||
}
|
||||
.listRowBackground(Color.mealMoodSurface)
|
||||
|
||||
// Calendar section
|
||||
Section {
|
||||
Toggle("settings_icloud_sync", isOn: Binding(
|
||||
get: { settings.iCloudSyncEnabledResolved },
|
||||
set: { settings.iCloudSyncEnabledResolved = $0 }
|
||||
))
|
||||
.tint(.mealMoodCoral)
|
||||
|
||||
Toggle("settings_sync", isOn: Binding(
|
||||
get: { settings.syncEnabled },
|
||||
set: { newValue in
|
||||
if newValue {
|
||||
Task {
|
||||
let granted = await viewModel.requestCalendarAccess()
|
||||
if granted {
|
||||
settings.syncEnabled = true
|
||||
}
|
||||
}
|
||||
} else {
|
||||
settings.syncEnabled = false
|
||||
}
|
||||
}
|
||||
))
|
||||
.tint(.mealMoodCoral)
|
||||
|
||||
if settings.syncEnabled {
|
||||
Picker("settings_sync_mode", selection: Binding(
|
||||
get: { settings.syncModeEnum },
|
||||
set: { settings.syncModeEnum = $0 }
|
||||
)) {
|
||||
ForEach(CalendarSyncMode.allCases, id: \.self) { mode in
|
||||
Text(LocalizedStringKey(mode.localizedKey)).tag(mode)
|
||||
}
|
||||
}
|
||||
|
||||
if !viewModel.availableCalendars.isEmpty {
|
||||
Picker("calendar_select", selection: Binding(
|
||||
get: { settings.calendarId },
|
||||
set: { settings.calendarId = $0 }
|
||||
)) {
|
||||
Text("calendar_select").tag(nil as String?)
|
||||
ForEach(viewModel.availableCalendars, id: \.calendarIdentifier) { cal in
|
||||
Text(cal.title).tag(cal.calendarIdentifier as String?)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DatePicker("settings_lunch_time", selection: Binding(
|
||||
get: { settings.lunchTime },
|
||||
set: { settings.lunchTime = $0 }
|
||||
), displayedComponents: .hourAndMinute)
|
||||
|
||||
DatePicker("settings_dinner_time", selection: Binding(
|
||||
get: { settings.dinnerTime },
|
||||
set: { settings.dinnerTime = $0 }
|
||||
), displayedComponents: .hourAndMinute)
|
||||
|
||||
Picker("settings_event_duration", selection: Binding(
|
||||
get: { settings.eventDuration },
|
||||
set: { settings.eventDuration = $0 }
|
||||
)) {
|
||||
Text("duration_30").tag(30)
|
||||
Text("duration_60").tag(60)
|
||||
Text("duration_90").tag(90)
|
||||
Text("duration_120").tag(120)
|
||||
}
|
||||
|
||||
TextField("settings_event_prefix", text: Binding(
|
||||
get: { settings.eventPrefix },
|
||||
set: { settings.eventPrefix = $0 }
|
||||
))
|
||||
|
||||
Picker("settings_reminder", selection: Binding(
|
||||
get: { settings.reminderMinutesBefore },
|
||||
set: { settings.reminderMinutesBefore = $0 }
|
||||
)) {
|
||||
Text("settings_reminder_none").tag(nil as Int?)
|
||||
Text("reminder_30").tag(30 as Int?)
|
||||
Text("reminder_60").tag(60 as Int?)
|
||||
Text("reminder_120").tag(120 as Int?)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Label("settings_calendar", systemImage: "calendar")
|
||||
}
|
||||
.listRowBackground(Color.mealMoodSurface)
|
||||
|
||||
// Tags section
|
||||
Section {
|
||||
NavigationLink(destination: TagListView()) {
|
||||
Text("settings_manage_tags")
|
||||
}
|
||||
} header: {
|
||||
Label("settings_tags", systemImage: "tag")
|
||||
}
|
||||
.listRowBackground(Color.mealMoodSurface)
|
||||
|
||||
// Language section
|
||||
Section {
|
||||
Picker("settings_language", selection: Binding(
|
||||
get: { settings.languageEnum },
|
||||
set: { settings.languageEnum = $0 }
|
||||
)) {
|
||||
ForEach(AppLanguage.allCases, id: \.self) { lang in
|
||||
Text(lang.displayName).tag(lang)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Label("settings_language", systemImage: "globe")
|
||||
}
|
||||
.listRowBackground(Color.mealMoodSurface)
|
||||
|
||||
// Premium section
|
||||
Section {
|
||||
HStack {
|
||||
Text("settings_premium_status")
|
||||
Spacer()
|
||||
Text(settings.isPremium ? "Premium" : "Free")
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
}
|
||||
|
||||
NavigationLink(destination: PremiumView(settings: settings)) {
|
||||
Text("settings_remove_ads")
|
||||
}
|
||||
} header: {
|
||||
Label("settings_premium", systemImage: "star")
|
||||
}
|
||||
.listRowBackground(Color.mealMoodSurface)
|
||||
|
||||
Section {
|
||||
Button {
|
||||
ReviewPromptService.shared.requestFromSettings()
|
||||
} label: {
|
||||
Label("settings_rate_app", systemImage: "star.bubble")
|
||||
}
|
||||
|
||||
Link(destination: URL(string: "https://mealmood.app")!) {
|
||||
Label("settings_website", systemImage: "safari")
|
||||
}
|
||||
|
||||
Link(destination: URL(string: "mailto:support@mealmood.app")!) {
|
||||
Label("settings_support", systemImage: "envelope")
|
||||
}
|
||||
} header: {
|
||||
Label("settings_about", systemImage: "info.circle")
|
||||
}
|
||||
.listRowBackground(Color.mealMoodSurface)
|
||||
|
||||
Section {
|
||||
Button(role: .destructive) {
|
||||
showResetAllDataAlert = true
|
||||
} label: {
|
||||
Label("settings_reset_all_data", systemImage: "trash")
|
||||
}
|
||||
} header: {
|
||||
Label("settings_danger_zone", systemImage: "exclamationmark.triangle")
|
||||
}
|
||||
.listRowBackground(Color.mealMoodSurface)
|
||||
}
|
||||
.scrollContentBackground(.hidden)
|
||||
.listStyle(.insetGrouped)
|
||||
.foregroundColor(.mealMoodTextPrimary)
|
||||
.tint(.mealMoodCoral)
|
||||
.onAppear {
|
||||
if settings.syncEnabled {
|
||||
viewModel.loadCalendars()
|
||||
}
|
||||
}
|
||||
.alert("calendar_permission_title", isPresented: $viewModel.showCalendarPermissionAlert) {
|
||||
Button("calendar_permission_settings") { viewModel.openSystemSettings() }
|
||||
Button("reset_cancel", role: .cancel) {}
|
||||
} message: {
|
||||
Text("calendar_permission_message")
|
||||
}
|
||||
.alert("settings_reset_all_data_title", isPresented: $showResetAllDataAlert) {
|
||||
Button("reset_cancel", role: .cancel) {}
|
||||
Button("settings_reset_all_data_confirm", role: .destructive) {
|
||||
viewModel.resetAllData(context: context)
|
||||
}
|
||||
} message: {
|
||||
Text("settings_reset_all_data_message")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
struct TagListView: View {
|
||||
@Query(sort: \Tag.sortOrder) private var tags: [Tag]
|
||||
@Query private var allSettings: [AppSettings]
|
||||
@State private var selectedTag: Tag?
|
||||
|
||||
private var language: AppLanguage {
|
||||
(allSettings.first?.languageEnum ?? .system).resolved()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color.mealMoodBackground.ignoresSafeArea()
|
||||
|
||||
List {
|
||||
ForEach(tags) { tag in
|
||||
Button {
|
||||
selectedTag = tag
|
||||
} label: {
|
||||
HStack {
|
||||
Circle()
|
||||
.fill(Color(hex: tag.color))
|
||||
.frame(width: 14, height: 14)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(tag.localizedName(language: language))
|
||||
.font(.mealMoodBodyBold)
|
||||
.foregroundColor(.mealMoodTextPrimary)
|
||||
|
||||
Text(tag.localizedRulesDescription(language: language))
|
||||
.font(.mealMoodCaption)
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
}
|
||||
}
|
||||
.listRowBackground(Color.mealMoodSurface)
|
||||
}
|
||||
}
|
||||
.scrollContentBackground(.hidden)
|
||||
}
|
||||
.navigationTitle("tags_title")
|
||||
.sheet(item: $selectedTag) { tag in
|
||||
TagRulesEditView(tag: tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
struct TagRulesEditView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@StateObject private var viewModel = TagViewModel()
|
||||
@Query private var allSettings: [AppSettings]
|
||||
@State private var showPremium = false
|
||||
let tag: Tag
|
||||
|
||||
private var language: AppLanguage {
|
||||
(allSettings.first?.languageEnum ?? .system).resolved()
|
||||
}
|
||||
|
||||
private var isPremium: Bool {
|
||||
allSettings.first?.isPremium ?? false
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
Color.mealMoodBackground.ignoresSafeArea()
|
||||
|
||||
ScrollView {
|
||||
VStack(spacing: 24) {
|
||||
// Tag header
|
||||
HStack(spacing: 12) {
|
||||
Circle()
|
||||
.fill(Color(hex: tag.color))
|
||||
.frame(width: 20, height: 20)
|
||||
Text(tag.localizedName(language: language))
|
||||
.font(.mealMoodH2)
|
||||
.foregroundColor(.mealMoodTextPrimary)
|
||||
}
|
||||
.padding(.top, 8)
|
||||
|
||||
// Max per week
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
Text("tags_max_per_week")
|
||||
.font(.mealMoodBodyBold)
|
||||
.foregroundColor(.mealMoodTextPrimary)
|
||||
Spacer()
|
||||
Toggle("", isOn: $viewModel.useMaxLimit)
|
||||
.tint(.mealMoodCoral)
|
||||
.labelsHidden()
|
||||
}
|
||||
|
||||
if viewModel.useMaxLimit {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(1...7, id: \.self) { num in
|
||||
Button {
|
||||
viewModel.maxPerWeek = num
|
||||
HapticManager.shared.selection()
|
||||
} label: {
|
||||
Text("\(num)")
|
||||
.font(.mealMoodSmall)
|
||||
.fontWeight(.semibold)
|
||||
.frame(width: 36, height: 36)
|
||||
.background(
|
||||
viewModel.maxPerWeek == num
|
||||
? Color.mealMoodCoral
|
||||
: Color.mealMoodSurface
|
||||
)
|
||||
.foregroundColor(
|
||||
viewModel.maxPerWeek == num
|
||||
? .white
|
||||
: .mealMoodTextPrimary
|
||||
)
|
||||
.cornerRadius(8)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.stroke(Color(hex: "#E0E0E0"), lineWidth: viewModel.maxPerWeek == num ? 0 : 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text("\(String(localized: "tags_max_per_week")): \(viewModel.maxPerWeek ?? 3)")
|
||||
.font(.mealMoodCaption)
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.background(Color.mealMoodSurface)
|
||||
.cornerRadius(14)
|
||||
|
||||
// No consecutive toggle
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("tags_no_consecutive")
|
||||
.font(.mealMoodBodyBold)
|
||||
.foregroundColor(.mealMoodTextPrimary)
|
||||
Text("tags_no_consecutive_desc")
|
||||
.font(.mealMoodCaption)
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
}
|
||||
Spacer()
|
||||
Toggle("", isOn: $viewModel.noConsecutive)
|
||||
.tint(.mealMoodCoral)
|
||||
.labelsHidden()
|
||||
.disabled(!isPremium)
|
||||
}
|
||||
.padding(16)
|
||||
}
|
||||
.background(Color.mealMoodSurface)
|
||||
.cornerRadius(14)
|
||||
|
||||
// No duplicate in day toggle
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("tags_no_duplicate")
|
||||
.font(.mealMoodBodyBold)
|
||||
.foregroundColor(.mealMoodTextPrimary)
|
||||
Text("tags_no_duplicate_desc")
|
||||
.font(.mealMoodCaption)
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
}
|
||||
Spacer()
|
||||
Toggle("", isOn: $viewModel.noDuplicateInDay)
|
||||
.tint(.mealMoodCoral)
|
||||
.labelsHidden()
|
||||
.disabled(!isPremium)
|
||||
}
|
||||
.padding(16)
|
||||
}
|
||||
.background(Color.mealMoodSurface)
|
||||
.cornerRadius(14)
|
||||
|
||||
// Meal type restriction
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("tags_restriction")
|
||||
.font(.mealMoodBodyBold)
|
||||
.foregroundColor(.mealMoodTextPrimary)
|
||||
|
||||
Picker("Restricción", selection: $viewModel.mealTypeRestriction) {
|
||||
Text("tags_no_restriction").tag(nil as String?)
|
||||
Text("tags_lunch_only").tag("lunch" as String?)
|
||||
Text("tags_dinner_only").tag("dinner" as String?)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.disabled(!isPremium)
|
||||
}
|
||||
.padding(16)
|
||||
.background(Color.mealMoodSurface)
|
||||
.cornerRadius(14)
|
||||
|
||||
if !isPremium {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "star.circle")
|
||||
Text("premium_limit_rules")
|
||||
Spacer()
|
||||
Button("settings_remove_ads") {
|
||||
showPremium = true
|
||||
}
|
||||
}
|
||||
.font(.mealMoodCaption)
|
||||
.foregroundColor(.mealMoodCoral)
|
||||
.padding(12)
|
||||
.background(Color.mealMoodSurface)
|
||||
.cornerRadius(12)
|
||||
}
|
||||
}
|
||||
.padding(24)
|
||||
}
|
||||
}
|
||||
.navigationTitle(tag.localizedName(language: language))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button {
|
||||
viewModel.save()
|
||||
dismiss()
|
||||
} label: {
|
||||
Image(systemName: "checkmark")
|
||||
.fontWeight(.semibold)
|
||||
.foregroundColor(.mealMoodCoral)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
viewModel.loadTag(tag)
|
||||
}
|
||||
.sheet(isPresented: $showPremium) {
|
||||
if let settings = allSettings.first {
|
||||
NavigationStack { PremiumView(settings: settings) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user