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>
This commit is contained in:
2026-02-15 06:31:14 +01:00
parent c38d4075be
commit 02118e3d7a
12 changed files with 177 additions and 53 deletions

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
.DS_Store
Nextcloud Cookbook iOS Client.xcodeproj/project.xcworkspace/xcuserdata/hendrik.hogertz.xcuserdatad/UserInterfaceState.xcuserstate
Nextcloud Cookbook iOS Client.xcodeproj/xcuserdata/hendrik.hogertz.xcuserdatad/xcschemes/xcschememanagement.plist

View File

@@ -69,6 +69,7 @@
C1F0AB022D0B000100000001 /* ImportURLSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1F0AB012D0B000100000001 /* ImportURLSheet.swift */; }; C1F0AB022D0B000100000001 /* ImportURLSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1F0AB012D0B000100000001 /* ImportURLSheet.swift */; };
A9E78A2B2BE7799F00206866 /* JsonAny.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9E78A2A2BE7799F00206866 /* JsonAny.swift */; }; A9E78A2B2BE7799F00206866 /* JsonAny.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9E78A2A2BE7799F00206866 /* JsonAny.swift */; };
A9FA2AB62B5079B200A43702 /* alarm_sound_0.mp3 in Resources */ = {isa = PBXBuildFile; fileRef = A9FA2AB52B5079B200A43702 /* alarm_sound_0.mp3 */; }; A9FA2AB62B5079B200A43702 /* alarm_sound_0.mp3 in Resources */ = {isa = PBXBuildFile; fileRef = A9FA2AB52B5079B200A43702 /* alarm_sound_0.mp3 */; };
A1B2C3D52F0A000100000001 /* AppearanceMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D42F0A000100000001 /* AppearanceMode.swift */; };
D1A0CE012D0A000100000001 /* GroceryListMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A0CE002D0A000100000001 /* GroceryListMode.swift */; }; D1A0CE012D0A000100000001 /* GroceryListMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A0CE002D0A000100000001 /* GroceryListMode.swift */; };
D1A0CE032D0A000200000002 /* RemindersGroceryStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A0CE022D0A000200000002 /* RemindersGroceryStore.swift */; }; D1A0CE032D0A000200000002 /* RemindersGroceryStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A0CE022D0A000200000002 /* RemindersGroceryStore.swift */; };
D1A0CE052D0A000300000003 /* GroceryListManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A0CE042D0A000300000003 /* GroceryListManager.swift */; }; D1A0CE052D0A000300000003 /* GroceryListManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A0CE042D0A000300000003 /* GroceryListManager.swift */; };
@@ -165,6 +166,7 @@
A9DA25D42B82096B0061FC2B /* Nextcloud-Cookbook-iOS-Client-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = "Nextcloud-Cookbook-iOS-Client-Info.plist"; sourceTree = SOURCE_ROOT; }; A9DA25D42B82096B0061FC2B /* Nextcloud-Cookbook-iOS-Client-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = "Nextcloud-Cookbook-iOS-Client-Info.plist"; sourceTree = SOURCE_ROOT; };
A9E78A2A2BE7799F00206866 /* JsonAny.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JsonAny.swift; sourceTree = "<group>"; }; A9E78A2A2BE7799F00206866 /* JsonAny.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JsonAny.swift; sourceTree = "<group>"; };
A9FA2AB52B5079B200A43702 /* alarm_sound_0.mp3 */ = {isa = PBXFileReference; lastKnownFileType = audio.mp3; path = alarm_sound_0.mp3; sourceTree = "<group>"; }; A9FA2AB52B5079B200A43702 /* alarm_sound_0.mp3 */ = {isa = PBXFileReference; lastKnownFileType = audio.mp3; path = alarm_sound_0.mp3; sourceTree = "<group>"; };
A1B2C3D42F0A000100000001 /* AppearanceMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppearanceMode.swift; sourceTree = "<group>"; };
D1A0CE002D0A000100000001 /* GroceryListMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroceryListMode.swift; sourceTree = "<group>"; }; D1A0CE002D0A000100000001 /* GroceryListMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroceryListMode.swift; sourceTree = "<group>"; };
D1A0CE022D0A000200000002 /* RemindersGroceryStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemindersGroceryStore.swift; sourceTree = "<group>"; }; D1A0CE022D0A000200000002 /* RemindersGroceryStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemindersGroceryStore.swift; sourceTree = "<group>"; };
D1A0CE042D0A000300000003 /* GroceryListManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroceryListManager.swift; sourceTree = "<group>"; }; D1A0CE042D0A000300000003 /* GroceryListManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroceryListManager.swift; sourceTree = "<group>"; };
@@ -312,6 +314,7 @@
A70171C52AB4C43A00064C43 /* DataModels.swift */, A70171C52AB4C43A00064C43 /* DataModels.swift */,
A97B4D312B80B3E900EC1A88 /* RecipeModels.swift */, A97B4D312B80B3E900EC1A88 /* RecipeModels.swift */,
A9BBB38F2B91BE31002DA7FF /* ObservableRecipeDetail.swift */, A9BBB38F2B91BE31002DA7FF /* ObservableRecipeDetail.swift */,
A1B2C3D42F0A000100000001 /* AppearanceMode.swift */,
D1A0CE002D0A000100000001 /* GroceryListMode.swift */, D1A0CE002D0A000100000001 /* GroceryListMode.swift */,
D1A0CE022D0A000200000002 /* RemindersGroceryStore.swift */, D1A0CE022D0A000200000002 /* RemindersGroceryStore.swift */,
D1A0CE042D0A000300000003 /* GroceryListManager.swift */, D1A0CE042D0A000300000003 /* GroceryListManager.swift */,
@@ -656,6 +659,7 @@
A76B8A6F2ADFFA8800096CEC /* SupportedLanguage.swift in Sources */, A76B8A6F2ADFFA8800096CEC /* SupportedLanguage.swift in Sources */,
A977D0E02B600318009783A9 /* RecipeTabView.swift in Sources */, A977D0E02B600318009783A9 /* RecipeTabView.swift in Sources */,
A703226F2ABB1DD700D7C4ED /* ColorExtension.swift in Sources */, A703226F2ABB1DD700D7C4ED /* ColorExtension.swift in Sources */,
A1B2C3D52F0A000100000001 /* AppearanceMode.swift in Sources */,
D1A0CE012D0A000100000001 /* GroceryListMode.swift in Sources */, D1A0CE012D0A000100000001 /* GroceryListMode.swift in Sources */,
D1A0CE032D0A000200000002 /* RemindersGroceryStore.swift in Sources */, D1A0CE032D0A000200000002 /* RemindersGroceryStore.swift in Sources */,
D1A0CE052D0A000300000003 /* GroceryListManager.swift in Sources */, D1A0CE052D0A000300000003 /* GroceryListManager.swift in Sources */,

View File

@@ -0,0 +1,22 @@
//
// AppearanceMode.swift
// Nextcloud Cookbook iOS Client
//
import Foundation
enum AppearanceMode: String, CaseIterable {
case system = "system"
case light = "light"
case dark = "dark"
func descriptor() -> String {
switch self {
case .system: return String(localized: "System")
case .light: return String(localized: "Light")
case .dark: return String(localized: "Dark")
}
}
static let allValues: [AppearanceMode] = AppearanceMode.allCases
}

View File

@@ -145,6 +145,12 @@ class UserSettings: ObservableObject {
} }
} }
@Published var appearanceMode: String {
didSet {
UserDefaults.standard.set(appearanceMode, forKey: "appearanceMode")
}
}
init() { init() {
self.username = UserDefaults.standard.object(forKey: "username") as? String ?? "" self.username = UserDefaults.standard.object(forKey: "username") as? String ?? ""
self.token = UserDefaults.standard.object(forKey: "token") as? String ?? "" self.token = UserDefaults.standard.object(forKey: "token") as? String ?? ""
@@ -168,6 +174,7 @@ class UserSettings: ObservableObject {
self.remindersListIdentifier = UserDefaults.standard.object(forKey: "remindersListIdentifier") as? String ?? "" self.remindersListIdentifier = UserDefaults.standard.object(forKey: "remindersListIdentifier") as? String ?? ""
self.grocerySyncEnabled = UserDefaults.standard.object(forKey: "grocerySyncEnabled") as? Bool ?? true self.grocerySyncEnabled = UserDefaults.standard.object(forKey: "grocerySyncEnabled") as? Bool ?? true
self.mealPlanSyncEnabled = UserDefaults.standard.object(forKey: "mealPlanSyncEnabled") as? Bool ?? true self.mealPlanSyncEnabled = UserDefaults.standard.object(forKey: "mealPlanSyncEnabled") as? Bool ?? true
self.appearanceMode = UserDefaults.standard.object(forKey: "appearanceMode") as? String ?? AppearanceMode.system.rawValue
if authString == "" { if authString == "" {
if token != "" && username != "" { if token != "" && username != "" {

View File

@@ -694,6 +694,7 @@
} }
}, },
"An HTML parsing and web scraping library for Swift. Used for importing schema.org recipes from websites." : { "An HTML parsing and web scraping library for Swift. Used for importing schema.org recipes from websites." : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -782,6 +783,28 @@
} }
} }
}, },
"Appearance" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Erscheinungsbild"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Apariencia"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Apparence"
}
}
}
},
"Apple Reminders" : { "Apple Reminders" : {
"localizations" : { "localizations" : {
"de" : { "de" : {
@@ -995,6 +1018,28 @@
} }
} }
}, },
"Choose whether the app follows the system appearance or always uses light or dark mode." : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Wähle, ob die App dem Erscheinungsbild des Systems folgt oder immer den hellen oder dunklen Modus verwendet."
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Elige si la app sigue la apariencia del sistema o siempre usa el modo claro u oscuro."
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Choisissez si l'app suit l'apparence du système ou utilise toujours le mode clair ou sombre."
}
}
}
},
"Clear" : { "Clear" : {
"localizations" : { "localizations" : {
"de" : { "de" : {
@@ -1293,6 +1338,28 @@
} }
} }
}, },
"Dark" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Dunkel"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Oscuro"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Sombre"
}
}
}
},
"Decimal number format" : { "Decimal number format" : {
"localizations" : { "localizations" : {
"de" : { "de" : {
@@ -2536,6 +2603,28 @@
} }
} }
}, },
"Light" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Hell"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Claro"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Clair"
}
}
}
},
"List your tools here. 🍴" : { "List your tools here. 🍴" : {
"extractionState" : "stale", "extractionState" : "stale",
"localizations" : { "localizations" : {
@@ -4460,6 +4549,7 @@
} }
}, },
"SwiftSoup" : { "SwiftSoup" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -4483,6 +4573,28 @@
}, },
"Sync grocery list across devices" : { "Sync grocery list across devices" : {
},
"System" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "System"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Sistema"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Système"
}
}
}
}, },
"Thank you for downloading" : { "Thank you for downloading" : {
"localizations" : { "localizations" : {
@@ -5170,6 +5282,7 @@
} }
}, },
"Username: %@" : { "Username: %@" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {

View File

@@ -13,6 +13,15 @@ import SwiftUI
struct Nextcloud_Cookbook_iOS_ClientApp: App { struct Nextcloud_Cookbook_iOS_ClientApp: App {
@AppStorage("onboarding") var onboarding = true @AppStorage("onboarding") var onboarding = true
@AppStorage("language") var language = Locale.current.language.languageCode?.identifier ?? "en" @AppStorage("language") var language = Locale.current.language.languageCode?.identifier ?? "en"
@AppStorage("appearanceMode") var appearanceMode = AppearanceMode.system.rawValue
var colorScheme: ColorScheme? {
switch appearanceMode {
case AppearanceMode.light.rawValue: return .light
case AppearanceMode.dark.rawValue: return .dark
default: return nil
}
}
var body: some Scene { var body: some Scene {
WindowGroup { WindowGroup {
@@ -23,6 +32,7 @@ struct Nextcloud_Cookbook_iOS_ClientApp: App {
MainView() MainView()
} }
} }
.preferredColorScheme(colorScheme)
.transition(.slide) .transition(.slide)
.environment( .environment(
\.locale, \.locale,

View File

@@ -56,7 +56,7 @@ struct AllRecipesListView: View {
.bold() .bold()
} }
.buttonStyle(.bordered) .buttonStyle(.bordered)
.tint(.nextcloudBlue) .tint(.primary)
}.padding() }.padding()
} }
} }

View File

@@ -68,7 +68,7 @@ struct RecipeListView: View {
.bold() .bold()
} }
.buttonStyle(.bordered) .buttonStyle(.bordered)
.tint(.nextcloudBlue) .tint(.primary)
}.padding() }.padding()
} }
} }

View File

@@ -22,30 +22,6 @@ struct SettingsView: View {
var body: some View { var body: some View {
Form { 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 { Section {
Picker("Select a default cookbook", selection: $userSettings.defaultCategory) { Picker("Select a default cookbook", selection: $userSettings.defaultCategory) {
Text("None").tag("None") Text("None").tag("None")
@@ -59,6 +35,16 @@ struct SettingsView: View {
Text("The selected cookbook will open on app launch by default.") 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 { Section {
Picker("Grocery list storage", selection: $userSettings.groceryListMode) { Picker("Grocery list storage", selection: $userSettings.groceryListMode) {
ForEach(GroceryListMode.allValues, id: \.self) { mode in ForEach(GroceryListMode.allValues, id: \.self) { mode in
@@ -211,13 +197,6 @@ struct SettingsView: View {
} }
Section(header: Text("Acknowledgements")) { 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) { VStack(alignment: .leading) {
if let url = URL(string: "https://github.com/techprimate/TPPDF") { if let url = URL(string: "https://github.com/techprimate/TPPDF") {
Link("TPPDF", destination: url) Link("TPPDF", destination: url)
@@ -240,7 +219,6 @@ struct SettingsView: View {
Text(viewModel.alertType.getMessage()) Text(viewModel.alertType.getMessage())
} }
.task { .task {
await viewModel.getUserData()
remindersPermission = groceryListManager.remindersPermissionStatus remindersPermission = groceryListManager.remindersPermissionStatus
if remindersPermission == .fullAccess { if remindersPermission == .fullAccess {
reminderLists = groceryListManager.availableReminderLists() reminderLists = groceryListManager.availableReminderLists()
@@ -270,9 +248,6 @@ struct SettingsView: View {
extension SettingsView { extension SettingsView {
class ViewModel: ObservableObject { class ViewModel: ObservableObject {
@Published var avatarImage: UIImage? = nil
@Published var userData: UserData? = nil
@Published var showAlert: Bool = false @Published var showAlert: Bool = false
fileprivate var alertType: SettingsAlert = .NONE fileprivate var alertType: SettingsAlert = .NONE
@@ -297,16 +272,6 @@ extension SettingsView {
} }
} }
} }
func getUserData() async {
let (data, _) = await NextcloudApi.getAvatar()
let (userData, _) = await NextcloudApi.getHoverCard()
DispatchQueue.main.async {
self.avatarImage = data
self.userData = userData
}
}
} }
} }

View File

@@ -55,7 +55,7 @@ struct GroceryListTabView: View {
groceryList.deleteAll() groceryList.deleteAll()
} label: { } label: {
Text("Delete") Text("Delete")
.foregroundStyle(Color.nextcloudBlue) .foregroundStyle(.primary)
} }
} }
} }

View File

@@ -191,7 +191,7 @@ fileprivate struct MealPlanDayRow: View {
.frame(maxWidth: .infinity, minHeight: 44) .frame(maxWidth: .infinity, minHeight: 44)
.background( .background(
RoundedRectangle(cornerRadius: 8) RoundedRectangle(cornerRadius: 8)
.fill(Color.nextcloudBlue.opacity(0.1)) .fill(Color.nextcloudBlue.opacity(0.15))
) )
} }
} }

View File

@@ -163,7 +163,7 @@ struct RecipeTabView: View {
} }
} }
} }
.tint(.nextcloudBlue) .tint(.primary)
.sheet(isPresented: $viewModel.showImportURLSheet) { .sheet(isPresented: $viewModel.showImportURLSheet) {
ImportURLSheet { recipeDetail in ImportURLSheet { recipeDetail in
viewModel.navigateToImportedRecipe(recipeDetail: recipeDetail) viewModel.navigateToImportedRecipe(recipeDetail: recipeDetail)