Files
FamilyMealPlanner/MealMood/Views/Settings/SettingsView.swift
T
alexandrev-tibco 163fd6026a hogar compartido: colaboracion entre cuentas sobre Firestore
CloudKit sincroniza la base privada de un Apple ID: ni llega a Android ni deja
que dos cuentas editen el mismo plan (CKShare sigue sin existir en SwiftData).
El contenido de un hogar pasa por tanto a Firestore, y un dispositivo que entra
en un hogar construye el store local sin CloudKit — dos espejos escribiendo los
mismos objetos se pelean, que es justo lo que ya obligó a apagar el sync por
iCloud KV.

SwiftData sigue siendo el store local y el modo offline; HouseholdSyncService es
lo unico que habla con la red. Detecta cambios comparando una huella del
contenido de cada documento con la ultima sincronizada (el "shadow"), asi que no
hace falta instrumentar con updatedAt las treinta vistas que mutan modelos. Los
borrados van como tombstone: un borrado duro volveria desde cualquier miembro
que estuviera sin conexion.

Semanas y slots usan id derivado del contenido (2026-09-14, 5-dinner) para que
dos miembros que abren la misma semana escriban el mismo documento en vez de
crear dos, y para que los conflictos se resuelvan por slot y no por semana.

Incluye reglas de seguridad (solo miembros; los codigos de invitacion se pueden
leer por id pero no listar), pantalla de hogar en Ajustes con Sign in with Apple,
invitacion por codigo de 6 caracteres sin vocales ni 0/O/1/I, y la eleccion al
unirse entre llevarse los platos propios o adoptar los del hogar.

Fuera de esta fase: fotos de platos (necesitan Storage) y el cliente Android.

Refs #33

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013su1ttRiMeMYxkZJ1Y3246
2026-09-12 13:00:38 +02:00

361 lines
15 KiB
Swift

import SwiftUI
import SwiftData
import EventKit
import UserNotifications
struct SettingsView: View {
@Environment(\.modelContext) private var context
@Query private var allSettings: [AppSettings]
@StateObject private var viewModel = SettingsViewModel()
@State private var showResetAllDataAlert = false
@State private var notificationAuthStatus: UNAuthorizationStatus = .notDetermined
@State private var remindersEnabled = NotificationService.shared.remindersEnabled
private var settings: AppSettings? { allSettings.first }
private func refreshNotificationStatus() async {
notificationAuthStatus = await NotificationService.shared.authorizationStatus()
remindersEnabled = NotificationService.shared.remindersEnabled
}
private func handleReminderToggle(_ enabled: Bool, settings: AppSettings) {
Task {
if enabled {
let granted = await NotificationService.shared.requestPermission()
NotificationService.shared.remindersEnabled = true
remindersEnabled = true
notificationAuthStatus = await NotificationService.shared.authorizationStatus()
AnalyticsService.logNotificationsToggled(enabled: granted)
if granted {
NotificationService.shared.schedulePlanningReminderIfNeeded(
nextWeekPlan: nil,
language: settings.languageEnum.resolved()
)
}
} else {
NotificationService.shared.remindersEnabled = false
remindersEnabled = false
AnalyticsService.logNotificationsToggled(enabled: false)
}
}
}
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)
.toast(isShowing: $viewModel.showToast, message: viewModel.toastMessage)
}
@ViewBuilder
private func settingsContent(settings: AppSettings) -> some View {
List {
// Planning section
Section {
ForEach(MealType.allCases, id: \.self) { mealType in
Toggle(isOn: Binding(
get: { settings.activeMealTypes.contains(mealType) },
set: { enabled in
var types = settings.activeMealTypes
if enabled {
types.append(mealType)
} else if types.count > 1 {
// Keep at least one meal type active.
types.removeAll { $0 == mealType }
}
settings.activeMealTypes = types
}
)) {
Label(
String(localized: String.LocalizationValue(mealType.localizedKey)),
systemImage: mealType.icon
)
}
.tint(.mealMoodCoral)
}
Toggle("settings_include_weekends", isOn: Binding(
get: { settings.includeWeekends },
set: { settings.includeWeekends = $0 }
))
.tint(.mealMoodCoral)
Toggle("settings_show_dish_photos", isOn: Binding(
get: { settings.showDishPhotosInPlannerResolved },
set: { settings.showDishPhotosInPlannerResolved = $0 }
))
.tint(.mealMoodCoral)
Picker("settings_export_style", selection: Binding(
get: { settings.weekExportStyleEnum },
set: { settings.weekExportStyleEnum = $0 }
)) {
ForEach(WeekExportStyle.allCases, id: \.self) { style in
Text(LocalizedStringKey(style.localizedKey)).tag(style)
}
}
} 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 {
let hasCalendar = viewModel.ensureValidCalendarSelection(settings: settings)
settings.syncEnabled = hasCalendar
}
}
} 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)
// Notifications section
Section {
Toggle("settings_weekly_reminder", isOn: Binding(
get: { remindersEnabled && notificationAuthStatus != .denied },
set: { newValue in handleReminderToggle(newValue, settings: settings) }
))
.tint(.mealMoodCoral)
.disabled(notificationAuthStatus == .denied)
if notificationAuthStatus == .denied {
VStack(alignment: .leading, spacing: 8) {
Text("settings_notifications_denied_hint")
.font(.mealMoodSmall)
.foregroundColor(.mealMoodTextSecondary)
Button {
if let url = URL(string: UIApplication.openSettingsURLString) {
UIApplication.shared.open(url)
}
} label: {
Text("settings_open_system_settings")
.font(.mealMoodSmall)
.foregroundColor(.mealMoodCoral)
}
}
}
} header: {
Label("settings_notifications_header", systemImage: "bell")
}
.listRowBackground(Color.mealMoodSurface)
.task { await refreshNotificationStatus() }
// Household section (2.2): shared planning across accounts.
Section {
NavigationLink(destination: HouseholdView()) {
HStack {
Text("household_title")
Spacer()
if let name = HouseholdRuntime.householdName, HouseholdRuntime.householdId != nil {
Text(name)
.font(.mealMoodCaption)
.foregroundColor(.mealMoodTextSecondary)
}
}
}
} header: {
Label("household_section", systemImage: "person.2")
} footer: {
Text("household_settings_footer")
}
.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, source: "settings")) {
Text("settings_remove_ads")
}
} header: {
Label("settings_premium", systemImage: "star")
}
.listRowBackground(Color.mealMoodSurface)
Section {
Link(destination: ReviewPromptService.writeReviewURL) {
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")
}
HStack {
Text("settings_app_version")
Spacer()
Text(viewModel.appVersion)
.foregroundColor(.mealMoodTextSecondary)
}
HStack {
Text("settings_app_build")
Spacer()
Text(viewModel.appBuild)
.foregroundColor(.mealMoodTextSecondary)
}
} 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 {
AnalyticsService.logScreenView("Settings")
if settings.syncEnabled {
let hasCalendar = viewModel.ensureValidCalendarSelection(settings: settings)
settings.syncEnabled = hasCalendar
}
}
.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")
}
}
}