diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..7bbe226 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,110 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Nextcloud Cookbook iOS Client — a native iOS/iPadOS/macOS (Mac Catalyst) client for the [Nextcloud Cookbook](https://github.com/nextcloud/cookbook) server application. Built entirely in Swift and SwiftUI. Licensed under GPLv3. + +**This is not a standalone app.** It requires a Nextcloud server with the Cookbook app installed. + +## Build & Run + +This is an Xcode project (no workspace, no CocoaPods). Open `Nextcloud Cookbook iOS Client.xcodeproj` in Xcode. + +```bash +# Build from command line +xcodebuild -project "Nextcloud Cookbook iOS Client.xcodeproj" \ + -scheme "Nextcloud Cookbook iOS Client" \ + -destination 'platform=iOS Simulator,name=iPhone 16' \ + build + +# Run tests (note: tests are currently boilerplate stubs with no real coverage) +xcodebuild -project "Nextcloud Cookbook iOS Client.xcodeproj" \ + -scheme "Nextcloud Cookbook iOS Client" \ + -destination 'platform=iOS Simulator,name=iPhone 16' \ + test +``` + +- **Deployment target**: iOS 16.4 +- **Swift version**: 5.0 +- **Targets**: iPhone and iPad, Mac Catalyst enabled + +## Dependencies (SPM) + +Only two third-party packages, managed via Swift Package Manager integrated in the Xcode project: + +| Package | Purpose | +|---------|---------| +| [SwiftSoup](https://github.com/scinfu/SwiftSoup.git) (2.6.1) | HTML parsing for client-side recipe scraping | +| [TPPDF](https://github.com/techprimate/TPPDF.git) (2.4.1) | PDF generation for recipe export | + +## Architecture + +### Central State Pattern + +The app uses a **centralized `AppState` ObservableObject** (`AppState.swift`) as the primary ViewModel, injected via `.environmentObject()` into the SwiftUI view hierarchy. `AppState` owns: +- All data (categories, recipes, recipe details, images, timers) +- All CRUD operations against the Cookbook API +- A `FetchMode` enum (`preferLocal`, `preferServer`, `onlyLocal`, `onlyServer`) that governs the data-fetching strategy (server-first vs local-first) +- Local persistence via `DataStore` + +Additional ViewModels exist as nested classes within their views (`RecipeTabView.ViewModel`, `SearchTabView.ViewModel`) or as standalone classes (`RecipeEditViewModel`, `GroceryList`). + +### Data Flow + +``` +SwiftUI Views + ├── @EnvironmentObject appState: AppState + ├── @EnvironmentObject groceryList: GroceryList + └── Per-view @StateObject ViewModels + │ + ▼ +AppState + ├── cookbookApi (CookbookApiV1 — static methods) → ApiRequest → URLSession + ├── DataStore (file-based JSON persistence in Documents directory) + └── UserSettings.shared (UserDefaults singleton) +``` + +### Network Layer + +- `CookbookApi` protocol defines all endpoints; `CookbookApiV1` is the concrete implementation with all `static` methods. +- The global `cookbookApi` constant (`CookbookApi.swift:14`) resolves the API version at launch. +- `ApiRequest` is a generic HTTP request builder using `URLSession.shared.data(for:)` with HTTP Basic Auth. +- `NextcloudApi` handles Nextcloud-specific auth (Login Flow v2 and token-based login). + +### Persistence + +- **`DataStore`**: File-based persistence using `JSONEncoder`/`JSONDecoder` writing to the app's Documents directory. No Core Data or SQLite. +- **`UserSettings`**: Singleton wrapping `UserDefaults` for all user preferences and credentials. +- **Image caching**: Two-tier — in-memory dictionary in `AppState` + on-disk base64-encoded PNG files via `DataStore`. + +### Key Source Directories + +``` +Nextcloud Cookbook iOS Client/ +├── Data/ # Models (Category, Recipe, RecipeDetail, Nutrition) + DataStore + UserSettings +├── Models/ # RecipeEditViewModel +├── Network/ # ApiRequest, NetworkError, CookbookApi protocol + V1, NextcloudApi +├── Views/ +│ ├── Tabs/ # Main tab views (RecipeTab, SearchTab, GroceryListTab) +│ ├── Recipes/ # Recipe detail, list, card, share, timer views +│ ├── RecipeViewSections/ # Decomposed recipe detail sections (ingredients, instructions, etc.) +│ ├── Onboarding/ # Login flows (V2LoginView, TokenLoginView) +│ └── ReusableViews/ +├── Extensions/ # Color, Date, JSONCoder, Logger extensions +├── Util/ # Alerts, DurationComponents (ISO 8601 PT parser), JsonAny, NumberFormatter +├── RecipeExport/ # PDF, text, JSON export via RecipeExporter +└── RecipeImport/ # HTML scraping via SwiftSoup (schema.org ld+json Recipe data) +``` + +## Localization + +Four languages supported via `Localizable.xcstrings`: English, German, Spanish, French. Spanish and French are mostly machine-translated. + +## Notable Design Decisions + +- The `cookbookApi` global is resolved once at launch based on `UserSettings.shared.cookbookApiVersion` and uses static protocol methods, which makes dependency injection and unit testing difficult. +- Server credentials (username, token, authString) are stored in `UserDefaults` via `UserSettings`, not in Keychain. +- No `.gitignore` file exists in the repository. +- No CI/CD, no linting tools, and no meaningful test coverage. diff --git a/Nextcloud Cookbook iOS Client.xcodeproj/project.pbxproj b/Nextcloud Cookbook iOS Client.xcodeproj/project.pbxproj index f58ea5e..3098f8b 100644 --- a/Nextcloud Cookbook iOS Client.xcodeproj/project.pbxproj +++ b/Nextcloud Cookbook iOS Client.xcodeproj/project.pbxproj @@ -40,6 +40,7 @@ A79AA8ED2B063AD5007D25F2 /* NextcloudApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79AA8EC2B063AD5007D25F2 /* NextcloudApi.swift */; }; A7AEAE642AD5521400135378 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = A7AEAE632AD5521400135378 /* Localizable.xcstrings */; }; A7CD3FD22B2C546A00D764AD /* CollapsibleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7CD3FD12B2C546A00D764AD /* CollapsibleView.swift */; }; + DFCB4E9FD4E0884AF217E5C5 /* LiquidGlassModifiers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04B6ECAD063AEE501543FC76 /* LiquidGlassModifiers.swift */; }; A7F3F8E82ACBFC760076C227 /* RecipeKeywordSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7F3F8E72ACBFC760076C227 /* RecipeKeywordSection.swift */; }; A7FB0D7A2B25C66600A3469E /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7FB0D792B25C66600A3469E /* OnboardingView.swift */; }; A7FB0D7C2B25C68500A3469E /* TokenLoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7FB0D7B2B25C68500A3469E /* TokenLoginView.swift */; }; @@ -122,6 +123,7 @@ A79AA8EC2B063AD5007D25F2 /* NextcloudApi.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NextcloudApi.swift; sourceTree = ""; }; A7AEAE632AD5521400135378 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; A7CD3FD12B2C546A00D764AD /* CollapsibleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollapsibleView.swift; sourceTree = ""; }; + 04B6ECAD063AEE501543FC76 /* LiquidGlassModifiers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiquidGlassModifiers.swift; sourceTree = ""; }; A7F3F8E72ACBFC760076C227 /* RecipeKeywordSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeKeywordSection.swift; sourceTree = ""; }; A7FB0D792B25C66600A3469E /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = ""; }; A7FB0D7B2B25C68500A3469E /* TokenLoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenLoginView.swift; sourceTree = ""; }; @@ -401,6 +403,7 @@ A9BBB38B2B8D3B0C002DA7FF /* ParallaxHeaderView.swift */, A7CD3FD12B2C546A00D764AD /* CollapsibleView.swift */, A9BBB38D2B8E44B3002DA7FF /* BottomClipper.swift */, + 04B6ECAD063AEE501543FC76 /* LiquidGlassModifiers.swift */, ); path = ReusableViews; sourceTree = ""; @@ -586,6 +589,7 @@ A787B0782B2B1E6400C2DF1B /* DateExtension.swift in Sources */, A79AA8E92B062DD1007D25F2 /* CookbookApiV1.swift in Sources */, A7CD3FD22B2C546A00D764AD /* CollapsibleView.swift in Sources */, + DFCB4E9FD4E0884AF217E5C5 /* LiquidGlassModifiers.swift in Sources */, A9E78A2B2BE7799F00206866 /* JsonAny.swift in Sources */, A9BBB3902B91BE31002DA7FF /* ObservableRecipeDetail.swift in Sources */, A97506212B92104700E86029 /* RecipeMetadataSection.swift in Sources */, @@ -793,7 +797,7 @@ "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 14.0; @@ -837,7 +841,7 @@ "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 14.0; @@ -863,7 +867,7 @@ DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = EF2ABA36D9; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; MACOSX_DEPLOYMENT_TARGET = 13.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = "VincentMeilinger.Nextcloud-Cookbook-iOS-ClientTests"; @@ -887,7 +891,7 @@ DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = EF2ABA36D9; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; MACOSX_DEPLOYMENT_TARGET = 13.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = "VincentMeilinger.Nextcloud-Cookbook-iOS-ClientTests"; @@ -910,7 +914,7 @@ DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = EF2ABA36D9; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; MACOSX_DEPLOYMENT_TARGET = 13.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = "VincentMeilinger.Nextcloud-Cookbook-iOS-ClientUITests"; @@ -933,7 +937,7 @@ DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = EF2ABA36D9; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; MACOSX_DEPLOYMENT_TARGET = 13.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = "VincentMeilinger.Nextcloud-Cookbook-iOS-ClientUITests"; diff --git a/Nextcloud Cookbook iOS Client/AppState.swift b/Nextcloud Cookbook iOS Client/AppState.swift index 11da792..1206c5c 100644 --- a/Nextcloud Cookbook iOS Client/AppState.swift +++ b/Nextcloud Cookbook iOS Client/AppState.swift @@ -137,7 +137,7 @@ import UIKit for recipe in recipes { if let dateModified = recipe.dateModified { if needsUpdate(category: category, lastModified: dateModified) { - print("\(recipe.name) needs an update. (last modified: \(recipe.dateModified)") + print("\(recipe.name) needs an update. (last modified: \(recipe.dateModified ?? "unknown")") await updateRecipeDetail(id: recipe.recipe_id, withThumb: UserSettings.shared.storeThumb, withImage: UserSettings.shared.storeImages) } else { print("\(recipe.name) is up to date.") diff --git a/Nextcloud Cookbook iOS Client/Views/MainView.swift b/Nextcloud Cookbook iOS Client/Views/MainView.swift index 81ad244..329d39e 100644 --- a/Nextcloud Cookbook iOS Client/Views/MainView.swift +++ b/Nextcloud Cookbook iOS Client/Views/MainView.swift @@ -10,46 +10,40 @@ import SwiftUI struct MainView: View { @StateObject var appState = AppState() @StateObject var groceryList = GroceryList() - + // Tab ViewModels @StateObject var recipeViewModel = RecipeTabView.ViewModel() @StateObject var searchViewModel = SearchTabView.ViewModel() - + + @State private var selectedTab: Tab = .recipes + enum Tab { case recipes, search, groceryList } - + var body: some View { - TabView { - RecipeTabView() - .environmentObject(recipeViewModel) - .environmentObject(appState) - .environmentObject(groceryList) - .tabItem { - Label("Recipes", systemImage: "book.closed.fill") - } - .tag(Tab.recipes) - - SearchTabView() - .environmentObject(searchViewModel) - .environmentObject(appState) - .environmentObject(groceryList) - .tabItem { - Label("Search", systemImage: "magnifyingglass") - } - .tag(Tab.search) - - GroceryListTabView() - .environmentObject(groceryList) - .tabItem { - if #available(iOS 17.0, *) { - Label("Grocery List", systemImage: "storefront") - } else { - Label("Grocery List", systemImage: "heart.text.square") - } - } - .tag(Tab.groceryList) + TabView(selection: $selectedTab) { + SwiftUI.Tab("Recipes", systemImage: "book.closed.fill", value: .recipes) { + RecipeTabView() + .environmentObject(recipeViewModel) + .environmentObject(appState) + .environmentObject(groceryList) + } + + SwiftUI.Tab("Search", systemImage: "magnifyingglass", value: .search, role: .search) { + SearchTabView() + .environmentObject(searchViewModel) + .environmentObject(appState) + .environmentObject(groceryList) + } + + SwiftUI.Tab("Grocery List", systemImage: "storefront", value: .groceryList) { + GroceryListTabView() + .environmentObject(groceryList) + } } + .tabViewStyle(.sidebarAdaptable) + .modifier(TabBarMinimizeModifier()) .task { recipeViewModel.presentLoadingIndicator = true await appState.getCategories() diff --git a/Nextcloud Cookbook iOS Client/Views/Onboarding/OnboardingView.swift b/Nextcloud Cookbook iOS Client/Views/Onboarding/OnboardingView.swift index cb99d38..808a1c2 100644 --- a/Nextcloud Cookbook iOS Client/Views/Onboarding/OnboardingView.swift +++ b/Nextcloud Cookbook iOS Client/Views/Onboarding/OnboardingView.swift @@ -148,7 +148,7 @@ struct BorderedLoginTextField: View { .autocorrectionDisabled() .textInputAutocapitalization(.never) .foregroundColor(color) - .accentColor(color) + .tint(color) .padding() .background( RoundedRectangle(cornerRadius: 10) @@ -170,7 +170,7 @@ struct LoginTextField: View { .autocorrectionDisabled() .textInputAutocapitalization(.never) .foregroundColor(color) - .accentColor(color) + .tint(color) .padding() .background( RoundedRectangle(cornerRadius: 10) diff --git a/Nextcloud Cookbook iOS Client/Views/Onboarding/V2LoginView.swift b/Nextcloud Cookbook iOS Client/Views/Onboarding/V2LoginView.swift index 2c56862..3bd7ea2 100644 --- a/Nextcloud Cookbook iOS Client/Views/Onboarding/V2LoginView.swift +++ b/Nextcloud Cookbook iOS Client/Views/Onboarding/V2LoginView.swift @@ -187,12 +187,17 @@ struct WebViewSheet: View { @State var url: String var body: some View { - NavigationView { + NavigationStack { WebView(url: URL(string: url)!) - .navigationBarTitle(Text("Nextcloud Login"), displayMode: .inline) - .navigationBarItems(trailing: Button("Done") { - dismiss() - }) + .navigationTitle("Nextcloud Login") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Done") { + dismiss() + } + } + } } } } diff --git a/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeGenericViews.swift b/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeGenericViews.swift index e56a61e..ab75b48 100644 --- a/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeGenericViews.swift +++ b/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeGenericViews.swift @@ -69,7 +69,7 @@ struct EditableListView: View { @State var axis: Axis = .vertical var body: some View { - NavigationView { + NavigationStack { ZStack { List { if items.isEmpty { @@ -83,12 +83,12 @@ struct EditableListView: View { .onDelete(perform: deleteItem) .onMove(perform: moveItem) .scrollDismissesKeyboard(.immediately) - + } } VStack { Spacer() - + Button { addItem() } label: { @@ -101,12 +101,15 @@ struct EditableListView: View { .padding() } } - .navigationBarTitle(title, displayMode: .inline) - .navigationBarItems( - trailing: Button(action: { isPresented = false }) { - Text("Done") + .navigationTitle(title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button(action: { isPresented = false }) { + Text("Done") + } } - ) + } .environment(\.editMode, .constant(.active)) } } diff --git a/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeIngredientSection.swift b/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeIngredientSection.swift index 25326d0..68f1d85 100644 --- a/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeIngredientSection.swift +++ b/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeIngredientSection.swift @@ -30,11 +30,7 @@ struct RecipeIngredientSection: View { } } } label: { - if #available(iOS 17.0, *) { - Image(systemName: "storefront") - } else { - Image(systemName: "heart.text.square") - } + Image(systemName: "storefront") }.disabled(viewModel.editMode) SecondaryLabel(text: LocalizedStringKey("Ingredients")) @@ -111,14 +107,8 @@ fileprivate struct IngredientListItem: View { var body: some View { HStack(alignment: .top) { if groceryList.containsItem(at: recipeId, item: ingredient) { - if #available(iOS 17.0, *) { - Image(systemName: "storefront") - .foregroundStyle(Color.green) - } else { - Image(systemName: "heart.text.square") - .foregroundStyle(Color.green) - } - + Image(systemName: "storefront") + .foregroundStyle(Color.green) } else if isSelected { Image(systemName: "checkmark.circle") } else { diff --git a/Nextcloud Cookbook iOS Client/Views/Recipes/TimerView.swift b/Nextcloud Cookbook iOS Client/Views/Recipes/TimerView.swift index ce3ee6c..7eb87fc 100644 --- a/Nextcloud Cookbook iOS Client/Views/Recipes/TimerView.swift +++ b/Nextcloud Cookbook iOS Client/Views/Recipes/TimerView.swift @@ -55,8 +55,13 @@ struct TimerView: View { .bold() .padding() .background { - RoundedRectangle(cornerRadius: 20) - .foregroundStyle(.ultraThickMaterial) + if #available(iOS 26, *) { + Color.clear + .glassEffect(.regular, in: .rect(cornerRadius: 20)) + } else { + RoundedRectangle(cornerRadius: 20) + .foregroundStyle(.ultraThickMaterial) + } } } } diff --git a/Nextcloud Cookbook iOS Client/Views/ReusableViews/LiquidGlassModifiers.swift b/Nextcloud Cookbook iOS Client/Views/ReusableViews/LiquidGlassModifiers.swift new file mode 100644 index 0000000..29ae6e1 --- /dev/null +++ b/Nextcloud Cookbook iOS Client/Views/ReusableViews/LiquidGlassModifiers.swift @@ -0,0 +1,28 @@ +// +// LiquidGlassModifiers.swift +// Nextcloud Cookbook iOS Client +// +// Created by Vincent Meilinger on 14.02.26. +// + +import SwiftUI + +struct TabBarMinimizeModifier: ViewModifier { + func body(content: Content) -> some View { + if #available(iOS 26, *) { + content.tabBarMinimizeBehavior(.onScrollDown) + } else { + content + } + } +} + +struct BackgroundExtensionModifier: ViewModifier { + func body(content: Content) -> some View { + if #available(iOS 26, *) { + content.backgroundExtensionEffect() + } else { + content + } + } +} diff --git a/Nextcloud Cookbook iOS Client/Views/Tabs/RecipeTabView.swift b/Nextcloud Cookbook iOS Client/Views/Tabs/RecipeTabView.swift index c060116..d0c1df5 100644 --- a/Nextcloud Cookbook iOS Client/Views/Tabs/RecipeTabView.swift +++ b/Nextcloud Cookbook iOS Client/Views/Tabs/RecipeTabView.swift @@ -72,7 +72,7 @@ struct RecipeTabView: View { ) .id(category.id) // Workaround: This is needed to update the detail view when the selection changes } - + } } .tint(.nextcloudBlue)