e15ff93465
- MealType gains breakfast/snack (chronological order drives slot/row order). - AppSettings.activeMealTypes: single source of truth for enabled meals, backed by new optional enabledMealTypesRaw with legacy mealWindows fallback — additive migration, keeps pre-2.0 installs and iCloud snapshots working. Legacy field kept coherent by the setter. time(for:) resolves per-meal event times (breakfast 8:00 / snack 17:00 defaults on old stores). - Replaced the 5 duplicated mealWindows→[MealType] derivations (slot sync, default plan creation, week calendar, export view) with activeMealTypes. - CalendarService: per-type event names and times. - Export styles: per-type accent colors and gradients. - Widget: generic meals list (N rows) instead of hardcoded lunch/dinner; compact families fall back to main meals when >2 are active. - Settings: 4 meal toggles (min 1) replace the 3-option picker. - Onboarding: meal step is now multi-select over the 4 types. - iCloud sync: enabledMealTypesRaw synced (optional, pre-2.0 compatible). - Localized breakfast/snack in all 6 languages. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BkanrydYtrme8wipTzWssG
337 lines
14 KiB
Swift
337 lines
14 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)
|
|
|
|
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() }
|
|
|
|
// 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 {
|
|
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")
|
|
}
|
|
|
|
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")
|
|
}
|
|
}
|
|
}
|