Files
Nextcloud-Cookbook-iOS/Nextcloud Cookbook iOS Client/Views/SettingsView.swift
Hendrik Hogertz fb6b16c1fc Fix category handling, recipe management, and dark mode toggle tint
- Map uncategorized category between * (internal) and empty string
  (API) so selecting Sonstige/Other correctly persists to the server
- Default new recipes to Other (*) category and remove None option
- Add "New Category" option to category picker in recipe edit view
- Include newly created/imported recipes in recently viewed list and
  pre-fetch thumbnails so images display immediately
- Remove deleted recipes from recently viewed list
- Remove broad .tint(.primary) from RecipeTabView that caused white
  toggles in Settings during dark mode
- Rename German "Other" translation from Andere to Sonstige
- Add missing translations for Servings stepper and new category strings

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-15 07:13:01 +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 == "*" ? String(localized: "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 ""
}
}
}
}
}