Files
Nextcloud-Cookbook-iOS/Nextcloud Cookbook iOS Client/Views/SettingsView.swift
Hendrik Hogertz 02118e3d7a Add dark mode support with appearance picker and fix hardcoded colors
Add user-facing appearance setting (System/Light/Dark) wired via
preferredColorScheme at the app root. Replace hardcoded .black tints
and foreground styles with .primary so toolbar buttons and text remain
visible in dark mode. Remove profile picture from settings and
SwiftSoup from acknowledgements.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-15 06:31:14 +01:00

281 lines
11 KiB
Swift

//
// SettingsView.swift
// Nextcloud Cookbook iOS Client
//
// Created by Vincent Meilinger on 15.09.23.
//
import EventKit
import Foundation
import OSLog
import SwiftUI
struct SettingsView: View {
@EnvironmentObject var appState: AppState
@EnvironmentObject var groceryListManager: GroceryListManager
@ObservedObject var userSettings = UserSettings.shared
@StateObject var viewModel = ViewModel()
@State private var reminderLists: [EKCalendar] = []
@State private var remindersPermission: EKAuthorizationStatus = .notDetermined
var body: some View {
Form {
Section {
Picker("Select a default cookbook", selection: $userSettings.defaultCategory) {
Text("None").tag("None")
ForEach(appState.categories, id: \.name) { category in
Text(category.name == "*" ? "Other" : category.name).tag(category)
}
}
} header: {
Text("General")
} footer: {
Text("The selected cookbook will open on app launch by default.")
}
Section {
Picker("Appearance", selection: $userSettings.appearanceMode) {
ForEach(AppearanceMode.allValues, id: \.self) { mode in
Text(mode.descriptor()).tag(mode.rawValue)
}
}
} footer: {
Text("Choose whether the app follows the system appearance or always uses light or dark mode.")
}
Section {
Picker("Grocery list storage", selection: $userSettings.groceryListMode) {
ForEach(GroceryListMode.allValues, id: \.self) { mode in
Text(mode.descriptor()).tag(mode.rawValue)
}
}
if userSettings.groceryListMode == GroceryListMode.appleReminders.rawValue {
if remindersPermission == .notDetermined {
Button("Grant Reminders Access") {
Task {
let granted = await groceryListManager.requestRemindersAccess()
remindersPermission = groceryListManager.remindersPermissionStatus
if granted {
reminderLists = groceryListManager.availableReminderLists()
}
}
}
} else if remindersPermission == .denied || remindersPermission == .restricted {
Text("Reminders access was denied. Please enable it in System Settings to use this feature.")
.foregroundStyle(.secondary)
Button("Open Settings") {
if let url = URL(string: UIApplication.openSettingsURLString) {
UIApplication.shared.open(url)
}
}
} else if remindersPermission == .fullAccess {
Picker("Reminders list", selection: $userSettings.remindersListIdentifier) {
ForEach(reminderLists, id: \.calendarIdentifier) { list in
Text(list.title).tag(list.calendarIdentifier)
}
}
}
}
Toggle(isOn: $userSettings.grocerySyncEnabled) {
Text("Sync grocery list across devices")
}
} header: {
Text("Grocery List")
} footer: {
if userSettings.grocerySyncEnabled {
Text("Grocery list state is synced via your Nextcloud server by storing it alongside recipe data.")
} else if userSettings.groceryListMode == GroceryListMode.appleReminders.rawValue {
Text("Grocery items will be saved to Apple Reminders. The Grocery List tab will be hidden since you can manage items directly in the Reminders app.")
} else {
Text("Grocery items are stored locally on this device.")
}
}
Section {
Toggle(isOn: $userSettings.expandNutritionSection) {
Text("Expand nutrition section")
}
Toggle(isOn: $userSettings.expandKeywordSection) {
Text("Expand keyword section")
}
Toggle(isOn: $userSettings.expandInfoSection) {
Text("Expand information section")
}
} header: {
Text("Recipes")
} footer: {
Text("Configure which sections in your recipes are expanded by default.")
}
Section {
Toggle(isOn: $userSettings.keepScreenAwake) {
Text("Keep screen awake when viewing recipes")
}
}
Section {
HStack {
Text("Decimal number format")
Spacer()
Picker("", selection: $userSettings.decimalNumberSeparator) {
Text("Point (e.g. 1.42)").tag(".")
Text("Comma (e.g. 1,42)").tag(",")
}
.pickerStyle(.menu)
}
} footer: {
Text("This setting will take effect after the app is restarted. It affects the adjustment of ingredient quantities.")
}
Section {
Toggle(isOn: $userSettings.storeRecipes) {
Text("Offline recipes")
}
Toggle(isOn: $userSettings.storeImages) {
Text("Store recipe images locally")
}
Toggle(isOn: $userSettings.storeThumb) {
Text("Store recipe thumbnails locally")
}
} header: {
Text("Downloads")
} footer: {
Text("Configure what is stored on your device.")
}
Section {
Picker("Language", selection: $userSettings.language) {
ForEach(SupportedLanguage.allValues, id: \.self) { lang in
Text(lang.descriptor()).tag(lang.rawValue)
}
}
} footer: {
Text("If \'Same as Device\' is selected and your device language is not supported yet, this option will default to english.")
}
Section {
Link("Visit the GitHub page", destination: URL(string: "https://github.com/VincentMeilinger/Nextcloud-Cookbook-iOS")!)
} header: {
Text("About")
} footer: {
Text("If you are interested in contributing to this project or simply wish to review its source code, we encourage you to visit the GitHub repository for this application.")
}
Section {
Link("Get support", destination: URL(string: "https://vincentmeilinger.github.io/Nextcloud-Cookbook-Client-Support/")!)
} header: {
Text("Support")
} footer: {
Text("If you have any inquiries, feedback, or require assistance, please refer to the support page for contact information.")
}
Section {
Button("Log out") {
Logger.view.debug("Log out.")
viewModel.alertType = .LOG_OUT
viewModel.showAlert = true
}
.tint(.red)
Button("Delete local data") {
Logger.view.debug("Clear cache.")
viewModel.alertType = .DELETE_CACHE
viewModel.showAlert = true
}
.tint(.red)
} header: {
Text("Other")
} footer: {
Text("Deleting local data will not affect the recipe data stored on your server.")
}
Section(header: Text("Acknowledgements")) {
VStack(alignment: .leading) {
if let url = URL(string: "https://github.com/techprimate/TPPDF") {
Link("TPPDF", destination: url)
.font(.headline)
Text("A simple-to-use PDF builder for Swift. Used for generating recipe PDF documents.")
}
}
}
}
.navigationTitle("Settings")
.alert(viewModel.alertType.getTitle(), isPresented: $viewModel.showAlert) {
Button("Cancel", role: .cancel) { }
if viewModel.alertType == .LOG_OUT {
Button("Log out", role: .destructive) { logOut() }
} else if viewModel.alertType == .DELETE_CACHE {
Button("Delete", role: .destructive) { deleteCache() }
}
} message: {
Text(viewModel.alertType.getMessage())
}
.task {
remindersPermission = groceryListManager.remindersPermissionStatus
if remindersPermission == .fullAccess {
reminderLists = groceryListManager.availableReminderLists()
}
}
.onChange(of: userSettings.groceryListMode) { _, _ in
remindersPermission = groceryListManager.remindersPermissionStatus
if remindersPermission == .fullAccess {
reminderLists = groceryListManager.availableReminderLists()
}
}
}
func logOut() {
userSettings.serverAddress = ""
userSettings.username = ""
userSettings.token = ""
userSettings.authString = ""
appState.deleteAllData()
userSettings.onboarding = true
}
func deleteCache() {
appState.deleteAllData()
}
}
extension SettingsView {
class ViewModel: ObservableObject {
@Published var showAlert: Bool = false
fileprivate var alertType: SettingsAlert = .NONE
enum SettingsAlert {
case LOG_OUT,
DELETE_CACHE,
NONE
func getTitle() -> String {
switch self {
case .LOG_OUT: return "Log out"
case .DELETE_CACHE: return "Delete local data"
default: return "Please confirm your action."
}
}
func getMessage() -> String {
switch self {
case .LOG_OUT: return "Are you sure that you want to log out of your account?"
case .DELETE_CACHE: return "Are you sure that you want to delete the downloaded recipes? This action will not affect any recipes stored on your server."
default: return ""
}
}
}
}
}