Add localization (DE/EN), vertical-scroll month view, context menus, custom colors

- Vertical-scroll month view with multi-day event spans, zig-zag month
  divider, CW number per week, on-demand event loading while scrolling
- Top bar redesign: icon-only view picker on right, month title centered
- Long-press context menus on day cells (month) and hour slots (week/day)
  for "New event", "Open in week view", "Open in day view", "Open in month view"
- Localization system with system/de/en switch covering top bar, view picker,
  settings, menu, profile, server, accounts, event editor, agenda
- Three new color pickers (text/background/line) + today-marker color
  applied in calendar views; current-time line now uses today color
- App icon: removed alpha channel, accent color set to icon green (#20A050)
- TestFlight: ITSAppUsesNonExemptEncryption=NO baked into Info.plist keys

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-05-19 22:00:49 +02:00
parent e5529ca653
commit 8b3cc11e25
20 changed files with 1623 additions and 388 deletions

View File

@@ -6,11 +6,27 @@
objectVersion = 77;
objects = {
/* Begin PBXContainerItemProxy section */
3927C7C02FB99D0F00EAD8ED /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = C0000301FB4E10100AB5001 /* Project object */;
proxyType = 1;
remoteGlobalIDString = C0000A01FB4E10100AB5001;
remoteInfo = "Calendarr iOS";
};
/* End PBXContainerItemProxy section */
/* Begin PBXFileReference section */
3927C7BC2FB99D0E00EAD8ED /* Calendarr iOSTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "Calendarr iOSTests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; };
C0000B01FC4E10100AB5001 /* Calendarr iOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Calendarr iOS.app"; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
3927C7BD2FB99D0F00EAD8ED /* Calendarr iOSTests */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = "Calendarr iOSTests";
sourceTree = "<group>";
};
C0000D01FC4E10100AB5001 /* Calendarr iOS */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = "Calendarr iOS";
@@ -19,6 +35,13 @@
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
3927C7B92FB99D0E00EAD8ED /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
C0000801FB4E10100AB5001 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
@@ -33,6 +56,7 @@
isa = PBXGroup;
children = (
C0000D01FC4E10100AB5001 /* Calendarr iOS */,
3927C7BD2FB99D0F00EAD8ED /* Calendarr iOSTests */,
C0000C01FB4E10100AB5001 /* Products */,
);
sourceTree = "<group>";
@@ -41,6 +65,7 @@
isa = PBXGroup;
children = (
C0000B01FC4E10100AB5001 /* Calendarr iOS.app */,
3927C7BC2FB99D0E00EAD8ED /* Calendarr iOSTests.xctest */,
);
name = Products;
sourceTree = "<group>";
@@ -48,6 +73,29 @@
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
3927C7BB2FB99D0E00EAD8ED /* Calendarr iOSTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = 3927C7C22FB99D0F00EAD8ED /* Build configuration list for PBXNativeTarget "Calendarr iOSTests" */;
buildPhases = (
3927C7B82FB99D0E00EAD8ED /* Sources */,
3927C7B92FB99D0E00EAD8ED /* Frameworks */,
3927C7BA2FB99D0E00EAD8ED /* Resources */,
);
buildRules = (
);
dependencies = (
3927C7C12FB99D0F00EAD8ED /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
3927C7BD2FB99D0F00EAD8ED /* Calendarr iOSTests */,
);
name = "Calendarr iOSTests";
packageProductDependencies = (
);
productName = "Calendarr iOSTests";
productReference = 3927C7BC2FB99D0E00EAD8ED /* Calendarr iOSTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
C0000A01FB4E10100AB5001 /* Calendarr iOS */ = {
isa = PBXNativeTarget;
buildConfigurationList = C0001601FB4E10100AB5001 /* Build configuration list for PBXNativeTarget "Calendarr iOS" */;
@@ -77,9 +125,13 @@
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 2640;
LastSwiftUpdateCheck = 2650;
LastUpgradeCheck = 2640;
TargetAttributes = {
3927C7BB2FB99D0E00EAD8ED = {
CreatedOnToolsVersion = 26.5;
TestTargetID = C0000A01FB4E10100AB5001;
};
C0000A01FB4E10100AB5001 = {
CreatedOnToolsVersion = 26.4.1;
};
@@ -101,11 +153,19 @@
projectRoot = "";
targets = (
C0000A01FB4E10100AB5001 /* Calendarr iOS */,
3927C7BB2FB99D0E00EAD8ED /* Calendarr iOSTests */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
3927C7BA2FB99D0E00EAD8ED /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
C0000901FB4E10100AB5001 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
@@ -116,6 +176,13 @@
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
3927C7B82FB99D0E00EAD8ED /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
C0000701FB4E10100AB5001 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
@@ -125,7 +192,67 @@
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
3927C7C12FB99D0F00EAD8ED /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = C0000A01FB4E10100AB5001 /* Calendarr iOS */;
targetProxy = 3927C7C02FB99D0F00EAD8ED /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */
3927C7C32FB99D0F00EAD8ED /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = PP34X97WS3;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.5;
MACOSX_DEPLOYMENT_TARGET = 26.5;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "com.local.scarriffle.Calendarr-iOSTests";
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = auto;
STRING_CATALOG_GENERATE_SYMBOLS = NO;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator";
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2,7";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Calendarr iOS.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Calendarr iOS";
XROS_DEPLOYMENT_TARGET = 26.5;
};
name = Debug;
};
3927C7C42FB99D0F00EAD8ED /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = PP34X97WS3;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.5;
MACOSX_DEPLOYMENT_TARGET = 26.5;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "com.local.scarriffle.Calendarr-iOSTests";
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = auto;
STRING_CATALOG_GENERATE_SYMBOLS = NO;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator";
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2,7";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Calendarr iOS.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Calendarr iOS";
XROS_DEPLOYMENT_TARGET = 26.5;
};
name = Release;
};
C0001401FB4E10100AB5001 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
@@ -254,16 +381,19 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = PP34X97WS3;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = Calendarr;
INFOPLIST_KEY_CFBundleName = Calendarr;
"INFOPLIST_KEY_ITSAppUsesNonExemptEncryption" = NO;
INFOPLIST_KEY_NSAppTransportSecurity_NSAllowsArbitraryLoads = YES;
INFOPLIST_KEY_NSHumanReadableCopyright = "© 2026 Scarriffleservices";
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleDefault;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleDarkContent;
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@@ -272,11 +402,16 @@
PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios;
PRODUCT_NAME = "Calendarr iOS";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = NO;
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 1;
};
name = Debug;
};
@@ -287,16 +422,19 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = PP34X97WS3;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = Calendarr;
INFOPLIST_KEY_CFBundleName = Calendarr;
"INFOPLIST_KEY_ITSAppUsesNonExemptEncryption" = NO;
INFOPLIST_KEY_NSAppTransportSecurity_NSAllowsArbitraryLoads = YES;
INFOPLIST_KEY_NSHumanReadableCopyright = "© 2026 Scarriffleservices";
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleDefault;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleDarkContent;
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@@ -305,17 +443,31 @@
PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios;
PRODUCT_NAME = "Calendarr iOS";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = NO;
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 1;
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
3927C7C22FB99D0F00EAD8ED /* Build configuration list for PBXNativeTarget "Calendarr iOSTests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
3927C7C32FB99D0F00EAD8ED /* Debug */,
3927C7C42FB99D0F00EAD8ED /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
C0000601FB4E10100AB5001 /* Build configuration list for PBXProject "Calendarr iOS" */ = {
isa = XCConfigurationList;
buildConfigurations = (

View File

@@ -0,0 +1,92 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2650"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "C0000A01FB4E10100AB5001"
BuildableName = "Calendarr iOS.app"
BlueprintName = "Calendarr iOS"
ReferencedContainer = "container:Calendarr iOS.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "3927C7BB2FB99D0E00EAD8ED"
BuildableName = "Calendarr iOSTests.xctest"
BlueprintName = "Calendarr iOSTests"
ReferencedContainer = "container:Calendarr iOS.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Release"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES"
queueDebuggingEnableBacktraceRecording = "Yes">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "C0000A01FB4E10100AB5001"
BuildableName = "Calendarr iOS.app"
BlueprintName = "Calendarr iOS"
ReferencedContainer = "container:Calendarr iOS.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "C0000A01FB4E10100AB5001"
BuildableName = "Calendarr iOS.app"
BlueprintName = "Calendarr iOS"
ReferencedContainer = "container:Calendarr iOS.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@@ -10,5 +10,18 @@
<integer>0</integer>
</dict>
</dict>
<key>SuppressBuildableAutocreation</key>
<dict>
<key>3927C7BB2FB99D0E00EAD8ED</key>
<dict>
<key>primary</key>
<true/>
</dict>
<key>C0000A01FB4E10100AB5001</key>
<dict>
<key>primary</key>
<true/>
</dict>
</dict>
</dict>
</plist>

View File

@@ -5,9 +5,9 @@
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.957",
"green" : "0.522",
"red" : "0.259"
"blue" : "0.314",
"green" : "0.627",
"red" : "0.125"
}
},
"idiom" : "universal"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 22 KiB

View File

@@ -13,6 +13,9 @@ struct AppSettings: Codable {
var language: String = "de"
var monthDividerColor: String = "#7090c0"
var monthLabelColor: String = "#7090c0"
var textColor: String = "#FFFFFF"
var backgroundColor: String = "#000000"
var lineColor: String = "#3A3A3C"
enum CodingKeys: String, CodingKey {
case defaultView = "default_view"
@@ -27,6 +30,9 @@ struct AppSettings: Codable {
case language
case monthDividerColor = "month_divider_color"
case monthLabelColor = "month_label_color"
case textColor = "text_color"
case backgroundColor = "background_color"
case lineColor = "line_color"
}
}

View File

@@ -4,13 +4,13 @@ import SwiftUI
enum CalViewType: String, CaseIterable {
case month, week, day, quarter, agenda
var label: String {
func label(_ lang: String) -> String {
switch self {
case .month: return "Monat"
case .week: return "Woche"
case .day: return "Tag"
case .quarter: return "Quartal"
case .agenda: return "Termine"
case .month: return L10n.t("view.month", lang)
case .week: return L10n.t("view.week", lang)
case .day: return L10n.t("view.day", lang)
case .quarter: return L10n.t("view.quarter", lang)
case .agenda: return L10n.t("view.agenda", lang)
}
}
@@ -221,28 +221,29 @@ class CalendarStore {
}
}
func titleForCurrentView() -> String {
func titleForCurrentView(language: String) -> String {
let cal = userCalendar
let fmt = DateFormatter()
let loc = L10n.locale(language)
let fmt = DateFormatter(); fmt.locale = loc
switch viewType {
case .month:
fmt.dateFormat = "MMMM yyyy"
return fmt.string(from: currentDate)
fmt.dateFormat = "LLLL yyyy"
return fmt.string(from: currentDate).capitalized(with: loc)
case .quarter:
fmt.dateFormat = "MMM yyyy"
fmt.dateFormat = "LLL yyyy"
let m3 = cal.date(byAdding: .month, value: 2, to: currentDate) ?? currentDate
return "\(fmt.string(from: currentDate)) \(fmt.string(from: m3))"
case .week:
let weekStart = cal.date(from: cal.dateComponents([.yearForWeekOfYear, .weekOfYear], from: currentDate))!
let weekEnd = cal.date(byAdding: .day, value: 6, to: weekStart)!
fmt.dateFormat = "d. MMM"
let ef = DateFormatter(); ef.dateFormat = "d. MMM yyyy"
let ef = DateFormatter(); ef.locale = loc; ef.dateFormat = "d. MMM yyyy"
return "\(fmt.string(from: weekStart)) \(ef.string(from: weekEnd))"
case .day:
fmt.dateFormat = "EEEE, d. MMMM yyyy"
return fmt.string(from: currentDate)
case .agenda:
return "Termine"
return L10n.t("view.agenda", language)
}
}
}

View File

@@ -0,0 +1,520 @@
import Foundation
import SwiftUI
enum AppLanguage: String, CaseIterable {
case system, de, en
var displayKey: String {
switch self {
case .system: return "lang.system"
case .de: return "lang.german"
case .en: return "lang.english"
}
}
}
enum L10n {
static func resolved(_ stored: String) -> String {
if stored == "de" || stored == "en" { return stored }
let pref = Locale.preferredLanguages.first ?? "en"
return pref.lowercased().hasPrefix("de") ? "de" : "en"
}
static func t(_ key: String, _ stored: String) -> String {
let lang = resolved(stored)
return strings[lang]?[key] ?? strings["en"]?[key] ?? key
}
static func locale(_ stored: String) -> Locale {
Locale(identifier: resolved(stored))
}
}
private let strings: [String: [String: String]] = [
"de": [
// Top bar / navigation
"nav.today": "Heute",
"nav.menu": "Menü",
"nav.done": "Fertig",
// View types
"view.month": "Monat",
"view.week": "Woche",
"view.day": "Tag",
"view.quarter": "Quartal",
"view.agenda": "Termine",
"view.change": "Ansicht",
// Calendar misc
"cal.cw": "KW",
"cal.allday": "Ganztägig",
"cal.no_events_title": "Keine Termine",
"cal.no_events_body": "In den nächsten 90 Tagen sind keine Termine vorhanden.",
"cal.loading_more": "Lade weitere Wochen…",
"cal.new_event": "Neues Ereignis",
"cal.show_in_day_view": "In Tagesansicht öffnen",
"cal.show_in_week_view": "In Wochenansicht öffnen",
"cal.show_in_month_view": "In Monatsansicht öffnen",
// Menu sheet
"menu.section.settings": "Einstellungen",
"menu.profile": "Profil",
"menu.appearance": "Darstellung",
"menu.accounts": "Konten & Kalender",
"menu.server": "Server",
"menu.logout": "Abmelden",
"menu.admin": "Admin",
// Settings chrome
"settings.title": "Darstellung",
"settings.loading": "Lade Einstellungen…",
"settings.save": "Speichern",
"settings.saved": "Gespeichert",
// Settings sections
"settings.appdesign": "App-Design",
"settings.liquidglass": "Liquid Glass",
"settings.liquidglass.desc": "Verwendet die neue iOS\u{202F}26 Glasoptik mit transparenter Navigationsleiste",
"settings.liquidglass.footer": "Änderung wirkt sofort kein Neustart nötig.",
"settings.cache.header": "Vorladen",
"settings.cache.title": "Vorladen",
"settings.cache.desc": "Events werden beim Start im Hintergrund für diesen Zeitraum geladen, danach ist Wischen sofort.",
"settings.cache.range": "Zeitraum",
"settings.cache.1m": "±1 Monat",
"settings.cache.3m": "±3 Monate",
"settings.cache.6m": "±6 Monate",
"settings.cache.1y": "±1 Jahr",
"settings.cache.footer": "Mehr Monate = längerer initialer Ladevorgang, danach komplett ohne Wartezeiten navigierbar.",
"settings.language": "Sprache",
"lang.system": "Systemstandard",
"lang.german": "Deutsch",
"lang.english": "English",
"settings.colors": "Farben",
"settings.color.primary": "Primärfarbe",
"settings.color.accent": "Akzentfarbe",
"settings.color.today": "Heutige-Tag-Farbe",
"settings.color.divider": "Monatswechsel-Linie",
"settings.color.label": "Monatskürzel",
"settings.color.text": "Schriftfarbe",
"settings.color.background": "Hintergrundfarbe",
"settings.color.line": "Linienfarbe",
"settings.textcontrast": "Schriftkontrast",
"settings.textcontrast.desc": "Helligkeit der Beschriftungen und Texte",
"settings.contrast.dark": "Dunkel",
"settings.contrast.medium": "Mittel",
"settings.contrast.bright": "Hell",
"settings.contrast.max": "Maximum",
"settings.linecontrast": "Linienkontrast",
"settings.linecontrast.desc": "Sichtbarkeit von Trennlinien und Rahmen",
"settings.linecontrast.barely": "Kaum",
"settings.linecontrast.subtle": "Subtil",
"settings.linecontrast.normal": "Normal",
"settings.linecontrast.strong": "Stark",
"settings.calview": "Kalenderansicht",
"settings.defaultview": "Standardansicht",
"settings.firstweekday": "Erster Wochentag",
"settings.monday": "Montag",
"settings.sunday": "Sonntag",
"settings.dimpast": "Vergangene Termine ausgrauen",
"settings.hourheight": "Stundenhöhe",
"settings.hourheight.desc": "Platz pro Stunde in der Wochen- & Tagesansicht",
"settings.hourheight.compact": "Kompakt",
"settings.hourheight.normal": "Normal",
"settings.hourheight.comfort": "Komfort",
"settings.hourheight.large": "Gross",
// Common buttons
"common.cancel": "Abbrechen",
"common.close": "Schliessen",
"common.ok": "OK",
"common.error": "Fehler",
// Server view
"server.title": "Server",
"server.connected": "Verbundener Server",
"server.switch": "Server wechseln",
"server.switch_msg": "Verbindung zu %@ wird getrennt und alle lokalen Anmeldedaten werden gelöscht.",
"server.logout_title": "Abmelden",
"server.logout_msg": "Du wirst von %@ abgemeldet.",
"server.info": "Info",
"server.imprint": "Impressum",
"server.version": "Version",
// Imprint
"imprint.company": "Scarriffleservices",
"imprint.role": "Software & Webentwicklung",
"imprint.copyright": "Diese Software wurde von Scarriffleservices mit grösster Sorgfalt entwickelt und bereitgestellt. Alle Rechte vorbehalten © 2026 Scarriffleservices.",
"imprint.storage.title": "Datenspeicherung",
"imprint.storage.body": "Alle Anwendungsdaten werden auf dem Server gespeichert und verarbeitet, auf dem diese Calendarr-Instanz betrieben wird. Der Speicherort hängt damit vom Betreiber des jeweiligen Servers ab. Bei Nutzung der Google Kalender-Anbindung werden Daten über die Google API ausgetauscht; für diese Daten gelten die Datenschutzbestimmungen von Google. Bei Nutzung der Home Assistant-Anbindung werden Daten mit der jeweiligen Home Assistant-Instanz ausgetauscht. Home Assistant ist ein Projekt der Open Home Foundation.",
"imprint.disclaimer.title": "Haftungsausschluss",
"imprint.disclaimer.body": "Trotz sorgfältiger Erstellung wird keine Haftung für die Richtigkeit, Vollständigkeit oder Aktualität der bereitgestellten Inhalte übernommen. Die Nutzung erfolgt auf eigene Verantwortung.",
"imprint.contact.title": "Kontakt",
// Profile view
"profile.title": "Profil",
"profile.loading": "Lade Profil…",
"profile.account": "Konto",
"profile.username": "Benutzername",
"profile.role": "Rolle",
"profile.role.admin": "Administrator",
"profile.role.user": "Benutzer",
"profile.email": "E-Mail",
"profile.no_email": "Keine E-Mail",
"profile.save_email": "E-Mail speichern",
"profile.email_saved": "E-Mail gespeichert",
"profile.change_password": "Passwort ändern",
"profile.current_password": "Aktuelles Passwort",
"profile.new_password": "Neues Passwort",
"profile.new_password_repeat": "Neues Passwort wiederholen",
"profile.password_mismatch": "Passwörter stimmen nicht überein",
"profile.password_changed": "Passwort geändert",
"profile.twofa": "Zwei-Faktor-Authentifizierung",
"profile.twofa.active": "2FA ist aktiviert",
"profile.twofa.inactive": "2FA ist deaktiviert",
"profile.twofa.enable": "2FA einrichten",
"profile.twofa.disable": "2FA deaktivieren",
"profile.twofa.enabled_toast": "2FA aktiviert",
"profile.twofa.disabled_toast": "2FA deaktiviert",
"twofa.setup_title": "2FA einrichten",
"twofa.scan_hint": "Scanne den QR-Code mit deiner Authenticator-App (z.B. Bitwarden, Google Authenticator).",
"twofa.qr_section": "QR-Code / Manueller Schlüssel",
"twofa.confirmation": "Bestätigung",
"twofa.code_placeholder": "6-stelliger Code",
"twofa.activate": "Aktivieren",
"twofa.disable_title": "2FA deaktivieren",
"twofa.password_section": "Passwort zum Deaktivieren",
"twofa.password_placeholder": "Passwort",
"twofa.disable": "Deaktivieren",
// Event editor
"event.title_placeholder": "Titel",
"event.allday": "Ganztägig",
"event.start": "Start",
"event.end": "Ende",
"event.location": "Ort",
"event.description": "Beschreibung",
"event.calendar_section": "Kalender",
"event.no_writable": "Keine beschreibbaren Kalender vorhanden",
"event.calendar_picker": "Kalender",
"event.color_section": "Farbe",
"event.color": "Terminfarbe",
"event.reset_color": "Zurücksetzen",
"event.edit_title": "Termin bearbeiten",
"event.new_title": "Neuer Termin",
"event.save": "Sichern",
"event.add": "Hinzufügen",
// Accounts
"accounts.title": "Konten",
"accounts.loading": "Lade Konten…",
"accounts.add.caldav": "CalDAV-Konto",
"accounts.add.local": "Lokaler Kalender",
"accounts.add.ical": "iCal-URL abonnieren",
"accounts.add.ha": "Home Assistant",
"accounts.caldav.header": "CalDAV-Konten",
"accounts.caldav.empty": "Keine CalDAV-Konten",
"accounts.caldav.add": "CalDAV hinzufügen",
"accounts.local.header": "Lokale Kalender",
"accounts.local.empty": "Keine lokalen Kalender",
"accounts.local.add": "Lokalen Kalender erstellen",
"accounts.ical.header": "iCal-Abonnements",
"accounts.ical.empty": "Keine Abonnements",
"accounts.ical.every": "Alle %d Min.",
"accounts.ical.add": "iCal-URL abonnieren",
"accounts.google.header": "Google-Konten",
"accounts.google.empty": "Keine Google-Konten",
"accounts.google.hint": "Google-Konten werden über den Browser verknüpft",
"accounts.ha.header": "Home Assistant",
"accounts.ha.empty": "Keine Home Assistant-Konten",
"accounts.ha.add": "Home Assistant hinzufügen",
// CalDAV add sheet
"caldav.section": "Konto-Details",
"caldav.display_name": "Anzeigename",
"caldav.url": "CalDAV-URL",
"caldav.username": "Benutzername",
"caldav.password": "Passwort",
"caldav.color": "Farbe",
"caldav.color_label": "Farbe",
"caldav.color_section": "Farbe",
"caldav.connect": "Verbinden",
"caldav.title": "CalDAV-Konto",
// Local cal add sheet
"local.title": "Lokaler Kalender",
"local.name": "Name",
"local.color": "Farbe",
"local.create": "Erstellen",
// iCal add sheet
"ical.title": "iCal abonnieren",
"ical.subscription": "Abonnement",
"ical.name": "Name",
"ical.url": "iCal-URL",
"ical.color": "Farbe",
"ical.refresh_section": "Aktualisierung",
"ical.interval": "Intervall",
"ical.subscribe": "Abonnieren",
"ical.refresh.15m": "Alle 15 Min.",
"ical.refresh.30m": "Alle 30 Min.",
"ical.refresh.1h": "Stündlich",
"ical.refresh.6h": "Alle 6 Std.",
"ical.refresh.1d": "Täglich",
// HA add sheet
"ha.section": "Home Assistant",
"ha.display_name": "Anzeigename",
"ha.url_placeholder": "URL (z.B. http://homeassistant.local:8123)",
"ha.auth_section": "Authentifizierung",
"ha.token": "Long-Lived Access Token",
"ha.token_hint": "Token erstellen unter: Profil → Sicherheit → Long-Lived Access Tokens",
"ha.connect": "Verbinden"
],
"en": [
"nav.today": "Today",
"nav.menu": "Menu",
"nav.done": "Done",
"view.month": "Month",
"view.week": "Week",
"view.day": "Day",
"view.quarter": "Quarter",
"view.agenda": "Agenda",
"view.change": "View",
"cal.cw": "W",
"cal.allday": "All-day",
"cal.no_events_title": "No events",
"cal.no_events_body": "No events in the next 90 days.",
"cal.loading_more": "Loading more weeks…",
"cal.new_event": "New event",
"cal.show_in_day_view": "Open in day view",
"cal.show_in_week_view": "Open in week view",
"cal.show_in_month_view": "Open in month view",
"menu.section.settings": "Settings",
"menu.profile": "Profile",
"menu.appearance": "Appearance",
"menu.accounts": "Accounts & Calendars",
"menu.server": "Server",
"menu.logout": "Sign out",
"menu.admin": "Admin",
"settings.title": "Appearance",
"settings.loading": "Loading settings…",
"settings.save": "Save",
"settings.saved": "Saved",
"settings.appdesign": "App design",
"settings.liquidglass": "Liquid Glass",
"settings.liquidglass.desc": "Uses the new iOS\u{202F}26 glass look with a translucent navigation bar",
"settings.liquidglass.footer": "Takes effect immediately no restart required.",
"settings.cache.header": "Preloading",
"settings.cache.title": "Preloading",
"settings.cache.desc": "Events are loaded in the background for this range on launch, so swiping is instant afterwards.",
"settings.cache.range": "Range",
"settings.cache.1m": "±1 month",
"settings.cache.3m": "±3 months",
"settings.cache.6m": "±6 months",
"settings.cache.1y": "±1 year",
"settings.cache.footer": "More months = longer initial load, but then fully wait-free navigation.",
"settings.language": "Language",
"lang.system": "System default",
"lang.german": "Deutsch",
"lang.english": "English",
"settings.colors": "Colors",
"settings.color.primary": "Primary color",
"settings.color.accent": "Accent color",
"settings.color.today": "Today color",
"settings.color.divider": "Month divider line",
"settings.color.label": "Month abbreviation",
"settings.color.text": "Text color",
"settings.color.background": "Background color",
"settings.color.line": "Line color",
"settings.textcontrast": "Text contrast",
"settings.textcontrast.desc": "Brightness of labels and text",
"settings.contrast.dark": "Dark",
"settings.contrast.medium": "Medium",
"settings.contrast.bright": "Bright",
"settings.contrast.max": "Maximum",
"settings.linecontrast": "Line contrast",
"settings.linecontrast.desc": "Visibility of dividers and borders",
"settings.linecontrast.barely": "Barely",
"settings.linecontrast.subtle": "Subtle",
"settings.linecontrast.normal": "Normal",
"settings.linecontrast.strong": "Strong",
"settings.calview": "Calendar view",
"settings.defaultview": "Default view",
"settings.firstweekday": "First day of week",
"settings.monday": "Monday",
"settings.sunday": "Sunday",
"settings.dimpast": "Dim past events",
"settings.hourheight": "Hour height",
"settings.hourheight.desc": "Space per hour in week & day view",
"settings.hourheight.compact": "Compact",
"settings.hourheight.normal": "Normal",
"settings.hourheight.comfort": "Comfort",
"settings.hourheight.large": "Large",
// Common buttons
"common.cancel": "Cancel",
"common.close": "Close",
"common.ok": "OK",
"common.error": "Error",
// Server view
"server.title": "Server",
"server.connected": "Connected server",
"server.switch": "Switch server",
"server.switch_msg": "The connection to %@ will be closed and all local credentials will be removed.",
"server.logout_title": "Sign out",
"server.logout_msg": "You will be signed out of %@.",
"server.info": "Info",
"server.imprint": "Legal notice",
"server.version": "Version",
// Imprint
"imprint.company": "Scarriffleservices",
"imprint.role": "Software & web development",
"imprint.copyright": "This software was carefully developed and provided by Scarriffleservices. All rights reserved © 2026 Scarriffleservices.",
"imprint.storage.title": "Data storage",
"imprint.storage.body": "All application data is stored and processed on the server on which this Calendarr instance is hosted. The storage location therefore depends on the operator of the respective server. When using the Google Calendar integration, data is exchanged via the Google API; Google's privacy policy applies to that data. When using the Home Assistant integration, data is exchanged with the respective Home Assistant instance. Home Assistant is a project of the Open Home Foundation.",
"imprint.disclaimer.title": "Disclaimer",
"imprint.disclaimer.body": "Despite careful preparation, no liability is assumed for the accuracy, completeness or topicality of the content provided. Use is at your own risk.",
"imprint.contact.title": "Contact",
// Profile view
"profile.title": "Profile",
"profile.loading": "Loading profile…",
"profile.account": "Account",
"profile.username": "Username",
"profile.role": "Role",
"profile.role.admin": "Administrator",
"profile.role.user": "User",
"profile.email": "Email",
"profile.no_email": "No email",
"profile.save_email": "Save email",
"profile.email_saved": "Email saved",
"profile.change_password": "Change password",
"profile.current_password": "Current password",
"profile.new_password": "New password",
"profile.new_password_repeat": "Repeat new password",
"profile.password_mismatch": "Passwords don't match",
"profile.password_changed": "Password changed",
"profile.twofa": "Two-factor authentication",
"profile.twofa.active": "2FA is enabled",
"profile.twofa.inactive": "2FA is disabled",
"profile.twofa.enable": "Set up 2FA",
"profile.twofa.disable": "Disable 2FA",
"profile.twofa.enabled_toast": "2FA enabled",
"profile.twofa.disabled_toast": "2FA disabled",
"twofa.setup_title": "Set up 2FA",
"twofa.scan_hint": "Scan the QR code with your authenticator app (e.g. Bitwarden, Google Authenticator).",
"twofa.qr_section": "QR code / Manual key",
"twofa.confirmation": "Verification",
"twofa.code_placeholder": "6-digit code",
"twofa.activate": "Activate",
"twofa.disable_title": "Disable 2FA",
"twofa.password_section": "Password to disable",
"twofa.password_placeholder": "Password",
"twofa.disable": "Disable",
// Event editor
"event.title_placeholder": "Title",
"event.allday": "All-day",
"event.start": "Start",
"event.end": "End",
"event.location": "Location",
"event.description": "Description",
"event.calendar_section": "Calendar",
"event.no_writable": "No writable calendars available",
"event.calendar_picker": "Calendar",
"event.color_section": "Color",
"event.color": "Event color",
"event.reset_color": "Reset",
"event.edit_title": "Edit event",
"event.new_title": "New event",
"event.save": "Save",
"event.add": "Add",
// Accounts
"accounts.title": "Accounts",
"accounts.loading": "Loading accounts…",
"accounts.add.caldav": "CalDAV account",
"accounts.add.local": "Local calendar",
"accounts.add.ical": "Subscribe to iCal URL",
"accounts.add.ha": "Home Assistant",
"accounts.caldav.header": "CalDAV accounts",
"accounts.caldav.empty": "No CalDAV accounts",
"accounts.caldav.add": "Add CalDAV",
"accounts.local.header": "Local calendars",
"accounts.local.empty": "No local calendars",
"accounts.local.add": "Create local calendar",
"accounts.ical.header": "iCal subscriptions",
"accounts.ical.empty": "No subscriptions",
"accounts.ical.every": "Every %d min",
"accounts.ical.add": "Subscribe to iCal URL",
"accounts.google.header": "Google accounts",
"accounts.google.empty": "No Google accounts",
"accounts.google.hint": "Google accounts are linked via the browser",
"accounts.ha.header": "Home Assistant",
"accounts.ha.empty": "No Home Assistant accounts",
"accounts.ha.add": "Add Home Assistant",
// CalDAV add sheet
"caldav.section": "Account details",
"caldav.display_name": "Display name",
"caldav.url": "CalDAV URL",
"caldav.username": "Username",
"caldav.password": "Password",
"caldav.color": "Color",
"caldav.color_label": "Color",
"caldav.color_section": "Color",
"caldav.connect": "Connect",
"caldav.title": "CalDAV account",
// Local cal add sheet
"local.title": "Local calendar",
"local.name": "Name",
"local.color": "Color",
"local.create": "Create",
// iCal add sheet
"ical.title": "Subscribe to iCal",
"ical.subscription": "Subscription",
"ical.name": "Name",
"ical.url": "iCal URL",
"ical.color": "Color",
"ical.refresh_section": "Refresh",
"ical.interval": "Interval",
"ical.subscribe": "Subscribe",
"ical.refresh.15m": "Every 15 min",
"ical.refresh.30m": "Every 30 min",
"ical.refresh.1h": "Hourly",
"ical.refresh.6h": "Every 6 hours",
"ical.refresh.1d": "Daily",
// HA add sheet
"ha.section": "Home Assistant",
"ha.display_name": "Display name",
"ha.url_placeholder": "URL (e.g. http://homeassistant.local:8123)",
"ha.auth_section": "Authentication",
"ha.token": "Long-Lived Access Token",
"ha.token_hint": "Create a token under: Profile → Security → Long-Lived Access Tokens",
"ha.connect": "Connect"
]
]

View File

@@ -15,11 +15,13 @@ struct AccountsView: View {
@State private var showAddHA = false
@State private var errorAlert: String?
@AppStorage("appLanguage") private var appLang = "system"
var body: some View {
NavigationStack {
Group {
if isLoading {
ProgressView("Lade Konten…")
ProgressView(L10n.t("accounts.loading", appLang))
} else {
List {
caldavSection
@@ -30,15 +32,15 @@ struct AccountsView: View {
}
}
}
.navigationTitle("Konten")
.navigationTitle(L10n.t("accounts.title", appLang))
.navigationBarTitleDisplayMode(.large)
.toolbar {
ToolbarItem(placement: .primaryAction) {
Menu {
Button("CalDAV-Konto") { showAddCalDAV = true }
Button("Lokaler Kalender") { showAddLocal = true }
Button("iCal-URL abonnieren") { showAddICal = true }
Button("Home Assistant") { showAddHA = true }
Button(L10n.t("accounts.add.caldav", appLang)) { showAddCalDAV = true }
Button(L10n.t("accounts.add.local", appLang)) { showAddLocal = true }
Button(L10n.t("accounts.add.ical", appLang)) { showAddICal = true }
Button(L10n.t("accounts.add.ha", appLang)) { showAddHA = true }
} label: {
Image(systemName: "plus")
}
@@ -56,8 +58,8 @@ struct AccountsView: View {
.sheet(isPresented: $showAddHA) {
AddHASheet(api: api) { await load() }
}
.alert("Fehler", isPresented: .constant(errorAlert != nil), actions: {
Button("OK") { errorAlert = nil }
.alert(L10n.t("common.error", appLang), isPresented: .constant(errorAlert != nil), actions: {
Button(L10n.t("common.ok", appLang)) { errorAlert = nil }
}, message: {
Text(errorAlert ?? "")
})
@@ -70,7 +72,7 @@ struct AccountsView: View {
var caldavSection: some View {
Section {
if caldavAccounts.isEmpty {
Text("Keine CalDAV-Konten")
Text(L10n.t("accounts.caldav.empty", appLang))
.foregroundStyle(.secondary)
} else {
ForEach(caldavAccounts) { acc in
@@ -91,17 +93,17 @@ struct AccountsView: View {
Task { await deleteCalDAV(offsets: offsets) }
}
}
Button("CalDAV hinzufügen") { showAddCalDAV = true }
Button(L10n.t("accounts.caldav.add", appLang)) { showAddCalDAV = true }
.foregroundStyle(Color.accentColor)
} header: {
Text("CalDAV-Konten")
Text(L10n.t("accounts.caldav.header", appLang))
}
}
var localSection: some View {
Section {
if localCalendars.isEmpty {
Text("Keine lokalen Kalender")
Text(L10n.t("accounts.local.empty", appLang))
.foregroundStyle(.secondary)
} else {
ForEach(localCalendars) { cal in
@@ -116,17 +118,17 @@ struct AccountsView: View {
Task { await deleteLocal(offsets: offsets) }
}
}
Button("Lokalen Kalender erstellen") { showAddLocal = true }
Button(L10n.t("accounts.local.add", appLang)) { showAddLocal = true }
.foregroundStyle(Color.accentColor)
} header: {
Text("Lokale Kalender")
Text(L10n.t("accounts.local.header", appLang))
}
}
var icalSection: some View {
Section {
if icalSubs.isEmpty {
Text("Keine Abonnements")
Text(L10n.t("accounts.ical.empty", appLang))
.foregroundStyle(.secondary)
} else {
ForEach(icalSubs) { sub in
@@ -136,7 +138,7 @@ struct AccountsView: View {
.frame(width: 12, height: 12)
VStack(alignment: .leading, spacing: 2) {
Text(sub.name).font(.body)
Text("Alle \(sub.refreshMinutes) Min.")
Text(String(format: L10n.t("accounts.ical.every", appLang), sub.refreshMinutes))
.font(.caption)
.foregroundStyle(.secondary)
}
@@ -146,17 +148,17 @@ struct AccountsView: View {
Task { await deleteICal(offsets: offsets) }
}
}
Button("iCal-URL abonnieren") { showAddICal = true }
Button(L10n.t("accounts.ical.add", appLang)) { showAddICal = true }
.foregroundStyle(Color.accentColor)
} header: {
Text("iCal-Abonnements")
Text(L10n.t("accounts.ical.header", appLang))
}
}
var googleSection: some View {
Section {
if googleAccounts.isEmpty {
Text("Keine Google-Konten")
Text(L10n.t("accounts.google.empty", appLang))
.foregroundStyle(.secondary)
} else {
ForEach(googleAccounts) { acc in
@@ -170,18 +172,18 @@ struct AccountsView: View {
Task { await deleteGoogle(offsets: offsets) }
}
}
Text("Google-Konten werden über den Browser verknüpft")
Text(L10n.t("accounts.google.hint", appLang))
.font(.caption)
.foregroundStyle(.secondary)
} header: {
Text("Google-Konten")
Text(L10n.t("accounts.google.header", appLang))
}
}
var haSection: some View {
Section {
if haAccounts.isEmpty {
Text("Keine Home Assistant-Konten")
Text(L10n.t("accounts.ha.empty", appLang))
.foregroundStyle(.secondary)
} else {
ForEach(haAccounts) { acc in
@@ -197,10 +199,10 @@ struct AccountsView: View {
Task { await deleteHA(offsets: offsets) }
}
}
Button("Home Assistant hinzufügen") { showAddHA = true }
Button(L10n.t("accounts.ha.add", appLang)) { showAddHA = true }
.foregroundStyle(Color.accentColor)
} header: {
Text("Home Assistant")
Text(L10n.t("accounts.ha.header", appLang))
}
}
@@ -259,6 +261,7 @@ struct AddCalDAVSheet: View {
let api: CalendarrAPI
let onDone: () async -> Void
@Environment(\.dismiss) var dismiss
@AppStorage("appLanguage") private var appLang = "system"
@State private var name = ""
@State private var url = ""
@@ -271,19 +274,19 @@ struct AddCalDAVSheet: View {
var body: some View {
NavigationStack {
Form {
Section("Konto-Details") {
TextField("Anzeigename", text: $name)
TextField("CalDAV-URL", text: $url)
Section(L10n.t("caldav.section", appLang)) {
TextField(L10n.t("caldav.display_name", appLang), text: $name)
TextField(L10n.t("caldav.url", appLang), text: $url)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.keyboardType(.URL)
TextField("Benutzername", text: $username)
TextField(L10n.t("caldav.username", appLang), text: $username)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
SecureField("Passwort", text: $password)
SecureField(L10n.t("caldav.password", appLang), text: $password)
}
Section("Farbe") {
ColorPicker("Farbe", selection: $color, supportsOpacity: false)
Section(L10n.t("caldav.color_section", appLang)) {
ColorPicker(L10n.t("caldav.color_label", appLang), selection: $color, supportsOpacity: false)
}
if !error.isEmpty {
Section {
@@ -291,14 +294,14 @@ struct AddCalDAVSheet: View {
}
}
}
.navigationTitle("CalDAV-Konto")
.navigationTitle(L10n.t("caldav.title", appLang))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Abbrechen") { dismiss() }
Button(L10n.t("common.cancel", appLang)) { dismiss() }
}
ToolbarItem(placement: .primaryAction) {
Button("Verbinden") {
Button(L10n.t("caldav.connect", appLang)) {
Task { await save() }
}
.bold()
@@ -326,6 +329,7 @@ struct AddLocalCalSheet: View {
let api: CalendarrAPI
let onDone: () async -> Void
@Environment(\.dismiss) var dismiss
@AppStorage("appLanguage") private var appLang = "system"
@State private var name = ""
@State private var color = Color(hex: "#34a853")
@@ -336,19 +340,19 @@ struct AddLocalCalSheet: View {
NavigationStack {
Form {
Section {
TextField("Name", text: $name)
ColorPicker("Farbe", selection: $color, supportsOpacity: false)
TextField(L10n.t("local.name", appLang), text: $name)
ColorPicker(L10n.t("local.color", appLang), selection: $color, supportsOpacity: false)
}
if !error.isEmpty {
Section { Text(error).foregroundStyle(.red) }
}
}
.navigationTitle("Lokaler Kalender")
.navigationTitle(L10n.t("local.title", appLang))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) { Button("Abbrechen") { dismiss() } }
ToolbarItem(placement: .cancellationAction) { Button(L10n.t("common.cancel", appLang)) { dismiss() } }
ToolbarItem(placement: .primaryAction) {
Button("Erstellen") {
Button(L10n.t("local.create", appLang)) {
Task { await save() }
}
.bold()
@@ -373,6 +377,7 @@ struct AddICalSheet: View {
let api: CalendarrAPI
let onDone: () async -> Void
@Environment(\.dismiss) var dismiss
@AppStorage("appLanguage") private var appLang = "system"
@State private var name = ""
@State private var url = ""
@@ -381,21 +386,27 @@ struct AddICalSheet: View {
@State private var isLoading = false
@State private var error = ""
let refreshOptions = [(15, "Alle 15 Min."), (30, "Alle 30 Min."), (60, "Stündlich"), (360, "Alle 6 Std."), (1440, "Täglich")]
private var refreshOptions: [(Int, String)] {
[(15, L10n.t("ical.refresh.15m", appLang)),
(30, L10n.t("ical.refresh.30m", appLang)),
(60, L10n.t("ical.refresh.1h", appLang)),
(360, L10n.t("ical.refresh.6h", appLang)),
(1440, L10n.t("ical.refresh.1d", appLang))]
}
var body: some View {
NavigationStack {
Form {
Section("Abonnement") {
TextField("Name", text: $name)
TextField("iCal-URL", text: $url)
Section(L10n.t("ical.subscription", appLang)) {
TextField(L10n.t("ical.name", appLang), text: $name)
TextField(L10n.t("ical.url", appLang), text: $url)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.keyboardType(.URL)
ColorPicker("Farbe", selection: $color, supportsOpacity: false)
ColorPicker(L10n.t("ical.color", appLang), selection: $color, supportsOpacity: false)
}
Section("Aktualisierung") {
Picker("Intervall", selection: $refreshMinutes) {
Section(L10n.t("ical.refresh_section", appLang)) {
Picker(L10n.t("ical.interval", appLang), selection: $refreshMinutes) {
ForEach(refreshOptions, id: \.0) { opt in
Text(opt.1).tag(opt.0)
}
@@ -405,12 +416,12 @@ struct AddICalSheet: View {
Section { Text(error).foregroundStyle(.red) }
}
}
.navigationTitle("iCal abonnieren")
.navigationTitle(L10n.t("ical.title", appLang))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) { Button("Abbrechen") { dismiss() } }
ToolbarItem(placement: .cancellationAction) { Button(L10n.t("common.cancel", appLang)) { dismiss() } }
ToolbarItem(placement: .primaryAction) {
Button("Abonnieren") {
Button(L10n.t("ical.subscribe", appLang)) {
Task { await save() }
}
.bold()
@@ -435,6 +446,7 @@ struct AddHASheet: View {
let api: CalendarrAPI
let onDone: () async -> Void
@Environment(\.dismiss) var dismiss
@AppStorage("appLanguage") private var appLang = "system"
@State private var name = ""
@State private var url = ""
@@ -445,16 +457,16 @@ struct AddHASheet: View {
var body: some View {
NavigationStack {
Form {
Section("Home Assistant") {
TextField("Anzeigename", text: $name)
TextField("URL (z.B. http://homeassistant.local:8123)", text: $url)
Section(L10n.t("ha.section", appLang)) {
TextField(L10n.t("ha.display_name", appLang), text: $name)
TextField(L10n.t("ha.url_placeholder", appLang), text: $url)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.keyboardType(.URL)
}
Section("Authentifizierung") {
SecureField("Long-Lived Access Token", text: $token)
Text("Token erstellen unter: Profil → Sicherheit → Long-Lived Access Tokens")
Section(L10n.t("ha.auth_section", appLang)) {
SecureField(L10n.t("ha.token", appLang), text: $token)
Text(L10n.t("ha.token_hint", appLang))
.font(.caption)
.foregroundStyle(.secondary)
}
@@ -462,12 +474,12 @@ struct AddHASheet: View {
Section { Text(error).foregroundStyle(.red) }
}
}
.navigationTitle("Home Assistant")
.navigationTitle(L10n.t("accounts.add.ha", appLang))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) { Button("Abbrechen") { dismiss() } }
ToolbarItem(placement: .cancellationAction) { Button(L10n.t("common.cancel", appLang)) { dismiss() } }
ToolbarItem(placement: .primaryAction) {
Button("Verbinden") {
Button(L10n.t("ha.connect", appLang)) {
Task { await save() }
}
.bold()

View File

@@ -3,6 +3,7 @@ import SwiftUI
struct AgendaView: View {
let store: CalendarStore
let onEventTap: (CalEvent) -> Void
@AppStorage("appLanguage") private var appLang = "system"
private var cal: Calendar { store.userCalendar }
@@ -17,25 +18,27 @@ struct AgendaView: View {
return dict.keys.sorted().map { ($0, dict[$0]!.sorted { $0.startDate < $1.startDate }) }
}
private let dayFmt: DateFormatter = {
private var dayFmt: DateFormatter {
let f = DateFormatter()
f.locale = L10n.locale(appLang)
f.dateFormat = "EEEE, d. MMMM yyyy"
return f
}()
}
private let timeFmt: DateFormatter = {
private var timeFmt: DateFormatter {
let f = DateFormatter()
f.locale = L10n.locale(appLang)
f.timeStyle = .short
f.dateStyle = .none
return f
}()
}
var body: some View {
if grouped.isEmpty {
ContentUnavailableView(
"Keine Termine",
L10n.t("cal.no_events_title", appLang),
systemImage: "calendar",
description: Text("In den nächsten 90 Tagen sind keine Termine vorhanden.")
description: Text(L10n.t("cal.no_events_body", appLang))
)
} else {
List {
@@ -43,7 +46,7 @@ struct AgendaView: View {
Section {
ForEach(evs) { ev in
Button { onEventTap(ev) } label: {
AgendaEventRow(event: ev, timeFmt: timeFmt)
AgendaEventRow(event: ev, timeFmt: timeFmt, allDayLabel: L10n.t("cal.allday", appLang))
}
.buttonStyle(.plain)
}
@@ -62,9 +65,10 @@ struct AgendaView: View {
private struct AgendaEventRow: View {
let event: CalEvent
let timeFmt: DateFormatter
let allDayLabel: String
var timeString: String {
if event.isAllDay { return "Ganztägig" }
if event.isAllDay { return allDayLabel }
return timeFmt.string(from: event.startDate)
}

View File

@@ -6,12 +6,25 @@ struct CalendarHostView: View {
@AppStorage("liquidGlass") private var liquidGlass = false
@AppStorage("cacheMonths") private var cacheMonths = 3
@AppStorage("appLanguage") private var appLang = "system"
@AppStorage("backgroundColor") private var bgHex = "#000000"
@State private var store = CalendarStore()
@State private var showEditor = false
@State private var editorDate: Date = .now
@State private var editingEvent: CalEvent? = nil
@State private var selectedEvent: CalEvent? = nil
@State private var visibleMonth: Date = .now
private var titleString: String {
if store.viewType == .month {
let f = DateFormatter()
f.locale = L10n.locale(appLang)
f.dateFormat = "LLLL yyyy"
return f.string(from: visibleMonth).capitalized(with: L10n.locale(appLang))
}
return store.titleForCurrentView(language: appLang)
}
var body: some View {
if liquidGlass {
@@ -30,6 +43,7 @@ struct CalendarHostView: View {
errorBanner
calendarContent
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(hex: bgHex))
.overlay(alignment: .top) {
if store.isLoading {
ProgressView().padding(.top, 10).transition(.opacity)
@@ -52,6 +66,7 @@ struct CalendarHostView: View {
.onChange(of: store.currentDate) { _, _ in Task { await onNavigate() } }
.onChange(of: store.viewType) { _, _ in Task { await onNavigate() } }
.onChange(of: cacheMonths) { _, _ in Task { await recache() } }
.onChange(of: visibleMonth) { _, new in Task { await ensureLoaded(around: new) } }
}
// MARK: Liquid Glass variant
@@ -60,6 +75,7 @@ struct CalendarHostView: View {
NavigationStack {
calendarContent
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(hex: bgHex))
.overlay(alignment: .top) {
if store.isLoading {
ProgressView().padding(.top, 10).transition(.opacity)
@@ -74,12 +90,20 @@ struct CalendarHostView: View {
HStack(spacing: 2) {
Button { store.navigatePrev() } label: { Image(systemName: "chevron.left") }
Button { store.navigateNext() } label: { Image(systemName: "chevron.right") }
Button("Heute") { store.moveToToday() }.font(.callout)
Button(L10n.t("nav.today", appLang)) { store.moveToToday() }.font(.callout)
}
}
ToolbarItem(placement: .principal) { viewPickerMenu }
ToolbarItem(placement: .principal) {
Text(titleString)
.font(.headline)
.lineLimit(1)
.minimumScaleFactor(0.7)
}
ToolbarItem(placement: .navigationBarTrailing) {
Button { showMenu = true } label: { Image(systemName: "line.3.horizontal") }
HStack(spacing: 8) {
viewPickerMenu
Button { showMenu = true } label: { Image(systemName: "line.3.horizontal") }
}
}
}
}
@@ -89,6 +113,7 @@ struct CalendarHostView: View {
.onChange(of: store.currentDate) { _, _ in Task { await onNavigate() } }
.onChange(of: store.viewType) { _, _ in Task { await onNavigate() } }
.onChange(of: cacheMonths) { _, _ in Task { await recache() } }
.onChange(of: visibleMonth) { _, new in Task { await ensureLoaded(around: new) } }
}
// MARK: Top bar (flat mode)
@@ -106,17 +131,21 @@ struct CalendarHostView: View {
.font(.system(size: 17, weight: .medium))
.frame(width: 36, height: 36)
}
Button("Heute") { store.moveToToday() }
Button(L10n.t("nav.today", appLang)) { store.moveToToday() }
.font(.callout).padding(.horizontal, 6)
}
.padding(.leading, 8)
Spacer()
Spacer(minLength: 8)
Text(titleString)
.font(.headline)
.lineLimit(1)
.minimumScaleFactor(0.7)
Spacer(minLength: 8)
viewPickerMenu
Spacer()
Button { showMenu = true } label: {
Image(systemName: "line.3.horizontal")
.font(.system(size: 18, weight: .medium))
.frame(width: 44, height: 44)
.frame(width: 40, height: 40)
}
.padding(.trailing, 4)
}
@@ -128,18 +157,16 @@ struct CalendarHostView: View {
Menu {
ForEach(CalViewType.allCases, id: \.self) { vt in
Button { store.viewType = vt } label: {
Label(vt.label, systemImage: vt.systemImage)
Label(vt.label(appLang), systemImage: vt.systemImage)
}
}
} label: {
HStack(spacing: 4) {
Text(store.viewType.label).font(.headline)
Image(systemName: "chevron.down").font(.caption2.weight(.semibold))
}
.foregroundStyle(.primary)
.padding(.horizontal, 12).padding(.vertical, 7)
.background(.quaternary, in: Capsule())
Image(systemName: store.viewType.systemImage)
.font(.system(size: 17, weight: .medium))
.foregroundStyle(.primary)
.frame(width: 40, height: 40)
}
.accessibilityLabel(L10n.t("view.change", appLang))
}
// MARK: Error banner
@@ -165,24 +192,60 @@ struct CalendarHostView: View {
@ViewBuilder
private var calendarContent: some View {
let swipe = DragGesture(minimumDistance: 35, coordinateSpace: .global)
let swipe = DragGesture(minimumDistance: 14, coordinateSpace: .local)
.onEnded { val in
let h = val.translation.width
let v = val.translation.height
guard abs(h) > abs(v) * 1.1, abs(h) > 50 else { return }
guard abs(h) > abs(v) * 1.2, abs(h) > 28 else { return }
withAnimation(.easeInOut(duration: 0.2)) {
if h < 0 { store.navigateNext() } else { store.navigatePrev() }
}
}
switch store.viewType {
case .month:
MonthView(store: store, onDayTap: { editorDate = $0 }, onEventTap: { selectedEvent = $0 })
.simultaneousGesture(swipe)
// Month view uses vertical scroll no horizontal swipe.
MonthView(store: store,
onDayTap: { editorDate = $0 },
onEventTap: { selectedEvent = $0 },
onCreateEvent: { day in
editingEvent = nil
editorDate = day
showEditor = true
},
onShowWeek: { day in
store.currentDate = day
store.viewType = .week
},
onShowDay: { day in
store.currentDate = day
store.viewType = .day
},
visibleMonth: $visibleMonth)
case .week:
WeekView(store: store, onEventTap: { selectedEvent = $0 }, onTimeTap: { editorDate = $0 })
WeekView(store: store,
onEventTap: { selectedEvent = $0 },
onCreateEvent: { date in
editingEvent = nil
editorDate = date
showEditor = true
},
onShowMonth: { date in
store.currentDate = date
store.viewType = .month
},
onShowDay: { date in
store.currentDate = date
store.viewType = .day
})
.simultaneousGesture(swipe)
case .day:
DayView(store: store, onEventTap: { selectedEvent = $0 }, onTimeTap: { editorDate = $0 })
DayView(store: store,
onEventTap: { selectedEvent = $0 },
onCreateEvent: { date in
editingEvent = nil
editorDate = date
showEditor = true
})
.simultaneousGesture(swipe)
case .quarter:
QuarterView(store: store, onEventTap: { selectedEvent = $0 })
@@ -263,6 +326,16 @@ struct CalendarHostView: View {
store.invalidateCache()
await startup()
}
/// Called when the user scrolls into a new month fetches a ±1 month window
/// around it on demand. `loadEvents` skips the network if cached.
private func ensureLoaded(around month: Date) async {
let cal = store.userCalendar
let monthStart = cal.date(from: cal.dateComponents([.year, .month], from: month)) ?? month
let s = cal.date(byAdding: .month, value: -1, to: monthStart) ?? monthStart
let e = cal.date(byAdding: .month, value: 2, to: monthStart) ?? monthStart
await store.loadEvents(api: api, start: s, end: e)
}
}
// MARK: Shared sheet modifier

View File

@@ -3,7 +3,12 @@ import SwiftUI
struct DayView: View {
let store: CalendarStore
let onEventTap: (CalEvent) -> Void
let onTimeTap: (Date) -> Void
let onCreateEvent: (Date) -> Void
@AppStorage("appLanguage") private var appLang = "system"
@AppStorage("todayColor") private var todayHex = "#4285f4"
@AppStorage("textColor") private var textHex = "#FFFFFF"
@AppStorage("lineColor") private var lineHex = "#3A3A3C"
private var cal: Calendar { store.userCalendar }
private var allDayEvents: [CalEvent] { store.events(on: store.currentDate).filter(\.isAllDay) }
@@ -17,25 +22,18 @@ struct DayView: View {
ScrollViewReader { proxy in
ScrollView {
ZStack(alignment: .topLeading) {
// Background grid
// Background grid with per-hour context menus
HStack(alignment: .top, spacing: 0) {
timeLabels
VStack(spacing: 0) {
ForEach(hours, id: \.self) { _ in
Rectangle()
.fill(Color(.separator).opacity(0.4))
.frame(height: 0.5)
Color.clear.frame(height: hourHeight - 0.5)
ForEach(hours, id: \.self) { hour in
DayHourSlot(day: store.currentDate, hour: hour,
hourHeight: hourHeight,
language: appLang,
onCreateEvent: onCreateEvent)
}
}
.frame(width: geo.size.width - timeColumnWidth)
.contentShape(Rectangle())
.onTapGesture { loc in
let h = Int(loc.y / hourHeight)
let m = Int((loc.y.truncatingRemainder(dividingBy: hourHeight)) / hourHeight * 60)
let date = cal.date(bySettingHour: h, minute: m, second: 0, of: store.currentDate) ?? store.currentDate
onTimeTap(date)
}
}
// Events
@@ -52,10 +50,11 @@ struct DayView: View {
// Current time
if cal.isDateInToday(store.currentDate) {
let lineY = nowLineY()
let nowColor = Color(hex: todayHex)
HStack(spacing: 0) {
Spacer().frame(width: timeColumnWidth - 4)
Circle().fill(Color.red).frame(width: 8, height: 8)
Rectangle().fill(Color.red)
Circle().fill(nowColor).frame(width: 8, height: 8)
Rectangle().fill(nowColor)
.frame(width: geo.size.width - timeColumnWidth - 4, height: 1.5)
}
.offset(y: lineY - 0.75)
@@ -98,7 +97,7 @@ struct DayView: View {
Color.clear.frame(height: hourHeight)
Text(String(format: "%02d:00", h))
.font(.system(size: 10))
.foregroundStyle(.secondary)
.foregroundStyle(Color(hex: textHex).opacity(0.6))
.offset(y: -6)
}
}
@@ -123,3 +122,31 @@ struct DayView: View {
}
}
}
// One-hour slot for the single-column day view.
private struct DayHourSlot: View {
let day: Date
let hour: Int
let hourHeight: CGFloat
let language: String
let onCreateEvent: (Date) -> Void
@AppStorage("lineColor") private var lineHex = "#3A3A3C"
private var date: Date {
Calendar.current.date(bySettingHour: hour, minute: 0, second: 0, of: day) ?? day
}
var body: some View {
VStack(spacing: 0) {
Rectangle().fill(Color(hex: lineHex).opacity(0.4)).frame(height: 0.5)
Color.clear.frame(height: hourHeight - 0.5)
}
.contentShape(Rectangle())
.contextMenu {
Button { onCreateEvent(date) } label: {
Label(L10n.t("cal.new_event", language), systemImage: "plus")
}
}
}
}

View File

@@ -8,6 +8,7 @@ struct EventEditorSheet: View {
let onSaved: () async -> Void
@Environment(\.dismiss) var dismiss
@AppStorage("appLanguage") private var appLang = "system"
@State private var title = ""
@State private var isAllDay = false
@State private var startDate = Date()
@@ -29,36 +30,36 @@ struct EventEditorSheet: View {
NavigationStack {
Form {
Section {
TextField("Titel", text: $title)
TextField(L10n.t("event.title_placeholder", appLang), text: $title)
.font(.body.weight(.medium))
}
Section {
Toggle("Ganztägig", isOn: $isAllDay.animation())
Toggle(L10n.t("event.allday", appLang), isOn: $isAllDay.animation())
.tint(Color.accentColor)
if isAllDay {
DatePicker("Start", selection: $startDate, displayedComponents: .date)
DatePicker("Ende", selection: $endDate, displayedComponents: .date)
DatePicker(L10n.t("event.start", appLang), selection: $startDate, displayedComponents: .date)
DatePicker(L10n.t("event.end", appLang), selection: $endDate, displayedComponents: .date)
} else {
DatePicker("Start", selection: $startDate)
DatePicker("Ende", selection: $endDate)
DatePicker(L10n.t("event.start", appLang), selection: $startDate)
DatePicker(L10n.t("event.end", appLang), selection: $endDate)
}
}
Section {
TextField("Ort", text: $location)
TextField("Beschreibung", text: $notes, axis: .vertical)
TextField(L10n.t("event.location", appLang), text: $location)
TextField(L10n.t("event.description", appLang), text: $notes, axis: .vertical)
.lineLimit(3...6)
}
Section("Kalender") {
Section(L10n.t("event.calendar_section", appLang)) {
if store.writableCalendars.isEmpty {
Text("Keine beschreibbaren Kalender vorhanden")
Text(L10n.t("event.no_writable", appLang))
.foregroundStyle(.secondary)
.font(.callout)
} else {
Picker("Kalender", selection: $selectedCalendarId) {
Picker(L10n.t("event.calendar_picker", appLang), selection: $selectedCalendarId) {
ForEach(store.writableCalendars) { cal in
HStack {
Circle()
@@ -72,9 +73,9 @@ struct EventEditorSheet: View {
}
}
Section("Farbe") {
Section(L10n.t("event.color_section", appLang)) {
HStack {
Text("Terminfarbe")
Text(L10n.t("event.color", appLang))
Spacer()
ColorPicker("", selection: Binding(
get: { Color(hex: color.isEmpty ? (selectedCal?.color ?? "#4285f4") : color) },
@@ -82,7 +83,7 @@ struct EventEditorSheet: View {
), supportsOpacity: false)
.labelsHidden()
if !color.isEmpty {
Button("Zurücksetzen") { color = "" }
Button(L10n.t("event.reset_color", appLang)) { color = "" }
.font(.caption)
.foregroundStyle(.secondary)
}
@@ -95,14 +96,18 @@ struct EventEditorSheet: View {
}
}
}
.navigationTitle(isEditing ? "Termin bearbeiten" : "Neuer Termin")
.navigationTitle(isEditing
? L10n.t("event.edit_title", appLang)
: L10n.t("event.new_title", appLang))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Abbrechen") { dismiss() }
Button(L10n.t("common.cancel", appLang)) { dismiss() }
}
ToolbarItem(placement: .primaryAction) {
Button(isEditing ? "Sichern" : "Hinzufügen") {
Button(isEditing
? L10n.t("event.save", appLang)
: L10n.t("event.add", appLang)) {
Task { await save() }
}
.bold()

View File

@@ -1,150 +1,388 @@
import SwiftUI
private let weeksBack = 104
private let weeksAhead = 104
private let weekdayHeaderHeight: CGFloat = 28
private let dayNumberRowHeight: CGFloat = 22
private let laneHeight: CGFloat = 16
private let laneSpacing: CGFloat = 2
private let maxLanesPerWeek = 5
private enum DividerEdge { case none, topHighlight, bottomHighlight }
struct MonthView: View {
let store: CalendarStore
let onDayTap: (Date) -> Void
let onEventTap: (CalEvent) -> Void
let onCreateEvent: (Date) -> Void
let onShowWeek: (Date) -> Void
let onShowDay: (Date) -> Void
@Binding var visibleMonth: Date
@AppStorage("appLanguage") private var appLang = "system"
@AppStorage("monthDividerColor") private var dividerHex = "#7090c0"
@AppStorage("monthLabelColor") private var labelHex = "#7090c0"
@AppStorage("textColor") private var textHex = "#FFFFFF"
@AppStorage("lineColor") private var lineHex = "#3A3A3C"
@State private var scrolledWeek: Date? = nil
@State private var didInitialScroll = false
private var cal: Calendar { store.userCalendar }
private var monthStart: Date {
cal.date(from: cal.dateComponents([.year, .month], from: store.currentDate))!
private var weekStarts: [Date] {
let today = cal.startOfDay(for: .now)
let thisWeek = cal.date(from: cal.dateComponents([.yearForWeekOfYear, .weekOfYear], from: today))!
return (-weeksBack...weeksAhead).compactMap {
cal.date(byAdding: .weekOfYear, value: $0, to: thisWeek)
}
}
private var gridDays: [Date] {
let firstWeekday = cal.firstWeekday
let weekday = cal.component(.weekday, from: monthStart)
let offset = ((weekday - firstWeekday) + 7) % 7
let gridStart = cal.date(byAdding: .day, value: -offset, to: monthStart)!
return (0..<42).compactMap { cal.date(byAdding: .day, value: $0, to: gridStart) }
}
private var rowCount: Int { gridDays.count / 7 } // always 6
private var weekdayHeaders: [String] {
let symbols = cal.shortWeekdaySymbols
let fmt = DateFormatter(); fmt.locale = L10n.locale(appLang)
let symbols = fmt.shortWeekdaySymbols ?? cal.shortWeekdaySymbols
let start = cal.firstWeekday - 1
return (0..<7).map { String(symbols[(start + $0) % 7].prefix(2)) }
return (0..<7).map { i in String(symbols[(start + i) % 7].prefix(2)) }
}
var body: some View {
VStack(spacing: 0) {
// Day-of-week header row (fixed height)
HStack(spacing: 0) {
ForEach(weekdayHeaders, id: \.self) { d in
Text(d)
.font(.caption2.weight(.semibold))
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, minHeight: 28)
headerRow
Divider()
ScrollView {
LazyVStack(spacing: 0) {
ForEach(weekStarts, id: \.self) { ws in
WeekRow(weekStart: ws,
store: store,
dividerColor: Color(hex: dividerHex),
labelColor: Color(hex: labelHex),
textColor: Color(hex: textHex),
lineColor: Color(hex: lineHex),
language: appLang,
onDayTap: onDayTap,
onEventTap: onEventTap,
onCreateEvent: onCreateEvent,
onShowWeek: onShowWeek,
onShowDay: onShowDay)
.id(ws)
}
}
.scrollTargetLayout()
}
.scrollPosition(id: $scrolledWeek, anchor: .top)
.onAppear {
if !didInitialScroll {
didInitialScroll = true
scrolledWeek = weekStart(for: store.currentDate)
publishVisibleMonth(from: scrolledWeek)
}
}
Divider()
// Grid fills all remaining space using GeometryReader
GeometryReader { geo in
let rowH = geo.size.height / CGFloat(rowCount)
VStack(spacing: 0) {
ForEach(0..<rowCount, id: \.self) { row in
HStack(spacing: 0) {
ForEach(0..<7, id: \.self) { col in
let day = gridDays[row * 7 + col]
DayCell(
date: day,
isCurrentMonth: cal.isDate(day, equalTo: monthStart, toGranularity: .month),
isToday: cal.isDateInToday(day),
events: store.events(on: day),
rowHeight: rowH,
onTap: { onDayTap(day) },
onEventTap: onEventTap
)
}
}
.frame(height: rowH)
.onChange(of: store.currentDate) { _, newDate in
let target = weekStart(for: newDate)
if scrolledWeek != target {
withAnimation(.easeInOut(duration: 0.25)) {
scrolledWeek = target
}
}
}
.onChange(of: scrolledWeek) { _, newWeek in
publishVisibleMonth(from: newWeek)
}
}
}
private var headerRow: some View {
HStack(spacing: 0) {
ForEach(weekdayHeaders, id: \.self) { d in
Text(d)
.font(.caption2.weight(.semibold))
.foregroundStyle(Color(hex: textHex).opacity(0.7))
.frame(maxWidth: .infinity, minHeight: weekdayHeaderHeight)
}
}
}
private func weekStart(for date: Date) -> Date {
cal.date(from: cal.dateComponents([.yearForWeekOfYear, .weekOfYear], from: date))!
}
/// Treat the visible month as the one that "owns" Thursday of the current week
/// matches ISO week-month conventions and avoids flicker on month boundaries.
private func publishVisibleMonth(from week: Date?) {
guard let w = week else { return }
let thursday = cal.date(byAdding: .day, value: 3, to: w) ?? w
let m = cal.date(from: cal.dateComponents([.year, .month], from: thursday)) ?? thursday
if visibleMonth != m { visibleMonth = m }
}
}
private struct DayCell: View {
let date: Date
let isCurrentMonth: Bool
let isToday: Bool
let events: [CalEvent]
let rowHeight: CGFloat
let onTap: () -> Void
let onEventTap: (CalEvent) -> Void
// MARK: Week Row
private var maxVisible: Int {
max(1, Int((rowHeight - 32) / 16))
private struct WeekRow: View {
let weekStart: Date
let store: CalendarStore
let dividerColor: Color
let labelColor: Color
let textColor: Color
let lineColor: Color
let language: String
let onDayTap: (Date) -> Void
let onEventTap: (CalEvent) -> Void
let onCreateEvent: (Date) -> Void
let onShowWeek: (Date) -> Void
let onShowDay: (Date) -> Void
private var cal: Calendar { store.userCalendar }
private var days: [Date] {
(0..<7).compactMap { cal.date(byAdding: .day, value: $0, to: weekStart) }
}
private var weekNumber: Int { cal.component(.weekOfYear, from: weekStart) }
private func columnRange(for ev: CalEvent) -> (startCol: Int, span: Int) {
let weekEnd = cal.date(byAdding: .day, value: 7, to: weekStart)!
let evStart = max(cal.startOfDay(for: ev.startDate), weekStart)
// All-day end is already exclusive; timed end-of-day-on-same-day shouldn't add a column.
let rawEnd: Date
if ev.isAllDay {
rawEnd = ev.endDate
} else {
// Treat timed events as occupying days from start up to and including the day of end.
rawEnd = cal.date(byAdding: .day, value: 1, to: cal.startOfDay(for: ev.endDate))!
}
let evEnd = min(rawEnd, weekEnd)
let sc = max(0, cal.dateComponents([.day], from: weekStart, to: evStart).day ?? 0)
let lastIncl = (cal.dateComponents([.day], from: weekStart, to: evEnd).day ?? 0) - 1
let ec = min(6, lastIncl)
return (sc, max(1, ec - sc + 1))
}
/// Greedy lane packing for events overlapping this week.
private func packEvents() -> (placed: [(event: CalEvent, lane: Int, startCol: Int, span: Int)],
extraPerCol: [Int]) {
let weekEndExclusive = cal.date(byAdding: .day, value: 7, to: weekStart)!
let evs = store.events(in: weekStart, end: weekEndExclusive)
.sorted { a, b in
if a.startDate != b.startDate { return a.startDate < b.startDate }
return a.endDate > b.endDate
}
var laneLastEnd: [Int] = []
var placed: [(CalEvent, Int, Int, Int)] = []
var overflowPerCol = [Int](repeating: 0, count: 7)
for ev in evs {
let (sc, sp) = columnRange(for: ev)
var assigned: Int? = nil
for laneIdx in 0..<laneLastEnd.count {
if laneLastEnd[laneIdx] < sc {
laneLastEnd[laneIdx] = sc + sp - 1
assigned = laneIdx
break
}
}
if assigned == nil {
if laneLastEnd.count < maxLanesPerWeek {
laneLastEnd.append(sc + sp - 1)
assigned = laneLastEnd.count - 1
}
}
if let lane = assigned {
placed.append((ev, lane, sc, sp))
} else {
for c in sc...min(6, sc + sp - 1) {
overflowPerCol[c] += 1
}
}
}
return (placed.map { (event: $0.0, lane: $0.1, startCol: $0.2, span: $0.3) },
overflowPerCol)
}
var body: some View {
VStack(alignment: .leading, spacing: 2) {
// Day number
let (placed, extras) = packEvents()
let rowHeight = dayNumberRowHeight + CGFloat(maxLanesPerWeek) * (laneHeight + laneSpacing) + 4
let mondayIdx = days.firstIndex(where: { cal.component(.weekday, from: $0) == 2 }) ?? 0
// Where in this row does a new month start? (col 1...6 = mid-row step; nil = no step)
let midRowBoundaryCol: Int? = {
for idx in 1..<7 where cal.component(.day, from: days[idx]) == 1 { return idx }
return nil
}()
let rowStartsNewMonth = cal.component(.day, from: days[0]) == 1
GeometryReader { geo in
let cellW = geo.size.width / 7
ZStack(alignment: .topLeading) {
HStack(spacing: 0) {
ForEach(Array(days.enumerated()), id: \.offset) { idx, day in
let edge: DividerEdge = {
if let b = midRowBoundaryCol {
return idx < b ? .bottomHighlight : .topHighlight
}
return rowStartsNewMonth ? .topHighlight : .none
}()
DayCell(date: day,
isToday: cal.isDateInToday(day),
monthLabelColor: labelColor,
dividerColor: dividerColor,
textColor: textColor,
lineColor: lineColor,
language: language,
extraCount: extras[idx],
weekNumber: idx == mondayIdx ? weekNumber : nil,
cwLabel: L10n.t("cal.cw", language),
edge: edge,
onTap: { onDayTap(day) },
onCreateEvent: { onCreateEvent(day) },
onShowWeek: { onShowWeek(day) },
onShowDay: { onShowDay(day) })
.frame(width: cellW, height: rowHeight)
}
}
ForEach(Array(placed.enumerated()), id: \.offset) { _, p in
Button { onEventTap(p.event) } label: {
EventBar(event: p.event)
.frame(width: cellW * CGFloat(p.span) - 2, height: laneHeight)
}
.buttonStyle(.plain)
.offset(x: CGFloat(p.startCol) * cellW + 1,
y: dayNumberRowHeight + CGFloat(p.lane) * (laneHeight + laneSpacing))
}
// Vertical connector at the month-boundary column ties the bottom-line
// of old-month cells to the top-line of new-month cells into a step.
if let b = midRowBoundaryCol {
Rectangle()
.fill(dividerColor)
.frame(width: 1.5, height: rowHeight)
.offset(x: CGFloat(b) * cellW - 0.75, y: 0)
}
}
}
.frame(height: rowHeight)
}
}
// MARK: Day Cell
private struct DayCell: View {
let date: Date
let isToday: Bool
let monthLabelColor: Color
let dividerColor: Color
let textColor: Color
let lineColor: Color
let language: String
let extraCount: Int
let weekNumber: Int?
let cwLabel: String
let edge: DividerEdge
let onTap: () -> Void
let onCreateEvent: () -> Void
let onShowWeek: () -> Void
let onShowDay: () -> Void
private var cal: Calendar { Calendar.current }
private var dayNum: Int { cal.component(.day, from: date) }
private var isFirstOfMonth: Bool { dayNum == 1 }
private var monthAbbrev: String {
let f = DateFormatter()
f.locale = L10n.locale(language)
f.dateFormat = "LLL"
return f.string(from: date).uppercased()
}
var body: some View {
VStack(alignment: .leading, spacing: 0) {
Button(action: onTap) {
Text("\(Calendar.current.component(.day, from: date))")
.font(.system(size: 13, weight: isToday ? .bold : .regular))
.foregroundStyle(
isToday ? Color.white :
isCurrentMonth ? Color.primary : Color.secondary.opacity(0.4)
)
.frame(width: 26, height: 26)
.background(isToday ? Color.accentColor : Color.clear)
.clipShape(Circle())
HStack(spacing: 4) {
Text("\(dayNum)")
.font(.system(size: 13, weight: isToday ? .bold : .regular))
.foregroundStyle(isToday ? Color.white : textColor)
.frame(width: 22, height: 22)
.background(isToday ? Color.accentColor : Color.clear)
.clipShape(Circle())
if isFirstOfMonth {
Text(monthAbbrev)
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(monthLabelColor)
.lineLimit(1)
.fixedSize()
}
Spacer(minLength: 0)
}
.padding(.leading, 4)
.padding(.top, 2)
}
.buttonStyle(.plain)
.padding(.leading, 4)
.padding(.top, 2)
// Events
ForEach(events.prefix(maxVisible)) { ev in
Button { onEventTap(ev) } label: {
EventChip(event: ev)
}
.buttonStyle(.plain)
}
if events.count > maxVisible {
Text("+\(events.count - maxVisible)")
.font(.system(size: 9, weight: .medium))
.foregroundStyle(.secondary)
.padding(.leading, 4)
}
Spacer(minLength: 0)
HStack(spacing: 0) {
if extraCount > 0 {
Text("+\(extraCount)")
.font(.system(size: 9, weight: .medium))
.foregroundStyle(textColor.opacity(0.6))
.padding(.leading, 4)
}
Spacer(minLength: 0)
if let wn = weekNumber {
Text("\(cwLabel) \(wn)")
.font(.system(size: 9, weight: .medium))
.foregroundStyle(textColor.opacity(0.6))
.padding(.trailing, 4)
}
}
.padding(.bottom, 1)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.overlay(alignment: .trailing) {
Rectangle().fill(Color(.separator)).frame(width: 0.5)
Rectangle().fill(lineColor.opacity(0.4)).frame(width: 0.5)
}
.overlay(alignment: .top) {
Rectangle()
.fill(edge == .topHighlight ? dividerColor : lineColor.opacity(0.3))
.frame(height: edge == .topHighlight ? 1.5 : 0.5)
}
.overlay(alignment: .bottom) {
Rectangle().fill(Color(.separator)).frame(height: 0.5)
if edge == .bottomHighlight {
Rectangle().fill(dividerColor).frame(height: 1.5)
}
}
.contentShape(Rectangle())
.contextMenu {
Button { onCreateEvent() } label: {
Label(L10n.t("cal.new_event", language), systemImage: "plus")
}
Button { onShowWeek() } label: {
Label(L10n.t("cal.show_in_week_view", language),
systemImage: "calendar.day.timeline.leading")
}
Button { onShowDay() } label: {
Label(L10n.t("cal.show_in_day_view", language), systemImage: "sun.max")
}
}
}
}
private struct EventChip: View {
// MARK: Event Bar
private struct EventBar: View {
let event: CalEvent
var body: some View {
HStack(spacing: 3) {
if !event.isAllDay {
Circle()
.fill(Color(hex: event.effectiveColor))
.frame(width: 6, height: 6)
}
Text(event.title)
.font(.system(size: 10, weight: .medium))
.lineLimit(1)
.foregroundStyle(event.isAllDay ? .white : .primary)
.foregroundStyle(.white)
.padding(.leading, 4)
Spacer(minLength: 0)
}
.padding(.horizontal, event.isAllDay ? 4 : 2)
.padding(.vertical, 1)
.frame(maxWidth: .infinity, alignment: .leading)
.background(event.isAllDay ? Color(hex: event.effectiveColor) : Color.clear)
.background(Color(hex: event.effectiveColor))
.clipShape(RoundedRectangle(cornerRadius: 3))
.padding(.horizontal, 2)
}
}

View File

@@ -3,7 +3,14 @@ import SwiftUI
struct WeekView: View {
let store: CalendarStore
let onEventTap: (CalEvent) -> Void
let onTimeTap: (Date) -> Void
let onCreateEvent: (Date) -> Void
let onShowMonth: (Date) -> Void
let onShowDay: (Date) -> Void
@AppStorage("appLanguage") private var appLang = "system"
@AppStorage("todayColor") private var todayHex = "#4285f4"
@AppStorage("textColor") private var textHex = "#FFFFFF"
@AppStorage("lineColor") private var lineHex = "#3A3A3C"
private var cal: Calendar { store.userCalendar }
@@ -49,10 +56,10 @@ struct WeekView: View {
ForEach(weekDays, id: \.self) { day in
Text(headerFmt.string(from: day).uppercased())
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(cal.isDateInToday(day) ? Color.accentColor : .secondary)
.foregroundStyle(cal.isDateInToday(day) ? Color.accentColor : Color(hex: textHex).opacity(0.7))
.frame(maxWidth: .infinity, minHeight: 36)
.overlay(alignment: .trailing) {
Rectangle().fill(Color(.separator)).frame(width: 0.5)
Rectangle().fill(Color(hex: lineHex)).frame(width: 0.5)
}
}
}
@@ -104,28 +111,23 @@ struct WeekView: View {
ScrollViewReader { proxy in
ScrollView {
ZStack(alignment: .topLeading) {
// Background: time labels + vertical grid lines
// Background: time labels + per-hour cells per day
HStack(alignment: .top, spacing: 0) {
timeLabels
ForEach(Array(weekDays.enumerated()), id: \.offset) { _, day in
VStack(spacing: 0) {
ForEach(hours, id: \.self) { _ in
Rectangle()
.fill(Color(.separator).opacity(0.4))
.frame(height: 0.5)
Color.clear.frame(height: hourHeight - 0.5)
ForEach(hours, id: \.self) { hour in
HourSlot(day: day, hour: hour,
hourHeight: hourHeight,
language: appLang,
onCreateEvent: onCreateEvent,
onShowMonth: onShowMonth,
onShowDay: onShowDay)
}
}
.frame(width: colW)
.contentShape(Rectangle())
.onTapGesture { loc in
let h = Int(loc.y / hourHeight)
let m = Int((loc.y.truncatingRemainder(dividingBy: hourHeight)) / hourHeight * 60)
let date = cal.date(bySettingHour: h, minute: m, second: 0, of: day) ?? day
onTimeTap(date)
}
.overlay(alignment: .trailing) {
Rectangle().fill(Color(.separator)).frame(width: 0.5)
Rectangle().fill(Color(hex: lineHex)).frame(width: 0.5)
}
}
}
@@ -144,10 +146,11 @@ struct WeekView: View {
// Current time line
if let ti = todayIndex {
let lineY = eventTop(Date.now)
let nowColor = Color(hex: todayHex)
HStack(spacing: 0) {
Spacer().frame(width: timeColumnWidth + CGFloat(ti) * colW - 4)
Circle().fill(Color.red).frame(width: 8, height: 8)
Rectangle().fill(Color.red).frame(width: colW - 4, height: 1.5)
Circle().fill(nowColor).frame(width: 8, height: 8)
Rectangle().fill(nowColor).frame(width: colW - 4, height: 1.5)
}
.offset(y: lineY - 0.75)
}
@@ -168,7 +171,7 @@ struct WeekView: View {
Color.clear.frame(height: hourHeight)
Text(String(format: "%02d:00", h))
.font(.system(size: 10))
.foregroundStyle(.secondary)
.foregroundStyle(Color(hex: textHex).opacity(0.6))
.offset(y: -6)
}
}
@@ -194,3 +197,39 @@ private func eventTop(_ date: Date) -> CGFloat {
let m = CGFloat(cal.component(.minute, from: date))
return h * hourHeight + m * hourHeight / 60
}
// One-hour slot with native long-press context menu.
struct HourSlot: View {
let day: Date
let hour: Int
let hourHeight: CGFloat
let language: String
let onCreateEvent: (Date) -> Void
let onShowMonth: (Date) -> Void
let onShowDay: (Date) -> Void
@AppStorage("lineColor") private var lineHex = "#3A3A3C"
private var date: Date {
Calendar.current.date(bySettingHour: hour, minute: 0, second: 0, of: day) ?? day
}
var body: some View {
VStack(spacing: 0) {
Rectangle().fill(Color(hex: lineHex).opacity(0.4)).frame(height: 0.5)
Color.clear.frame(height: hourHeight - 0.5)
}
.contentShape(Rectangle())
.contextMenu {
Button { onCreateEvent(date) } label: {
Label(L10n.t("cal.new_event", language), systemImage: "plus")
}
Button { onShowMonth(date) } label: {
Label(L10n.t("cal.show_in_month_view", language), systemImage: "calendar")
}
Button { onShowDay(date) } label: {
Label(L10n.t("cal.show_in_day_view", language), systemImage: "sun.max")
}
}
}
}

View File

@@ -4,6 +4,7 @@ struct MenuSheet: View {
let api: CalendarrAPI
@Environment(AppState.self) var appState
@Environment(\.dismiss) var dismiss
@AppStorage("appLanguage") private var appLang = "system"
var body: some View {
NavigationStack {
@@ -30,7 +31,7 @@ struct MenuSheet: View {
}
if appState.isAdmin {
Spacer()
Text("Admin")
Text(L10n.t("menu.admin", appLang))
.font(.caption2.weight(.semibold))
.padding(.horizontal, 8).padding(.vertical, 3)
.background(Color.accentColor.opacity(0.15))
@@ -41,30 +42,29 @@ struct MenuSheet: View {
.padding(.vertical, 4)
}
// Navigation links direct destination syntax (no value-based nav)
Section("Einstellungen") {
Section(L10n.t("menu.section.settings", appLang)) {
NavigationLink {
ProfileView(api: api)
} label: {
Label("Profil", systemImage: "person.circle")
Label(L10n.t("menu.profile", appLang), systemImage: "person.circle")
}
NavigationLink {
SettingsView(api: api)
} label: {
Label("Darstellung", systemImage: "paintpalette")
Label(L10n.t("menu.appearance", appLang), systemImage: "paintpalette")
}
NavigationLink {
AccountsView(api: api)
} label: {
Label("Konten & Kalender", systemImage: "tray.2")
Label(L10n.t("menu.accounts", appLang), systemImage: "tray.2")
}
NavigationLink {
ServerView()
} label: {
Label("Server", systemImage: "server.rack")
Label(L10n.t("menu.server", appLang), systemImage: "server.rack")
}
}
@@ -75,15 +75,15 @@ struct MenuSheet: View {
appState.logout()
}
} label: {
Label("Abmelden", systemImage: "rectangle.portrait.and.arrow.right")
Label(L10n.t("menu.logout", appLang), systemImage: "rectangle.portrait.and.arrow.right")
}
}
}
.navigationTitle("Menü")
.navigationTitle(L10n.t("nav.menu", appLang))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button("Fertig") { dismiss() }
Button(L10n.t("nav.done", appLang)) { dismiss() }
}
}
}

View File

@@ -21,11 +21,13 @@ struct ProfileView: View {
@State private var disablePW = ""
@State private var isSaving2FA = false
@AppStorage("appLanguage") private var appLang = "system"
var body: some View {
NavigationStack {
Group {
if isLoading {
ProgressView("Lade Profil…")
ProgressView(L10n.t("profile.loading", appLang))
} else if let profile {
Form {
kontoSection(profile: profile)
@@ -34,7 +36,7 @@ struct ProfileView: View {
}
}
}
.navigationTitle("Profil")
.navigationTitle(L10n.t("profile.title", appLang))
.navigationBarTitleDisplayMode(.large)
.overlay(alignment: .bottom) {
if showToast {
@@ -68,29 +70,31 @@ struct ProfileView: View {
}
func kontoSection(profile: UserProfile) -> some View {
Section("Konto") {
Section(L10n.t("profile.account", appLang)) {
HStack {
Text("Benutzername")
Text(L10n.t("profile.username", appLang))
Spacer()
Text(profile.username)
.foregroundStyle(.secondary)
}
HStack {
Text("Rolle")
Text(L10n.t("profile.role", appLang))
Spacer()
Text(profile.isAdmin ? "Administrator" : "Benutzer")
Text(profile.isAdmin
? L10n.t("profile.role.admin", appLang)
: L10n.t("profile.role.user", appLang))
.foregroundStyle(.secondary)
}
HStack {
Text("E-Mail")
Text(L10n.t("profile.email", appLang))
Spacer()
TextField("Keine E-Mail", text: $newEmail)
TextField(L10n.t("profile.no_email", appLang), text: $newEmail)
.multilineTextAlignment(.trailing)
.foregroundStyle(.secondary)
.keyboardType(.emailAddress)
.textInputAutocapitalization(.never)
}
Button("E-Mail speichern") {
Button(L10n.t("profile.save_email", appLang)) {
Task { await saveEmail() }
}
.foregroundStyle(Color.accentColor)
@@ -98,11 +102,11 @@ struct ProfileView: View {
}
var passwordSection: some View {
Section("Passwort ändern") {
SecureField("Aktuelles Passwort", text: $currentPW)
SecureField("Neues Passwort", text: $newPW)
SecureField("Neues Passwort wiederholen", text: $confirmPW)
Button("Passwort ändern") {
Section(L10n.t("profile.change_password", appLang)) {
SecureField(L10n.t("profile.current_password", appLang), text: $currentPW)
SecureField(L10n.t("profile.new_password", appLang), text: $newPW)
SecureField(L10n.t("profile.new_password_repeat", appLang), text: $confirmPW)
Button(L10n.t("profile.change_password", appLang)) {
Task { await changePassword() }
}
.foregroundStyle(Color.accentColor)
@@ -111,14 +115,14 @@ struct ProfileView: View {
}
func twoFASection(profile: UserProfile) -> some View {
Section("Zwei-Faktor-Authentifizierung") {
Section(L10n.t("profile.twofa", appLang)) {
if profile.totpEnabled {
HStack {
Image(systemName: "checkmark.shield.fill")
.foregroundStyle(.green)
Text("2FA ist aktiviert")
Text(L10n.t("profile.twofa.active", appLang))
}
Button("2FA deaktivieren") {
Button(L10n.t("profile.twofa.disable", appLang)) {
show2FADisable = true
}
.foregroundStyle(.red)
@@ -126,10 +130,10 @@ struct ProfileView: View {
HStack {
Image(systemName: "shield")
.foregroundStyle(.secondary)
Text("2FA ist deaktiviert")
Text(L10n.t("profile.twofa.inactive", appLang))
.foregroundStyle(.secondary)
}
Button("2FA einrichten") {
Button(L10n.t("profile.twofa.enable", appLang)) {
Task { await setup2FA() }
}
.foregroundStyle(Color.accentColor)
@@ -149,7 +153,7 @@ struct ProfileView: View {
private func saveEmail() async {
do {
try await api.updateEmail(newEmail)
showNotice("E-Mail gespeichert")
showNotice(L10n.t("profile.email_saved", appLang))
} catch {
showNotice(error.localizedDescription)
}
@@ -157,13 +161,13 @@ struct ProfileView: View {
private func changePassword() async {
guard newPW == confirmPW else {
showNotice("Passwörter stimmen nicht überein")
showNotice(L10n.t("profile.password_mismatch", appLang))
return
}
do {
try await api.changePassword(current: currentPW, new: newPW)
currentPW = ""; newPW = ""; confirmPW = ""
showNotice("Passwort geändert")
showNotice(L10n.t("profile.password_changed", appLang))
} catch {
showNotice(error.localizedDescription)
}
@@ -186,7 +190,7 @@ struct ProfileView: View {
do {
try await api.enable2FA(code: totpCode)
show2FASetup = false
showNotice("2FA aktiviert")
showNotice(L10n.t("profile.twofa.enabled_toast", appLang))
await load()
} catch {
showNotice(error.localizedDescription)
@@ -198,7 +202,7 @@ struct ProfileView: View {
do {
try await api.disable2FA(password: disablePW)
show2FADisable = false
showNotice("2FA deaktiviert")
showNotice(L10n.t("profile.twofa.disabled_toast", appLang))
await load()
} catch {
showNotice(error.localizedDescription)
@@ -222,15 +226,16 @@ struct TwoFASetupSheet: View {
let isSaving: Bool
let onEnable: () -> Void
@Environment(\.dismiss) var dismiss
@AppStorage("appLanguage") private var appLang = "system"
var body: some View {
NavigationStack {
Form {
Section {
Text("Scanne den QR-Code mit deiner Authenticator-App (z.B. Bitwarden, Google Authenticator).")
Text(L10n.t("twofa.scan_hint", appLang))
.font(.body)
}
Section("QR-Code / Manueller Schlüssel") {
Section(L10n.t("twofa.qr_section", appLang)) {
if let url = URL(string: qrURL) {
AsyncImage(url: url) { phase in
switch phase {
@@ -259,17 +264,17 @@ struct TwoFASetupSheet: View {
.foregroundStyle(Color.accentColor)
}
}
Section("Bestätigung") {
TextField("6-stelliger Code", text: $code)
Section(L10n.t("twofa.confirmation", appLang)) {
TextField(L10n.t("twofa.code_placeholder", appLang), text: $code)
.keyboardType(.numberPad)
}
}
.navigationTitle("2FA einrichten")
.navigationTitle(L10n.t("twofa.setup_title", appLang))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) { Button("Abbrechen") { dismiss() } }
ToolbarItem(placement: .cancellationAction) { Button(L10n.t("common.cancel", appLang)) { dismiss() } }
ToolbarItem(placement: .primaryAction) {
Button("Aktivieren") { onEnable() }
Button(L10n.t("twofa.activate", appLang)) { onEnable() }
.bold()
.disabled(code.count < 6 || isSaving)
}
@@ -282,20 +287,21 @@ struct TwoFADisableSheet: View {
@Binding var password: String
let onDisable: () -> Void
@Environment(\.dismiss) var dismiss
@AppStorage("appLanguage") private var appLang = "system"
var body: some View {
NavigationStack {
Form {
Section("Passwort zum Deaktivieren") {
SecureField("Passwort", text: $password)
Section(L10n.t("twofa.password_section", appLang)) {
SecureField(L10n.t("twofa.password_placeholder", appLang), text: $password)
}
}
.navigationTitle("2FA deaktivieren")
.navigationTitle(L10n.t("twofa.disable_title", appLang))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) { Button("Abbrechen") { dismiss() } }
ToolbarItem(placement: .cancellationAction) { Button(L10n.t("common.cancel", appLang)) { dismiss() } }
ToolbarItem(placement: .primaryAction) {
Button("Deaktivieren") { onDisable() }
Button(L10n.t("twofa.disable", appLang)) { onDisable() }
.bold()
.foregroundStyle(.red)
.disabled(password.isEmpty)

View File

@@ -5,6 +5,7 @@ struct ServerView: View {
@State private var showLogoutConfirm = false
@State private var showChangeServer = false
@State private var showImpressum = false
@AppStorage("appLanguage") private var appLang = "system"
var serverHost: String {
appState.serverURL
@@ -15,7 +16,7 @@ struct ServerView: View {
var body: some View {
NavigationStack {
Form {
Section("Verbundener Server") {
Section(L10n.t("server.connected", appLang)) {
HStack {
Image(systemName: "server.rack")
.foregroundStyle(Color.accentColor)
@@ -36,7 +37,7 @@ struct ServerView: View {
Text(appState.username)
if appState.isAdmin {
Spacer()
Text("Admin")
Text(L10n.t("menu.admin", appLang))
.font(.caption.weight(.semibold))
.padding(.horizontal, 8)
.padding(.vertical, 3)
@@ -54,7 +55,7 @@ struct ServerView: View {
HStack {
Image(systemName: "arrow.triangle.swap")
.frame(width: 28)
Text("Server wechseln")
Text(L10n.t("server.switch", appLang))
}
}
.foregroundStyle(.primary)
@@ -65,48 +66,48 @@ struct ServerView: View {
HStack {
Image(systemName: "rectangle.portrait.and.arrow.right")
.frame(width: 28)
Text("Abmelden")
Text(L10n.t("menu.logout", appLang))
}
}
}
Section("Info") {
Section(L10n.t("server.info", appLang)) {
Button {
showImpressum = true
} label: {
HStack {
Image(systemName: "info.circle")
.frame(width: 28)
Text("Impressum")
Text(L10n.t("server.imprint", appLang))
}
}
.foregroundStyle(.secondary)
HStack {
Text("Version")
Text(L10n.t("server.version", appLang))
Spacer()
Text("1.0")
.foregroundStyle(.secondary)
}
}
}
.navigationTitle("Server")
.navigationTitle(L10n.t("server.title", appLang))
.navigationBarTitleDisplayMode(.large)
.confirmationDialog("Abmelden", isPresented: $showLogoutConfirm, titleVisibility: .visible) {
Button("Abmelden", role: .destructive) {
.confirmationDialog(L10n.t("server.logout_title", appLang), isPresented: $showLogoutConfirm, titleVisibility: .visible) {
Button(L10n.t("menu.logout", appLang), role: .destructive) {
appState.logout()
}
Button("Abbrechen", role: .cancel) {}
Button(L10n.t("common.cancel", appLang), role: .cancel) {}
} message: {
Text("Du wirst von \(serverHost) abgemeldet.")
Text(String(format: L10n.t("server.logout_msg", appLang), serverHost))
}
.confirmationDialog("Server wechseln", isPresented: $showChangeServer, titleVisibility: .visible) {
Button("Server wechseln", role: .destructive) {
.confirmationDialog(L10n.t("server.switch", appLang), isPresented: $showChangeServer, titleVisibility: .visible) {
Button(L10n.t("server.switch", appLang), role: .destructive) {
appState.resetServer()
}
Button("Abbrechen", role: .cancel) {}
Button(L10n.t("common.cancel", appLang), role: .cancel) {}
} message: {
Text("Verbindung zu \(serverHost) wird getrennt und alle lokalen Anmeldedaten werden gelöscht.")
Text(String(format: L10n.t("server.switch_msg", appLang), serverHost))
}
.sheet(isPresented: $showImpressum) {
ImpressumView()
@@ -117,34 +118,35 @@ struct ServerView: View {
struct ImpressumView: View {
@Environment(\.dismiss) var dismiss
@AppStorage("appLanguage") private var appLang = "system"
var body: some View {
NavigationStack {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
Group {
Text("Scarriffleservices")
Text(L10n.t("imprint.company", appLang))
.font(.title2.bold())
Text("Software & Webentwicklung")
Text(L10n.t("imprint.role", appLang))
.foregroundStyle(.secondary)
}
Divider()
Text("Diese Software wurde von Scarriffleservices mit grösster Sorgfalt entwickelt und bereitgestellt. Alle Rechte vorbehalten © 2026 Scarriffleservices.")
Text(L10n.t("imprint.copyright", appLang))
VStack(alignment: .leading, spacing: 6) {
Text("Datenspeicherung").font(.headline)
Text("Alle Anwendungsdaten werden auf dem Server gespeichert und verarbeitet, auf dem diese Calendarr-Instanz betrieben wird. Der Speicherort hängt damit vom Betreiber des jeweiligen Servers ab. Bei Nutzung der Google Kalender-Anbindung werden Daten über die Google API ausgetauscht; für diese Daten gelten die Datenschutzbestimmungen von Google. Bei Nutzung der Home Assistant-Anbindung werden Daten mit der jeweiligen Home Assistant-Instanz ausgetauscht. Home Assistant ist ein Projekt der Open Home Foundation.")
Text(L10n.t("imprint.storage.title", appLang)).font(.headline)
Text(L10n.t("imprint.storage.body", appLang))
}
VStack(alignment: .leading, spacing: 6) {
Text("Haftungsausschluss").font(.headline)
Text("Trotz sorgfältiger Erstellung wird keine Haftung für die Richtigkeit, Vollständigkeit oder Aktualität der bereitgestellten Inhalte übernommen. Die Nutzung erfolgt auf eigene Verantwortung.")
Text(L10n.t("imprint.disclaimer.title", appLang)).font(.headline)
Text(L10n.t("imprint.disclaimer.body", appLang))
}
VStack(alignment: .leading, spacing: 6) {
Text("Kontakt").font(.headline)
Text(L10n.t("imprint.contact.title", appLang)).font(.headline)
Link("scarriffleservices@gmail.com", destination: URL(string: "mailto:scarriffleservices@gmail.com")!)
}
@@ -156,11 +158,11 @@ struct ImpressumView: View {
}
.padding(24)
}
.navigationTitle("Impressum")
.navigationTitle(L10n.t("server.imprint", appLang))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button("Schliessen") { dismiss() }
Button(L10n.t("common.close", appLang)) { dismiss() }
}
}
}

View File

@@ -7,14 +7,21 @@ struct SettingsView: View {
@State private var isSaving = false
@State private var toast = ""
@State private var showToast = false
@AppStorage("liquidGlass") private var liquidGlass = false
@AppStorage("cacheMonths") private var cacheMonths = 3
@AppStorage("liquidGlass") private var liquidGlass = false
@AppStorage("cacheMonths") private var cacheMonths = 3
@AppStorage("appLanguage") private var appLang = "system"
@AppStorage("monthDividerColor") private var dividerHex = "#7090c0"
@AppStorage("monthLabelColor") private var labelHex = "#7090c0"
@AppStorage("todayColor") private var todayHex = "#4285f4"
@AppStorage("textColor") private var textHex = "#FFFFFF"
@AppStorage("backgroundColor") private var bgHex = "#000000"
@AppStorage("lineColor") private var lineHex = "#3A3A3C"
var body: some View {
NavigationStack {
Group {
if isLoading {
ProgressView("Lade Einstellungen…")
ProgressView(L10n.t("settings.loading", appLang))
} else {
Form {
liquidGlassSection
@@ -28,7 +35,7 @@ struct SettingsView: View {
}
}
}
.navigationTitle("Darstellung")
.navigationTitle(L10n.t("settings.title", appLang))
.navigationBarTitleDisplayMode(.large)
.toolbar {
ToolbarItem(placement: .primaryAction) {
@@ -38,7 +45,7 @@ struct SettingsView: View {
if isSaving {
ProgressView()
} else {
Text("Speichern").bold()
Text(L10n.t("settings.save", appLang)).bold()
}
}
.disabled(isSaving)
@@ -67,8 +74,8 @@ struct SettingsView: View {
Toggle(isOn: $liquidGlass) {
Label {
VStack(alignment: .leading, spacing: 2) {
Text("Liquid Glass")
Text("Verwendet die neue iOS\u{202F}26 Glasoptik mit transparenter Navigationsleiste")
Text(L10n.t("settings.liquidglass", appLang))
Text(L10n.t("settings.liquidglass.desc", appLang))
.font(.caption)
.foregroundStyle(.secondary)
}
@@ -79,9 +86,9 @@ struct SettingsView: View {
}
.tint(Color.accentColor)
} header: {
Text("App-Design")
Text(L10n.t("settings.appdesign", appLang))
} footer: {
Text("Änderung wirkt sofort kein Neustart nötig.")
Text(L10n.t("settings.liquidglass.footer", appLang))
.font(.caption)
}
}
@@ -93,8 +100,8 @@ struct SettingsView: View {
VStack(alignment: .leading, spacing: 10) {
Label {
VStack(alignment: .leading, spacing: 2) {
Text("Vorladen")
Text("Events werden beim Start im Hintergrund für diesen Zeitraum geladen, danach ist Wischen sofort.")
Text(L10n.t("settings.cache.title", appLang))
Text(L10n.t("settings.cache.desc", appLang))
.font(.caption)
.foregroundStyle(.secondary)
}
@@ -103,19 +110,19 @@ struct SettingsView: View {
.foregroundStyle(.green)
}
Picker("Zeitraum", selection: $cacheMonths) {
Text("±1 Monat").tag(1)
Text("±3 Monate").tag(3)
Text("±6 Monate").tag(6)
Text("±1 Jahr").tag(12)
Picker(L10n.t("settings.cache.range", appLang), selection: $cacheMonths) {
Text(L10n.t("settings.cache.1m", appLang)).tag(1)
Text(L10n.t("settings.cache.3m", appLang)).tag(3)
Text(L10n.t("settings.cache.6m", appLang)).tag(6)
Text(L10n.t("settings.cache.1y", appLang)).tag(12)
}
.pickerStyle(.segmented)
}
.padding(.vertical, 4)
} header: {
Text("Vorladen")
Text(L10n.t("settings.cache.header", appLang))
} footer: {
Text("Mehr Monate = längerer initialer Ladevorgang, danach komplett ohne Wartezeiten navigierbar.")
Text(L10n.t("settings.cache.footer", appLang))
.font(.caption)
}
}
@@ -123,10 +130,11 @@ struct SettingsView: View {
// MARK: Sprache
var spracheSection: some View {
Section("Sprache") {
Picker("Sprache", selection: $settings.language) {
Text("Deutsch").tag("de")
Text("English").tag("en")
Section(L10n.t("settings.language", appLang)) {
Picker(L10n.t("settings.language", appLang), selection: $appLang) {
Text(L10n.t("lang.system", appLang)).tag("system")
Text(L10n.t("lang.german", appLang)).tag("de")
Text(L10n.t("lang.english", appLang)).tag("en")
}
}
}
@@ -134,12 +142,15 @@ struct SettingsView: View {
// MARK: Farben
var farbenSection: some View {
Section("Farben") {
ColorPickerRow(label: "Primärfarbe", hex: $settings.primaryColor)
ColorPickerRow(label: "Akzentfarbe", hex: $settings.accentColor)
ColorPickerRow(label: "Heutige-Tag-Farbe", hex: $settings.todayColor)
ColorPickerRow(label: "Monatswechsel-Linie", hex: $settings.monthDividerColor)
ColorPickerRow(label: "Monatskürzel", hex: $settings.monthLabelColor)
Section(L10n.t("settings.colors", appLang)) {
ColorPickerRow(label: L10n.t("settings.color.primary", appLang), hex: $settings.primaryColor)
ColorPickerRow(label: L10n.t("settings.color.accent", appLang), hex: $settings.accentColor)
ColorPickerRow(label: L10n.t("settings.color.today", appLang), hex: $todayHex)
ColorPickerRow(label: L10n.t("settings.color.text", appLang), hex: $textHex)
ColorPickerRow(label: L10n.t("settings.color.background", appLang), hex: $bgHex)
ColorPickerRow(label: L10n.t("settings.color.line", appLang), hex: $lineHex)
ColorPickerRow(label: L10n.t("settings.color.divider", appLang), hex: $dividerHex)
ColorPickerRow(label: L10n.t("settings.color.label", appLang), hex: $labelHex)
}
}
@@ -148,18 +159,18 @@ struct SettingsView: View {
var schriftSection: some View {
Section {
VStack(alignment: .leading, spacing: 10) {
Text("Schriftkontrast")
Text(L10n.t("settings.textcontrast", appLang))
.font(.headline)
Text("Helligkeit der Beschriftungen und Texte")
Text(L10n.t("settings.textcontrast.desc", appLang))
.font(.caption)
.foregroundStyle(.secondary)
ContrastSelector(
value: $settings.textContrast,
options: [
(1, "Dunkel"),
(2, "Mittel"),
(3, "Hell"),
(4, "Maximum")
(1, L10n.t("settings.contrast.dark", appLang)),
(2, L10n.t("settings.contrast.medium", appLang)),
(3, L10n.t("settings.contrast.bright", appLang)),
(4, L10n.t("settings.contrast.max", appLang))
]
)
}
@@ -172,18 +183,18 @@ struct SettingsView: View {
var linienSection: some View {
Section {
VStack(alignment: .leading, spacing: 10) {
Text("Linienkontrast")
Text(L10n.t("settings.linecontrast", appLang))
.font(.headline)
Text("Sichtbarkeit von Trennlinien und Rahmen")
Text(L10n.t("settings.linecontrast.desc", appLang))
.font(.caption)
.foregroundStyle(.secondary)
ContrastSelector(
value: $settings.lineContrast,
options: [
(1, "Kaum"),
(2, "Subtil"),
(3, "Normal"),
(4, "Stark")
(1, L10n.t("settings.linecontrast.barely", appLang)),
(2, L10n.t("settings.linecontrast.subtle", appLang)),
(3, L10n.t("settings.linecontrast.normal", appLang)),
(4, L10n.t("settings.linecontrast.strong", appLang))
]
)
}
@@ -194,19 +205,19 @@ struct SettingsView: View {
// MARK: Ansicht
var ansichtSection: some View {
Section("Kalenderansicht") {
Picker("Standardansicht", selection: $settings.defaultView) {
Text("Monat").tag("month")
Text("Woche").tag("week")
Text("Tag").tag("day")
Text("Quartal").tag("quarter")
Text("Termine").tag("agenda")
Section(L10n.t("settings.calview", appLang)) {
Picker(L10n.t("settings.defaultview", appLang), selection: $settings.defaultView) {
Text(L10n.t("view.month", appLang)).tag("month")
Text(L10n.t("view.week", appLang)).tag("week")
Text(L10n.t("view.day", appLang)).tag("day")
Text(L10n.t("view.quarter", appLang)).tag("quarter")
Text(L10n.t("view.agenda", appLang)).tag("agenda")
}
Picker("Erster Wochentag", selection: $settings.weekStartDay) {
Text("Montag").tag("monday")
Text("Sonntag").tag("sunday")
Picker(L10n.t("settings.firstweekday", appLang), selection: $settings.weekStartDay) {
Text(L10n.t("settings.monday", appLang)).tag("monday")
Text(L10n.t("settings.sunday", appLang)).tag("sunday")
}
Toggle("Vergangene Termine ausgrauen", isOn: $settings.dimPastEvents)
Toggle(L10n.t("settings.dimpast", appLang), isOn: $settings.dimPastEvents)
.tint(Color.accentColor)
}
}
@@ -216,18 +227,18 @@ struct SettingsView: View {
var stundenSection: some View {
Section {
VStack(alignment: .leading, spacing: 10) {
Text("Stundenhöhe")
Text(L10n.t("settings.hourheight", appLang))
.font(.headline)
Text("Platz pro Stunde in der Wochen- & Tagesansicht")
Text(L10n.t("settings.hourheight.desc", appLang))
.font(.caption)
.foregroundStyle(.secondary)
ContrastSelector(
value: $settings.hourHeight,
options: [
(28, "Kompakt"),
(44, "Normal"),
(60, "Komfort"),
(80, "Gross")
(28, L10n.t("settings.hourheight.compact", appLang)),
(44, L10n.t("settings.hourheight.normal", appLang)),
(60, L10n.t("settings.hourheight.comfort", appLang)),
(80, L10n.t("settings.hourheight.large", appLang))
]
)
}
@@ -240,15 +251,31 @@ struct SettingsView: View {
private func load() async {
isLoading = true
defer { isLoading = false }
if let s = try? await api.getSettings() { settings = s }
if let s = try? await api.getSettings() {
settings = s
// Mirror server-side color settings so calendar views (which read AppStorage) see them.
dividerHex = s.monthDividerColor
labelHex = s.monthLabelColor
todayHex = s.todayColor
textHex = s.textColor
bgHex = s.backgroundColor
lineHex = s.lineColor
}
}
private func save() async {
isSaving = true
defer { isSaving = false }
// Push local AppStorage colors back into the settings struct before saving.
settings.monthDividerColor = dividerHex
settings.monthLabelColor = labelHex
settings.todayColor = todayHex
settings.textColor = textHex
settings.backgroundColor = bgHex
settings.lineColor = lineHex
do {
try await api.updateSettings(settings)
showNotice("Gespeichert")
showNotice(L10n.t("settings.saved", appLang))
} catch {
showNotice(error.localizedDescription)
}

View File

@@ -0,0 +1,18 @@
//
// Calendarr_iOSTests.swift
// Calendarr iOSTests
//
// Created by Guido Schmit on 17.05.2026.
//
import Testing
struct Calendarr_iOSTests {
@Test func example() async throws {
// Write your test here and use APIs like `#expect(...)` to check expected conditions.
// Swift Testing Documentation
// https://developer.apple.com/documentation/testing
}
}