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:
@@ -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 = (
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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 |
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
520
Calendarr iOS/Models/Localization.swift
Normal file
520
Calendarr iOS/Models/Localization.swift
Normal 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"
|
||||
]
|
||||
]
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
18
Calendarr iOSTests/Calendarr_iOSTests.swift
Normal file
18
Calendarr iOSTests/Calendarr_iOSTests.swift
Normal 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
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user