Store a _groceryState JSON field on each recipe to track which ingredients have been added, completed, or removed. Uses per-item last-writer-wins conflict resolution with ISO 8601 timestamps. Debounced push (2s) avoids excessive API calls; pull reconciles on recipe open and app launch. Includes a settings toggle to enable/disable sync. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
316 lines
12 KiB
Swift
316 lines
12 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 {
|
|
HStack(alignment: .center) {
|
|
if let avatarImage = viewModel.avatarImage {
|
|
Image(uiImage: avatarImage)
|
|
.resizable()
|
|
.clipShape(Circle())
|
|
.frame(width: 100, height: 100)
|
|
|
|
}
|
|
if let userData = viewModel.userData {
|
|
VStack(alignment: .leading) {
|
|
Text(userData.userDisplayName)
|
|
.font(.title)
|
|
.padding(.leading)
|
|
Text("Username: \(userData.userId)")
|
|
.font(.subheadline)
|
|
.padding(.leading)
|
|
|
|
|
|
// TODO: Add actions
|
|
}
|
|
}
|
|
Spacer()
|
|
}
|
|
|
|
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("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/scinfu/SwiftSoup") {
|
|
Link("SwiftSoup", destination: url)
|
|
.font(.headline)
|
|
Text("An HTML parsing and web scraping library for Swift. Used for importing schema.org recipes from websites.")
|
|
}
|
|
}
|
|
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 {
|
|
await viewModel.getUserData()
|
|
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 avatarImage: UIImage? = nil
|
|
@Published var userData: UserData? = nil
|
|
|
|
@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 ""
|
|
}
|
|
}
|
|
}
|
|
|
|
func getUserData() async {
|
|
let (data, _) = await NextcloudApi.getAvatar()
|
|
let (userData, _) = await NextcloudApi.getHoverCard()
|
|
|
|
DispatchQueue.main.async {
|
|
self.avatarImage = data
|
|
self.userData = userData
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|