Compare commits
29 Commits
e5529ca653
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59a879ea23 | ||
|
|
f480b438cb | ||
|
|
587a0e65fa | ||
|
|
e7d8effb47 | ||
|
|
68349d36e5 | ||
|
|
451d3d4d6b | ||
|
|
51218b9aa3 | ||
|
|
b61a90d960 | ||
|
|
b9547c15f9 | ||
|
|
8521a28520 | ||
|
|
7f76df2600 | ||
|
|
852e46fcf8 | ||
|
|
a62b200dfa | ||
|
|
c6f9981a54 | ||
|
|
815f2cf01a | ||
|
|
6dc8724a9a | ||
|
|
c9803d80a3 | ||
|
|
9fac13f99c | ||
|
|
da2e39911c | ||
|
|
023f90be3b | ||
|
|
e7e4998fb9 | ||
|
|
b1e0cf1fdc | ||
|
|
e71fd7512f | ||
|
|
4125bfc728 | ||
|
|
07a9e9eb7f | ||
|
|
1395aaa0c0 | ||
|
|
6c506770ba | ||
|
|
d1004a9111 | ||
|
|
8b3cc11e25 |
@@ -6,19 +6,91 @@
|
||||
objectVersion = 77;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
D0001A0CABCDEF0100AB5001 /* CalendarrWidgets.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = D0001A02ABCDEF0100AB5001 /* CalendarrWidgets.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
3927C7C02FB99D0F00EAD8ED /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = C0000301FB4E10100AB5001 /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = C0000A01FB4E10100AB5001;
|
||||
remoteInfo = "Calendarr iOS";
|
||||
};
|
||||
D0001A0DABCDEF0100AB5001 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = C0000301FB4E10100AB5001 /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = D0001A01ABCDEF0100AB5001;
|
||||
remoteInfo = CalendarrWidgets;
|
||||
};
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXCopyFilesBuildPhase section */
|
||||
D0001A0BABCDEF0100AB5001 /* Embed Foundation Extensions */ = {
|
||||
isa = PBXCopyFilesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
dstPath = "";
|
||||
dstSubfolderSpec = 13;
|
||||
files = (
|
||||
D0001A0CABCDEF0100AB5001 /* CalendarrWidgets.appex in Embed Foundation Extensions */,
|
||||
);
|
||||
name = "Embed Foundation Extensions";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXCopyFilesBuildPhase 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; };
|
||||
D0001A02ABCDEF0100AB5001 /* CalendarrWidgets.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = CalendarrWidgets.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
D0001A0FABCDEF0100AB5001 /* Exceptions for "CalendarrWidgets" folder in "CalendarrWidgets" target */ = {
|
||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||
membershipExceptions = (
|
||||
Info.plist,
|
||||
);
|
||||
target = D0001A01ABCDEF0100AB5001 /* CalendarrWidgets */;
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
3927C7BD2FB99D0F00EAD8ED /* Calendarr iOSTests */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
path = "Calendarr iOSTests";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
C0000D01FC4E10100AB5001 /* Calendarr iOS */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
path = "Calendarr iOS";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
D0001A03ABCDEF0100AB5001 /* CalendarrWidgets */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
D0001A0FABCDEF0100AB5001 /* Exceptions for "CalendarrWidgets" folder in "CalendarrWidgets" target */,
|
||||
);
|
||||
path = CalendarrWidgets;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
D0001A04ABCDEF0100AB5001 /* Shared */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
path = Shared;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
3927C7B92FB99D0E00EAD8ED /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
C0000801FB4E10100AB5001 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
@@ -26,6 +98,13 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
D0001A06ABCDEF0100AB5001 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
@@ -33,6 +112,9 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
C0000D01FC4E10100AB5001 /* Calendarr iOS */,
|
||||
D0001A03ABCDEF0100AB5001 /* CalendarrWidgets */,
|
||||
D0001A04ABCDEF0100AB5001 /* Shared */,
|
||||
3927C7BD2FB99D0F00EAD8ED /* Calendarr iOSTests */,
|
||||
C0000C01FB4E10100AB5001 /* Products */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
@@ -41,6 +123,8 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
C0000B01FC4E10100AB5001 /* Calendarr iOS.app */,
|
||||
3927C7BC2FB99D0E00EAD8ED /* Calendarr iOSTests.xctest */,
|
||||
D0001A02ABCDEF0100AB5001 /* CalendarrWidgets.appex */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
@@ -48,6 +132,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" */;
|
||||
@@ -55,13 +162,16 @@
|
||||
C0000701FB4E10100AB5001 /* Sources */,
|
||||
C0000801FB4E10100AB5001 /* Frameworks */,
|
||||
C0000901FB4E10100AB5001 /* Resources */,
|
||||
D0001A0BABCDEF0100AB5001 /* Embed Foundation Extensions */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
D0001A0EABCDEF0100AB5001 /* PBXTargetDependency */,
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
C0000D01FC4E10100AB5001 /* Calendarr iOS */,
|
||||
D0001A04ABCDEF0100AB5001 /* Shared */,
|
||||
);
|
||||
name = "Calendarr iOS";
|
||||
packageProductDependencies = (
|
||||
@@ -70,6 +180,29 @@
|
||||
productReference = C0000B01FC4E10100AB5001 /* Calendarr iOS.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
D0001A01ABCDEF0100AB5001 /* CalendarrWidgets */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = D0001A08ABCDEF0100AB5001 /* Build configuration list for PBXNativeTarget "CalendarrWidgets" */;
|
||||
buildPhases = (
|
||||
D0001A05ABCDEF0100AB5001 /* Sources */,
|
||||
D0001A06ABCDEF0100AB5001 /* Frameworks */,
|
||||
D0001A07ABCDEF0100AB5001 /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
D0001A03ABCDEF0100AB5001 /* CalendarrWidgets */,
|
||||
D0001A04ABCDEF0100AB5001 /* Shared */,
|
||||
);
|
||||
name = CalendarrWidgets;
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = CalendarrWidgets;
|
||||
productReference = D0001A02ABCDEF0100AB5001 /* CalendarrWidgets.appex */;
|
||||
productType = "com.apple.product-type.app-extension";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
@@ -77,12 +210,19 @@
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = 1;
|
||||
LastSwiftUpdateCheck = 2640;
|
||||
LastSwiftUpdateCheck = 2650;
|
||||
LastUpgradeCheck = 2640;
|
||||
TargetAttributes = {
|
||||
3927C7BB2FB99D0E00EAD8ED = {
|
||||
CreatedOnToolsVersion = 26.5;
|
||||
TestTargetID = C0000A01FB4E10100AB5001;
|
||||
};
|
||||
C0000A01FB4E10100AB5001 = {
|
||||
CreatedOnToolsVersion = 26.4.1;
|
||||
};
|
||||
D0001A01ABCDEF0100AB5001 = {
|
||||
CreatedOnToolsVersion = 26.5;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = C0000601FB4E10100AB5001 /* Build configuration list for PBXProject "Calendarr iOS" */;
|
||||
@@ -101,11 +241,20 @@
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
C0000A01FB4E10100AB5001 /* Calendarr iOS */,
|
||||
D0001A01ABCDEF0100AB5001 /* CalendarrWidgets */,
|
||||
3927C7BB2FB99D0E00EAD8ED /* Calendarr iOSTests */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
3927C7BA2FB99D0E00EAD8ED /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
C0000901FB4E10100AB5001 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
@@ -113,9 +262,23 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
D0001A07ABCDEF0100AB5001 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
3927C7B82FB99D0E00EAD8ED /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
C0000701FB4E10100AB5001 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
@@ -123,9 +286,83 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
D0001A05ABCDEF0100AB5001 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXTargetDependency section */
|
||||
3927C7C12FB99D0F00EAD8ED /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = C0000A01FB4E10100AB5001 /* Calendarr iOS */;
|
||||
targetProxy = 3927C7C02FB99D0F00EAD8ED /* PBXContainerItemProxy */;
|
||||
};
|
||||
D0001A0EABCDEF0100AB5001 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = D0001A01ABCDEF0100AB5001 /* CalendarrWidgets */;
|
||||
targetProxy = D0001A0DABCDEF0100AB5001 /* PBXContainerItemProxy */;
|
||||
};
|
||||
/* End PBXTargetDependency section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
3927C7C32FB99D0F00EAD8ED /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 2;
|
||||
DEVELOPMENT_TEAM = PP34X97WS3;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 26.5;
|
||||
MACOSX_DEPLOYMENT_TARGET = 26.5;
|
||||
MARKETING_VERSION = 1.0.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "com.local.scarriffle.Calendarr-iOSTests";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = auto;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
SUPPORTS_MACCATALYST = NO;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = 1;
|
||||
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 = 2;
|
||||
DEVELOPMENT_TEAM = PP34X97WS3;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 26.5;
|
||||
MACOSX_DEPLOYMENT_TARGET = 26.5;
|
||||
MARKETING_VERSION = 1.0.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "com.local.scarriffle.Calendarr-iOSTests";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = auto;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
SUPPORTS_MACCATALYST = NO;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = 1;
|
||||
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 = {
|
||||
@@ -252,31 +489,41 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = "Calendarr iOS/Calendarr iOS.entitlements";
|
||||
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_LSApplicationCategoryType = "public.app-category.utilities";
|
||||
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",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
MARKETING_VERSION = 2.0;
|
||||
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;
|
||||
};
|
||||
@@ -285,37 +532,125 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = "Calendarr iOS/Calendarr iOS.entitlements";
|
||||
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_LSApplicationCategoryType = "public.app-category.utilities";
|
||||
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",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
MARKETING_VERSION = 2.0;
|
||||
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;
|
||||
};
|
||||
D0001A09ABCDEF0100AB5001 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = CalendarrWidgets/CalendarrWidgets.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 2;
|
||||
DEVELOPMENT_TEAM = PP34X97WS3;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = CalendarrWidgets/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "Calendarr Widgets";
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "© 2026 Scarriffleservices";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios.CalendarrWidgets;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
SKIP_INSTALL = YES;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
SUPPORTS_MACCATALYST = NO;
|
||||
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = 1;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
D0001A0AABCDEF0100AB5001 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = CalendarrWidgets/CalendarrWidgets.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 2;
|
||||
DEVELOPMENT_TEAM = PP34X97WS3;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = CalendarrWidgets/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "Calendarr Widgets";
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "© 2026 Scarriffleservices";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios.CalendarrWidgets;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
SKIP_INSTALL = YES;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
SUPPORTS_MACCATALYST = NO;
|
||||
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
SWIFT_EMIT_LOC_STRINGS = 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 = (
|
||||
@@ -334,6 +669,15 @@
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
D0001A08ABCDEF0100AB5001 /* Build configuration list for PBXNativeTarget "CalendarrWidgets" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
D0001A09ABCDEF0100AB5001 /* Debug */,
|
||||
D0001A0AABCDEF0100AB5001 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
};
|
||||
rootObject = C0000301FB4E10100AB5001 /* Project object */;
|
||||
|
||||
@@ -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>
|
||||
@@ -9,6 +9,24 @@
|
||||
<key>orderHint</key>
|
||||
<integer>0</integer>
|
||||
</dict>
|
||||
<key>CalendarrWidgets.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>1</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 |
10
Calendarr iOS/Calendarr iOS.entitlements
Normal file
10
Calendarr iOS/Calendarr iOS.entitlements
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.com.scarriffleservices.calendarr</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -13,6 +13,12 @@ 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"
|
||||
var privateEventVisibility: String = "busy" // 'hidden' | 'busy'
|
||||
var groupVisibleCalendarId: Int? = nil
|
||||
var defaultReminderMinutes: Int? = nil // minutes before start; nil = off
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case defaultView = "default_view"
|
||||
@@ -27,6 +33,42 @@ 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"
|
||||
case privateEventVisibility = "private_event_visibility"
|
||||
case groupVisibleCalendarId = "group_visible_calendar_id"
|
||||
case defaultReminderMinutes = "default_reminder_minutes"
|
||||
}
|
||||
|
||||
init() {}
|
||||
|
||||
/// Resilient decoding: the server only stores a subset of these fields
|
||||
/// (e.g. it has no `text_color`/`background_color`/`line_color`, which are
|
||||
/// iOS-only). Using `decodeIfPresent` with the property defaults means a
|
||||
/// missing key no longer aborts the whole decode — otherwise the entire
|
||||
/// settings sync silently breaks.
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
let d = AppSettings()
|
||||
defaultView = try c.decodeIfPresent(String.self, forKey: .defaultView) ?? d.defaultView
|
||||
weekStartDay = try c.decodeIfPresent(String.self, forKey: .weekStartDay) ?? d.weekStartDay
|
||||
primaryColor = try c.decodeIfPresent(String.self, forKey: .primaryColor) ?? d.primaryColor
|
||||
accentColor = try c.decodeIfPresent(String.self, forKey: .accentColor) ?? d.accentColor
|
||||
todayColor = try c.decodeIfPresent(String.self, forKey: .todayColor) ?? d.todayColor
|
||||
dimPastEvents = try c.decodeIfPresent(Bool.self, forKey: .dimPastEvents) ?? d.dimPastEvents
|
||||
textContrast = try c.decodeIfPresent(Int.self, forKey: .textContrast) ?? d.textContrast
|
||||
lineContrast = try c.decodeIfPresent(Int.self, forKey: .lineContrast) ?? d.lineContrast
|
||||
hourHeight = try c.decodeIfPresent(Int.self, forKey: .hourHeight) ?? d.hourHeight
|
||||
language = try c.decodeIfPresent(String.self, forKey: .language) ?? d.language
|
||||
monthDividerColor = try c.decodeIfPresent(String.self, forKey: .monthDividerColor) ?? d.monthDividerColor
|
||||
monthLabelColor = try c.decodeIfPresent(String.self, forKey: .monthLabelColor) ?? d.monthLabelColor
|
||||
textColor = try c.decodeIfPresent(String.self, forKey: .textColor) ?? d.textColor
|
||||
backgroundColor = try c.decodeIfPresent(String.self, forKey: .backgroundColor) ?? d.backgroundColor
|
||||
lineColor = try c.decodeIfPresent(String.self, forKey: .lineColor) ?? d.lineColor
|
||||
privateEventVisibility = try c.decodeIfPresent(String.self, forKey: .privateEventVisibility) ?? d.privateEventVisibility
|
||||
groupVisibleCalendarId = try c.decodeIfPresent(Int.self, forKey: .groupVisibleCalendarId)
|
||||
defaultReminderMinutes = try c.decodeIfPresent(Int.self, forKey: .defaultReminderMinutes)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,6 +104,27 @@ struct LocalCalendar: Codable, Identifiable {
|
||||
var name: String
|
||||
var color: String
|
||||
var enabled: Bool
|
||||
var owned: Bool = true
|
||||
var sharedBy: String? = nil
|
||||
var permission: String? = nil
|
||||
var group: Bool = false
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, name, color, enabled, owned, permission, group
|
||||
case sharedBy = "shared_by"
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try c.decode(Int.self, forKey: .id)
|
||||
name = try c.decodeIfPresent(String.self, forKey: .name) ?? ""
|
||||
color = try c.decodeIfPresent(String.self, forKey: .color) ?? "#34a853"
|
||||
enabled = try c.decodeIfPresent(Bool.self, forKey: .enabled) ?? true
|
||||
owned = try c.decodeIfPresent(Bool.self, forKey: .owned) ?? true
|
||||
sharedBy = try c.decodeIfPresent(String.self, forKey: .sharedBy)
|
||||
permission = try c.decodeIfPresent(String.self, forKey: .permission)
|
||||
group = try c.decodeIfPresent(Bool.self, forKey: .group) ?? false
|
||||
}
|
||||
}
|
||||
|
||||
struct ICalSubscription: Codable, Identifiable {
|
||||
@@ -118,16 +181,29 @@ struct HACalendar: Codable, Identifiable {
|
||||
var entityId: String
|
||||
var color: String?
|
||||
var enabled: Bool
|
||||
var sidebarHidden: Bool
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, name, color, enabled
|
||||
case entityId = "entity_id"
|
||||
case sidebarHidden = "sidebar_hidden"
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try c.decode(Int.self, forKey: .id)
|
||||
name = try c.decode(String.self, forKey: .name)
|
||||
entityId = try c.decodeIfPresent(String.self, forKey: .entityId) ?? ""
|
||||
color = try c.decodeIfPresent(String.self, forKey: .color)
|
||||
enabled = try c.decodeIfPresent(Bool.self, forKey: .enabled) ?? true
|
||||
sidebarHidden = try c.decodeIfPresent(Bool.self, forKey: .sidebarHidden) ?? false
|
||||
}
|
||||
}
|
||||
|
||||
struct UserProfile: Codable {
|
||||
let id: Int
|
||||
let username: String
|
||||
var displayName: String?
|
||||
var email: String?
|
||||
let isAdmin: Bool
|
||||
let hasAvatar: Bool
|
||||
@@ -135,12 +211,61 @@ struct UserProfile: Codable {
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, username, email
|
||||
case displayName = "display_name"
|
||||
case isAdmin = "is_admin"
|
||||
case hasAvatar = "has_avatar"
|
||||
case totpEnabled = "totp_enabled"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Sharing & groups
|
||||
|
||||
struct DirectoryUser: Codable, Identifiable {
|
||||
let id: Int
|
||||
let displayName: String
|
||||
enum CodingKeys: String, CodingKey { case id; case displayName = "display_name" }
|
||||
}
|
||||
|
||||
struct CalendarShare: Codable, Identifiable {
|
||||
let userId: Int
|
||||
let displayName: String?
|
||||
var permission: String
|
||||
var id: Int { userId }
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case userId = "user_id"
|
||||
case displayName = "display_name"
|
||||
case permission
|
||||
}
|
||||
}
|
||||
|
||||
struct GroupMember: Codable, Identifiable {
|
||||
let id: Int
|
||||
let displayName: String?
|
||||
var role: String
|
||||
var color: String?
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, role, color
|
||||
case displayName = "display_name"
|
||||
}
|
||||
}
|
||||
|
||||
struct CalGroup: Codable, Identifiable {
|
||||
let id: Int
|
||||
var name: String
|
||||
var icon: String?
|
||||
var role: String?
|
||||
var memberCount: Int?
|
||||
var groupCalendarId: Int?
|
||||
var groupCalendarColor: String?
|
||||
var members: [GroupMember]?
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, name, icon, role, members
|
||||
case memberCount = "member_count"
|
||||
case groupCalendarId = "group_calendar_id"
|
||||
case groupCalendarColor = "group_calendar_color"
|
||||
}
|
||||
}
|
||||
|
||||
extension Color {
|
||||
init(hex: String) {
|
||||
let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
|
||||
|
||||
@@ -1,6 +1,23 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
/// Creator (or owner, in the group combined view) of an event.
|
||||
/// `id` is nil for imported events.
|
||||
struct EventPerson: Hashable {
|
||||
let id: Int?
|
||||
let displayName: String
|
||||
|
||||
static func from(_ json: Any?) -> EventPerson? {
|
||||
guard let obj = json as? [String: Any],
|
||||
let name = obj["display_name"] as? String, !name.isEmpty else { return nil }
|
||||
let id: Int?
|
||||
if let n = obj["id"] as? Int { id = n }
|
||||
else if let s = obj["id"] as? String { id = Int(s) }
|
||||
else { id = nil }
|
||||
return EventPerson(id: id, displayName: name)
|
||||
}
|
||||
}
|
||||
|
||||
struct CalEvent: Identifiable, Hashable {
|
||||
let id: String
|
||||
let url: String
|
||||
@@ -15,8 +32,20 @@ struct CalEvent: Identifiable, Hashable {
|
||||
var calendarName: String
|
||||
var calendarColor: String
|
||||
var source: String
|
||||
var creator: EventPerson? = nil
|
||||
var isPrivate: Bool = false
|
||||
// Only set in the group combined view:
|
||||
var owner: EventPerson? = nil
|
||||
var isGroupEvent: Bool = false
|
||||
var displayColor: String? = nil
|
||||
// Server-decorated title for the group combined view (group icon / owner
|
||||
// prefix); rendered in group mode while `title` stays raw for editing.
|
||||
var displayTitle: String? = nil
|
||||
// Reminder offsets in minutes-before-start (0 = at start). Local events only.
|
||||
var reminders: [Int] = []
|
||||
|
||||
var effectiveColor: String { color ?? calendarColor }
|
||||
// Group view supplies a server-resolved colour; otherwise per-event then calendar colour.
|
||||
var effectiveColor: String { displayColor ?? color ?? calendarColor }
|
||||
|
||||
static func from(json: [String: Any]) -> CalEvent? {
|
||||
guard
|
||||
@@ -49,7 +78,14 @@ struct CalEvent: Identifiable, Hashable {
|
||||
calendarId: json["calendar_id"].map { "\($0)" } ?? "",
|
||||
calendarName: json["calendar_name"] as? String ?? "",
|
||||
calendarColor: json["calendarColor"] as? String ?? "#4285f4",
|
||||
source: json["source"] as? String ?? "local"
|
||||
source: json["source"] as? String ?? "local",
|
||||
creator: EventPerson.from(json["creator"]),
|
||||
isPrivate: json["private"] as? Bool ?? false,
|
||||
owner: EventPerson.from(json["owner"]),
|
||||
isGroupEvent: json["is_group_event"] as? Bool ?? false,
|
||||
displayColor: (json["display_color"] as? String).flatMap { $0.isEmpty ? nil : $0 },
|
||||
displayTitle: (json["display_title"] as? String).flatMap { $0.isEmpty ? nil : $0 },
|
||||
reminders: (json["reminders"] as? [Int]) ?? (json["reminders"] as? [Any])?.compactMap { ($0 as? Int) ?? Int("\($0)") } ?? []
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,28 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
extension Notification.Name {
|
||||
/// Posted whenever the persistent "banished calendars" set is mutated from
|
||||
/// outside the active `CalendarStore` (e.g. by `AccountsView`). The store
|
||||
/// listens for this in `CalendarHostView` and refreshes its filter.
|
||||
static let banishedCalendarsChanged = Notification.Name("banishedCalendarsChanged")
|
||||
|
||||
/// Posted when the user taps the manual "sync with server" button in the
|
||||
/// menu. `CalendarHostView` responds by invalidating the cache and
|
||||
/// re-fetching events from the server.
|
||||
static let manualSyncRequested = Notification.Name("manualSyncRequested")
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,17 +51,179 @@ class CalendarStore {
|
||||
var events: [CalEvent] = []
|
||||
var viewType: CalViewType = .month
|
||||
var currentDate: Date = .now
|
||||
// The month currently scrolled into view (month view). Lives in the store so
|
||||
// the Liquid-Glass navigation title — read in the system toolbar — updates
|
||||
// via @Observable tracking (a plain @State did not refresh the toolbar).
|
||||
var visibleMonth: Date = .now
|
||||
var isLoading = false
|
||||
var isCachingBackground = false
|
||||
var lastError: String? = nil
|
||||
var weekStartsOnMonday = true
|
||||
var writableCalendars: [WritableCalendar] = []
|
||||
// When set, the calendar shows the group's combined overlay instead of the
|
||||
// user's own events. nil = personal view.
|
||||
var activeGroup: CalGroup? = nil
|
||||
|
||||
/// Set of `"source:calendarId"` keys the user has chosen to hide from the
|
||||
/// calendar views. Persisted in UserDefaults as a JSON array. Events whose
|
||||
/// key matches one of these are filtered out before being rendered.
|
||||
var hiddenCalendarKeys: Set<String> = CalendarStore.loadHiddenKeys()
|
||||
|
||||
/// "Banished" calendars – like `hiddenCalendarKeys` but expressing a
|
||||
/// stronger user intent: the calendar should not even appear in the quick
|
||||
/// show/hide list. Re-activation happens in AccountsView.
|
||||
var banishedCalendarKeys: Set<String> = CalendarStore.loadBanishedKeys()
|
||||
|
||||
/// Group-overlay visibility: which members' calendars (`gm:<userId>`) and the
|
||||
/// group calendar (`gc`) are hidden in the combined view — like hiding
|
||||
/// individual people in Outlook. In-memory; resets when leaving/switching a
|
||||
/// group (the per-calendar hide/banish sets are for the personal view only).
|
||||
var hiddenGroupKeys: Set<String> = []
|
||||
|
||||
static func groupMemberKey(_ ownerId: Int) -> String { "gm:\(ownerId)" }
|
||||
static let groupCalendarKey = "gc"
|
||||
|
||||
// Cache bookkeeping
|
||||
private var cachedStart: Date? = nil
|
||||
private var cachedEnd: Date? = nil
|
||||
private var allCachedEvents: [CalEvent] = []
|
||||
|
||||
// MARK: – Hidden-calendar persistence
|
||||
|
||||
private static let hiddenKeysDefaultsKey = "hiddenCalendarKeys"
|
||||
|
||||
private static func loadHiddenKeys() -> Set<String> {
|
||||
guard let raw = UserDefaults.standard.string(forKey: hiddenKeysDefaultsKey),
|
||||
let data = raw.data(using: .utf8),
|
||||
let arr = try? JSONDecoder().decode([String].self, from: data)
|
||||
else { return [] }
|
||||
return Set(arr)
|
||||
}
|
||||
|
||||
private func saveHiddenKeys() {
|
||||
let arr = Array(hiddenCalendarKeys)
|
||||
if let data = try? JSONEncoder().encode(arr),
|
||||
let s = String(data: data, encoding: .utf8) {
|
||||
UserDefaults.standard.set(s, forKey: Self.hiddenKeysDefaultsKey)
|
||||
}
|
||||
}
|
||||
|
||||
/// Toggle visibility of a single calendar and immediately refresh the
|
||||
/// visible event list + widget snapshot.
|
||||
func setCalendarHidden(_ key: String, hidden: Bool) {
|
||||
if hidden { hiddenCalendarKeys.insert(key) } else { hiddenCalendarKeys.remove(key) }
|
||||
saveHiddenKeys()
|
||||
let (s, e) = rangeForCurrentView()
|
||||
refreshFromCache(start: s, end: e)
|
||||
publishWidgetSnapshot()
|
||||
}
|
||||
|
||||
/// Replace the entire set (used by the filter sheet's bulk show/hide).
|
||||
func setHiddenCalendars(_ keys: Set<String>) {
|
||||
hiddenCalendarKeys = keys
|
||||
saveHiddenKeys()
|
||||
let (s, e) = rangeForCurrentView()
|
||||
refreshFromCache(start: s, end: e)
|
||||
publishWidgetSnapshot()
|
||||
}
|
||||
|
||||
/// Toggle / replace group-overlay visibility (members or the group calendar).
|
||||
func setGroupKeyHidden(_ key: String, hidden: Bool) {
|
||||
if hidden { hiddenGroupKeys.insert(key) } else { hiddenGroupKeys.remove(key) }
|
||||
let (s, e) = rangeForCurrentView()
|
||||
refreshFromCache(start: s, end: e)
|
||||
}
|
||||
|
||||
func setHiddenGroupKeys(_ keys: Set<String>) {
|
||||
hiddenGroupKeys = keys
|
||||
let (s, e) = rangeForCurrentView()
|
||||
refreshFromCache(start: s, end: e)
|
||||
}
|
||||
|
||||
static func calendarKey(source: String, calendarId: String) -> String {
|
||||
// The events API returns `calendar_id` inconsistently: a raw numeric for
|
||||
// CalDAV, but "<source>-<id>" for local / ical / google / homeassistant
|
||||
// (e.g. "local-3", "google-5"). The filter UI keys off the numeric DB id,
|
||||
// so strip any leading "<source>-" prefix to make event keys and filter
|
||||
// keys comparable — otherwise local hiding/banishing silently does nothing
|
||||
// for those sources.
|
||||
var id = calendarId
|
||||
let prefix = "\(source)-"
|
||||
if id.hasPrefix(prefix) { id = String(id.dropFirst(prefix.count)) }
|
||||
return "\(source):\(id)"
|
||||
}
|
||||
|
||||
// MARK: – Banished-calendar persistence
|
||||
|
||||
private static let banishedKeysDefaultsKey = "banishedCalendarKeys"
|
||||
|
||||
static func loadBanishedKeys() -> Set<String> {
|
||||
guard let raw = UserDefaults.standard.string(forKey: banishedKeysDefaultsKey),
|
||||
let data = raw.data(using: .utf8),
|
||||
let arr = try? JSONDecoder().decode([String].self, from: data)
|
||||
else { return [] }
|
||||
return Set(arr)
|
||||
}
|
||||
|
||||
static func saveBanishedKeys(_ keys: Set<String>) {
|
||||
let arr = Array(keys)
|
||||
if let data = try? JSONEncoder().encode(arr),
|
||||
let s = String(data: data, encoding: .utf8) {
|
||||
UserDefaults.standard.set(s, forKey: banishedKeysDefaultsKey)
|
||||
}
|
||||
}
|
||||
|
||||
/// Move a calendar to / out of the banished set. Also clears any quick
|
||||
/// hidden flag for that key – once banished, the dual state is redundant.
|
||||
/// Posts `.banishedCalendarsChanged` so other views in the navigation
|
||||
/// stack (e.g. AccountsView) stay in sync.
|
||||
func setCalendarBanished(_ key: String, banished: Bool) {
|
||||
if banished {
|
||||
banishedCalendarKeys.insert(key)
|
||||
hiddenCalendarKeys.remove(key)
|
||||
} else {
|
||||
banishedCalendarKeys.remove(key)
|
||||
}
|
||||
Self.saveBanishedKeys(banishedCalendarKeys)
|
||||
saveHiddenKeys()
|
||||
NotificationCenter.default.post(name: .banishedCalendarsChanged, object: nil)
|
||||
let (s, e) = rangeForCurrentView()
|
||||
refreshFromCache(start: s, end: e)
|
||||
publishWidgetSnapshot()
|
||||
}
|
||||
|
||||
/// Replace the whole banished set (used when reconciling with the server's
|
||||
/// `sidebar_hidden` flags). Persists, notifies, refreshes.
|
||||
func setBanishedCalendars(_ keys: Set<String>) {
|
||||
guard keys != banishedCalendarKeys else { return }
|
||||
banishedCalendarKeys = keys
|
||||
Self.saveBanishedKeys(keys)
|
||||
NotificationCenter.default.post(name: .banishedCalendarsChanged, object: nil)
|
||||
let (s, e) = rangeForCurrentView()
|
||||
refreshFromCache(start: s, end: e)
|
||||
publishWidgetSnapshot()
|
||||
}
|
||||
|
||||
/// Re-read the banished set from UserDefaults – called when an external
|
||||
/// view (AccountsView) mutated it. Refreshes visible events + widgets.
|
||||
func syncBanishedFromDefaults() {
|
||||
banishedCalendarKeys = Self.loadBanishedKeys()
|
||||
let (s, e) = rangeForCurrentView()
|
||||
refreshFromCache(start: s, end: e)
|
||||
publishWidgetSnapshot()
|
||||
}
|
||||
|
||||
/// Split a `"source:calendarId"` key back into its parts.
|
||||
static func parseCalendarKey(_ key: String) -> (source: String, id: Int)? {
|
||||
guard let colon = key.firstIndex(of: ":") else { return nil }
|
||||
let source = String(key[..<colon])
|
||||
guard let id = Int(key[key.index(after: colon)...]) else { return nil }
|
||||
return (source, id)
|
||||
}
|
||||
|
||||
/// Sources whose visibility is backed by the server's `sidebar_hidden`.
|
||||
static let serverManagedSources: Set<String> = ["caldav", "google", "homeassistant"]
|
||||
|
||||
var userCalendar: Calendar {
|
||||
var cal = Calendar.current
|
||||
cal.firstWeekday = weekStartsOnMonday ? 2 : 1
|
||||
@@ -63,19 +237,61 @@ class CalendarStore {
|
||||
return cs <= start && ce >= end
|
||||
}
|
||||
|
||||
/// Fast in-memory refresh of `events` for the current visible range.
|
||||
/// Call this after navigation without hitting the network.
|
||||
/// Republish the full cached event set, applying only visibility filters
|
||||
/// (hidden + banished). We deliberately do NOT slice by the current view's
|
||||
/// date window: the user's chosen cache range is already loaded, and
|
||||
/// scrolling within it must not make events vanish. Per-day / per-range
|
||||
/// rendering is the responsibility of `events(on:)` / `events(in:)`.
|
||||
/// `start` / `end` are kept in the signature for call-site clarity.
|
||||
func refreshFromCache(start: Date, end: Date) {
|
||||
_ = (start, end)
|
||||
// In group overlay mode the per-calendar hide/banish toggles don't apply;
|
||||
// instead honour the per-member / group-calendar toggles (hiddenGroupKeys).
|
||||
if activeGroup != nil {
|
||||
if hiddenGroupKeys.isEmpty {
|
||||
events = allCachedEvents
|
||||
} else {
|
||||
events = allCachedEvents.filter { ev in
|
||||
ev.startDate < end && ev.endDate > start
|
||||
if ev.isGroupEvent { return !hiddenGroupKeys.contains(Self.groupCalendarKey) }
|
||||
if let o = ev.owner { return !hiddenGroupKeys.contains(Self.groupMemberKey(o.id ?? -1)) }
|
||||
return true
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
events = allCachedEvents.filter { ev in
|
||||
let key = Self.calendarKey(source: ev.source, calendarId: ev.calendarId)
|
||||
return !hiddenCalendarKeys.contains(key)
|
||||
&& !banishedCalendarKeys.contains(key)
|
||||
}
|
||||
// Personal events drive local reminder notifications.
|
||||
NotificationScheduler.reschedule(events: allCachedEvents)
|
||||
}
|
||||
|
||||
/// Recompute scheduled reminder notifications from the personal cache
|
||||
/// (skipped while a group overlay is active).
|
||||
func rescheduleNotifications() {
|
||||
guard activeGroup == nil else { return }
|
||||
NotificationScheduler.reschedule(events: allCachedEvents)
|
||||
}
|
||||
|
||||
/// Optimistically drop a just-deleted event from the cache so it disappears
|
||||
/// from the UI immediately, without waiting for a server round-trip (HA
|
||||
/// deletes can lag several seconds, and an immediate refetch could even
|
||||
/// re-add it before the source propagated the deletion).
|
||||
func removeCachedEvent(id: String) {
|
||||
allCachedEvents.removeAll { $0.id == id }
|
||||
events.removeAll { $0.id == id }
|
||||
publishWidgetSnapshot()
|
||||
}
|
||||
|
||||
// MARK: – Network loading
|
||||
|
||||
/// Load events for a specific range – skips network if already cached.
|
||||
func loadEvents(api: CalendarrAPI, start: Date, end: Date) async {
|
||||
if isCached(start: start, end: end) {
|
||||
/// Load events for a specific range. Skips the network if already cached,
|
||||
/// unless `force` is set (used after create/edit to pull fresh server data
|
||||
/// for the visible range, bypassing the cache).
|
||||
func loadEvents(api: CalendarrAPI, start: Date, end: Date, force: Bool = false) async {
|
||||
if !force, isCached(start: start, end: end) {
|
||||
refreshFromCache(start: start, end: end)
|
||||
return
|
||||
}
|
||||
@@ -83,7 +299,7 @@ class CalendarStore {
|
||||
lastError = nil
|
||||
defer { isLoading = false }
|
||||
do {
|
||||
let fetched = try await api.fetchEvents(start: start, end: end)
|
||||
let fetched = try await fetchForMode(api: api, start: start, end: end)
|
||||
mergeIntoCache(fetched, rangeStart: start, rangeEnd: end)
|
||||
refreshFromCache(start: start, end: end)
|
||||
} catch {
|
||||
@@ -91,6 +307,41 @@ class CalendarStore {
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch events for the current mode (personal vs. group overlay). Group
|
||||
/// events go through the same cache/prefetch/refresh path as personal ones,
|
||||
/// so the whole visible grid is covered (no "only the middle weeks" gaps).
|
||||
private func fetchForMode(api: CalendarrAPI, start: Date, end: Date) async throws -> [CalEvent] {
|
||||
if let g = activeGroup {
|
||||
let combined = try await api.fetchGroupCombined(groupId: g.id, start: start, end: end)
|
||||
return combined.map { decorateGroupEvent($0) }
|
||||
}
|
||||
return try await api.fetchEvents(start: start, end: end)
|
||||
}
|
||||
|
||||
/// Prefix a combined-view event with its owner (others) or 👥 + creator
|
||||
/// (group calendar). Colour comes from the server's display_color.
|
||||
private func decorateGroupEvent(_ ev: CalEvent) -> CalEvent {
|
||||
// Prefer the server-decorated title (group icon + owner prefix) so web,
|
||||
// iOS and Android render group events identically. `title` stays raw.
|
||||
if let dt = ev.displayTitle, !dt.isEmpty {
|
||||
var e = ev
|
||||
e.title = dt
|
||||
return e
|
||||
}
|
||||
// Fallback for older servers without display_title.
|
||||
var e = ev
|
||||
let me = UserDefaults.standard.integer(forKey: "userId")
|
||||
let groupIcon = activeGroup?.icon ?? "👥"
|
||||
func first(_ s: String) -> String { s.split(separator: " ").first.map(String.init) ?? s }
|
||||
if ev.isGroupEvent {
|
||||
if let c = ev.creator, c.id != me { e.title = "\(groupIcon) \(first(c.displayName)): \(ev.title)" }
|
||||
else { e.title = "\(groupIcon) \(ev.title)" }
|
||||
} else if let o = ev.owner, o.id != me {
|
||||
e.title = "\(first(o.displayName)): \(ev.title)"
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
/// Background prefetch for ±months around today – called once on startup.
|
||||
func prefetchBackground(api: CalendarrAPI, months: Int) async {
|
||||
let cal = userCalendar
|
||||
@@ -102,7 +353,7 @@ class CalendarStore {
|
||||
isCachingBackground = true
|
||||
defer { isCachingBackground = false }
|
||||
do {
|
||||
let fetched = try await api.fetchEvents(start: start, end: end)
|
||||
let fetched = try await fetchForMode(api: api, start: start, end: end)
|
||||
mergeIntoCache(fetched, rangeStart: start, rangeEnd: end)
|
||||
// Refresh visible range from newly expanded cache
|
||||
let (vs, ve) = rangeForCurrentView()
|
||||
@@ -113,11 +364,13 @@ class CalendarStore {
|
||||
}
|
||||
|
||||
/// Trigger a full cache reload (e.g. when cache-range setting changes).
|
||||
/// Intentionally keeps `events` intact so the UI stays populated while
|
||||
/// the network fetch runs; `refreshFromCache` will swap in fresh data
|
||||
/// atomically once it arrives.
|
||||
func invalidateCache() {
|
||||
cachedStart = nil
|
||||
cachedEnd = nil
|
||||
allCachedEvents = []
|
||||
events = []
|
||||
}
|
||||
|
||||
private func mergeIntoCache(_ newEvents: [CalEvent], rangeStart: Date, rangeEnd: Date) {
|
||||
@@ -135,6 +388,53 @@ class CalendarStore {
|
||||
cachedStart = rangeStart
|
||||
cachedEnd = rangeEnd
|
||||
}
|
||||
|
||||
publishWidgetSnapshot()
|
||||
}
|
||||
|
||||
/// Write a slim snapshot of the next ~6 weeks into the App-Group container
|
||||
/// so the widget extension can render without a network call. 42 days
|
||||
/// covers the worst-case month grid (6 rows × 7 cols) for the calendar
|
||||
/// widget. Also asks the system to refresh the widget timeline.
|
||||
private func publishWidgetSnapshot() {
|
||||
let cal = userCalendar
|
||||
let now = Date()
|
||||
// Include the week before today so widgets that show the current week
|
||||
// (e.g. "This Week", "Up Next + Calendar") have data for Monday–today.
|
||||
let from = cal.date(byAdding: .day, value: -7, to: cal.startOfDay(for: now)) ?? now
|
||||
let to = cal.date(byAdding: .day, value: 42, to: cal.startOfDay(for: now)) ?? from
|
||||
let visible = allCachedEvents
|
||||
.filter { ev in
|
||||
let key = Self.calendarKey(source: ev.source, calendarId: ev.calendarId)
|
||||
return ev.startDate < to && ev.endDate > from
|
||||
&& !hiddenCalendarKeys.contains(key)
|
||||
&& !banishedCalendarKeys.contains(key)
|
||||
}
|
||||
.sorted { $0.startDate < $1.startDate }
|
||||
.prefix(500)
|
||||
.map { ev in
|
||||
WidgetEvent(id: ev.id,
|
||||
title: ev.title,
|
||||
start: ev.startDate,
|
||||
end: ev.endDate,
|
||||
isAllDay: ev.isAllDay,
|
||||
colorHex: ev.effectiveColor,
|
||||
location: ev.location)
|
||||
}
|
||||
let defaults = UserDefaults.standard
|
||||
let snap = WidgetSnapshot(
|
||||
writtenAt: now,
|
||||
events: Array(visible),
|
||||
todayColorHex: defaults.string(forKey: "todayColor") ?? "#4285f4",
|
||||
textColorHex: defaults.string(forKey: "textColor") ?? "#FFFFFF",
|
||||
backgroundColorHex: defaults.string(forKey: "backgroundColor") ?? "#000000",
|
||||
lineColorHex: defaults.string(forKey: "lineColor") ?? "#3A3A3C",
|
||||
primaryColorHex: defaults.string(forKey: "primaryColor") ?? "#4285f4",
|
||||
accentColorHex: defaults.string(forKey: "accentColor") ?? "#ea4335",
|
||||
language: defaults.string(forKey: "appLanguage") ?? "system"
|
||||
)
|
||||
WidgetStore.write(snap)
|
||||
WidgetTimelineNotifier.reload()
|
||||
}
|
||||
|
||||
// MARK: – Writable calendars
|
||||
@@ -221,28 +521,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
644
Calendarr iOS/Models/Localization.swift
Normal file
644
Calendarr iOS/Models/Localization.swift
Normal file
@@ -0,0 +1,644 @@
|
||||
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",
|
||||
"menu.sync": "Mit Server synchronisieren",
|
||||
"menu.sync.section": "Synchronisierung",
|
||||
|
||||
// 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.sync": "Einstellungen synchronisieren",
|
||||
"settings.sync.desc": "Darstellung mit dem Server abgleichen",
|
||||
"settings.sync.footer": "Wenn aktiv, werden Farben, Kontraste und Stundenhöhe mit dem Server abgeglichen (der Server hat Vorrang). Ansicht, erster Wochentag und das Ausgrauen vergangener Termine werden immer synchronisiert – auch wenn der Schalter aus ist.",
|
||||
|
||||
"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.nav.profile": "Profil",
|
||||
"settings.privacy": "Privatsphäre",
|
||||
"settings.private_visibility": "Private Termine für Gruppen",
|
||||
"settings.private_visibility.desc": "Wie private Termine für andere Gruppenmitglieder erscheinen",
|
||||
"settings.private.busy": "Als „Beschäftigt“",
|
||||
"settings.private.hidden": "Ausblenden",
|
||||
"settings.calendars": "Geteilter Kalender",
|
||||
"settings.group_visible": "Für Gruppen sichtbar",
|
||||
"settings.group_visible.desc": "Wähle, welcher deiner Kalender für Gruppenmitglieder sichtbar ist",
|
||||
"group.visible.none": "Keiner",
|
||||
"profile.display_name": "Anzeigename",
|
||||
"profile.login_name": "Login-Name",
|
||||
"accounts.shared_by": "geteilt von %@",
|
||||
"share.title": "Teilen",
|
||||
"share.current": "Aktuelle Freigaben",
|
||||
"share.none": "Noch nicht geteilt",
|
||||
"share.add": "Benutzer hinzufügen",
|
||||
"share.search": "Benutzer suchen…",
|
||||
"share.permission": "Berechtigung",
|
||||
"perm.read": "Nur lesen",
|
||||
"perm.read_write": "Lesen & schreiben",
|
||||
"ics.import": "Importieren",
|
||||
"ics.export": "Exportieren",
|
||||
"ics.import_result": "%d importiert, %d übersprungen",
|
||||
"common.info": "Info",
|
||||
"common.done": "Fertig",
|
||||
"groups.title": "Gruppen",
|
||||
"groups.personal": "Persönlich",
|
||||
"groups.view_label": "Gruppenansicht",
|
||||
"groups.exit": "Verlassen",
|
||||
"groups.none": "Noch keine Gruppen",
|
||||
"groups.combined_empty": "Keine Termine in diesem Zeitraum",
|
||||
"group.create": "Gruppe erstellen",
|
||||
"group.manage": "Gruppe verwalten",
|
||||
"group.name": "Name",
|
||||
"group.icon": "Icon",
|
||||
"group.members": "Mitglieder",
|
||||
"group.calendar": "Gruppenkalender",
|
||||
"group.member_colors": "Farben der Mitglieder",
|
||||
"group.delete": "Gruppe löschen",
|
||||
|
||||
"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.private": "Privat",
|
||||
"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.copy_title": "Termin kopieren",
|
||||
"event.copy_to": "In Kalender kopieren",
|
||||
"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",
|
||||
"profile.admin_note": "Hinweis: Die Benutzerverwaltung – sowohl das Erstellen als auch das Löschen von Benutzerkonten – erfolgt ausschließlich durch den Administrator des Servers.",
|
||||
|
||||
// Kalender-Filter (Sidebar)
|
||||
"filter.title": "Kalender",
|
||||
"filter.loading": "Lade Kalender…",
|
||||
"filter.empty": "Keine Kalender vorhanden",
|
||||
"filter.show_all": "Alle anzeigen",
|
||||
"filter.hide_all": "Alle ausblenden",
|
||||
"filter.button": "Kalender ein-/ausblenden",
|
||||
"filter.banish": "Dauerhaft ausblenden",
|
||||
"filter.banished_footer": "Dauerhaft ausgeblendete Kalender erscheinen unter »Konten & Kalender« und können dort wieder eingeblendet werden.",
|
||||
"accounts.banished_header": "Ausgeblendete Kalender",
|
||||
"accounts.banished_unhide": "Wieder einblenden",
|
||||
"accounts.banished_unknown": "Unbekannter Kalender",
|
||||
|
||||
// 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",
|
||||
"menu.sync": "Sync with server",
|
||||
"menu.sync.section": "Synchronization",
|
||||
|
||||
"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.sync": "Sync settings",
|
||||
"settings.sync.desc": "Keep appearance in sync with the server",
|
||||
"settings.sync.footer": "When on, colors, contrasts and hour height sync with the server (the server wins). View, first weekday and dimming past events always sync – even when the switch is off.",
|
||||
|
||||
"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.nav.profile": "Profile",
|
||||
"settings.privacy": "Privacy",
|
||||
"settings.private_visibility": "Private events for groups",
|
||||
"settings.private_visibility.desc": "How your private events appear to other group members",
|
||||
"settings.private.busy": "Show as \"Busy\"",
|
||||
"settings.private.hidden": "Hide",
|
||||
"settings.calendars": "Shared calendar",
|
||||
"settings.group_visible": "Visible to groups",
|
||||
"settings.group_visible.desc": "Choose which of your calendars group members can see",
|
||||
"group.visible.none": "None",
|
||||
"profile.display_name": "Display name",
|
||||
"profile.login_name": "Login name",
|
||||
"accounts.shared_by": "shared by %@",
|
||||
"share.title": "Share",
|
||||
"share.current": "Current shares",
|
||||
"share.none": "Not shared yet",
|
||||
"share.add": "Add user",
|
||||
"share.search": "Search users…",
|
||||
"share.permission": "Permission",
|
||||
"perm.read": "Read only",
|
||||
"perm.read_write": "Read & write",
|
||||
"ics.import": "Import",
|
||||
"ics.export": "Export",
|
||||
"ics.import_result": "%d imported, %d skipped",
|
||||
"common.info": "Info",
|
||||
"common.done": "Done",
|
||||
"groups.title": "Groups",
|
||||
"groups.personal": "Personal",
|
||||
"groups.view_label": "Group view",
|
||||
"groups.exit": "Exit",
|
||||
"groups.none": "No groups yet",
|
||||
"groups.combined_empty": "No events in this period",
|
||||
"group.create": "Create group",
|
||||
"group.manage": "Manage group",
|
||||
"group.name": "Name",
|
||||
"group.icon": "Icon",
|
||||
"group.members": "Members",
|
||||
"group.calendar": "Group calendar",
|
||||
"group.member_colors": "Member colours",
|
||||
"group.delete": "Delete group",
|
||||
|
||||
"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.private": "Private",
|
||||
"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.copy_title": "Copy event",
|
||||
"event.copy_to": "Copy to calendar",
|
||||
"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",
|
||||
"profile.admin_note": "Note: User management — both the creation and deletion of user accounts — is handled exclusively by the server administrator.",
|
||||
|
||||
// Calendar filter (sidebar)
|
||||
"filter.title": "Calendars",
|
||||
"filter.loading": "Loading calendars…",
|
||||
"filter.empty": "No calendars available",
|
||||
"filter.show_all": "Show all",
|
||||
"filter.hide_all": "Hide all",
|
||||
"filter.button": "Show/hide calendars",
|
||||
"filter.banish": "Hide permanently",
|
||||
"filter.banished_footer": "Permanently hidden calendars appear under “Accounts & Calendars”, where you can show them again.",
|
||||
"accounts.banished_header": "Hidden calendars",
|
||||
"accounts.banished_unhide": "Show again",
|
||||
"accounts.banished_unknown": "Unknown calendar",
|
||||
|
||||
// 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"
|
||||
]
|
||||
]
|
||||
37
Calendarr iOS/Models/ReminderOptions.swift
Normal file
37
Calendarr iOS/Models/ReminderOptions.swift
Normal file
@@ -0,0 +1,37 @@
|
||||
import Foundation
|
||||
|
||||
/// Reminder offset options (minutes before an event's start; 0 = at start) and
|
||||
/// their localized labels. Shared by the event editor, the settings default
|
||||
/// picker, and the notification scheduler so the choices stay consistent.
|
||||
enum ReminderOptions {
|
||||
/// Selectable offsets in minutes-before-start.
|
||||
static let all: [Int] = [0, 5, 10, 15, 30, 60, 120, 1440, 2880]
|
||||
|
||||
private static func isEnglish(_ appLang: String) -> Bool {
|
||||
if appLang == "en" { return true }
|
||||
if appLang == "de" { return false }
|
||||
return (Locale.current.language.languageCode?.identifier ?? "de").hasPrefix("en")
|
||||
}
|
||||
|
||||
static func label(_ minutes: Int, _ appLang: String) -> String {
|
||||
let en = isEnglish(appLang)
|
||||
if minutes <= 0 { return en ? "At start time" : "Zur Startzeit" }
|
||||
if minutes < 60 { return en ? "\(minutes) min before" : "\(minutes) Min. vorher" }
|
||||
if minutes < 1440 {
|
||||
let h = minutes / 60
|
||||
return en ? "\(h) h before" : "\(h) Std. vorher"
|
||||
}
|
||||
let d = minutes / 1440
|
||||
return en ? "\(d) day\(d == 1 ? "" : "s") before" : "\(d) Tag\(d == 1 ? "" : "e") vorher"
|
||||
}
|
||||
|
||||
static func sectionTitle(_ l: String) -> String { isEnglish(l) ? "Reminders" : "Benachrichtigungen" }
|
||||
static func addLabel(_ l: String) -> String { isEnglish(l) ? "Add reminder" : "Benachrichtigung hinzufügen" }
|
||||
static func off(_ l: String) -> String { isEnglish(l) ? "Off" : "Aus" }
|
||||
static func defaultTitle(_ l: String) -> String { isEnglish(l) ? "Default reminder" : "Standardbenachrichtigung" }
|
||||
static func defaultFooter(_ l: String) -> String {
|
||||
isEnglish(l)
|
||||
? "Applies to all events unless an event has its own reminders."
|
||||
: "Gilt für alle Termine, sofern ein Termin keine eigenen Benachrichtigungen hat."
|
||||
}
|
||||
}
|
||||
@@ -72,6 +72,9 @@ class CalendarrAPI {
|
||||
throw APIError.decodingError
|
||||
}
|
||||
let admin = user["is_admin"] as? Bool ?? false
|
||||
// Persist id + display name for creator/owner comparisons and display.
|
||||
UserDefaults.standard.set(user["id"] as? Int ?? 0, forKey: "userId")
|
||||
UserDefaults.standard.set(user["display_name"] as? String ?? uname, forKey: "displayName")
|
||||
return (token, uname, admin)
|
||||
}
|
||||
|
||||
@@ -225,7 +228,8 @@ class CalendarrAPI {
|
||||
}
|
||||
|
||||
func createLocalEvent(calendarId: Int, title: String, start: Date, end: Date,
|
||||
isAllDay: Bool, location: String, description: String, color: String?) async throws -> CalEvent {
|
||||
isAllDay: Bool, location: String, description: String, color: String?,
|
||||
isPrivate: Bool = false, reminders: [Int]? = nil) async throws -> CalEvent {
|
||||
var body: [String: Any] = [
|
||||
"calendar_id": calendarId,
|
||||
"title": title,
|
||||
@@ -233,9 +237,11 @@ class CalendarrAPI {
|
||||
"end": formatISO(end, allDay: isAllDay),
|
||||
"allDay": isAllDay,
|
||||
"location": location,
|
||||
"description": description
|
||||
"description": description,
|
||||
"private": isPrivate
|
||||
]
|
||||
if let c = color, !c.isEmpty { body["color"] = c }
|
||||
if let reminders { body["reminders"] = reminders }
|
||||
let data = try await request("/api/local/events", method: "POST", body: body)
|
||||
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let ev = CalEvent.from(json: json) else { throw APIError.decodingError }
|
||||
@@ -243,16 +249,19 @@ class CalendarrAPI {
|
||||
}
|
||||
|
||||
func updateLocalEvent(uid: String, title: String, start: Date, end: Date,
|
||||
isAllDay: Bool, location: String, description: String, color: String?) async throws {
|
||||
isAllDay: Bool, location: String, description: String, color: String?,
|
||||
isPrivate: Bool = false, reminders: [Int]? = nil) async throws {
|
||||
var body: [String: Any] = [
|
||||
"title": title,
|
||||
"start": formatISO(start, allDay: isAllDay),
|
||||
"end": formatISO(end, allDay: isAllDay),
|
||||
"allDay": isAllDay,
|
||||
"location": location,
|
||||
"description": description
|
||||
"description": description,
|
||||
"private": isPrivate
|
||||
]
|
||||
if let c = color { body["color"] = c }
|
||||
if let reminders { body["reminders"] = reminders }
|
||||
_ = try await request("/api/local/events/\(uid)", method: "PUT", body: body)
|
||||
}
|
||||
|
||||
@@ -365,4 +374,194 @@ class CalendarrAPI {
|
||||
]
|
||||
_ = try await request("/api/homeassistant/events", method: "POST", body: body)
|
||||
}
|
||||
|
||||
/// Delete a Home Assistant calendar event.
|
||||
/// `calendarId` is the numeric HA-calendar DB id; `uid` is the HA event uid.
|
||||
func deleteHAEvent(calendarId: Int, uid: String) async throws {
|
||||
// uid is a path segment and may contain "/" or other reserved chars.
|
||||
let allowed = CharacterSet.urlPathAllowed.subtracting(CharacterSet(charactersIn: "/"))
|
||||
let encUid = uid.addingPercentEncoding(withAllowedCharacters: allowed) ?? uid
|
||||
_ = try await request("/api/homeassistant/events/\(calendarId)/\(encUid)", method: "DELETE")
|
||||
}
|
||||
|
||||
// MARK: – Calendar visibility (sidebar_hidden)
|
||||
|
||||
/// Toggle a calendar's server-side visibility. Mirrors the web: hiding sets
|
||||
/// `enabled=false, sidebar_hidden=true` (server then omits its events);
|
||||
/// showing sets `enabled=true, sidebar_hidden=false`. Only CalDAV / Google /
|
||||
/// Home Assistant have this flag; `local` / `ical` are a no-op.
|
||||
func setCalendarSidebarHidden(source: String, calendarId: Int, hidden: Bool) async throws {
|
||||
let path: String
|
||||
switch source {
|
||||
case "caldav": path = "/api/caldav/calendars/\(calendarId)"
|
||||
case "google": path = "/api/google/calendars/\(calendarId)"
|
||||
case "homeassistant": path = "/api/homeassistant/calendars/\(calendarId)"
|
||||
default: return
|
||||
}
|
||||
_ = try await request(path, method: "PUT",
|
||||
body: ["enabled": !hidden, "sidebar_hidden": hidden])
|
||||
}
|
||||
|
||||
// MARK: – Calendar colour
|
||||
|
||||
func updateLocalCalendarColor(id: Int, color: String) async throws {
|
||||
_ = try await request("/api/local/calendars/\(id)", method: "PUT", body: ["color": color])
|
||||
}
|
||||
|
||||
func updateICalColor(id: Int, color: String) async throws {
|
||||
_ = try await request("/api/ical/subscriptions/\(id)", method: "PUT", body: ["color": color])
|
||||
}
|
||||
|
||||
/// Set a per-calendar colour for server-managed sources (caldav/google/homeassistant).
|
||||
func setCalendarColor(source: String, calendarId: Int, color: String) async throws {
|
||||
let path: String
|
||||
switch source {
|
||||
case "caldav": path = "/api/caldav/calendars/\(calendarId)"
|
||||
case "google": path = "/api/google/calendars/\(calendarId)"
|
||||
case "homeassistant": path = "/api/homeassistant/calendars/\(calendarId)"
|
||||
default: return
|
||||
}
|
||||
_ = try await request(path, method: "PUT", body: ["color": color])
|
||||
}
|
||||
|
||||
// MARK: – Profile (display name / login name / email)
|
||||
|
||||
/// Update profile fields. A login-name change returns a fresh token (the old
|
||||
/// one becomes invalid) — the caller must store the returned token.
|
||||
func updateProfile(displayName: String?, username: String?, email: String?) async throws -> String? {
|
||||
var body: [String: Any] = [:]
|
||||
if let d = displayName { body["display_name"] = d }
|
||||
if let u = username { body["username"] = u }
|
||||
if let e = email { body["email"] = e } else { body["email"] = NSNull() }
|
||||
let data = try await request("/api/profile/", method: "PUT", body: body)
|
||||
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
|
||||
return json?["access_token"] as? String
|
||||
}
|
||||
|
||||
// MARK: – Targeted settings (avoid overwriting the whole AppSettings)
|
||||
|
||||
func updatePrivateVisibility(_ value: String) async throws {
|
||||
_ = try await request("/api/settings/", method: "PUT", body: ["private_event_visibility": value])
|
||||
}
|
||||
|
||||
func updateGroupVisibleCalendar(_ calendarId: Int?) async throws {
|
||||
_ = try await request("/api/settings/", method: "PUT",
|
||||
body: ["group_visible_calendar_id": calendarId as Any? ?? NSNull()])
|
||||
}
|
||||
|
||||
// MARK: – Sharing
|
||||
|
||||
func getUserDirectory() async throws -> [DirectoryUser] {
|
||||
let data = try await request("/api/users/directory")
|
||||
return (try? JSONDecoder().decode([DirectoryUser].self, from: data)) ?? []
|
||||
}
|
||||
|
||||
func getShares(calendarId: Int) async throws -> [CalendarShare] {
|
||||
let data = try await request("/api/local/calendars/\(calendarId)/shares")
|
||||
return (try? JSONDecoder().decode([CalendarShare].self, from: data)) ?? []
|
||||
}
|
||||
|
||||
func addShare(calendarId: Int, userId: Int, permission: String) async throws {
|
||||
_ = try await request("/api/local/calendars/\(calendarId)/shares", method: "POST",
|
||||
body: ["user_id": userId, "permission": permission])
|
||||
}
|
||||
|
||||
func removeShare(calendarId: Int, userId: Int) async throws {
|
||||
_ = try await request("/api/local/calendars/\(calendarId)/shares/\(userId)", method: "DELETE")
|
||||
}
|
||||
|
||||
// MARK: – Groups
|
||||
|
||||
func getGroups() async throws -> [CalGroup] {
|
||||
let data = try await request("/api/groups/")
|
||||
return (try? JSONDecoder().decode([CalGroup].self, from: data)) ?? []
|
||||
}
|
||||
|
||||
func getGroup(id: Int) async throws -> CalGroup {
|
||||
let data = try await request("/api/groups/\(id)")
|
||||
guard let g = try? JSONDecoder().decode(CalGroup.self, from: data) else { throw APIError.decodingError }
|
||||
return g
|
||||
}
|
||||
|
||||
func createGroup(name: String, memberIds: [Int], icon: String?) async throws -> CalGroup {
|
||||
var body: [String: Any] = ["name": name, "member_ids": memberIds]
|
||||
if let icon { body["icon"] = icon }
|
||||
let data = try await request("/api/groups/", method: "POST", body: body)
|
||||
guard let g = try? JSONDecoder().decode(CalGroup.self, from: data) else { throw APIError.decodingError }
|
||||
return g
|
||||
}
|
||||
|
||||
func updateGroup(id: Int, name: String?, icon: String?) async throws {
|
||||
var body: [String: Any] = [:]
|
||||
if let name { body["name"] = name }
|
||||
if let icon { body["icon"] = icon }
|
||||
_ = try await request("/api/groups/\(id)", method: "PUT", body: body)
|
||||
}
|
||||
|
||||
func deleteGroup(id: Int) async throws {
|
||||
_ = try await request("/api/groups/\(id)", method: "DELETE")
|
||||
}
|
||||
|
||||
func addGroupMember(groupId: Int, userId: Int) async throws {
|
||||
_ = try await request("/api/groups/\(groupId)/members", method: "POST", body: ["user_id": userId])
|
||||
}
|
||||
|
||||
func removeGroupMember(groupId: Int, userId: Int) async throws {
|
||||
_ = try await request("/api/groups/\(groupId)/members/\(userId)", method: "DELETE")
|
||||
}
|
||||
|
||||
func setGroupMemberColor(groupId: Int, userId: Int, color: String) async throws {
|
||||
_ = try await request("/api/groups/\(groupId)/members/\(userId)/color", method: "PUT",
|
||||
body: ["color": color])
|
||||
}
|
||||
|
||||
func fetchGroupCombined(groupId: Int, start: Date, end: Date) async throws -> [CalEvent] {
|
||||
let iso = ISO8601DateFormatter()
|
||||
iso.formatOptions = [.withInternetDateTime]
|
||||
iso.timeZone = TimeZone(abbreviation: "UTC")
|
||||
let s = iso.string(from: start)
|
||||
let e = iso.string(from: end)
|
||||
let sEnc = s.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? s
|
||||
let eEnc = e.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? e
|
||||
let data = try await request("/api/groups/\(groupId)/combined?start=\(sEnc)&end=\(eEnc)")
|
||||
guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let arr = root["events"] as? [[String: Any]] else { return [] }
|
||||
return arr.compactMap { CalEvent.from(json: $0) }
|
||||
}
|
||||
|
||||
// MARK: – iCal import / export
|
||||
|
||||
/// Import a .ics file into a local calendar. Returns (imported, skipped, errors).
|
||||
func importICS(calendarId: Int, fileURL: URL) async throws -> (imported: Int, skipped: Int, errors: [String]) {
|
||||
guard let url = URL(string: baseURL + "/api/local/calendars/\(calendarId)/import") else { throw APIError.invalidURL }
|
||||
let fileData = try Data(contentsOf: fileURL)
|
||||
let boundary = "Boundary-\(UUID().uuidString)"
|
||||
var req = URLRequest(url: url)
|
||||
req.httpMethod = "POST"
|
||||
req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
req.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
|
||||
var bodyData = Data()
|
||||
let filename = fileURL.lastPathComponent.isEmpty ? "import.ics" : fileURL.lastPathComponent
|
||||
bodyData.append("--\(boundary)\r\n".data(using: .utf8)!)
|
||||
bodyData.append("Content-Disposition: form-data; name=\"file\"; filename=\"\(filename)\"\r\n".data(using: .utf8)!)
|
||||
bodyData.append("Content-Type: text/calendar\r\n\r\n".data(using: .utf8)!)
|
||||
bodyData.append(fileData)
|
||||
bodyData.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!)
|
||||
req.httpBody = bodyData
|
||||
let (data, response) = try await URLSession.shared.data(for: req)
|
||||
let status = (response as? HTTPURLResponse)?.statusCode ?? 0
|
||||
if status == 401 { throw APIError.unauthorized }
|
||||
if status >= 400 {
|
||||
let msg = (try? JSONSerialization.jsonObject(with: data) as? [String: Any])?["detail"] as? String ?? "Fehler \(status)"
|
||||
throw APIError.serverError(msg)
|
||||
}
|
||||
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
|
||||
let errs = (json?["errors"] as? [String]) ?? []
|
||||
return (json?["imported"] as? Int ?? 0, json?["skipped"] as? Int ?? 0, errs)
|
||||
}
|
||||
|
||||
/// Export a local calendar as raw .ics bytes.
|
||||
func exportICS(calendarId: Int) async throws -> Data {
|
||||
return try await request("/api/local/calendars/\(calendarId)/export")
|
||||
}
|
||||
}
|
||||
|
||||
65
Calendarr iOS/Services/NotificationScheduler.swift
Normal file
65
Calendarr iOS/Services/NotificationScheduler.swift
Normal file
@@ -0,0 +1,65 @@
|
||||
import Foundation
|
||||
import UserNotifications
|
||||
|
||||
extension Notification.Name {
|
||||
/// Posted when the default-reminder setting changes, so the calendar host
|
||||
/// can recompute the scheduled local notifications from its cached events.
|
||||
static let rescheduleReminders = Notification.Name("rescheduleReminders")
|
||||
}
|
||||
|
||||
/// Schedules local OS notifications for upcoming events. Per-event reminders
|
||||
/// (local events) take precedence; otherwise the user's default reminder applies
|
||||
/// to every event (incl. external). Re-run whenever events or the default change.
|
||||
enum NotificationScheduler {
|
||||
|
||||
static func requestAuthorizationIfNeeded() {
|
||||
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { _, _ in }
|
||||
}
|
||||
|
||||
/// Recompute and (re)schedule notifications from the given events. The iOS
|
||||
/// pending-notification cap is 64, so only the soonest are scheduled.
|
||||
static func reschedule(events: [CalEvent]) {
|
||||
let center = UNUserNotificationCenter.current()
|
||||
center.getNotificationSettings { settings in
|
||||
guard settings.authorizationStatus == .authorized
|
||||
|| settings.authorizationStatus == .provisional else { return }
|
||||
|
||||
let defaultMin = (UserDefaults.standard.object(forKey: "defaultReminderMinutes") as? Int) ?? -1
|
||||
let now = Date()
|
||||
var pending: [(fire: Date, event: CalEvent)] = []
|
||||
for ev in events {
|
||||
let offsets = ev.reminders.isEmpty
|
||||
? (defaultMin >= 0 ? [defaultMin] : [])
|
||||
: ev.reminders
|
||||
for m in offsets {
|
||||
let fire = ev.startDate.addingTimeInterval(-Double(m) * 60)
|
||||
if fire > now { pending.append((fire, ev)) }
|
||||
}
|
||||
}
|
||||
pending.sort { $0.fire < $1.fire }
|
||||
let limited = pending.prefix(60) // stay safely under the 64 system cap
|
||||
|
||||
center.removeAllPendingNotificationRequests()
|
||||
for item in limited {
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = item.event.title
|
||||
content.body = bodyText(item.event)
|
||||
content.sound = .default
|
||||
let comps = Calendar.current.dateComponents(
|
||||
[.year, .month, .day, .hour, .minute, .second], from: item.fire)
|
||||
let trigger = UNCalendarNotificationTrigger(dateMatching: comps, repeats: false)
|
||||
center.add(UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: trigger))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func bodyText(_ ev: CalEvent) -> String {
|
||||
var parts: [String] = []
|
||||
if !ev.isAllDay {
|
||||
let f = DateFormatter(); f.timeStyle = .short; f.dateStyle = .none
|
||||
parts.append(f.string(from: ev.startDate))
|
||||
}
|
||||
if !ev.location.isEmpty { parts.append(ev.location) }
|
||||
return parts.joined(separator: " · ")
|
||||
}
|
||||
}
|
||||
157
Calendarr iOS/Services/SettingsSync.swift
Normal file
157
Calendarr iOS/Services/SettingsSync.swift
Normal file
@@ -0,0 +1,157 @@
|
||||
import Foundation
|
||||
|
||||
extension Notification.Name {
|
||||
/// Posted after a successful pull applied new settings to UserDefaults, so
|
||||
/// views holding live state (CalendarHostView → store, widgets) can react.
|
||||
static let settingsDidChange = Notification.Name("settingsDidChange")
|
||||
}
|
||||
|
||||
/// Two-way synchronisation of appearance/behaviour settings between the app and
|
||||
/// the Calendarr server. The server is treated as the source of truth on pull;
|
||||
/// local edits are pushed immediately so the server then holds the newest value.
|
||||
///
|
||||
/// Two groups:
|
||||
/// - **optional** (colors, contrasts, hour height) only sync when the user has
|
||||
/// enabled the `settingsSync` toggle.
|
||||
/// - **always** (default view, week start, dim past events) sync regardless of
|
||||
/// the toggle, because they describe how the user expects the calendar to be
|
||||
/// computed/presented everywhere.
|
||||
enum SettingsSync {
|
||||
|
||||
// MARK: – UserDefaults keys
|
||||
|
||||
enum Key {
|
||||
// optional group
|
||||
static let primaryColor = "primaryColor"
|
||||
static let accentColor = "accentColor"
|
||||
static let todayColor = "todayColor"
|
||||
static let textColor = "textColor"
|
||||
static let backgroundColor = "backgroundColor"
|
||||
static let lineColor = "lineColor"
|
||||
static let monthDividerColor = "monthDividerColor"
|
||||
static let monthLabelColor = "monthLabelColor"
|
||||
static let textContrast = "textContrast"
|
||||
static let lineContrast = "lineContrast"
|
||||
static let hourHeight = "hourHeight"
|
||||
// always group
|
||||
static let defaultView = "defaultView"
|
||||
static let weekStartDay = "weekStartDay"
|
||||
static let dimPastEvents = "dimPastEvents"
|
||||
static let defaultReminder = "defaultReminderMinutes" // Int, -1 = off
|
||||
// master switch
|
||||
static let enabled = "settingsSync"
|
||||
}
|
||||
|
||||
static var isEnabled: Bool { UserDefaults.standard.bool(forKey: Key.enabled) }
|
||||
|
||||
// MARK: – Defaults (mirror the historical hard-coded values)
|
||||
|
||||
private static func int(_ key: String, _ fallback: Int) -> Int {
|
||||
let v = UserDefaults.standard.object(forKey: key) as? Int
|
||||
return v ?? fallback
|
||||
}
|
||||
private static func str(_ key: String, _ fallback: String) -> String {
|
||||
UserDefaults.standard.string(forKey: key) ?? fallback
|
||||
}
|
||||
|
||||
// MARK: – Build AppSettings from local UserDefaults
|
||||
|
||||
static func currentSettings() -> AppSettings {
|
||||
var s = AppSettings()
|
||||
s.primaryColor = str(Key.primaryColor, "#4285f4")
|
||||
s.accentColor = str(Key.accentColor, "#ea4335")
|
||||
s.todayColor = str(Key.todayColor, "#4285f4")
|
||||
s.textColor = str(Key.textColor, "#FFFFFF")
|
||||
s.backgroundColor = str(Key.backgroundColor, "#000000")
|
||||
s.lineColor = str(Key.lineColor, "#3A3A3C")
|
||||
s.monthDividerColor = str(Key.monthDividerColor, "#7090c0")
|
||||
s.monthLabelColor = str(Key.monthLabelColor, "#7090c0")
|
||||
s.textContrast = int(Key.textContrast, 3)
|
||||
s.lineContrast = int(Key.lineContrast, 3)
|
||||
s.hourHeight = int(Key.hourHeight, 60)
|
||||
s.defaultView = str(Key.defaultView, "month")
|
||||
s.weekStartDay = str(Key.weekStartDay, "monday")
|
||||
s.dimPastEvents = UserDefaults.standard.bool(forKey: Key.dimPastEvents)
|
||||
let rem = int(Key.defaultReminder, -1)
|
||||
s.defaultReminderMinutes = rem < 0 ? nil : rem
|
||||
return s
|
||||
}
|
||||
|
||||
// MARK: – Apply a server snapshot to local UserDefaults
|
||||
|
||||
/// Always writes the "always" trio. Writes the optional group only when
|
||||
/// `includeOptional` is true.
|
||||
static func apply(_ s: AppSettings, includeOptional: Bool) {
|
||||
let d = UserDefaults.standard
|
||||
// always group
|
||||
d.set(s.defaultView, forKey: Key.defaultView)
|
||||
d.set(s.weekStartDay, forKey: Key.weekStartDay)
|
||||
d.set(s.dimPastEvents, forKey: Key.dimPastEvents)
|
||||
d.set(s.defaultReminderMinutes ?? -1, forKey: Key.defaultReminder)
|
||||
guard includeOptional else { return }
|
||||
// NOTE: textColor / backgroundColor / lineColor are intentionally NOT
|
||||
// synced – the server has no columns for them (iOS-only). Writing the
|
||||
// resilient-decoded defaults here would wipe the user's local choices.
|
||||
d.set(s.primaryColor, forKey: Key.primaryColor)
|
||||
d.set(s.accentColor, forKey: Key.accentColor)
|
||||
d.set(s.todayColor, forKey: Key.todayColor)
|
||||
d.set(s.monthDividerColor, forKey: Key.monthDividerColor)
|
||||
d.set(s.monthLabelColor, forKey: Key.monthLabelColor)
|
||||
d.set(s.textContrast, forKey: Key.textContrast)
|
||||
d.set(s.lineContrast, forKey: Key.lineContrast)
|
||||
d.set(s.hourHeight, forKey: Key.hourHeight)
|
||||
}
|
||||
|
||||
// MARK: – Pull
|
||||
|
||||
/// Fetch the server's settings and apply them locally (server wins).
|
||||
static func pull(api: CalendarrAPI) async {
|
||||
guard let server = try? await api.getSettings() else { return }
|
||||
apply(server, includeOptional: isEnabled)
|
||||
await MainActor.run {
|
||||
NotificationCenter.default.post(name: .settingsDidChange, object: nil)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Push (debounced)
|
||||
|
||||
private static var pushTask: Task<Void, Never>?
|
||||
|
||||
/// Schedule a debounced push. Repeated calls (e.g. while dragging a colour
|
||||
/// slider) collapse into a single network write ~1.2 s after the last edit.
|
||||
static func push(api: CalendarrAPI) {
|
||||
pushTask?.cancel()
|
||||
pushTask = Task {
|
||||
try? await Task.sleep(for: .milliseconds(1200))
|
||||
if Task.isCancelled { return }
|
||||
await performPush(api: api)
|
||||
}
|
||||
}
|
||||
|
||||
/// Read-modify-write: start from the server's current settings so that,
|
||||
/// when the optional group is NOT being synced, the server's colours stay
|
||||
/// intact. Overwrite the trio always, the optional group only if enabled.
|
||||
private static func performPush(api: CalendarrAPI) async {
|
||||
guard var merged = try? await api.getSettings() else { return }
|
||||
let local = currentSettings()
|
||||
// always group
|
||||
merged.defaultView = local.defaultView
|
||||
merged.weekStartDay = local.weekStartDay
|
||||
merged.dimPastEvents = local.dimPastEvents
|
||||
merged.defaultReminderMinutes = local.defaultReminderMinutes
|
||||
if isEnabled {
|
||||
merged.primaryColor = local.primaryColor
|
||||
merged.accentColor = local.accentColor
|
||||
merged.todayColor = local.todayColor
|
||||
merged.textColor = local.textColor
|
||||
merged.backgroundColor = local.backgroundColor
|
||||
merged.lineColor = local.lineColor
|
||||
merged.monthDividerColor = local.monthDividerColor
|
||||
merged.monthLabelColor = local.monthLabelColor
|
||||
merged.textContrast = local.textContrast
|
||||
merged.lineContrast = local.lineContrast
|
||||
merged.hourHeight = local.hourHeight
|
||||
}
|
||||
try? await api.updateSettings(merged)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
struct AccountsView: View {
|
||||
let api: CalendarrAPI
|
||||
@@ -14,14 +15,25 @@ struct AccountsView: View {
|
||||
@State private var showAddICal = false
|
||||
@State private var showAddHA = false
|
||||
@State private var errorAlert: String?
|
||||
@State private var banishedKeys: Set<String> = CalendarStore.loadBanishedKeys()
|
||||
|
||||
// Sharing / import / export
|
||||
@State private var shareCalId: Int?
|
||||
@State private var showImporter = false
|
||||
@State private var importTargetCalId: Int?
|
||||
@State private var exportDoc: ExportedICS?
|
||||
@State private var infoMessage: 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 {
|
||||
if !banishedKeys.isEmpty { banishedSection }
|
||||
caldavSection
|
||||
localSection
|
||||
icalSection
|
||||
@@ -30,15 +42,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,34 +68,85 @@ 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 ?? "")
|
||||
})
|
||||
.sheet(item: Binding(get: { shareCalId.map { IdentifiableInt(id: $0) } },
|
||||
set: { shareCalId = $0?.id })) { wrap in
|
||||
SharingView(api: api, calendarId: wrap.id)
|
||||
}
|
||||
.sheet(item: $exportDoc) { doc in
|
||||
ActivityView(items: [doc.url])
|
||||
}
|
||||
.fileImporter(isPresented: $showImporter,
|
||||
allowedContentTypes: [UTType(filenameExtension: "ics") ?? .data, .data],
|
||||
allowsMultipleSelection: false) { result in
|
||||
Task { await handleImport(result) }
|
||||
}
|
||||
.alert(L10n.t("common.info", appLang), isPresented: .constant(infoMessage != nil), actions: {
|
||||
Button(L10n.t("common.ok", appLang)) { infoMessage = nil }
|
||||
}, message: { Text(infoMessage ?? "") })
|
||||
}
|
||||
.task { await load() }
|
||||
}
|
||||
|
||||
private func exportCalendar(_ cal: LocalCalendar) async {
|
||||
do {
|
||||
let data = try await api.exportICS(calendarId: cal.id)
|
||||
let safe = cal.name.components(separatedBy: CharacterSet.alphanumerics.inverted).joined(separator: "_")
|
||||
let url = FileManager.default.temporaryDirectory.appendingPathComponent("\(safe.isEmpty ? "calendar" : safe).ics")
|
||||
try data.write(to: url)
|
||||
exportDoc = ExportedICS(url: url)
|
||||
} catch {
|
||||
errorAlert = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
private func handleImport(_ result: Result<[URL], Error>) async {
|
||||
guard let calId = importTargetCalId else { return }
|
||||
switch result {
|
||||
case .success(let urls):
|
||||
guard let url = urls.first else { return }
|
||||
let scoped = url.startAccessingSecurityScopedResource()
|
||||
defer { if scoped { url.stopAccessingSecurityScopedResource() } }
|
||||
do {
|
||||
let r = try await api.importICS(calendarId: calId, fileURL: url)
|
||||
infoMessage = String(format: L10n.t("ics.import_result", appLang), r.imported, r.skipped)
|
||||
} catch {
|
||||
errorAlert = error.localizedDescription
|
||||
}
|
||||
case .failure(let err):
|
||||
errorAlert = err.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Sections
|
||||
|
||||
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
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Circle()
|
||||
.fill(Color(hex: acc.color))
|
||||
.frame(width: 12, height: 12)
|
||||
Circle().fill(Color(hex: acc.color)).frame(width: 12, height: 12)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(acc.name).font(.body)
|
||||
Text(acc.url)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
Text(acc.url).font(.caption).foregroundStyle(.secondary).lineLimit(1)
|
||||
}
|
||||
}
|
||||
ForEach(acc.calendars ?? []) { cal in
|
||||
HStack {
|
||||
CalendarColorDot(hex: cal.color ?? acc.color) { hex in
|
||||
try? await api.setCalendarColor(source: "caldav", calendarId: cal.id, color: hex)
|
||||
}
|
||||
Text(cal.name).font(.callout)
|
||||
}
|
||||
.padding(.leading, 8)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -91,52 +154,77 @@ 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
|
||||
HStack {
|
||||
Circle()
|
||||
.fill(Color(hex: cal.color))
|
||||
.frame(width: 12, height: 12)
|
||||
CalendarColorDot(hex: cal.color, editable: cal.owned) { hex in
|
||||
try? await api.updateLocalCalendarColor(id: cal.id, color: hex)
|
||||
}
|
||||
Text(cal.name)
|
||||
if cal.group {
|
||||
Image(systemName: "person.2.fill").font(.caption2).foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
if !cal.owned, let by = cal.sharedBy {
|
||||
Text(String(format: L10n.t("accounts.shared_by", appLang), by))
|
||||
.font(.caption2).foregroundStyle(.secondary)
|
||||
}
|
||||
Menu {
|
||||
if cal.owned && !cal.group {
|
||||
Button { shareCalId = cal.id } label: {
|
||||
Label(L10n.t("share.title", appLang), systemImage: "person.crop.circle.badge.plus")
|
||||
}
|
||||
}
|
||||
if cal.owned || cal.permission == "read_write" {
|
||||
Button { importTargetCalId = cal.id; showImporter = true } label: {
|
||||
Label(L10n.t("ics.import", appLang), systemImage: "square.and.arrow.down")
|
||||
}
|
||||
}
|
||||
Button { Task { await exportCalendar(cal) } } label: {
|
||||
Label(L10n.t("ics.export", appLang), systemImage: "square.and.arrow.up")
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "ellipsis.circle").foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onDelete { offsets in
|
||||
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
|
||||
HStack {
|
||||
Circle()
|
||||
.fill(Color(hex: sub.color))
|
||||
.frame(width: 12, height: 12)
|
||||
CalendarColorDot(hex: sub.color) { hex in
|
||||
try? await api.updateICalColor(id: sub.id, color: hex)
|
||||
}
|
||||
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,61 +234,159 @@ 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
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Image(systemName: "g.circle.fill")
|
||||
.foregroundStyle(.red)
|
||||
Image(systemName: "g.circle.fill").foregroundStyle(.red)
|
||||
Text(acc.email)
|
||||
}
|
||||
ForEach(acc.calendars ?? []) { cal in
|
||||
HStack {
|
||||
CalendarColorDot(hex: cal.color ?? "#4285f4") { hex in
|
||||
try? await api.setCalendarColor(source: "google", calendarId: cal.id, color: hex)
|
||||
}
|
||||
Text(cal.name).font(.callout)
|
||||
}
|
||||
.padding(.leading, 8)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onDelete { offsets in
|
||||
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 banishedSection: some View {
|
||||
Section {
|
||||
ForEach(Array(banishedKeys).sorted(), id: \.self) { key in
|
||||
let info = resolveBanished(key)
|
||||
HStack(spacing: 10) {
|
||||
Circle()
|
||||
.fill(Color(hex: info.colorHex))
|
||||
.frame(width: 12, height: 12)
|
||||
.opacity(0.5)
|
||||
Text(info.name)
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Button(L10n.t("accounts.banished_unhide", appLang)) {
|
||||
unbanish(key)
|
||||
}
|
||||
.font(.callout)
|
||||
.foregroundStyle(Color.accentColor)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text(L10n.t("accounts.banished_header", appLang))
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-show a banished calendar. For server-backed sources this clears the
|
||||
/// server's sidebar_hidden (re-enabling the calendar); for local/ical it's
|
||||
/// just the local set.
|
||||
private func unbanish(_ key: String) {
|
||||
banishedKeys.remove(key)
|
||||
CalendarStore.saveBanishedKeys(banishedKeys)
|
||||
NotificationCenter.default.post(name: .banishedCalendarsChanged, object: nil)
|
||||
if let parsed = CalendarStore.parseCalendarKey(key),
|
||||
CalendarStore.serverManagedSources.contains(parsed.source) {
|
||||
// The server excluded this calendar's events while hidden, so they
|
||||
// aren't in the cache. Re-enable on the server, then force a refetch
|
||||
// so the events actually reappear without a manual sync.
|
||||
Task {
|
||||
try? await api.setCalendarSidebarHidden(source: parsed.source, calendarId: parsed.id, hidden: false)
|
||||
NotificationCenter.default.post(name: .manualSyncRequested, object: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func resolveBanished(_ key: String) -> (name: String, colorHex: String) {
|
||||
let parts = key.split(separator: ":", maxSplits: 1).map(String.init)
|
||||
guard parts.count == 2, let id = Int(parts[1]) else {
|
||||
return (L10n.t("accounts.banished_unknown", appLang), "#888888")
|
||||
}
|
||||
switch parts[0] {
|
||||
case "local":
|
||||
if let c = localCalendars.first(where: { $0.id == id }) {
|
||||
return (c.name, c.color)
|
||||
}
|
||||
case "caldav":
|
||||
for acc in caldavAccounts {
|
||||
if let c = acc.calendars?.first(where: { $0.id == id }) {
|
||||
return ("\(acc.name) – \(c.name)", c.color ?? acc.color)
|
||||
}
|
||||
}
|
||||
case "ical":
|
||||
if let s = icalSubs.first(where: { $0.id == id }) {
|
||||
return (s.name, s.color)
|
||||
}
|
||||
case "google":
|
||||
for acc in googleAccounts {
|
||||
if let c = acc.calendars?.first(where: { $0.id == id }) {
|
||||
return ("\(acc.email) – \(c.name)", c.color ?? "#4285f4")
|
||||
}
|
||||
}
|
||||
case "homeassistant":
|
||||
for acc in haAccounts {
|
||||
if let c = acc.calendars?.first(where: { $0.id == id }) {
|
||||
return ("\(acc.name) – \(c.name)", c.color ?? "#46bdc6")
|
||||
}
|
||||
}
|
||||
default: break
|
||||
}
|
||||
return (L10n.t("accounts.banished_unknown", appLang), "#888888")
|
||||
}
|
||||
|
||||
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
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(acc.name).font(.body)
|
||||
Text(acc.url)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
Text(acc.url).font(.caption).foregroundStyle(.secondary).lineLimit(1)
|
||||
}
|
||||
ForEach(acc.calendars ?? []) { cal in
|
||||
HStack {
|
||||
CalendarColorDot(hex: cal.color ?? "#03a9f4") { hex in
|
||||
try? await api.setCalendarColor(source: "homeassistant", calendarId: cal.id, color: hex)
|
||||
}
|
||||
Text(cal.name).font(.callout)
|
||||
}
|
||||
.padding(.leading, 8)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onDelete { offsets in
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,12 +394,29 @@ struct AccountsView: View {
|
||||
|
||||
private func load() async {
|
||||
isLoading = true
|
||||
banishedKeys = CalendarStore.loadBanishedKeys()
|
||||
async let c = (try? await api.getCalDAVAccounts()) ?? []
|
||||
async let l = (try? await api.getLocalCalendars()) ?? []
|
||||
async let i = (try? await api.getICalSubscriptions()) ?? []
|
||||
async let g = (try? await api.getGoogleAccounts()) ?? []
|
||||
async let h = (try? await api.getHomeAssistantAccounts()) ?? []
|
||||
(caldavAccounts, localCalendars, icalSubs, googleAccounts, haAccounts) = await (c, l, i, g, h)
|
||||
|
||||
// Reconcile banished list with the server's sidebar_hidden (server wins
|
||||
// for CalDAV/Google/HA; local/ical keep their local state).
|
||||
var b = banishedKeys
|
||||
func applyServerHidden(_ source: String, _ id: Int, _ hidden: Bool) {
|
||||
let key = CalendarStore.calendarKey(source: source, calendarId: "\(id)")
|
||||
if hidden { b.insert(key) } else { b.remove(key) }
|
||||
}
|
||||
for acc in caldavAccounts { for cal in acc.calendars ?? [] { applyServerHidden("caldav", cal.id, cal.sidebarHidden) } }
|
||||
for acc in googleAccounts { for cal in acc.calendars ?? [] { applyServerHidden("google", cal.id, cal.sidebarHidden) } }
|
||||
for acc in haAccounts { for cal in acc.calendars ?? [] { applyServerHidden("homeassistant", cal.id, cal.sidebarHidden) } }
|
||||
if b != banishedKeys {
|
||||
banishedKeys = b
|
||||
CalendarStore.saveBanishedKeys(b)
|
||||
NotificationCenter.default.post(name: .banishedCalendarsChanged, object: nil)
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
@@ -259,6 +462,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 +475,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 +495,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 +530,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 +541,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 +578,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 +587,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 +617,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 +647,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 +658,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 +675,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()
|
||||
@@ -487,3 +700,127 @@ struct AddHASheet: View {
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Sharing / Import-Export helpers
|
||||
|
||||
struct IdentifiableInt: Identifiable { let id: Int }
|
||||
struct ExportedICS: Identifiable { let id = UUID(); let url: URL }
|
||||
|
||||
/// A tappable colour swatch (ColorPicker) for a calendar. Persists via `onPick`
|
||||
/// when the chosen colour changes. Read-only fallback when `editable` is false.
|
||||
struct CalendarColorDot: View {
|
||||
let hex: String
|
||||
var editable: Bool = true
|
||||
let onPick: (String) async -> Void
|
||||
@State private var color: Color
|
||||
|
||||
init(hex: String, editable: Bool = true, onPick: @escaping (String) async -> Void) {
|
||||
self.hex = hex; self.editable = editable; self.onPick = onPick
|
||||
_color = State(initialValue: Color(hex: hex))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if editable {
|
||||
ColorPicker("", selection: $color, supportsOpacity: false)
|
||||
.labelsHidden()
|
||||
.frame(width: 26, height: 26)
|
||||
.onChange(of: color) { _, c in Task { await onPick(c.toHex()) } }
|
||||
} else {
|
||||
Circle().fill(Color(hex: hex)).frame(width: 14, height: 14)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Wraps UIActivityViewController so an exported .ics can be shared/saved.
|
||||
struct ActivityView: UIViewControllerRepresentable {
|
||||
let items: [Any]
|
||||
func makeUIViewController(context: Context) -> UIActivityViewController {
|
||||
UIActivityViewController(activityItems: items, applicationActivities: nil)
|
||||
}
|
||||
func updateUIViewController(_ vc: UIActivityViewController, context: Context) {}
|
||||
}
|
||||
|
||||
/// Manage who a local calendar is shared with (owner only).
|
||||
struct SharingView: View {
|
||||
let api: CalendarrAPI
|
||||
let calendarId: Int
|
||||
@Environment(\.dismiss) var dismiss
|
||||
@AppStorage("appLanguage") private var appLang = "system"
|
||||
|
||||
@State private var shares: [CalendarShare] = []
|
||||
@State private var directory: [DirectoryUser] = []
|
||||
@State private var search = ""
|
||||
@State private var permission = "read"
|
||||
@State private var error = ""
|
||||
|
||||
private var candidates: [DirectoryUser] {
|
||||
let sharedIds = Set(shares.map { $0.userId })
|
||||
return directory.filter { !sharedIds.contains($0.id) &&
|
||||
(search.isEmpty || $0.displayName.localizedCaseInsensitiveContains(search)) }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
Section(L10n.t("share.current", appLang)) {
|
||||
if shares.isEmpty {
|
||||
Text(L10n.t("share.none", appLang)).foregroundStyle(.secondary)
|
||||
} else {
|
||||
ForEach(shares) { s in
|
||||
HStack {
|
||||
Text(s.displayName ?? "—")
|
||||
Spacer()
|
||||
Text(s.permission == "read_write"
|
||||
? L10n.t("perm.read_write", appLang)
|
||||
: L10n.t("perm.read", appLang))
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.onDelete { offsets in
|
||||
Task { await removeShares(offsets) }
|
||||
}
|
||||
}
|
||||
}
|
||||
Section(L10n.t("share.add", appLang)) {
|
||||
Picker(L10n.t("share.permission", appLang), selection: $permission) {
|
||||
Text(L10n.t("perm.read", appLang)).tag("read")
|
||||
Text(L10n.t("perm.read_write", appLang)).tag("read_write")
|
||||
}
|
||||
TextField(L10n.t("share.search", appLang), text: $search)
|
||||
.autocapitalization(.none)
|
||||
ForEach(candidates) { u in
|
||||
Button { Task { await addShare(u.id) } } label: {
|
||||
HStack {
|
||||
Text(u.displayName)
|
||||
Spacer()
|
||||
Image(systemName: "plus.circle").foregroundStyle(Color.accentColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !error.isEmpty { Section { Text(error).foregroundStyle(.red) } }
|
||||
}
|
||||
.navigationTitle(L10n.t("share.title", appLang))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button(L10n.t("common.done", appLang)) { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
.task { await load() }
|
||||
}
|
||||
|
||||
private func load() async {
|
||||
shares = (try? await api.getShares(calendarId: calendarId)) ?? []
|
||||
directory = (try? await api.getUserDirectory()) ?? []
|
||||
}
|
||||
private func addShare(_ userId: Int) async {
|
||||
do { try await api.addShare(calendarId: calendarId, userId: userId, permission: permission); await load() }
|
||||
catch { self.error = error.localizedDescription }
|
||||
}
|
||||
private func removeShares(_ offsets: IndexSet) async {
|
||||
for i in offsets { try? await api.removeShare(calendarId: calendarId, userId: shares[i].userId) }
|
||||
await load()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,17 +1,45 @@
|
||||
import SwiftUI
|
||||
|
||||
private enum CalEditorContext: Identifiable {
|
||||
case create(Date)
|
||||
case edit(CalEvent)
|
||||
var id: String {
|
||||
switch self {
|
||||
case .create(let d): return "new-\(d.timeIntervalSince1970)"
|
||||
case .edit(let ev): return "edit-\(ev.id)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct CalendarHostView: View {
|
||||
let api: CalendarrAPI
|
||||
@Binding var showMenu: Bool
|
||||
|
||||
@AppStorage("liquidGlass") private var liquidGlass = false
|
||||
@AppStorage("cacheMonths") private var cacheMonths = 3
|
||||
@AppStorage("appLanguage") private var appLang = "system"
|
||||
@AppStorage("backgroundColor") private var bgHex = "#000000"
|
||||
@AppStorage("weekStartDay") private var weekStartDay = "monday"
|
||||
@AppStorage("defaultView") private var defaultView = "month"
|
||||
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
|
||||
@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 editorContext: CalEditorContext? = nil
|
||||
@State private var selectedEvent: CalEvent? = nil
|
||||
@State private var showFilter = false
|
||||
@State private var didApplyDefaultView = false
|
||||
@State private var groups: [CalGroup] = []
|
||||
|
||||
private var titleString: String {
|
||||
if store.viewType == .month {
|
||||
let f = DateFormatter()
|
||||
f.locale = L10n.locale(appLang)
|
||||
f.dateFormat = "LLLL yyyy"
|
||||
return f.string(from: store.visibleMonth).capitalized(with: L10n.locale(appLang))
|
||||
}
|
||||
return store.titleForCurrentView(language: appLang)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if liquidGlass {
|
||||
@@ -21,65 +49,99 @@ struct CalendarHostView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Loading indicator
|
||||
|
||||
@ViewBuilder
|
||||
private var loadingIndicator: some View {
|
||||
if store.isLoading || store.isCachingBackground {
|
||||
ProgressView()
|
||||
.padding(14)
|
||||
.background(.regularMaterial, in: Circle())
|
||||
.shadow(color: .black.opacity(0.12), radius: 6, y: 2)
|
||||
.transition(.opacity.combined(with: .scale(scale: 0.85)))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Flat variant
|
||||
|
||||
private var flatVariant: some View {
|
||||
VStack(spacing: 0) {
|
||||
topBar
|
||||
groupBanner
|
||||
Divider()
|
||||
errorBanner
|
||||
calendarContent
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Color(hex: bgHex))
|
||||
.overlay(alignment: .top) {
|
||||
if store.isLoading {
|
||||
ProgressView().padding(.top, 10).transition(.opacity)
|
||||
}
|
||||
loadingIndicator.padding(.top, 12)
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.2), value: store.isLoading || store.isCachingBackground)
|
||||
}
|
||||
.overlay(alignment: .bottomTrailing) { solidFAB }
|
||||
// Subtle background cache indicator (top-leading)
|
||||
.overlay(alignment: .topLeading) {
|
||||
if store.isCachingBackground {
|
||||
Image(systemName: "arrow.triangle.2.circlepath")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(6)
|
||||
.transition(.opacity)
|
||||
}
|
||||
}
|
||||
.modifier(calendarSheets)
|
||||
.task { await startup() }
|
||||
.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: store.visibleMonth) { _, new in Task { await ensureLoaded(around: new) } }
|
||||
.onChange(of: scenePhase) { _, phase in if phase == .active { Task { await SettingsSync.pull(api: api) } } }
|
||||
.onReceive(NotificationCenter.default.publisher(for: .banishedCalendarsChanged)) { _ in
|
||||
store.syncBanishedFromDefaults()
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .settingsDidChange)) { _ in
|
||||
applyServerDrivenSettings(initial: false)
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .manualSyncRequested)) { _ in
|
||||
Task { await forceReload() }
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .rescheduleReminders)) { _ in
|
||||
store.rescheduleNotifications()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Liquid Glass variant
|
||||
|
||||
private var glassVariant: some View {
|
||||
// Real iOS-26 Liquid Glass: the system NavigationStack toolbar renders the
|
||||
// glass bar (buttons). The month TITLE is NOT placed in the toolbar — the
|
||||
// system title silently fails to refresh on month change on iOS 26 — but
|
||||
// as a normal inline Text in a top safe-area inset just below the glass
|
||||
// bar, where it updates reliably (same mechanism as the flat variant).
|
||||
NavigationStack {
|
||||
calendarContent
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Color(hex: bgHex))
|
||||
.overlay(alignment: .top) {
|
||||
if store.isLoading {
|
||||
ProgressView().padding(.top, 10).transition(.opacity)
|
||||
}
|
||||
}
|
||||
.overlay(alignment: .top) {
|
||||
if let err = store.lastError { errorBannerView(err).padding(.top, 8) }
|
||||
loadingIndicator.padding(.top, 12)
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.2), value: store.isLoading || store.isCachingBackground)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .principal) { viewPickerMenu }
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button { showMenu = true } label: { Image(systemName: "line.3.horizontal") }
|
||||
HStack(spacing: 8) {
|
||||
Button(L10n.t("nav.today", appLang)) { store.moveToToday() }.font(.callout)
|
||||
menuButton
|
||||
}
|
||||
}
|
||||
}
|
||||
.safeAreaInset(edge: .top, spacing: 0) {
|
||||
VStack(spacing: 0) {
|
||||
Text(titleString)
|
||||
.font(.headline)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.7)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 6)
|
||||
.background(.bar)
|
||||
groupBanner
|
||||
errorBanner
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -89,11 +151,28 @@ 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: store.visibleMonth) { _, new in Task { await ensureLoaded(around: new) } }
|
||||
.onChange(of: scenePhase) { _, phase in if phase == .active { Task { await SettingsSync.pull(api: api) } } }
|
||||
.onReceive(NotificationCenter.default.publisher(for: .banishedCalendarsChanged)) { _ in
|
||||
store.syncBanishedFromDefaults()
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .settingsDidChange)) { _ in
|
||||
applyServerDrivenSettings(initial: false)
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .manualSyncRequested)) { _ in
|
||||
Task { await forceReload() }
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .rescheduleReminders)) { _ in
|
||||
store.rescheduleNotifications()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Top bar (flat mode)
|
||||
|
||||
private var topBar: some View {
|
||||
/// Shared bar contents (chevrons / today / title / group / view / filter / menu).
|
||||
/// Used by both the flat and the glass top bar so the inline title — which
|
||||
/// updates reliably on month change — is identical in both modes.
|
||||
@ViewBuilder private var barContents: some View {
|
||||
HStack(spacing: 0) {
|
||||
HStack(spacing: 2) {
|
||||
Button { store.navigatePrev() } label: {
|
||||
@@ -106,40 +185,103 @@ struct CalendarHostView: View {
|
||||
.font(.system(size: 17, weight: .medium))
|
||||
.frame(width: 36, height: 36)
|
||||
}
|
||||
Button("Heute") { store.moveToToday() }
|
||||
}
|
||||
.padding(.leading, 6)
|
||||
Spacer(minLength: 6)
|
||||
Text(titleString)
|
||||
.font(.headline)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.7)
|
||||
.layoutPriority(1)
|
||||
Spacer(minLength: 6)
|
||||
Button(L10n.t("nav.today", appLang)) { store.moveToToday() }
|
||||
.font(.callout).padding(.horizontal, 6)
|
||||
}
|
||||
.padding(.leading, 8)
|
||||
Spacer()
|
||||
viewPickerMenu
|
||||
Spacer()
|
||||
Button { showMenu = true } label: {
|
||||
Image(systemName: "line.3.horizontal")
|
||||
.font(.system(size: 18, weight: .medium))
|
||||
.frame(width: 44, height: 44)
|
||||
}
|
||||
.padding(.trailing, 4)
|
||||
.lineLimit(1).fixedSize()
|
||||
menuButton
|
||||
.padding(.trailing, 2)
|
||||
}
|
||||
.frame(height: 48)
|
||||
.background(.bar)
|
||||
}
|
||||
|
||||
private var viewPickerMenu: some View {
|
||||
private var topBar: some View {
|
||||
barContents.background(.bar)
|
||||
}
|
||||
|
||||
@ViewBuilder private var groupBanner: some View {
|
||||
if let g = store.activeGroup {
|
||||
HStack(spacing: 6) {
|
||||
GroupIconView(icon: g.icon).font(.subheadline)
|
||||
Text("\(L10n.t("groups.view_label", appLang)): \(g.name)")
|
||||
.font(.subheadline).lineLimit(1)
|
||||
Spacer()
|
||||
Button(L10n.t("groups.exit", appLang)) { switchGroup(nil) }
|
||||
.font(.callout)
|
||||
}
|
||||
.padding(.horizontal, 12).padding(.vertical, 7)
|
||||
.background(Color.accentColor.opacity(0.18))
|
||||
}
|
||||
}
|
||||
|
||||
private func switchGroup(_ g: CalGroup?) {
|
||||
store.activeGroup = g
|
||||
store.hiddenGroupKeys = [] // member visibility is per-group; start fresh
|
||||
// The cache holds the previous mode's events — drop it and reload the
|
||||
// visible range + prefetch a wide window so the whole grid is covered.
|
||||
Task { await forceReload() }
|
||||
}
|
||||
|
||||
/// The single top-bar action: a compact popup holding view / filter /
|
||||
/// groups / sync, plus an "Einstellungen" entry that opens the full menu.
|
||||
/// (Replaces the separate view / filter / group icons in the bar.)
|
||||
private var menuButton: some View {
|
||||
Menu {
|
||||
// View (fixed icon, not per-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: store.viewType == vt ? "checkmark" : vt.systemImage)
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
Text(store.viewType.label).font(.headline)
|
||||
Image(systemName: "chevron.down").font(.caption2.weight(.semibold))
|
||||
Label(L10n.t("view.change", appLang), systemImage: "rectangle.3.group")
|
||||
}
|
||||
.foregroundStyle(.primary)
|
||||
.padding(.horizontal, 12).padding(.vertical, 7)
|
||||
.background(.quaternary, in: Capsule())
|
||||
// Filter
|
||||
Button { showFilter = true } label: {
|
||||
Label(L10n.t("filter.button", appLang), systemImage: "line.3.horizontal.decrease.circle")
|
||||
}
|
||||
// Groups
|
||||
if !groups.isEmpty {
|
||||
Menu {
|
||||
Button { switchGroup(nil) } label: {
|
||||
Label(L10n.t("groups.personal", appLang),
|
||||
systemImage: store.activeGroup == nil ? "checkmark" : "person")
|
||||
}
|
||||
ForEach(groups) { g in
|
||||
Button { switchGroup(g) } label: {
|
||||
Label(g.name,
|
||||
systemImage: store.activeGroup?.id == g.id ? "checkmark" : GroupIcons.symbol(g.icon))
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Label(L10n.t("groups.title", appLang), systemImage: "person.2")
|
||||
}
|
||||
}
|
||||
// Sync
|
||||
Button { Task { await SettingsSync.pull(api: api); await forceReload() } } label: {
|
||||
Label(L10n.t("menu.sync", appLang), systemImage: "arrow.triangle.2.circlepath")
|
||||
}
|
||||
Divider()
|
||||
// Full settings menu
|
||||
Button { showMenu = true } label: {
|
||||
Label(L10n.t("menu.section.settings", appLang), systemImage: "gearshape")
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "line.3.horizontal")
|
||||
.font(.system(size: 18, weight: .medium))
|
||||
.foregroundStyle(store.activeGroup == nil && store.hiddenCalendarKeys.isEmpty ? .primary : Color.accentColor)
|
||||
.frame(width: 36, height: 36)
|
||||
}
|
||||
.accessibilityLabel(L10n.t("nav.menu", appLang))
|
||||
}
|
||||
|
||||
// MARK: – Error banner
|
||||
@@ -165,24 +307,47 @@ 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: { store.currentDate = $0 },
|
||||
onEventTap: { selectedEvent = $0 },
|
||||
onCreateEvent: { day in editorContext = .create(day) },
|
||||
onShowWeek: { day in
|
||||
store.currentDate = day
|
||||
store.viewType = .week
|
||||
},
|
||||
onShowDay: { day in
|
||||
store.currentDate = day
|
||||
store.viewType = .day
|
||||
})
|
||||
case .week:
|
||||
WeekView(store: store, onEventTap: { selectedEvent = $0 }, onTimeTap: { editorDate = $0 })
|
||||
WeekView(store: store,
|
||||
onEventTap: { selectedEvent = $0 },
|
||||
onCreateEvent: { date in editorContext = .create(date) },
|
||||
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 editorContext = .create(date) })
|
||||
.simultaneousGesture(swipe)
|
||||
case .quarter:
|
||||
QuarterView(store: store, onEventTap: { selectedEvent = $0 })
|
||||
@@ -197,7 +362,7 @@ struct CalendarHostView: View {
|
||||
/// Standard solid FAB (flat mode)
|
||||
private var solidFAB: some View {
|
||||
Button {
|
||||
editingEvent = nil; editorDate = .now; showEditor = true
|
||||
editorContext = .create(.now)
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
.font(.system(size: 22, weight: .semibold))
|
||||
@@ -215,7 +380,7 @@ struct CalendarHostView: View {
|
||||
private var glassFAB: some View {
|
||||
if #available(iOS 26, *) {
|
||||
Button {
|
||||
editingEvent = nil; editorDate = .now; showEditor = true
|
||||
editorContext = .create(.now)
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
.font(.system(size: 22, weight: .semibold))
|
||||
@@ -233,16 +398,25 @@ struct CalendarHostView: View {
|
||||
// MARK: – Sheets modifier
|
||||
|
||||
private var calendarSheets: CalendarSheets {
|
||||
CalendarSheets(store: store, showEditor: $showEditor,
|
||||
editorDate: $editorDate, editingEvent: $editingEvent,
|
||||
selectedEvent: $selectedEvent, api: api,
|
||||
reload: { await onNavigate() })
|
||||
CalendarSheets(store: store, editorContext: $editorContext,
|
||||
selectedEvent: $selectedEvent, showFilter: $showFilter,
|
||||
api: api,
|
||||
reload: { await onNavigate() },
|
||||
reloadForce: { await reloadVisible(force: true) })
|
||||
}
|
||||
|
||||
// MARK: – Loading logic
|
||||
|
||||
private func startup() async {
|
||||
// Ask for notification permission early so reminders can be scheduled.
|
||||
NotificationScheduler.requestAuthorizationIfNeeded()
|
||||
// 0. Pull settings first so week-start / default-view are correct
|
||||
// before we compute the initial range and load events.
|
||||
await SettingsSync.pull(api: api)
|
||||
applyServerDrivenSettings(initial: true)
|
||||
|
||||
await store.loadWritableCalendars(api: api)
|
||||
groups = (try? await api.getGroups()) ?? []
|
||||
// 1. Load current view immediately (visible)
|
||||
let (s, e) = store.rangeForCurrentView()
|
||||
await store.loadEvents(api: api, start: s, end: e)
|
||||
@@ -250,6 +424,25 @@ struct CalendarHostView: View {
|
||||
Task(priority: .background) {
|
||||
await store.prefetchBackground(api: api, months: cacheMonths)
|
||||
}
|
||||
// 3. Periodic settings pull (tied to this .task's lifetime).
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(for: .seconds(600))
|
||||
if Task.isCancelled { break }
|
||||
await SettingsSync.pull(api: api)
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply the server-driven "always sync" settings to the live store.
|
||||
/// `weekStartsOnMonday` is applied every time; the default view is applied
|
||||
/// only once at startup so it never overrides the user's manual switches.
|
||||
private func applyServerDrivenSettings(initial: Bool) {
|
||||
store.weekStartsOnMonday = (weekStartDay != "sunday")
|
||||
if initial, !didApplyDefaultView {
|
||||
didApplyDefaultView = true
|
||||
if let vt = CalViewType(rawValue: defaultView) {
|
||||
store.viewType = vt
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Called on every navigation – instant if within cache, fetches otherwise.
|
||||
@@ -258,38 +451,84 @@ struct CalendarHostView: View {
|
||||
await store.loadEvents(api: api, start: s, end: e)
|
||||
}
|
||||
|
||||
/// Re-fetch the visible range. With `force` it bypasses the cache so a just
|
||||
/// created/edited event shows up immediately (the server is authoritative).
|
||||
private func reloadVisible(force: Bool) async {
|
||||
let (s, e) = store.rangeForCurrentView()
|
||||
await store.loadEvents(api: api, start: s, end: e, force: force)
|
||||
}
|
||||
|
||||
/// Called when cacheMonths setting changes – clear cache and re-prefetch.
|
||||
private func recache() async {
|
||||
store.invalidateCache()
|
||||
await startup()
|
||||
}
|
||||
|
||||
/// Manual sync from the menu: drop the event cache and re-fetch from the
|
||||
/// server (the periodic loop in `startup()` is untouched, so we don't spawn
|
||||
/// a second one). Settings were already pulled by the menu action.
|
||||
private func forceReload() async {
|
||||
store.invalidateCache()
|
||||
let (s, e) = store.rangeForCurrentView()
|
||||
await store.loadEvents(api: api, start: s, end: e)
|
||||
Task(priority: .background) {
|
||||
await store.prefetchBackground(api: api, months: cacheMonths)
|
||||
}
|
||||
}
|
||||
|
||||
/// Called when the user scrolls into a new month – refreshes the visible range
|
||||
/// immediately from cache, then fetches on demand if needed.
|
||||
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
|
||||
|
||||
// Only narrow the visible event set if the requested window is already cached.
|
||||
// Otherwise keep the current events visible until the network fetch finishes,
|
||||
// so previous/current/next month events don't disappear temporarily.
|
||||
if store.isCached(start: s, end: e) {
|
||||
store.refreshFromCache(start: s, end: e)
|
||||
}
|
||||
|
||||
await store.loadEvents(api: api, start: s, end: e)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Shared sheet modifier
|
||||
|
||||
private struct CalendarSheets: ViewModifier {
|
||||
let store: CalendarStore
|
||||
@Binding var showEditor: Bool
|
||||
@Binding var editorDate: Date
|
||||
@Binding var editingEvent: CalEvent?
|
||||
@Binding var editorContext: CalEditorContext?
|
||||
@Binding var selectedEvent: CalEvent?
|
||||
@Binding var showFilter: Bool
|
||||
let api: CalendarrAPI
|
||||
let reload: () async -> Void
|
||||
let reloadForce: () async -> Void
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.sheet(isPresented: $showEditor) {
|
||||
// Use sheet(item:) so the editing event is captured atomically –
|
||||
// avoiding the race where sheet(isPresented:) evaluates its content
|
||||
// before the editingEvent state update propagates.
|
||||
.sheet(item: $editorContext) { ctx in
|
||||
let editingEv: CalEvent? = { if case .edit(let ev) = ctx { return ev }; return nil }()
|
||||
let date: Date = { if case .create(let d) = ctx { return d }; return .now }()
|
||||
EventEditorSheet(api: api, store: store,
|
||||
initialDate: editorDate, editingEvent: editingEvent) {
|
||||
editingEvent = nil; await reload()
|
||||
initialDate: date, editingEvent: editingEv) {
|
||||
editorContext = nil
|
||||
await reloadForce()
|
||||
}
|
||||
}
|
||||
.sheet(item: $selectedEvent) { ev in
|
||||
EventDetailSheet(event: ev, api: api, store: store) { updated in
|
||||
EventDetailSheet(event: ev, api: api, store: store) { updated, needsForce in
|
||||
selectedEvent = nil
|
||||
if let u = updated { editingEvent = u; showEditor = true }
|
||||
await reload()
|
||||
if let u = updated { editorContext = .edit(u) }
|
||||
if needsForce { await reloadForce() } else { await reload() }
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showFilter) {
|
||||
CalendarFilterSheet(api: api, store: store)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,11 +3,22 @@ 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"
|
||||
@AppStorage("textContrast") private var textContrast = 3
|
||||
@AppStorage("hourHeight") private var hourHeightPref = 60 // observed for live re-layout
|
||||
|
||||
private var cal: Calendar { store.userCalendar }
|
||||
private var allDayEvents: [CalEvent] { store.events(on: store.currentDate).filter(\.isAllDay) }
|
||||
private var timedEvents: [CalEvent] { store.events(on: store.currentDate).filter { !$0.isAllDay } }
|
||||
private var allDayEvents: [CalEvent] {
|
||||
store.events(on: store.currentDate).filter { $0.isAllDay || eventSpansMultipleDays($0) }
|
||||
}
|
||||
private var timedEvents: [CalEvent] {
|
||||
store.events(on: store.currentDate).filter { !$0.isAllDay && !eventSpansMultipleDays($0) }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
@@ -17,25 +28,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 +56,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 +103,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(secondaryTextOpacity(textContrast)))
|
||||
.offset(y: -6)
|
||||
}
|
||||
}
|
||||
@@ -123,3 +128,32 @@ 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"
|
||||
@AppStorage("lineContrast") private var lineContrast = 3
|
||||
|
||||
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(gridLineOpacity(lineContrast))).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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,11 +4,16 @@ struct EventDetailSheet: View {
|
||||
let event: CalEvent
|
||||
let api: CalendarrAPI
|
||||
let store: CalendarStore
|
||||
let onDone: (CalEvent?) async -> Void
|
||||
/// Called when the sheet should close.
|
||||
/// - `editEvent`: non-nil when the user wants to edit this event
|
||||
/// - `forceReload`: true when server data changed (create/copy) and the
|
||||
/// caller must bypass the cache to fetch fresh events
|
||||
let onDone: (_ editEvent: CalEvent?, _ forceReload: Bool) async -> Void
|
||||
|
||||
@Environment(\.dismiss) var dismiss
|
||||
@State private var showDeleteConfirm = false
|
||||
@State private var isDeleting = false
|
||||
@State private var showCopySheet = false
|
||||
|
||||
private let timeFmt: DateFormatter = {
|
||||
let f = DateFormatter()
|
||||
@@ -37,7 +42,14 @@ struct EventDetailSheet: View {
|
||||
}
|
||||
|
||||
private var canEdit: Bool {
|
||||
event.source == "local" || event.source == "caldav"
|
||||
event.source == "local" || event.source == "caldav" || event.source == "homeassistant"
|
||||
}
|
||||
|
||||
private var canDelete: Bool { canEdit }
|
||||
|
||||
private var currentUserId: Int? {
|
||||
let id = UserDefaults.standard.integer(forKey: "userId")
|
||||
return id == 0 ? nil : id
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -84,9 +96,31 @@ struct EventDetailSheet: View {
|
||||
Text(event.source.capitalized)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if let creator = event.creator, creator.id != currentUserId {
|
||||
HStack {
|
||||
Label("Erstellt von", systemImage: "person")
|
||||
Spacer()
|
||||
Text(creator.displayName)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
if event.isPrivate {
|
||||
Label("Privat", systemImage: "lock")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
if canEdit {
|
||||
if !store.writableCalendars.isEmpty {
|
||||
Section {
|
||||
Button {
|
||||
showCopySheet = true
|
||||
} label: {
|
||||
Label("Termin kopieren", systemImage: "doc.on.doc")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if canDelete {
|
||||
Section {
|
||||
Button(role: .destructive) {
|
||||
showDeleteConfirm = true
|
||||
@@ -104,18 +138,18 @@ struct EventDetailSheet: View {
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Schliessen") {
|
||||
Task { await onDone(nil) }
|
||||
Task { await onDone(nil, false) }
|
||||
}
|
||||
}
|
||||
if canEdit {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button("Bearbeiten") {
|
||||
Task { await onDone(event) }
|
||||
Task { await onDone(event, false) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.confirmationDialog("Termin löschen?", isPresented: $showDeleteConfirm, titleVisibility: .visible) {
|
||||
.alert("Termin löschen?", isPresented: $showDeleteConfirm) {
|
||||
Button("Löschen", role: .destructive) {
|
||||
Task { await deleteEvent() }
|
||||
}
|
||||
@@ -123,19 +157,39 @@ struct EventDetailSheet: View {
|
||||
} message: {
|
||||
Text("\"\(event.title)\" wird dauerhaft gelöscht.")
|
||||
}
|
||||
.sheet(isPresented: $showCopySheet) {
|
||||
EventEditorSheet(
|
||||
api: api,
|
||||
store: store,
|
||||
initialDate: event.startDate,
|
||||
editingEvent: nil,
|
||||
copyFrom: event
|
||||
) {
|
||||
// Copy created a new server-side event → force reload so it appears
|
||||
await onDone(nil, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func deleteEvent() async {
|
||||
isDeleting = true
|
||||
do {
|
||||
if event.source == "local" {
|
||||
switch event.source {
|
||||
case "local":
|
||||
try await api.deleteLocalEvent(uid: event.id)
|
||||
} else {
|
||||
case "homeassistant":
|
||||
// calendarId looks like "homeassistant-42" → numeric DB id 42
|
||||
let calId = Int(event.calendarId.replacingOccurrences(of: "homeassistant-", with: "")) ?? 0
|
||||
try await api.deleteHAEvent(calendarId: calId, uid: event.id)
|
||||
default:
|
||||
let calId = Int(event.calendarId)
|
||||
try await api.deleteCalDAVEvent(uid: event.id, url: event.url, calendarId: calId)
|
||||
}
|
||||
await onDone(nil)
|
||||
// Optimistically drop it from the cache so it vanishes immediately,
|
||||
// regardless of how long the source takes to propagate the delete.
|
||||
store.removeCachedEvent(id: event.id)
|
||||
await onDone(nil, false)
|
||||
} catch {
|
||||
isDeleting = false
|
||||
}
|
||||
|
||||
@@ -5,9 +5,12 @@ struct EventEditorSheet: View {
|
||||
let store: CalendarStore
|
||||
let initialDate: Date
|
||||
let editingEvent: CalEvent?
|
||||
var copyFrom: CalEvent? = nil
|
||||
let onSaved: () async -> Void
|
||||
|
||||
@Environment(\.dismiss) var dismiss
|
||||
@AppStorage("appLanguage") private var appLang = "system"
|
||||
@AppStorage("defaultReminderMinutes") private var defaultReminderMinutes = -1
|
||||
@State private var title = ""
|
||||
@State private var isAllDay = false
|
||||
@State private var startDate = Date()
|
||||
@@ -16,10 +19,13 @@ struct EventEditorSheet: View {
|
||||
@State private var notes = ""
|
||||
@State private var selectedCalendarId: String = ""
|
||||
@State private var color = ""
|
||||
@State private var isPrivate = false
|
||||
@State private var reminders: [Int] = []
|
||||
@State private var isSaving = false
|
||||
@State private var error = ""
|
||||
|
||||
private var isEditing: Bool { editingEvent != nil }
|
||||
private var isCopying: Bool { copyFrom != nil && editingEvent == nil }
|
||||
|
||||
private var selectedCal: WritableCalendar? {
|
||||
store.writableCalendars.first { $0.id == selectedCalendarId }
|
||||
@@ -29,36 +35,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 +78,37 @@ struct EventEditorSheet: View {
|
||||
}
|
||||
}
|
||||
|
||||
Section("Farbe") {
|
||||
if selectedCal?.source == "local" {
|
||||
Section {
|
||||
Toggle(L10n.t("event.private", appLang), isOn: $isPrivate)
|
||||
.tint(Color.accentColor)
|
||||
}
|
||||
|
||||
Section(ReminderOptions.sectionTitle(appLang)) {
|
||||
ForEach(Array(reminders.enumerated()), id: \.offset) { idx, _ in
|
||||
Picker(ReminderOptions.sectionTitle(appLang), selection: Binding(
|
||||
get: { reminders.indices.contains(idx) ? reminders[idx] : 0 },
|
||||
set: { if reminders.indices.contains(idx) { reminders[idx] = $0 } }
|
||||
)) {
|
||||
ForEach(ReminderOptions.all, id: \.self) { opt in
|
||||
Text(ReminderOptions.label(opt, appLang)).tag(opt)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
}
|
||||
.onDelete { reminders.remove(atOffsets: $0) }
|
||||
Button {
|
||||
let next = ReminderOptions.all.first { !reminders.contains($0) } ?? 15
|
||||
reminders.append(next)
|
||||
} label: {
|
||||
Label(ReminderOptions.addLabel(appLang), systemImage: "bell.badge.plus")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 +116,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 +129,20 @@ struct EventEditorSheet: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(isEditing ? "Termin bearbeiten" : "Neuer Termin")
|
||||
.navigationTitle(
|
||||
isEditing ? L10n.t("event.edit_title", appLang) :
|
||||
isCopying ? L10n.t("event.copy_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()
|
||||
@@ -111,6 +151,12 @@ struct EventEditorSheet: View {
|
||||
}
|
||||
}
|
||||
.onAppear { setup() }
|
||||
.onChange(of: startDate) { oldStart, newStart in
|
||||
guard newStart >= endDate else { return }
|
||||
let duration = endDate.timeIntervalSince(oldStart)
|
||||
let minDuration: TimeInterval = isAllDay ? 86400 : 3600
|
||||
endDate = newStart.addingTimeInterval(max(duration, minDuration))
|
||||
}
|
||||
}
|
||||
|
||||
private func setup() {
|
||||
@@ -118,17 +164,43 @@ struct EventEditorSheet: View {
|
||||
title = ev.title
|
||||
isAllDay = ev.isAllDay
|
||||
startDate = ev.startDate
|
||||
endDate = ev.endDate
|
||||
// All-day end dates are stored as exclusive (day after last); subtract 1 for the picker.
|
||||
endDate = ev.isAllDay
|
||||
? Calendar.current.date(byAdding: .day, value: -1, to: ev.endDate) ?? ev.endDate
|
||||
: ev.endDate
|
||||
location = ev.location
|
||||
notes = ev.notes
|
||||
color = ev.color ?? ""
|
||||
isPrivate = ev.isPrivate
|
||||
reminders = ev.reminders
|
||||
// HA events use "homeassistant-42" in CalEvent but "ha-42" in WritableCalendar
|
||||
if ev.source == "homeassistant" {
|
||||
let num = ev.calendarId.replacingOccurrences(of: "homeassistant-", with: "")
|
||||
selectedCalendarId = "ha-\(num)"
|
||||
} else {
|
||||
selectedCalendarId = ev.calendarId
|
||||
}
|
||||
} else if let ev = copyFrom {
|
||||
title = ev.title
|
||||
isAllDay = ev.isAllDay
|
||||
startDate = ev.startDate
|
||||
endDate = ev.isAllDay
|
||||
? Calendar.current.date(byAdding: .day, value: -1, to: ev.endDate) ?? ev.endDate
|
||||
: ev.endDate
|
||||
location = ev.location
|
||||
notes = ev.notes
|
||||
color = ev.color ?? ""
|
||||
isPrivate = ev.isPrivate
|
||||
reminders = ev.reminders
|
||||
selectedCalendarId = store.writableCalendars.first?.id ?? ""
|
||||
} else {
|
||||
let cal = Calendar.current
|
||||
startDate = cal.date(bySettingHour: cal.component(.hour, from: initialDate),
|
||||
minute: 0, second: 0, of: initialDate) ?? initialDate
|
||||
endDate = startDate.addingTimeInterval(3600)
|
||||
selectedCalendarId = store.writableCalendars.first?.id ?? ""
|
||||
// New events inherit the user's default reminder (editable).
|
||||
if defaultReminderMinutes >= 0 { reminders = [defaultReminderMinutes] }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,10 +216,20 @@ struct EventEditorSheet: View {
|
||||
|
||||
do {
|
||||
if let ev = editingEvent {
|
||||
if ev.source == "local" {
|
||||
switch ev.source {
|
||||
case "local":
|
||||
try await api.updateLocalEvent(uid: ev.id, title: title, start: start, end: end,
|
||||
isAllDay: isAllDay, location: location, description: notes, color: colorVal)
|
||||
} else {
|
||||
isAllDay: isAllDay, location: location, description: notes, color: colorVal,
|
||||
isPrivate: isPrivate, reminders: reminders)
|
||||
case "homeassistant":
|
||||
// No update API exists – delete the old event and recreate with new data.
|
||||
let rawId = ev.calendarId.replacingOccurrences(of: "homeassistant-", with: "")
|
||||
let haCalId = Int(rawId) ?? 0
|
||||
try await api.deleteHAEvent(calendarId: haCalId, uid: ev.id)
|
||||
try await api.createHAEvent(calendarId: haCalId, title: title,
|
||||
start: start, end: end, isAllDay: isAllDay,
|
||||
location: location, description: notes)
|
||||
default: // caldav
|
||||
let calId = Int(ev.calendarId)
|
||||
try await api.updateCalDAVEvent(uid: ev.id, url: ev.url, calendarId: calId,
|
||||
title: title, start: start, end: end, isAllDay: isAllDay,
|
||||
@@ -158,7 +240,8 @@ struct EventEditorSheet: View {
|
||||
case "local":
|
||||
_ = try await api.createLocalEvent(calendarId: cal.numericId, title: title,
|
||||
start: start, end: end, isAllDay: isAllDay,
|
||||
location: location, description: notes, color: colorVal)
|
||||
location: location, description: notes, color: colorVal,
|
||||
isPrivate: isPrivate, reminders: reminders)
|
||||
case "google":
|
||||
try await api.createGoogleEvent(calendarDbId: cal.numericId, title: title,
|
||||
start: start, end: end, isAllDay: isAllDay,
|
||||
|
||||
@@ -1,150 +1,401 @@
|
||||
import SwiftUI
|
||||
|
||||
// Cover ~10 years back and ~77 years ahead, so the scrollable range goes well
|
||||
// past 2100 — enough room for any vacation that's actually getting planned.
|
||||
private let weeksBack = 520
|
||||
private let weeksAhead = 4000
|
||||
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
|
||||
|
||||
@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"
|
||||
@AppStorage("textContrast") private var textContrast = 3
|
||||
|
||||
@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)
|
||||
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()
|
||||
}
|
||||
.scrollIndicators(.hidden)
|
||||
.scrollPosition(id: $scrolledWeek, anchor: .top)
|
||||
.onAppear {
|
||||
if !didInitialScroll {
|
||||
didInitialScroll = true
|
||||
scrolledWeek = weekStart(for: store.currentDate)
|
||||
publishVisibleMonth(from: scrolledWeek)
|
||||
}
|
||||
}
|
||||
.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(.secondary)
|
||||
.frame(maxWidth: .infinity, minHeight: 28)
|
||||
.foregroundStyle(Color(hex: textHex).opacity(secondaryTextOpacity(textContrast)))
|
||||
.frame(maxWidth: .infinity, minHeight: weekdayHeaderHeight)
|
||||
}
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
private func weekStart(for date: Date) -> Date {
|
||||
cal.date(from: cal.dateComponents([.yearForWeekOfYear, .weekOfYear], from: date))!
|
||||
}
|
||||
|
||||
/// Determine the header month from the currently-scrolled week.
|
||||
/// Rule: take the month of the topmost visible week's start day. This
|
||||
/// means as long as the "1." of the next month is still visible in the
|
||||
/// top row, the header keeps showing the previous month – and only flips
|
||||
/// to the new month once its "1." has scrolled out of view above.
|
||||
private func publishVisibleMonth(from week: Date?) {
|
||||
guard let w = week else { return }
|
||||
let month = cal.date(from: cal.dateComponents([.year, .month], from: w)) ?? w
|
||||
if store.visibleMonth != month {
|
||||
store.visibleMonth = month
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Week Row
|
||||
|
||||
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 {
|
||||
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 isCurrentMonth: Bool
|
||||
let isToday: Bool
|
||||
let events: [CalEvent]
|
||||
let rowHeight: CGFloat
|
||||
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 onEventTap: (CalEvent) -> Void
|
||||
let onCreateEvent: () -> Void
|
||||
let onShowWeek: () -> Void
|
||||
let onShowDay: () -> Void
|
||||
|
||||
private var maxVisible: Int {
|
||||
max(1, Int((rowHeight - 32) / 16))
|
||||
@AppStorage("textContrast") private var textContrast = 3
|
||||
@AppStorage("lineContrast") private var lineContrast = 3
|
||||
|
||||
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: 2) {
|
||||
// Day number
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Button(action: onTap) {
|
||||
Text("\(Calendar.current.component(.day, from: date))")
|
||||
HStack(spacing: 4) {
|
||||
Text("\(dayNum)")
|
||||
.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)
|
||||
.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)
|
||||
}
|
||||
.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)")
|
||||
Spacer(minLength: 0)
|
||||
HStack(spacing: 0) {
|
||||
if extraCount > 0 {
|
||||
Text("+\(extraCount)")
|
||||
.font(.system(size: 9, weight: .medium))
|
||||
.foregroundStyle(.secondary)
|
||||
.foregroundStyle(textColor.opacity(secondaryTextOpacity(textContrast)))
|
||||
.padding(.leading, 4)
|
||||
}
|
||||
|
||||
Spacer(minLength: 0)
|
||||
if let wn = weekNumber {
|
||||
Text("\(cwLabel) \(wn)")
|
||||
.font(.system(size: 9, weight: .medium))
|
||||
.foregroundStyle(textColor.opacity(secondaryTextOpacity(textContrast)))
|
||||
.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(gridLineOpacity(lineContrast))).frame(width: 0.5)
|
||||
}
|
||||
.overlay(alignment: .top) {
|
||||
Rectangle()
|
||||
.fill(edge == .topHighlight ? dividerColor : lineColor.opacity(gridLineOpacity(lineContrast)))
|
||||
.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
|
||||
@AppStorage("dimPastEvents") private var dimPast = false
|
||||
|
||||
private var isPast: Bool { event.endDate < .now }
|
||||
|
||||
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)
|
||||
.opacity(dimPast && isPast ? 0.5 : 1.0)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,54 @@
|
||||
import SwiftUI
|
||||
|
||||
// Shared constants used by WeekView, DayView, EventEditorSheet
|
||||
let hourHeight: CGFloat = 60
|
||||
let timeColumnWidth: CGFloat = 44
|
||||
let hours = Array(0..<24)
|
||||
|
||||
/// Live hour-row height, driven by the synced `hourHeight` setting.
|
||||
/// Falls back to 60 when unset (fresh install / value 0). Views that lay out
|
||||
/// against this also observe `@AppStorage("hourHeight")` so their body
|
||||
/// re-renders when it changes.
|
||||
var hourHeight: CGFloat {
|
||||
let v = UserDefaults.standard.integer(forKey: "hourHeight")
|
||||
return v > 0 ? CGFloat(v) : 60
|
||||
}
|
||||
|
||||
/// Opacity for secondary text (weekday headers, time labels, "+N"/"KW"),
|
||||
/// mapped from the 1–4 `textContrast` level. Level 3 ≈ the previous hard-coded
|
||||
/// look so existing installs are visually unchanged.
|
||||
func secondaryTextOpacity(_ level: Int) -> Double {
|
||||
switch level {
|
||||
case 1: return 0.4
|
||||
case 2: return 0.55
|
||||
case 4: return 1.0
|
||||
default: return 0.75
|
||||
}
|
||||
}
|
||||
|
||||
/// Opacity for grid lines / separators, mapped from the 1–4 `lineContrast`
|
||||
/// level. Level 3 ≈ the previous hard-coded ~0.4 look.
|
||||
func gridLineOpacity(_ level: Int) -> Double {
|
||||
switch level {
|
||||
case 1: return 0.15
|
||||
case 2: return 0.3
|
||||
case 4: return 0.8
|
||||
default: return 0.5
|
||||
}
|
||||
}
|
||||
|
||||
/// A timed (non-all-day) event that crosses a day boundary. Such events must
|
||||
/// NOT be placed in the hourly grid — their height would be `duration ×
|
||||
/// hourHeight`, i.e. taller than the whole day, rendering as a giant block
|
||||
/// (and, sharing one id across days, only drawing on the first day). They are
|
||||
/// shown in the all-day strip instead, like all-day events.
|
||||
func eventSpansMultipleDays(_ ev: CalEvent) -> Bool {
|
||||
guard !ev.isAllDay, ev.endDate > ev.startDate else { return false }
|
||||
let cal = Calendar.current
|
||||
// End is exclusive: an event ending exactly at midnight is still single-day.
|
||||
let lastInstant = ev.endDate.addingTimeInterval(-1)
|
||||
return !cal.isDate(ev.startDate, inSameDayAs: lastInstant)
|
||||
}
|
||||
|
||||
// Position helpers
|
||||
func eventTop(_ ev: CalEvent) -> CGFloat {
|
||||
let cal = Calendar.current
|
||||
@@ -21,6 +65,9 @@ func eventHeight(_ ev: CalEvent) -> CGFloat {
|
||||
// Shared event block used in WeekView and DayView
|
||||
struct EventBlock: View {
|
||||
let event: CalEvent
|
||||
@AppStorage("dimPastEvents") private var dimPast = false
|
||||
|
||||
private var isPast: Bool { event.endDate < .now }
|
||||
|
||||
var body: some View {
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
@@ -41,5 +88,6 @@ struct EventBlock: View {
|
||||
.padding(4)
|
||||
}
|
||||
.padding(.horizontal, 1)
|
||||
.opacity(dimPast && isPast ? 0.5 : 1.0)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,17 @@ 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"
|
||||
@AppStorage("textContrast") private var textContrast = 3
|
||||
@AppStorage("lineContrast") private var lineContrast = 3
|
||||
@AppStorage("hourHeight") private var hourHeightPref = 60 // observed for live re-layout
|
||||
|
||||
private var cal: Calendar { store.userCalendar }
|
||||
|
||||
@@ -14,14 +24,16 @@ struct WeekView: View {
|
||||
|
||||
private var timedEvents: [(Int, CalEvent)] {
|
||||
weekDays.enumerated().flatMap { idx, day in
|
||||
store.events(on: day).filter { !$0.isAllDay }.map { (idx, $0) }
|
||||
store.events(on: day)
|
||||
.filter { !$0.isAllDay && !eventSpansMultipleDays($0) }
|
||||
.map { (idx, $0) }
|
||||
}
|
||||
}
|
||||
|
||||
private var allDayEvents: [CalEvent] {
|
||||
let s = weekDays.first ?? .now
|
||||
let e = cal.date(byAdding: .day, value: 1, to: weekDays.last ?? .now)!
|
||||
return store.events(in: s, end: e).filter(\.isAllDay)
|
||||
return store.events(in: s, end: e).filter { $0.isAllDay || eventSpansMultipleDays($0) }
|
||||
}
|
||||
|
||||
private var todayIndex: Int? {
|
||||
@@ -49,10 +61,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(hex: todayHex) : Color(hex: textHex).opacity(secondaryTextOpacity(textContrast)))
|
||||
.frame(maxWidth: .infinity, minHeight: 36)
|
||||
.overlay(alignment: .trailing) {
|
||||
Rectangle().fill(Color(.separator)).frame(width: 0.5)
|
||||
Rectangle().fill(Color(hex: lineHex).opacity(gridLineOpacity(lineContrast))).frame(width: 0.5)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -87,7 +99,7 @@ struct WeekView: View {
|
||||
.padding(.horizontal, 1)
|
||||
.frame(maxWidth: .infinity)
|
||||
.overlay(alignment: .trailing) {
|
||||
Rectangle().fill(Color(.separator)).frame(width: 0.5)
|
||||
Rectangle().fill(Color(hex: lineHex).opacity(gridLineOpacity(lineContrast))).frame(width: 0.5)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -104,28 +116,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).opacity(gridLineOpacity(lineContrast))).frame(width: 0.5)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -144,10 +151,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 +176,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(secondaryTextOpacity(textContrast)))
|
||||
.offset(y: -6)
|
||||
}
|
||||
}
|
||||
@@ -194,3 +202,40 @@ 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"
|
||||
@AppStorage("lineContrast") private var lineContrast = 3
|
||||
|
||||
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(gridLineOpacity(lineContrast))).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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
285
Calendarr iOS/Views/CalendarFilterSheet.swift
Normal file
285
Calendarr iOS/Views/CalendarFilterSheet.swift
Normal file
@@ -0,0 +1,285 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Lets the user toggle which calendars contribute events to the displayed
|
||||
/// calendar views (and the home-screen widgets). Filtering is purely
|
||||
/// client-side: hidden keys live in UserDefaults via `CalendarStore`. No
|
||||
/// server roundtrip is required to toggle visibility.
|
||||
struct CalendarFilterSheet: View {
|
||||
let api: CalendarrAPI
|
||||
let store: CalendarStore
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@AppStorage("appLanguage") private var appLang = "system"
|
||||
|
||||
@State private var caldavAccounts: [CalDAVAccount] = []
|
||||
@State private var localCalendars: [LocalCalendar] = []
|
||||
@State private var icalSubs: [ICalSubscription] = []
|
||||
@State private var googleAccounts: [GoogleAccount] = []
|
||||
@State private var haAccounts: [HomeAssistantAccount] = []
|
||||
@State private var isLoading = true
|
||||
@State private var hidden: Set<String> = []
|
||||
@State private var banished: Set<String> = []
|
||||
/// All non-banished keys discovered during load — used by bulk show/hide.
|
||||
@State private var allKeys: Set<String> = []
|
||||
/// Group-mode: the active group's full detail (members + colours) and the
|
||||
/// per-member / group-calendar hidden keys.
|
||||
@State private var groupDetail: CalGroup? = nil
|
||||
@State private var hiddenGroup: Set<String> = []
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Group {
|
||||
if isLoading {
|
||||
ProgressView(L10n.t("filter.loading", appLang))
|
||||
} else if store.activeGroup != nil {
|
||||
groupFilterList
|
||||
} else if allKeys.isEmpty {
|
||||
Text(L10n.t("filter.empty", appLang))
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
List {
|
||||
let visibleLocals = localCalendars.filter {
|
||||
!banished.contains(CalendarStore.calendarKey(source: "local", calendarId: "\($0.id)"))
|
||||
}
|
||||
if !visibleLocals.isEmpty {
|
||||
Section(L10n.t("accounts.local.header", appLang)) {
|
||||
ForEach(visibleLocals) { cal in
|
||||
row(name: cal.name, colorHex: cal.color,
|
||||
key: CalendarStore.calendarKey(source: "local", calendarId: "\(cal.id)"))
|
||||
}
|
||||
}
|
||||
}
|
||||
ForEach(caldavAccounts) { acc in
|
||||
let cals = (acc.calendars ?? []).filter {
|
||||
!banished.contains(CalendarStore.calendarKey(source: "caldav", calendarId: "\($0.id)"))
|
||||
}
|
||||
if !cals.isEmpty {
|
||||
Section(acc.name) {
|
||||
ForEach(cals) { cal in
|
||||
row(name: cal.name,
|
||||
colorHex: cal.color ?? acc.color,
|
||||
key: CalendarStore.calendarKey(source: "caldav", calendarId: "\(cal.id)"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let visibleSubs = icalSubs.filter {
|
||||
!banished.contains(CalendarStore.calendarKey(source: "ical", calendarId: "\($0.id)"))
|
||||
}
|
||||
if !visibleSubs.isEmpty {
|
||||
Section(L10n.t("accounts.ical.header", appLang)) {
|
||||
ForEach(visibleSubs) { sub in
|
||||
row(name: sub.name, colorHex: sub.color,
|
||||
key: CalendarStore.calendarKey(source: "ical", calendarId: "\(sub.id)"))
|
||||
}
|
||||
}
|
||||
}
|
||||
ForEach(googleAccounts) { acc in
|
||||
let cals = (acc.calendars ?? []).filter {
|
||||
!banished.contains(CalendarStore.calendarKey(source: "google", calendarId: "\($0.id)"))
|
||||
}
|
||||
if !cals.isEmpty {
|
||||
Section(acc.email) {
|
||||
ForEach(cals) { cal in
|
||||
row(name: cal.name,
|
||||
colorHex: cal.color ?? "#4285f4",
|
||||
key: CalendarStore.calendarKey(source: "google", calendarId: "\(cal.id)"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ForEach(haAccounts) { acc in
|
||||
let cals = (acc.calendars ?? []).filter {
|
||||
!banished.contains(CalendarStore.calendarKey(source: "homeassistant", calendarId: "\($0.id)"))
|
||||
}
|
||||
if !cals.isEmpty {
|
||||
Section(acc.name) {
|
||||
ForEach(cals) { cal in
|
||||
row(name: cal.name,
|
||||
colorHex: cal.color ?? "#46bdc6",
|
||||
key: CalendarStore.calendarKey(source: "homeassistant", calendarId: "\(cal.id)"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !banished.isEmpty {
|
||||
Section {
|
||||
Text(L10n.t("filter.banished_footer", appLang))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(L10n.t("filter.title", appLang))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Menu {
|
||||
Button(L10n.t("filter.show_all", appLang)) {
|
||||
hidden = []
|
||||
store.setHiddenCalendars(hidden)
|
||||
}
|
||||
Button(L10n.t("filter.hide_all", appLang)) {
|
||||
hidden = allKeys
|
||||
store.setHiddenCalendars(hidden)
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "ellipsis.circle")
|
||||
}
|
||||
.disabled(allKeys.isEmpty)
|
||||
}
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button(L10n.t("nav.done", appLang)) { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
.task { await load() }
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func row(name: String, colorHex: String, key: String) -> some View {
|
||||
let isVisible = !hidden.contains(key)
|
||||
Button {
|
||||
if isVisible { hidden.insert(key) } else { hidden.remove(key) }
|
||||
// New hidden state == was-visible (flip). Previous code passed the
|
||||
// inverse, which persisted the opposite of what the UI showed.
|
||||
store.setCalendarHidden(key, hidden: isVisible)
|
||||
} label: {
|
||||
HStack(spacing: 12) {
|
||||
Circle()
|
||||
.fill(Color(hex: colorHex))
|
||||
.frame(width: 14, height: 14)
|
||||
.opacity(isVisible ? 1.0 : 0.35)
|
||||
Text(name)
|
||||
.foregroundStyle(isVisible ? .primary : .secondary)
|
||||
.strikethrough(!isVisible, color: .secondary)
|
||||
Spacer()
|
||||
Image(systemName: isVisible ? "eye" : "eye.slash")
|
||||
.foregroundStyle(isVisible ? Color.accentColor : .secondary)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
|
||||
Button(role: .destructive) {
|
||||
hidden.remove(key)
|
||||
banished.insert(key)
|
||||
store.setCalendarBanished(key, banished: true)
|
||||
pushBanishToServer(key: key, hidden: true)
|
||||
} label: {
|
||||
Label(L10n.t("filter.banish", appLang), systemImage: "archivebox")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Group overlay filter (hide individual members / the group calendar)
|
||||
|
||||
@ViewBuilder
|
||||
private var groupFilterList: some View {
|
||||
if let g = groupDetail {
|
||||
List {
|
||||
Section(header: Label(g.name, systemImage: GroupIcons.symbol(g.icon))) {
|
||||
ForEach(g.members ?? []) { m in
|
||||
groupRow(name: m.displayName ?? "—",
|
||||
colorHex: m.color ?? "#4285f4",
|
||||
key: CalendarStore.groupMemberKey(m.id))
|
||||
}
|
||||
groupRow(name: L10n.t("group.calendar", appLang),
|
||||
colorHex: g.groupCalendarColor ?? "#4285f4",
|
||||
key: CalendarStore.groupCalendarKey)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Text(L10n.t("filter.empty", appLang)).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func groupRow(name: String, colorHex: String, key: String) -> some View {
|
||||
let isVisible = !hiddenGroup.contains(key)
|
||||
Button {
|
||||
if isVisible { hiddenGroup.insert(key) } else { hiddenGroup.remove(key) }
|
||||
store.setGroupKeyHidden(key, hidden: isVisible)
|
||||
} label: {
|
||||
HStack(spacing: 12) {
|
||||
Circle()
|
||||
.fill(Color(hex: colorHex))
|
||||
.frame(width: 14, height: 14)
|
||||
.opacity(isVisible ? 1.0 : 0.35)
|
||||
Text(name)
|
||||
.foregroundStyle(isVisible ? .primary : .secondary)
|
||||
.strikethrough(!isVisible, color: .secondary)
|
||||
Spacer()
|
||||
Image(systemName: isVisible ? "eye" : "eye.slash")
|
||||
.foregroundStyle(isVisible ? Color.accentColor : .secondary)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private func load() async {
|
||||
isLoading = true
|
||||
// Group overlay: list members (+ the group calendar) to hide individually.
|
||||
if let g = store.activeGroup {
|
||||
hiddenGroup = store.hiddenGroupKeys
|
||||
groupDetail = try? await api.getGroup(id: g.id)
|
||||
isLoading = false
|
||||
return
|
||||
}
|
||||
hidden = store.hiddenCalendarKeys
|
||||
banished = store.banishedCalendarKeys
|
||||
async let c = (try? await api.getCalDAVAccounts()) ?? []
|
||||
async let l = (try? await api.getLocalCalendars()) ?? []
|
||||
async let i = (try? await api.getICalSubscriptions()) ?? []
|
||||
async let g = (try? await api.getGoogleAccounts()) ?? []
|
||||
async let h = (try? await api.getHomeAssistantAccounts()) ?? []
|
||||
(caldavAccounts, localCalendars, icalSubs, googleAccounts, haAccounts) = await (c, l, i, g, h)
|
||||
|
||||
// Reconcile banished state with the server's sidebar_hidden flags
|
||||
// (server wins for CalDAV/Google/HA; local/ical keep their local state).
|
||||
var b = store.banishedCalendarKeys
|
||||
func applyServerHidden(_ source: String, _ id: Int, _ hidden: Bool) {
|
||||
let key = CalendarStore.calendarKey(source: source, calendarId: "\(id)")
|
||||
if hidden { b.insert(key) } else { b.remove(key) }
|
||||
}
|
||||
for acc in caldavAccounts { for cal in acc.calendars ?? [] { applyServerHidden("caldav", cal.id, cal.sidebarHidden) } }
|
||||
for acc in googleAccounts { for cal in acc.calendars ?? [] { applyServerHidden("google", cal.id, cal.sidebarHidden) } }
|
||||
for acc in haAccounts { for cal in acc.calendars ?? [] { applyServerHidden("homeassistant", cal.id, cal.sidebarHidden) } }
|
||||
store.setBanishedCalendars(b)
|
||||
banished = b
|
||||
|
||||
var keys = Set<String>()
|
||||
for cal in localCalendars {
|
||||
keys.insert(CalendarStore.calendarKey(source: "local", calendarId: "\(cal.id)"))
|
||||
}
|
||||
for acc in caldavAccounts {
|
||||
for cal in acc.calendars ?? [] {
|
||||
keys.insert(CalendarStore.calendarKey(source: "caldav", calendarId: "\(cal.id)"))
|
||||
}
|
||||
}
|
||||
for sub in icalSubs {
|
||||
keys.insert(CalendarStore.calendarKey(source: "ical", calendarId: "\(sub.id)"))
|
||||
}
|
||||
for acc in googleAccounts {
|
||||
for cal in acc.calendars ?? [] {
|
||||
keys.insert(CalendarStore.calendarKey(source: "google", calendarId: "\(cal.id)"))
|
||||
}
|
||||
}
|
||||
for acc in haAccounts {
|
||||
for cal in acc.calendars ?? [] {
|
||||
keys.insert(CalendarStore.calendarKey(source: "homeassistant", calendarId: "\(cal.id)"))
|
||||
}
|
||||
}
|
||||
allKeys = keys
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
/// For server-backed sources, persist the banish on the server too.
|
||||
private func pushBanishToServer(key: String, hidden: Bool) {
|
||||
guard let parsed = CalendarStore.parseCalendarKey(key),
|
||||
CalendarStore.serverManagedSources.contains(parsed.source) else { return }
|
||||
Task { try? await api.setCalendarSidebarHidden(source: parsed.source, calendarId: parsed.id, hidden: hidden) }
|
||||
}
|
||||
}
|
||||
412
Calendarr iOS/Views/GroupsView.swift
Normal file
412
Calendarr iOS/Views/GroupsView.swift
Normal file
@@ -0,0 +1,412 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Group icons (cross-platform, non-emoji)
|
||||
|
||||
/// Canonical group-icon keys stored server-side and rendered as native SF
|
||||
/// Symbols here (Material on Android, SVG on web), so groups look consistent
|
||||
/// instead of relying on OS-specific emoji rendering.
|
||||
enum GroupIcons {
|
||||
static let keys = ["people", "home", "heart", "work", "school", "sports",
|
||||
"party", "pet", "travel", "music", "food", "star"]
|
||||
|
||||
static func symbol(_ key: String?) -> String {
|
||||
switch key {
|
||||
case "people": return "person.2.fill"
|
||||
case "home": return "house.fill"
|
||||
case "heart": return "heart.fill"
|
||||
case "work": return "briefcase.fill"
|
||||
case "school": return "graduationcap.fill"
|
||||
case "sports": return "figure.run"
|
||||
case "party": return "party.popper.fill"
|
||||
case "pet": return "pawprint.fill"
|
||||
case "travel": return "airplane"
|
||||
case "music": return "music.note"
|
||||
case "food": return "fork.knife"
|
||||
case "star": return "star.fill"
|
||||
default: return "person.2.fill"
|
||||
}
|
||||
}
|
||||
|
||||
static func isKey(_ s: String?) -> Bool { if let s { return keys.contains(s) }; return false }
|
||||
}
|
||||
|
||||
/// Render a group's icon: native SF Symbol for known keys, the legacy emoji for
|
||||
/// pre-migration groups, else a default people glyph.
|
||||
struct GroupIconView: View {
|
||||
let icon: String?
|
||||
var body: some View {
|
||||
if GroupIcons.isKey(icon) {
|
||||
Image(systemName: GroupIcons.symbol(icon))
|
||||
} else if let e = icon, !e.isEmpty {
|
||||
Text(e)
|
||||
} else {
|
||||
Image(systemName: "person.2.fill")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Groups list
|
||||
|
||||
struct GroupsView: View {
|
||||
let api: CalendarrAPI
|
||||
@AppStorage("appLanguage") private var appLang = "system"
|
||||
@State private var groups: [CalGroup] = []
|
||||
@State private var isLoading = true
|
||||
@State private var showCreate = false
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if isLoading {
|
||||
ProgressView()
|
||||
} else {
|
||||
List {
|
||||
if groups.isEmpty {
|
||||
Text(L10n.t("groups.none", appLang)).foregroundStyle(.secondary)
|
||||
}
|
||||
ForEach(groups) { g in
|
||||
NavigationLink {
|
||||
GroupCombinedView(api: api, group: g)
|
||||
} label: {
|
||||
HStack {
|
||||
GroupIconView(icon: g.icon)
|
||||
Text(g.name)
|
||||
Spacer()
|
||||
if let n = g.memberCount {
|
||||
Text("\(n)").font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.swipeActions {
|
||||
NavigationLink {
|
||||
GroupManageSheet(api: api, groupId: g.id) { await load() }
|
||||
} label: {
|
||||
Label(L10n.t("group.manage", appLang), systemImage: "slider.horizontal.3")
|
||||
}.tint(.gray)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(L10n.t("groups.title", appLang))
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button { showCreate = true } label: { Image(systemName: "plus") }
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showCreate) {
|
||||
GroupEditSheet(api: api, existing: nil) { await load() }
|
||||
}
|
||||
.task { await load() }
|
||||
}
|
||||
|
||||
private func load() async {
|
||||
isLoading = true
|
||||
groups = (try? await api.getGroups()) ?? []
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Create / edit a group (name + icon + members)
|
||||
|
||||
struct GroupEditSheet: View {
|
||||
let api: CalendarrAPI
|
||||
let existing: CalGroup?
|
||||
let onDone: () async -> Void
|
||||
@Environment(\.dismiss) var dismiss
|
||||
@AppStorage("appLanguage") private var appLang = "system"
|
||||
|
||||
@State private var name = ""
|
||||
@State private var icon = "people"
|
||||
@State private var directory: [DirectoryUser] = []
|
||||
@State private var selected: Set<Int> = []
|
||||
@State private var error = ""
|
||||
|
||||
private let icons = GroupIcons.keys
|
||||
private let cols = [GridItem(.adaptive(minimum: 46))]
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section(L10n.t("group.name", appLang)) {
|
||||
TextField(L10n.t("group.name", appLang), text: $name)
|
||||
}
|
||||
Section(L10n.t("group.icon", appLang)) {
|
||||
LazyVGrid(columns: cols, spacing: 8) {
|
||||
ForEach(icons, id: \.self) { ic in
|
||||
Image(systemName: GroupIcons.symbol(ic))
|
||||
.font(.title3)
|
||||
.frame(width: 44, height: 44)
|
||||
.background(ic == icon ? Color.accentColor.opacity(0.25) : Color(.systemGray6))
|
||||
.foregroundStyle(ic == icon ? Color.accentColor : .primary)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
.onTapGesture { icon = ic }
|
||||
}
|
||||
}
|
||||
}
|
||||
Section(L10n.t("group.members", appLang)) {
|
||||
ForEach(directory) { u in
|
||||
Button {
|
||||
if selected.contains(u.id) { selected.remove(u.id) } else { selected.insert(u.id) }
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: selected.contains(u.id) ? "checkmark.square.fill" : "square")
|
||||
.foregroundStyle(selected.contains(u.id) ? Color.accentColor : .secondary)
|
||||
Text(u.displayName).foregroundStyle(.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !error.isEmpty { Section { Text(error).foregroundStyle(.red) } }
|
||||
}
|
||||
.navigationTitle(existing == nil ? L10n.t("group.create", appLang) : L10n.t("group.manage", appLang))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) { Button(L10n.t("common.cancel", appLang)) { dismiss() } }
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button(L10n.t("event.save", appLang)) { Task { await save() } }
|
||||
.bold().disabled(name.isEmpty)
|
||||
}
|
||||
}
|
||||
}
|
||||
.task { directory = (try? await api.getUserDirectory()) ?? [] }
|
||||
}
|
||||
|
||||
private func save() async {
|
||||
do {
|
||||
_ = try await api.createGroup(name: name, memberIds: Array(selected), icon: icon)
|
||||
await onDone()
|
||||
dismiss()
|
||||
} catch { self.error = error.localizedDescription }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Manage existing group (rename, icon, members, colors, delete)
|
||||
|
||||
struct GroupManageSheet: View {
|
||||
let api: CalendarrAPI
|
||||
let groupId: Int
|
||||
let onDone: () async -> Void
|
||||
@Environment(\.dismiss) var dismiss
|
||||
@AppStorage("appLanguage") private var appLang = "system"
|
||||
|
||||
@State private var group: CalGroup?
|
||||
@State private var name = ""
|
||||
@State private var icon = "people"
|
||||
@State private var directory: [DirectoryUser] = []
|
||||
@State private var memberIds: Set<Int> = []
|
||||
@State private var showDeleteConfirm = false
|
||||
@State private var error = ""
|
||||
|
||||
private let icons = GroupIcons.keys
|
||||
private let cols = [GridItem(.adaptive(minimum: 46))]
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section(L10n.t("group.name", appLang)) {
|
||||
TextField(L10n.t("group.name", appLang), text: $name)
|
||||
}
|
||||
Section(L10n.t("group.icon", appLang)) {
|
||||
LazyVGrid(columns: cols, spacing: 8) {
|
||||
ForEach(icons, id: \.self) { ic in
|
||||
Image(systemName: GroupIcons.symbol(ic))
|
||||
.font(.title3)
|
||||
.frame(width: 44, height: 44)
|
||||
.background(ic == icon ? Color.accentColor.opacity(0.25) : Color(.systemGray6))
|
||||
.foregroundStyle(ic == icon ? Color.accentColor : .primary)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
.onTapGesture { icon = ic }
|
||||
}
|
||||
}
|
||||
}
|
||||
Section(L10n.t("group.members", appLang)) {
|
||||
ForEach(directory) { u in
|
||||
Button {
|
||||
if memberIds.contains(u.id) { memberIds.remove(u.id) } else { memberIds.insert(u.id) }
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: memberIds.contains(u.id) ? "checkmark.square.fill" : "square")
|
||||
.foregroundStyle(memberIds.contains(u.id) ? Color.accentColor : .secondary)
|
||||
Text(u.displayName).foregroundStyle(.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let members = group?.members {
|
||||
Section(L10n.t("group.member_colors", appLang)) {
|
||||
ForEach(members) { m in
|
||||
MemberColorRow(api: api, groupId: groupId, member: m)
|
||||
}
|
||||
}
|
||||
}
|
||||
Section {
|
||||
Button(role: .destructive) { showDeleteConfirm = true } label: {
|
||||
Label(L10n.t("group.delete", appLang), systemImage: "trash")
|
||||
}
|
||||
}
|
||||
if !error.isEmpty { Section { Text(error).foregroundStyle(.red) } }
|
||||
}
|
||||
.navigationTitle(L10n.t("group.manage", appLang))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) { Button(L10n.t("common.cancel", appLang)) { dismiss() } }
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button(L10n.t("event.save", appLang)) { Task { await save() } }.bold()
|
||||
}
|
||||
}
|
||||
.alert(L10n.t("group.delete", appLang), isPresented: $showDeleteConfirm) {
|
||||
Button(L10n.t("group.delete", appLang), role: .destructive) { Task { await deleteGroup() } }
|
||||
Button(L10n.t("common.cancel", appLang), role: .cancel) {}
|
||||
}
|
||||
}
|
||||
.task { await load() }
|
||||
}
|
||||
|
||||
private func load() async {
|
||||
directory = (try? await api.getUserDirectory()) ?? []
|
||||
if let g = try? await api.getGroup(id: groupId) {
|
||||
group = g
|
||||
name = g.name
|
||||
icon = GroupIcons.isKey(g.icon) ? g.icon! : "people"
|
||||
let me = UserDefaults.standard.integer(forKey: "userId")
|
||||
memberIds = Set((g.members ?? []).map { $0.id }.filter { $0 != me })
|
||||
}
|
||||
}
|
||||
|
||||
private func save() async {
|
||||
do {
|
||||
try await api.updateGroup(id: groupId, name: name, icon: icon)
|
||||
let me = UserDefaults.standard.integer(forKey: "userId")
|
||||
let current = Set((group?.members ?? []).map { $0.id }.filter { $0 != me })
|
||||
for id in memberIds where !current.contains(id) { try await api.addGroupMember(groupId: groupId, userId: id) }
|
||||
for id in current where !memberIds.contains(id) { try await api.removeGroupMember(groupId: groupId, userId: id) }
|
||||
await onDone()
|
||||
dismiss()
|
||||
} catch { self.error = error.localizedDescription }
|
||||
}
|
||||
|
||||
private func deleteGroup() async {
|
||||
do { try await api.deleteGroup(id: groupId); await onDone(); dismiss() }
|
||||
catch { self.error = error.localizedDescription }
|
||||
}
|
||||
}
|
||||
|
||||
struct MemberColorRow: View {
|
||||
let api: CalendarrAPI
|
||||
let groupId: Int
|
||||
let member: GroupMember
|
||||
@State private var color: Color
|
||||
|
||||
init(api: CalendarrAPI, groupId: Int, member: GroupMember) {
|
||||
self.api = api; self.groupId = groupId; self.member = member
|
||||
_color = State(initialValue: Color(hex: member.color ?? "#4285f4"))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text(member.displayName ?? "—")
|
||||
Spacer()
|
||||
ColorPicker("", selection: $color, supportsOpacity: false)
|
||||
.labelsHidden()
|
||||
.onChange(of: color) { _, c in
|
||||
Task { try? await api.setGroupMemberColor(groupId: groupId, userId: member.id, color: c.toHex()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Combined (overlay) agenda view
|
||||
|
||||
struct GroupCombinedView: View {
|
||||
let api: CalendarrAPI
|
||||
let group: CalGroup
|
||||
@AppStorage("appLanguage") private var appLang = "system"
|
||||
|
||||
@State private var anchor = Date()
|
||||
@State private var events: [CalEvent] = []
|
||||
@State private var isLoading = false
|
||||
|
||||
private var monthRange: (Date, Date) {
|
||||
let cal = Calendar.current
|
||||
let start = cal.date(from: cal.dateComponents([.year, .month], from: anchor)) ?? anchor
|
||||
let end = cal.date(byAdding: .month, value: 1, to: start) ?? anchor
|
||||
return (start, end)
|
||||
}
|
||||
|
||||
private var grouped: [(day: Date, items: [CalEvent])] {
|
||||
let cal = Calendar.current
|
||||
let dict = Dictionary(grouping: events.sorted { $0.startDate < $1.startDate }) {
|
||||
cal.startOfDay(for: $0.startDate)
|
||||
}
|
||||
return dict.keys.sorted().map { ($0, dict[$0] ?? []) }
|
||||
}
|
||||
|
||||
private let monthFmt: DateFormatter = {
|
||||
let f = DateFormatter(); f.dateFormat = "MMMM yyyy"; return f
|
||||
}()
|
||||
private let dayFmt: DateFormatter = {
|
||||
let f = DateFormatter(); f.dateFormat = "EEEE, d. MMM"; return f
|
||||
}()
|
||||
private let timeFmt: DateFormatter = {
|
||||
let f = DateFormatter(); f.timeStyle = .short; f.dateStyle = .none; return f
|
||||
}()
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
ForEach(grouped, id: \.day) { section in
|
||||
Section(dayFmt.string(from: section.day)) {
|
||||
ForEach(section.items) { ev in
|
||||
HStack(spacing: 10) {
|
||||
RoundedRectangle(cornerRadius: 3)
|
||||
.fill(Color(hex: ev.effectiveColor))
|
||||
.frame(width: 5, height: 34)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(displayTitle(ev)).font(.body)
|
||||
Text(ev.isAllDay ? L10n.t("event.allday", appLang) : timeFmt.string(from: ev.startDate))
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !isLoading && events.isEmpty {
|
||||
Text(L10n.t("groups.combined_empty", appLang)).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.navigationTitle(group.name)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
HStack {
|
||||
Button { shift(-1) } label: { Image(systemName: "chevron.left") }
|
||||
Button { shift(1) } label: { Image(systemName: "chevron.right") }
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .principal) {
|
||||
Text(monthFmt.string(from: anchor)).font(.headline)
|
||||
}
|
||||
}
|
||||
.task(id: anchor) { await load() }
|
||||
}
|
||||
|
||||
// Prefer the server-decorated title; fall back to a name prefix.
|
||||
private func displayTitle(_ ev: CalEvent) -> String {
|
||||
if let dt = ev.displayTitle, !dt.isEmpty { return dt }
|
||||
let me = UserDefaults.standard.integer(forKey: "userId")
|
||||
if let c = ev.creator, ev.isGroupEvent, c.id != me { return "\(firstName(c.displayName)): \(ev.title)" }
|
||||
if let o = ev.owner, o.id != me { return "\(firstName(o.displayName)): \(ev.title)" }
|
||||
return ev.title
|
||||
}
|
||||
private func firstName(_ s: String) -> String { s.split(separator: " ").first.map(String.init) ?? s }
|
||||
|
||||
private func shift(_ months: Int) {
|
||||
anchor = Calendar.current.date(byAdding: .month, value: months, to: anchor) ?? anchor
|
||||
}
|
||||
|
||||
private func load() async {
|
||||
isLoading = true
|
||||
let (s, e) = monthRange
|
||||
events = (try? await api.fetchGroupCombined(groupId: group.id, start: s, end: e)) ?? []
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@ struct MenuSheet: View {
|
||||
let api: CalendarrAPI
|
||||
@Environment(AppState.self) var appState
|
||||
@Environment(\.dismiss) var dismiss
|
||||
@AppStorage("appLanguage") private var appLang = "system"
|
||||
@State private var isSyncing = false
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
@@ -30,7 +32,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,33 +43,51 @@ 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 {
|
||||
GroupsView(api: api)
|
||||
} label: {
|
||||
Label(L10n.t("groups.title", appLang), systemImage: "person.2")
|
||||
}
|
||||
|
||||
NavigationLink {
|
||||
ServerView()
|
||||
} label: {
|
||||
Label("Server", systemImage: "server.rack")
|
||||
Label(L10n.t("menu.server", appLang), systemImage: "server.rack")
|
||||
}
|
||||
}
|
||||
|
||||
Section(L10n.t("menu.sync.section", appLang)) {
|
||||
Button {
|
||||
Task { await syncNow() }
|
||||
} label: {
|
||||
HStack {
|
||||
Label(L10n.t("menu.sync", appLang), systemImage: "arrow.triangle.2.circlepath")
|
||||
Spacer()
|
||||
if isSyncing { ProgressView() }
|
||||
}
|
||||
}
|
||||
.disabled(isSyncing)
|
||||
}
|
||||
|
||||
Section {
|
||||
Button(role: .destructive) {
|
||||
dismiss()
|
||||
@@ -75,17 +95,27 @@ 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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Manual sync: pull appearance/behaviour settings from the server, then
|
||||
/// ask the calendar host to re-fetch events (cache-busting).
|
||||
private func syncNow() async {
|
||||
isSyncing = true
|
||||
await SettingsSync.pull(api: api)
|
||||
NotificationCenter.default.post(name: .manualSyncRequested, object: nil)
|
||||
isSyncing = false
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,20 +21,23 @@ 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)
|
||||
passwordSection
|
||||
twoFASection(profile: profile)
|
||||
adminNoteSection
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Profil")
|
||||
.navigationTitle(L10n.t("profile.title", appLang))
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
.overlay(alignment: .bottom) {
|
||||
if showToast {
|
||||
@@ -68,29 +71,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 +103,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 +116,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 +131,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)
|
||||
@@ -137,6 +142,20 @@ struct ProfileView: View {
|
||||
}
|
||||
}
|
||||
|
||||
var adminNoteSection: some View {
|
||||
Section {
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
Image(systemName: "info.circle")
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.top, 1)
|
||||
Text(L10n.t("profile.admin_note", appLang))
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
|
||||
private func load() async {
|
||||
isLoading = true
|
||||
defer { isLoading = false }
|
||||
@@ -149,7 +168,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 +176,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 +205,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 +217,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 +241,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 +279,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 +302,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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,21 +2,44 @@ import SwiftUI
|
||||
|
||||
struct SettingsView: View {
|
||||
let api: CalendarrAPI
|
||||
@State private var settings = AppSettings()
|
||||
@State private var isLoading = true
|
||||
@State private var isSaving = false
|
||||
@State private var toast = ""
|
||||
@State private var showToast = false
|
||||
@AppStorage("liquidGlass") private var liquidGlass = false
|
||||
@AppStorage("settingsSync") private var settingsSync = 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"
|
||||
@AppStorage("primaryColor") private var primaryHex = "#4285f4"
|
||||
@AppStorage("accentColor") private var accentHex = "#ea4335"
|
||||
// Previously server-only; now AppStorage-backed so they persist and the
|
||||
// calendar views actually apply them.
|
||||
@AppStorage("textContrast") private var textContrast = 3
|
||||
@AppStorage("lineContrast") private var lineContrast = 3
|
||||
@AppStorage("hourHeight") private var hourHeight = 60
|
||||
@AppStorage("defaultView") private var defaultView = "month"
|
||||
@AppStorage("weekStartDay") private var weekStartDay = "monday"
|
||||
@AppStorage("dimPastEvents") private var dimPastEvents = false
|
||||
@AppStorage("defaultReminderMinutes") private var defaultReminderMinutes = -1
|
||||
|
||||
// Profile chapter (server-backed; loaded on appear).
|
||||
@State private var displayName = ""
|
||||
@State private var loginName = ""
|
||||
@State private var email = ""
|
||||
@State private var privateVisibility = "busy"
|
||||
@State private var groupVisibleId = 0 // 0 = none
|
||||
@State private var ownLocalCals: [LocalCalendar] = []
|
||||
@State private var profileMsg = ""
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Group {
|
||||
if isLoading {
|
||||
ProgressView("Lade Einstellungen…")
|
||||
} else {
|
||||
Form {
|
||||
profilSection
|
||||
privatsphaereSection
|
||||
benachrichtigungenSection
|
||||
geteilterKalenderSection
|
||||
liquidGlassSection
|
||||
cacheSection
|
||||
spracheSection
|
||||
@@ -26,38 +49,148 @@ struct SettingsView: View {
|
||||
ansichtSection
|
||||
stundenSection
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Darstellung")
|
||||
.navigationTitle(L10n.t("settings.title", appLang))
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button {
|
||||
Task { await save() }
|
||||
} label: {
|
||||
if isSaving {
|
||||
ProgressView()
|
||||
} else {
|
||||
Text("Speichern").bold()
|
||||
}
|
||||
// Reflect the latest server values when opening the screen.
|
||||
.task { await SettingsSync.pull(api: api) }
|
||||
.task { await loadProfile() }
|
||||
// Appearance changes update widgets live; synced values are also pushed
|
||||
// to the server (debounced). `push` itself decides what actually gets
|
||||
// sent based on the sync toggle, so every change can simply call it.
|
||||
.onChange(of: primaryHex) { _, _ in WidgetStore.republishAppearanceOnly(); SettingsSync.push(api: api) }
|
||||
.onChange(of: accentHex) { _, _ in WidgetStore.republishAppearanceOnly(); SettingsSync.push(api: api) }
|
||||
.onChange(of: todayHex) { _, _ in WidgetStore.republishAppearanceOnly(); SettingsSync.push(api: api) }
|
||||
.onChange(of: textHex) { _, _ in WidgetStore.republishAppearanceOnly(); SettingsSync.push(api: api) }
|
||||
.onChange(of: bgHex) { _, _ in WidgetStore.republishAppearanceOnly(); SettingsSync.push(api: api) }
|
||||
.onChange(of: lineHex) { _, _ in WidgetStore.republishAppearanceOnly(); SettingsSync.push(api: api) }
|
||||
.onChange(of: dividerHex) { _, _ in SettingsSync.push(api: api) }
|
||||
.onChange(of: labelHex) { _, _ in SettingsSync.push(api: api) }
|
||||
.onChange(of: textContrast) { _, _ in SettingsSync.push(api: api) }
|
||||
.onChange(of: lineContrast) { _, _ in SettingsSync.push(api: api) }
|
||||
.onChange(of: hourHeight) { _, _ in SettingsSync.push(api: api) }
|
||||
.onChange(of: defaultView) { _, _ in SettingsSync.push(api: api) }
|
||||
.onChange(of: weekStartDay) { _, _ in SettingsSync.push(api: api) }
|
||||
.onChange(of: dimPastEvents) { _, _ in SettingsSync.push(api: api) }
|
||||
.onChange(of: appLang) { _, _ in WidgetStore.republishAppearanceOnly() }
|
||||
// Enabling sync adopts the server's appearance (server wins).
|
||||
.onChange(of: settingsSync) { _, on in if on { Task { await SettingsSync.pull(api: api) } } }
|
||||
}
|
||||
|
||||
// MARK: – Profil
|
||||
|
||||
var profilSection: some View {
|
||||
Section(L10n.t("settings.nav.profile", appLang)) {
|
||||
HStack {
|
||||
Text(L10n.t("profile.display_name", appLang))
|
||||
Spacer()
|
||||
TextField(L10n.t("profile.display_name", appLang), text: $displayName)
|
||||
.multilineTextAlignment(.trailing)
|
||||
}
|
||||
HStack {
|
||||
Text(L10n.t("profile.login_name", appLang))
|
||||
Spacer()
|
||||
Text(loginName).foregroundStyle(.secondary)
|
||||
}
|
||||
HStack {
|
||||
Text("E-Mail")
|
||||
Spacer()
|
||||
TextField("E-Mail", text: $email)
|
||||
.multilineTextAlignment(.trailing)
|
||||
.keyboardType(.emailAddress)
|
||||
.autocapitalization(.none)
|
||||
}
|
||||
Button(L10n.t("event.save", appLang)) { Task { await saveProfile() } }
|
||||
if !profileMsg.isEmpty {
|
||||
Text(profileMsg).font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.disabled(isSaving)
|
||||
}
|
||||
|
||||
// MARK: – Benachrichtigungen
|
||||
|
||||
var benachrichtigungenSection: some View {
|
||||
Section {
|
||||
Picker(ReminderOptions.defaultTitle(appLang), selection: $defaultReminderMinutes) {
|
||||
Text(ReminderOptions.off(appLang)).tag(-1)
|
||||
ForEach(ReminderOptions.all, id: \.self) { m in
|
||||
Text(ReminderOptions.label(m, appLang)).tag(m)
|
||||
}
|
||||
}
|
||||
.overlay(alignment: .bottom) {
|
||||
if showToast {
|
||||
Text(toast)
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 10)
|
||||
.background(.regularMaterial)
|
||||
.clipShape(Capsule())
|
||||
.padding(.bottom, 20)
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
.onChange(of: defaultReminderMinutes) { _, _ in
|
||||
SettingsSync.push(api: api)
|
||||
NotificationCenter.default.post(name: .rescheduleReminders, object: nil)
|
||||
}
|
||||
} header: {
|
||||
Text(ReminderOptions.sectionTitle(appLang))
|
||||
} footer: {
|
||||
Text(ReminderOptions.defaultFooter(appLang)).font(.caption)
|
||||
}
|
||||
}
|
||||
.animation(.easeInOut, value: showToast)
|
||||
|
||||
// MARK: – Privatsphäre
|
||||
|
||||
var privatsphaereSection: some View {
|
||||
Section {
|
||||
Picker(L10n.t("settings.private_visibility", appLang), selection: $privateVisibility) {
|
||||
Text(L10n.t("settings.private.busy", appLang)).tag("busy")
|
||||
Text(L10n.t("settings.private.hidden", appLang)).tag("hidden")
|
||||
}
|
||||
.onChange(of: privateVisibility) { _, v in
|
||||
Task { try? await api.updatePrivateVisibility(v) }
|
||||
}
|
||||
} header: {
|
||||
Text(L10n.t("settings.privacy", appLang))
|
||||
} footer: {
|
||||
Text(L10n.t("settings.private_visibility.desc", appLang)).font(.caption)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Geteilter Kalender
|
||||
|
||||
var geteilterKalenderSection: some View {
|
||||
Section {
|
||||
Picker(L10n.t("settings.group_visible", appLang), selection: $groupVisibleId) {
|
||||
Text(L10n.t("group.visible.none", appLang)).tag(0)
|
||||
ForEach(ownLocalCals) { cal in
|
||||
Text(cal.name).tag(cal.id)
|
||||
}
|
||||
}
|
||||
.onChange(of: groupVisibleId) { _, id in
|
||||
Task { try? await api.updateGroupVisibleCalendar(id == 0 ? nil : id) }
|
||||
}
|
||||
} header: {
|
||||
Text(L10n.t("settings.calendars", appLang))
|
||||
} footer: {
|
||||
Text(L10n.t("settings.group_visible.desc", appLang)).font(.caption)
|
||||
}
|
||||
}
|
||||
|
||||
private func loadProfile() async {
|
||||
if let p = try? await api.getProfile() {
|
||||
displayName = p.displayName ?? p.username
|
||||
loginName = p.username
|
||||
email = p.email ?? ""
|
||||
}
|
||||
if let s = try? await api.getSettings() {
|
||||
privateVisibility = s.privateEventVisibility
|
||||
groupVisibleId = s.groupVisibleCalendarId ?? 0
|
||||
}
|
||||
if let cals = try? await api.getLocalCalendars() {
|
||||
ownLocalCals = cals.filter { $0.owned && !$0.group }
|
||||
}
|
||||
}
|
||||
|
||||
private func saveProfile() async {
|
||||
do {
|
||||
_ = try await api.updateProfile(displayName: displayName.isEmpty ? nil : displayName,
|
||||
username: nil,
|
||||
email: email.isEmpty ? "" : email)
|
||||
UserDefaults.standard.set(displayName, forKey: "displayName")
|
||||
profileMsg = L10n.t("settings.saved", appLang)
|
||||
} catch {
|
||||
profileMsg = error.localizedDescription
|
||||
}
|
||||
.task { await load() }
|
||||
}
|
||||
|
||||
// MARK: – Liquid Glass
|
||||
@@ -67,8 +200,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)
|
||||
}
|
||||
@@ -78,10 +211,25 @@ struct SettingsView: View {
|
||||
}
|
||||
}
|
||||
.tint(Color.accentColor)
|
||||
|
||||
Toggle(isOn: $settingsSync) {
|
||||
Label {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(L10n.t("settings.sync", appLang))
|
||||
Text(L10n.t("settings.sync.desc", appLang))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
} icon: {
|
||||
Image(systemName: "arrow.triangle.2.circlepath")
|
||||
.foregroundStyle(.teal)
|
||||
}
|
||||
}
|
||||
.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.sync.footer", appLang))
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
@@ -93,8 +241,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 +251,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 +271,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 +283,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: $primaryHex)
|
||||
ColorPickerRow(label: L10n.t("settings.color.accent", appLang), hex: $accentHex)
|
||||
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 +300,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,
|
||||
value: $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 +324,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,
|
||||
value: $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 +346,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: $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: $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: $dimPastEvents)
|
||||
.tint(Color.accentColor)
|
||||
}
|
||||
}
|
||||
@@ -216,18 +368,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,
|
||||
value: $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))
|
||||
]
|
||||
)
|
||||
}
|
||||
@@ -235,33 +387,6 @@ struct SettingsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Actions
|
||||
|
||||
private func load() async {
|
||||
isLoading = true
|
||||
defer { isLoading = false }
|
||||
if let s = try? await api.getSettings() { settings = s }
|
||||
}
|
||||
|
||||
private func save() async {
|
||||
isSaving = true
|
||||
defer { isSaving = false }
|
||||
do {
|
||||
try await api.updateSettings(settings)
|
||||
showNotice("Gespeichert")
|
||||
} catch {
|
||||
showNotice(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private func showNotice(_ msg: String) {
|
||||
toast = msg
|
||||
withAnimation { showToast = true }
|
||||
Task {
|
||||
try? await Task.sleep(for: .seconds(2))
|
||||
withAnimation { showToast = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Reusable Components
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
}
|
||||
151
CalendarrWidgets/CalendarDayWidgetView.swift
Normal file
151
CalendarrWidgets/CalendarDayWidgetView.swift
Normal file
@@ -0,0 +1,151 @@
|
||||
import SwiftUI
|
||||
import WidgetKit
|
||||
|
||||
struct CalendarDayWidgetView: View {
|
||||
let entry: CalendarrEntry
|
||||
|
||||
private var snapshot: WidgetSnapshot? { entry.snapshot }
|
||||
private var lang: String { snapshot?.language ?? "system" }
|
||||
|
||||
private var cal: Calendar {
|
||||
var c = Calendar(identifier: .gregorian)
|
||||
c.locale = WidgetL10n.locale(lang)
|
||||
c.firstWeekday = 2
|
||||
return c
|
||||
}
|
||||
|
||||
private var weekDays: [Date] {
|
||||
let start = cal.date(from: cal.dateComponents([.yearForWeekOfYear, .weekOfYear], from: entry.date)) ?? entry.date
|
||||
return (0..<7).compactMap { cal.date(byAdding: .day, value: $0, to: start) }
|
||||
}
|
||||
|
||||
private var upcomingEvents: [WidgetEvent] {
|
||||
guard let s = snapshot else { return [] }
|
||||
return WidgetHelpers.upcoming(from: entry.date, daysAhead: 1, in: s)
|
||||
}
|
||||
|
||||
private var monthFmt: DateFormatter {
|
||||
let f = DateFormatter(); f.locale = WidgetL10n.locale(lang); f.dateFormat = "LLLL"; return f
|
||||
}
|
||||
private var weekdayFmt: DateFormatter {
|
||||
let f = DateFormatter(); f.locale = WidgetL10n.locale(lang); f.dateFormat = "EEEE"; return f
|
||||
}
|
||||
private var timeFmt: DateFormatter {
|
||||
let f = DateFormatter(); f.locale = WidgetL10n.locale(lang); f.dateFormat = "HH:mm"; return f
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if let s = snapshot {
|
||||
let primary = Color(widgetHex: s.primaryColorHex)
|
||||
let accent = Color(widgetHex: s.accentColorHex)
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
header(primary: primary)
|
||||
weekStrip(snapshot: s, primary: primary, accent: accent)
|
||||
.padding(.vertical, 5)
|
||||
Rectangle()
|
||||
.fill(Color(widgetHex: s.lineColorHex).opacity(0.4))
|
||||
.frame(height: 0.5)
|
||||
.padding(.bottom, 6)
|
||||
eventList(accent: accent)
|
||||
}
|
||||
} else {
|
||||
Text(WidgetL10n.t("widget.no_data", lang))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Header
|
||||
|
||||
private func header(primary: Color) -> some View {
|
||||
HStack(alignment: .top, spacing: 6) {
|
||||
Text("\(cal.component(.day, from: entry.date))")
|
||||
.font(.system(size: 36, weight: .bold))
|
||||
.foregroundStyle(primary)
|
||||
.frame(width: 44, alignment: .leading)
|
||||
.minimumScaleFactor(0.7)
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(monthFmt.string(from: entry.date).uppercased())
|
||||
.font(.system(size: 11, weight: .bold))
|
||||
.foregroundStyle(primary)
|
||||
Text("\(WidgetL10n.t("widget.today", lang)), \(weekdayFmt.string(from: entry.date))")
|
||||
.font(.system(size: 10))
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.75)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(.bottom, 2)
|
||||
}
|
||||
|
||||
// MARK: – Week strip
|
||||
|
||||
private func weekStrip(snapshot: WidgetSnapshot, primary: Color, accent: Color) -> some View {
|
||||
HStack(spacing: 0) {
|
||||
ForEach(weekDays, id: \.self) { day in
|
||||
let isToday = cal.isDateInToday(day)
|
||||
let hasEvs = !WidgetHelpers.events(for: day, in: snapshot).isEmpty
|
||||
VStack(spacing: 2) {
|
||||
Text(shortDay(day))
|
||||
.font(.system(size: 8, weight: .bold))
|
||||
.foregroundStyle(isToday ? accent : .secondary)
|
||||
ZStack {
|
||||
if isToday {
|
||||
Circle().fill(primary)
|
||||
} else if hasEvs {
|
||||
Circle().fill(accent.opacity(0.18))
|
||||
}
|
||||
Text("\(cal.component(.day, from: day))")
|
||||
.font(.system(size: 11, weight: isToday ? .bold : .medium))
|
||||
.foregroundStyle(isToday ? .white : .primary)
|
||||
}
|
||||
.frame(width: 22, height: 22)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func shortDay(_ date: Date) -> String {
|
||||
let f = DateFormatter()
|
||||
f.locale = WidgetL10n.locale(lang)
|
||||
f.dateFormat = "EEE"
|
||||
return String(f.string(from: date).prefix(2)).uppercased()
|
||||
}
|
||||
|
||||
// MARK: – Event list
|
||||
|
||||
@ViewBuilder
|
||||
private func eventList(accent: Color) -> some View {
|
||||
if upcomingEvents.isEmpty {
|
||||
Text(WidgetL10n.t("widget.no_events", lang))
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer(minLength: 0)
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
ForEach(upcomingEvents.prefix(3)) { ev in
|
||||
HStack(alignment: .center, spacing: 6) {
|
||||
RoundedRectangle(cornerRadius: 1.5)
|
||||
.fill(Color(widgetHex: ev.colorHex))
|
||||
.frame(width: 3, height: 26)
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(ev.title)
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.lineLimit(1)
|
||||
Text(ev.isAllDay
|
||||
? WidgetL10n.t("widget.allday", lang)
|
||||
: "\(timeFmt.string(from: ev.start)) – \(timeFmt.string(from: ev.end))")
|
||||
.font(.system(size: 9))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
53
CalendarrWidgets/CalendarrTimelineProvider.swift
Normal file
53
CalendarrWidgets/CalendarrTimelineProvider.swift
Normal file
@@ -0,0 +1,53 @@
|
||||
import WidgetKit
|
||||
|
||||
struct CalendarrEntry: TimelineEntry {
|
||||
let date: Date
|
||||
let snapshot: WidgetSnapshot?
|
||||
}
|
||||
|
||||
struct CalendarrTimelineProvider: TimelineProvider {
|
||||
func placeholder(in context: Context) -> CalendarrEntry {
|
||||
CalendarrEntry(date: .now, snapshot: WidgetStore.read())
|
||||
}
|
||||
|
||||
func getSnapshot(in context: Context, completion: @escaping (CalendarrEntry) -> Void) {
|
||||
completion(CalendarrEntry(date: .now, snapshot: WidgetStore.read()))
|
||||
}
|
||||
|
||||
func getTimeline(in context: Context, completion: @escaping (Timeline<CalendarrEntry>) -> Void) {
|
||||
let snapshot = WidgetStore.read()
|
||||
let now = Date()
|
||||
|
||||
// Provide one entry per hour for the next 24h so the widget keeps
|
||||
// re-rendering as time progresses (past events drop off, "now" advances).
|
||||
var entries: [CalendarrEntry] = []
|
||||
for h in 0..<24 {
|
||||
let date = Calendar.current.date(byAdding: .hour, value: h, to: now) ?? now
|
||||
entries.append(CalendarrEntry(date: date, snapshot: snapshot))
|
||||
}
|
||||
// Ask iOS to refresh in 30 min to pick up any new data the app wrote.
|
||||
let refreshAt = Calendar.current.date(byAdding: .minute, value: 30, to: now) ?? now
|
||||
completion(Timeline(entries: entries, policy: .after(refreshAt)))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Shared helpers used by all widget views
|
||||
|
||||
enum WidgetHelpers {
|
||||
static func events(for day: Date, in snapshot: WidgetSnapshot) -> [WidgetEvent] {
|
||||
let cal = Calendar.current
|
||||
let dayStart = cal.startOfDay(for: day)
|
||||
let dayEnd = cal.date(byAdding: .day, value: 1, to: dayStart) ?? dayStart
|
||||
return snapshot.events
|
||||
.filter { $0.start < dayEnd && $0.end > dayStart }
|
||||
.sorted { $0.start < $1.start }
|
||||
}
|
||||
|
||||
static func upcoming(from now: Date, daysAhead: Int, in snapshot: WidgetSnapshot) -> [WidgetEvent] {
|
||||
let cal = Calendar.current
|
||||
let end = cal.date(byAdding: .day, value: daysAhead, to: cal.startOfDay(for: now)) ?? now
|
||||
return snapshot.events
|
||||
.filter { $0.end > now && $0.start < end }
|
||||
.sorted { $0.start < $1.start }
|
||||
}
|
||||
}
|
||||
10
CalendarrWidgets/CalendarrWidgets.entitlements
Normal file
10
CalendarrWidgets/CalendarrWidgets.entitlements
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.com.scarriffleservices.calendarr</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
243
CalendarrWidgets/CalendarrWidgets.swift
Normal file
243
CalendarrWidgets/CalendarrWidgets.swift
Normal file
@@ -0,0 +1,243 @@
|
||||
import WidgetKit
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct CalendarrWidgetBundle: WidgetBundle {
|
||||
var body: some Widget {
|
||||
TodayWidget()
|
||||
TwoDaysWidget()
|
||||
ThreeDaysWidget()
|
||||
ThisWeekWidget()
|
||||
TwoWeeksWidget()
|
||||
UpcomingWidget()
|
||||
UpNextWidget()
|
||||
CalendarDayWidget()
|
||||
TwoMonthWidget()
|
||||
NowNextEventsWidget()
|
||||
LockScreenWidget()
|
||||
LockScreenCountWidget()
|
||||
LockScreenCountdownWidget()
|
||||
}
|
||||
}
|
||||
|
||||
// Shared chrome modifier — keeps every home-screen widget on the same theme.
|
||||
private struct CalendarrWidgetChrome: ViewModifier {
|
||||
let snapshot: WidgetSnapshot?
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
let lang = snapshot?.language ?? "system"
|
||||
content
|
||||
.containerBackground(for: .widget) {
|
||||
Color(widgetHex: snapshot?.backgroundColorHex ?? "#000000")
|
||||
}
|
||||
.foregroundStyle(Color(widgetHex: snapshot?.textColorHex ?? "#FFFFFF"))
|
||||
.environment(\.locale, WidgetL10n.locale(lang))
|
||||
}
|
||||
}
|
||||
|
||||
private extension View {
|
||||
func calendarrChrome(_ snapshot: WidgetSnapshot?) -> some View {
|
||||
modifier(CalendarrWidgetChrome(snapshot: snapshot))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Today (small)
|
||||
|
||||
struct TodayWidget: Widget {
|
||||
let kind: String = "TodayWidget"
|
||||
|
||||
var body: some WidgetConfiguration {
|
||||
StaticConfiguration(kind: kind, provider: CalendarrTimelineProvider()) { entry in
|
||||
TodayWidgetView(entry: entry).calendarrChrome(entry.snapshot)
|
||||
}
|
||||
.configurationDisplayName(WidgetL10n.t("widget.display.today_title", "system"))
|
||||
.description(WidgetL10n.t("widget.display.today_desc", "system"))
|
||||
.supportedFamilies([.systemSmall])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Today & Tomorrow (medium)
|
||||
|
||||
struct TwoDaysWidget: Widget {
|
||||
let kind: String = "TwoDaysWidget"
|
||||
|
||||
var body: some WidgetConfiguration {
|
||||
StaticConfiguration(kind: kind, provider: CalendarrTimelineProvider()) { entry in
|
||||
TwoDaysWidgetView(entry: entry).calendarrChrome(entry.snapshot)
|
||||
}
|
||||
.configurationDisplayName(WidgetL10n.t("widget.display.days_title", "system"))
|
||||
.description(WidgetL10n.t("widget.display.days_desc", "system"))
|
||||
.supportedFamilies([.systemMedium])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Three Days (medium)
|
||||
|
||||
struct ThreeDaysWidget: Widget {
|
||||
let kind: String = "ThreeDaysWidget"
|
||||
|
||||
var body: some WidgetConfiguration {
|
||||
StaticConfiguration(kind: kind, provider: CalendarrTimelineProvider()) { entry in
|
||||
ThreeDaysWidgetView(entry: entry).calendarrChrome(entry.snapshot)
|
||||
}
|
||||
.configurationDisplayName(WidgetL10n.t("widget.display.threedays_title", "system"))
|
||||
.description(WidgetL10n.t("widget.display.threedays_desc", "system"))
|
||||
.supportedFamilies([.systemMedium])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – This Week (medium)
|
||||
|
||||
struct ThisWeekWidget: Widget {
|
||||
let kind: String = "ThisWeekWidget"
|
||||
|
||||
var body: some WidgetConfiguration {
|
||||
StaticConfiguration(kind: kind, provider: CalendarrTimelineProvider()) { entry in
|
||||
ThisWeekWidgetView(entry: entry).calendarrChrome(entry.snapshot)
|
||||
}
|
||||
.configurationDisplayName(WidgetL10n.t("widget.display.thisweek_title", "system"))
|
||||
.description(WidgetL10n.t("widget.display.thisweek_desc", "system"))
|
||||
.supportedFamilies([.systemMedium])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Two Weeks (medium)
|
||||
|
||||
struct TwoWeeksWidget: Widget {
|
||||
let kind: String = "TwoWeeksWidget"
|
||||
|
||||
var body: some WidgetConfiguration {
|
||||
StaticConfiguration(kind: kind, provider: CalendarrTimelineProvider()) { entry in
|
||||
TwoWeeksWidgetView(entry: entry).calendarrChrome(entry.snapshot)
|
||||
}
|
||||
.configurationDisplayName(WidgetL10n.t("widget.display.twoweeks_title", "system"))
|
||||
.description(WidgetL10n.t("widget.display.twoweeks_desc", "system"))
|
||||
.supportedFamilies([.systemMedium])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Upcoming (large + extra large on iPad)
|
||||
|
||||
struct UpcomingWidget: Widget {
|
||||
let kind: String = "UpcomingWidget"
|
||||
|
||||
var body: some WidgetConfiguration {
|
||||
StaticConfiguration(kind: kind, provider: CalendarrTimelineProvider()) { entry in
|
||||
UpcomingWidgetView(entry: entry).calendarrChrome(entry.snapshot)
|
||||
}
|
||||
.configurationDisplayName(WidgetL10n.t("widget.display.upcoming_title", "system"))
|
||||
.description(WidgetL10n.t("widget.display.upcoming_desc", "system"))
|
||||
.supportedFamilies([.systemLarge, .systemExtraLarge])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Up Next + Calendar (medium)
|
||||
|
||||
struct UpNextWidget: Widget {
|
||||
let kind: String = "UpNextWidget"
|
||||
|
||||
var body: some WidgetConfiguration {
|
||||
StaticConfiguration(kind: kind, provider: CalendarrTimelineProvider()) { entry in
|
||||
UpNextWidgetView(entry: entry).calendarrChrome(entry.snapshot)
|
||||
}
|
||||
.configurationDisplayName(WidgetL10n.t("widget.display.upnext_title", "system"))
|
||||
.description(WidgetL10n.t("widget.display.upnext_desc", "system"))
|
||||
.supportedFamilies([.systemMedium])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Calendar Day: date + week strip + events (medium)
|
||||
|
||||
struct CalendarDayWidget: Widget {
|
||||
let kind: String = "CalendarDayWidget"
|
||||
|
||||
var body: some WidgetConfiguration {
|
||||
StaticConfiguration(kind: kind, provider: CalendarrTimelineProvider()) { entry in
|
||||
CalendarDayWidgetView(entry: entry).calendarrChrome(entry.snapshot)
|
||||
}
|
||||
.configurationDisplayName(WidgetL10n.t("widget.display.calday_title", "system"))
|
||||
.description(WidgetL10n.t("widget.display.calday_desc", "system"))
|
||||
.supportedFamilies([.systemMedium])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Two Month calendar grid (medium + large)
|
||||
|
||||
struct TwoMonthWidget: Widget {
|
||||
let kind: String = "TwoMonthWidget"
|
||||
|
||||
var body: some WidgetConfiguration {
|
||||
StaticConfiguration(kind: kind, provider: CalendarrTimelineProvider()) { entry in
|
||||
TwoMonthWidgetView(entry: entry).calendarrChrome(entry.snapshot)
|
||||
}
|
||||
.configurationDisplayName(WidgetL10n.t("widget.display.twomonth_title", "system"))
|
||||
.description(WidgetL10n.t("widget.display.twomonth_desc", "system"))
|
||||
.supportedFamilies([.systemMedium, .systemLarge])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Now & Next events (medium)
|
||||
|
||||
struct NowNextEventsWidget: Widget {
|
||||
let kind: String = "NowNextEventsWidget"
|
||||
|
||||
var body: some WidgetConfiguration {
|
||||
StaticConfiguration(kind: kind, provider: CalendarrTimelineProvider()) { entry in
|
||||
NowNextWidgetView(entry: entry).calendarrChrome(entry.snapshot)
|
||||
}
|
||||
.configurationDisplayName(WidgetL10n.t("widget.display.nownext_title", "system"))
|
||||
.description(WidgetL10n.t("widget.display.nownext_desc", "system"))
|
||||
.supportedFamilies([.systemMedium])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Lock Screen: date (circular, rectangular, inline)
|
||||
|
||||
struct LockScreenWidget: Widget {
|
||||
let kind: String = "LockScreenWidget"
|
||||
|
||||
var body: some WidgetConfiguration {
|
||||
StaticConfiguration(kind: kind, provider: CalendarrTimelineProvider()) { entry in
|
||||
LockScreenWidgetView(entry: entry)
|
||||
.containerBackground(for: .widget) { Color.clear }
|
||||
.environment(\.locale, WidgetL10n.locale(entry.snapshot?.language ?? "system"))
|
||||
}
|
||||
.configurationDisplayName(WidgetL10n.t("widget.display.lockscreen_title", "system"))
|
||||
.description(WidgetL10n.t("widget.display.lockscreen_desc", "system"))
|
||||
.supportedFamilies([.accessoryCircular, .accessoryRectangular, .accessoryInline])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Lock Screen: today event count (circular, rectangular, inline)
|
||||
|
||||
struct LockScreenCountWidget: Widget {
|
||||
let kind: String = "LockScreenCountWidget"
|
||||
|
||||
var body: some WidgetConfiguration {
|
||||
StaticConfiguration(kind: kind, provider: CalendarrTimelineProvider()) { entry in
|
||||
LockScreenCountWidgetView(entry: entry)
|
||||
.containerBackground(for: .widget) { Color.clear }
|
||||
.environment(\.locale, WidgetL10n.locale(entry.snapshot?.language ?? "system"))
|
||||
}
|
||||
.configurationDisplayName(WidgetL10n.t("widget.display.lockscreen_count_title", "system"))
|
||||
.description(WidgetL10n.t("widget.display.lockscreen_count_desc", "system"))
|
||||
.supportedFamilies([.accessoryCircular, .accessoryRectangular, .accessoryInline])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Lock Screen: countdown to next event (circular, rectangular, inline)
|
||||
|
||||
struct LockScreenCountdownWidget: Widget {
|
||||
let kind: String = "LockScreenCountdownWidget"
|
||||
|
||||
var body: some WidgetConfiguration {
|
||||
StaticConfiguration(kind: kind, provider: CalendarrTimelineProvider()) { entry in
|
||||
LockScreenCountdownWidgetView(entry: entry)
|
||||
.containerBackground(for: .widget) { Color.clear }
|
||||
.environment(\.locale, WidgetL10n.locale(entry.snapshot?.language ?? "system"))
|
||||
}
|
||||
.configurationDisplayName(WidgetL10n.t("widget.display.lockscreen_countdown_title", "system"))
|
||||
.description(WidgetL10n.t("widget.display.lockscreen_countdown_desc", "system"))
|
||||
.supportedFamilies([.accessoryCircular, .accessoryRectangular, .accessoryInline])
|
||||
}
|
||||
}
|
||||
29
CalendarrWidgets/Info.plist
Normal file
29
CalendarrWidgets/Info.plist
Normal file
@@ -0,0 +1,29 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Calendarr Widgets</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
<string>com.apple.widgetkit-extension</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
288
CalendarrWidgets/LockScreenWidgetViews.swift
Normal file
288
CalendarrWidgets/LockScreenWidgetViews.swift
Normal file
@@ -0,0 +1,288 @@
|
||||
import SwiftUI
|
||||
import WidgetKit
|
||||
|
||||
// MARK: – Date widget (existing)
|
||||
|
||||
struct LockScreenWidgetView: View {
|
||||
let entry: CalendarrEntry
|
||||
@Environment(\.widgetFamily) private var family
|
||||
|
||||
private var snapshot: WidgetSnapshot? { entry.snapshot }
|
||||
private var lang: String { snapshot?.language ?? "system" }
|
||||
|
||||
private var cal: Calendar {
|
||||
var c = Calendar(identifier: .gregorian)
|
||||
c.locale = WidgetL10n.locale(lang)
|
||||
return c
|
||||
}
|
||||
|
||||
private var nextEvent: WidgetEvent? {
|
||||
guard let s = snapshot else { return nil }
|
||||
return WidgetHelpers.upcoming(from: entry.date, daysAhead: 1, in: s).first
|
||||
}
|
||||
|
||||
private var timeFmt: DateFormatter {
|
||||
let f = DateFormatter(); f.locale = WidgetL10n.locale(lang); f.dateFormat = "HH:mm"; return f
|
||||
}
|
||||
|
||||
private var monthAbbrev: String {
|
||||
let f = DateFormatter(); f.locale = WidgetL10n.locale(lang); f.dateFormat = "LLL"
|
||||
return f.string(from: entry.date).uppercased()
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
var body: some View {
|
||||
switch family {
|
||||
case .accessoryCircular:
|
||||
circularView
|
||||
case .accessoryRectangular:
|
||||
rectangularView
|
||||
default:
|
||||
inlineView
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Circular: today's date
|
||||
|
||||
private var circularView: some View {
|
||||
ZStack {
|
||||
AccessoryWidgetBackground()
|
||||
VStack(spacing: 0) {
|
||||
Text("\(cal.component(.day, from: entry.date))")
|
||||
.font(.system(size: 22, weight: .bold))
|
||||
.minimumScaleFactor(0.7)
|
||||
.widgetAccentable()
|
||||
Text(monthAbbrev)
|
||||
.font(.system(size: 8, weight: .semibold))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Rectangular: next event
|
||||
|
||||
private var rectangularView: some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
if let ev = nextEvent {
|
||||
Text(ev.isAllDay
|
||||
? WidgetL10n.t("widget.allday", lang)
|
||||
: timeFmt.string(from: ev.start))
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.widgetAccentable()
|
||||
Text(ev.title)
|
||||
.font(.system(size: 14, weight: .bold))
|
||||
.lineLimit(1)
|
||||
if !ev.location.isEmpty {
|
||||
Text(ev.location)
|
||||
.font(.system(size: 11))
|
||||
.lineLimit(1)
|
||||
}
|
||||
} else {
|
||||
Image(systemName: "calendar")
|
||||
.font(.system(size: 13))
|
||||
.widgetAccentable()
|
||||
Text(WidgetL10n.t("widget.no_events", lang))
|
||||
.font(.system(size: 13))
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
}
|
||||
|
||||
// MARK: – Inline: brief next event
|
||||
|
||||
private var inlineView: some View {
|
||||
let text: String = {
|
||||
guard let ev = nextEvent else {
|
||||
return WidgetL10n.t("widget.no_events", lang)
|
||||
}
|
||||
return ev.isAllDay ? ev.title : "\(timeFmt.string(from: ev.start)) \(ev.title)"
|
||||
}()
|
||||
return Label(text, systemImage: "calendar")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Today event count widget
|
||||
|
||||
struct LockScreenCountWidgetView: View {
|
||||
let entry: CalendarrEntry
|
||||
@Environment(\.widgetFamily) private var family
|
||||
|
||||
private var snapshot: WidgetSnapshot? { entry.snapshot }
|
||||
private var lang: String { snapshot?.language ?? "system" }
|
||||
|
||||
private var timeFmt: DateFormatter {
|
||||
let f = DateFormatter()
|
||||
f.locale = WidgetL10n.locale(lang)
|
||||
f.dateFormat = "HH:mm"
|
||||
return f
|
||||
}
|
||||
|
||||
private var todayEvents: [WidgetEvent] {
|
||||
guard let s = snapshot else { return [] }
|
||||
return WidgetHelpers.upcoming(from: entry.date, daysAhead: 1, in: s)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
var body: some View {
|
||||
switch family {
|
||||
case .accessoryCircular:
|
||||
circularView
|
||||
case .accessoryRectangular:
|
||||
rectangularView
|
||||
default:
|
||||
inlineView
|
||||
}
|
||||
}
|
||||
|
||||
private var circularView: some View {
|
||||
ZStack {
|
||||
AccessoryWidgetBackground()
|
||||
VStack(spacing: 1) {
|
||||
Image(systemName: "calendar")
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.widgetAccentable()
|
||||
Text("\(todayEvents.count)")
|
||||
.font(.system(size: 22, weight: .bold))
|
||||
.minimumScaleFactor(0.7)
|
||||
.widgetAccentable()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var rectangularView: some View {
|
||||
let countLabel = "\(todayEvents.count) \(WidgetL10n.t("widget.events_count", lang))"
|
||||
return VStack(alignment: .leading, spacing: 3) {
|
||||
HStack(spacing: 4) {
|
||||
Text(WidgetL10n.t("widget.today", lang).uppercased())
|
||||
.font(.system(size: 9, weight: .bold))
|
||||
.widgetAccentable()
|
||||
Text("· \(countLabel)")
|
||||
.font(.system(size: 9))
|
||||
}
|
||||
if todayEvents.isEmpty {
|
||||
Text(WidgetL10n.t("widget.no_events", lang))
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
ForEach(todayEvents.prefix(2)) { ev in
|
||||
HStack(spacing: 4) {
|
||||
Text(ev.isAllDay ? "·" : timeFmt.string(from: ev.start))
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.widgetAccentable()
|
||||
.frame(width: 32, alignment: .leading)
|
||||
Text(ev.title)
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
}
|
||||
|
||||
private var inlineView: some View {
|
||||
let label = "\(todayEvents.count) \(WidgetL10n.t("widget.events_count", lang))"
|
||||
return Label(label, systemImage: "calendar.badge.clock")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Countdown to next event widget
|
||||
|
||||
struct LockScreenCountdownWidgetView: View {
|
||||
let entry: CalendarrEntry
|
||||
@Environment(\.widgetFamily) private var family
|
||||
|
||||
private var snapshot: WidgetSnapshot? { entry.snapshot }
|
||||
private var lang: String { snapshot?.language ?? "system" }
|
||||
|
||||
private var timeFmt: DateFormatter {
|
||||
let f = DateFormatter()
|
||||
f.locale = WidgetL10n.locale(lang)
|
||||
f.dateFormat = "HH:mm"
|
||||
return f
|
||||
}
|
||||
|
||||
private var nextEvent: WidgetEvent? {
|
||||
guard let s = snapshot else { return nil }
|
||||
return WidgetHelpers.upcoming(from: entry.date, daysAhead: 1, in: s).first
|
||||
}
|
||||
|
||||
private var isRunning: Bool {
|
||||
guard let ev = nextEvent, !ev.isAllDay else { return false }
|
||||
return ev.start <= entry.date && ev.end > entry.date
|
||||
}
|
||||
|
||||
private var countdownText: String {
|
||||
guard let ev = nextEvent else { return WidgetL10n.t("widget.no_events", lang) }
|
||||
if isRunning { return WidgetL10n.t("widget.running", lang) }
|
||||
if ev.isAllDay { return WidgetL10n.t("widget.allday", lang) }
|
||||
let total = Int(max(0, ev.start.timeIntervalSince(entry.date)) / 60)
|
||||
if total < 60 { return "in \(total)m" }
|
||||
let h = total / 60; let m = total % 60
|
||||
return m == 0 ? "in \(h)h" : "in \(h)h \(m)m"
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
var body: some View {
|
||||
switch family {
|
||||
case .accessoryCircular:
|
||||
circularView
|
||||
case .accessoryRectangular:
|
||||
rectangularView
|
||||
default:
|
||||
inlineView
|
||||
}
|
||||
}
|
||||
|
||||
private var circularView: some View {
|
||||
ZStack {
|
||||
AccessoryWidgetBackground()
|
||||
VStack(spacing: 1) {
|
||||
Text(countdownText)
|
||||
.font(.system(size: 13, weight: .bold))
|
||||
.minimumScaleFactor(0.5)
|
||||
.lineLimit(1)
|
||||
.widgetAccentable()
|
||||
if let ev = nextEvent, !ev.isAllDay {
|
||||
Text(timeFmt.string(from: ev.start))
|
||||
.font(.system(size: 8))
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
}
|
||||
|
||||
private var rectangularView: some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
if let ev = nextEvent {
|
||||
Text(countdownText)
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.widgetAccentable()
|
||||
Text(ev.title)
|
||||
.font(.system(size: 14, weight: .bold))
|
||||
.lineLimit(1)
|
||||
let timeStr = ev.isAllDay
|
||||
? WidgetL10n.t("widget.allday", lang)
|
||||
: "\(timeFmt.string(from: ev.start)) – \(timeFmt.string(from: ev.end))"
|
||||
Text(timeStr)
|
||||
.font(.system(size: 11))
|
||||
.lineLimit(1)
|
||||
} else {
|
||||
Image(systemName: "timer")
|
||||
.font(.system(size: 13))
|
||||
.widgetAccentable()
|
||||
Text(WidgetL10n.t("widget.no_events", lang))
|
||||
.font(.system(size: 13))
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
}
|
||||
|
||||
private var inlineView: some View {
|
||||
let text: String = {
|
||||
guard let ev = nextEvent else { return WidgetL10n.t("widget.no_events", lang) }
|
||||
return "\(ev.title) \(countdownText)"
|
||||
}()
|
||||
return Label(text, systemImage: "timer")
|
||||
}
|
||||
}
|
||||
162
CalendarrWidgets/NowNextWidgetView.swift
Normal file
162
CalendarrWidgets/NowNextWidgetView.swift
Normal file
@@ -0,0 +1,162 @@
|
||||
import SwiftUI
|
||||
import WidgetKit
|
||||
|
||||
struct NowNextWidgetView: View {
|
||||
let entry: CalendarrEntry
|
||||
|
||||
private var snapshot: WidgetSnapshot? { entry.snapshot }
|
||||
private var lang: String { snapshot?.language ?? "system" }
|
||||
|
||||
private var cal: Calendar {
|
||||
var c = Calendar(identifier: .gregorian)
|
||||
c.locale = WidgetL10n.locale(lang)
|
||||
return c
|
||||
}
|
||||
|
||||
private var timeFmt: DateFormatter {
|
||||
let f = DateFormatter(); f.locale = WidgetL10n.locale(lang); f.dateFormat = "HH:mm"; return f
|
||||
}
|
||||
private var dayOfWeekFmt: DateFormatter {
|
||||
let f = DateFormatter(); f.locale = WidgetL10n.locale(lang); f.dateFormat = "EEEE"; return f
|
||||
}
|
||||
|
||||
// Currently running event, or next upcoming timed event, or first all-day event
|
||||
private var featuredEvent: WidgetEvent? {
|
||||
guard let s = snapshot else { return nil }
|
||||
let pool = WidgetHelpers.upcoming(from: entry.date, daysAhead: 1, in: s)
|
||||
if let running = pool.first(where: { !$0.isAllDay && $0.start <= entry.date }) { return running }
|
||||
if let next = pool.first(where: { !$0.isAllDay }) { return next }
|
||||
return pool.first
|
||||
}
|
||||
|
||||
// All upcoming events today except the featured one
|
||||
private var remainingEvents: [WidgetEvent] {
|
||||
guard let s = snapshot else { return [] }
|
||||
let pool = WidgetHelpers.upcoming(from: entry.date, daysAhead: 1, in: s)
|
||||
guard let featured = featuredEvent else { return pool }
|
||||
return pool.filter { $0.id != featured.id }
|
||||
}
|
||||
|
||||
private func timeRange(_ ev: WidgetEvent) -> String {
|
||||
ev.isAllDay
|
||||
? WidgetL10n.t("widget.allday", lang)
|
||||
: "\(timeFmt.string(from: ev.start)) – \(timeFmt.string(from: ev.end))"
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if let s = snapshot {
|
||||
let line = Color(widgetHex: s.lineColorHex)
|
||||
VStack(spacing: 6) {
|
||||
featuredCard(snapshot: s)
|
||||
bottomRow(line: line)
|
||||
}
|
||||
} else {
|
||||
Text(WidgetL10n.t("widget.no_data", lang))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Featured event card
|
||||
|
||||
private func featuredCard(snapshot: WidgetSnapshot) -> some View {
|
||||
let ev = featuredEvent
|
||||
let baseColor = ev.map { Color(widgetHex: $0.colorHex) } ?? Color(widgetHex: snapshot.primaryColorHex)
|
||||
|
||||
return ZStack(alignment: .leading) {
|
||||
LinearGradient(
|
||||
colors: [baseColor.opacity(0.75), baseColor],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
|
||||
HStack(spacing: 0) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(ev?.title ?? WidgetL10n.t("widget.no_events", lang))
|
||||
.font(.system(size: 13, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
.lineLimit(1)
|
||||
Text(ev.map { timeRange($0) } ?? "")
|
||||
.font(.system(size: 10))
|
||||
.foregroundStyle(.white.opacity(0.85))
|
||||
}
|
||||
.padding(.leading, 10)
|
||||
.padding(.vertical, 9)
|
||||
Spacer()
|
||||
if ev != nil {
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(.white.opacity(0.7))
|
||||
.padding(.trailing, 12)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
// MARK: – Bottom: date + event list
|
||||
|
||||
private func bottomRow(line: Color) -> some View {
|
||||
HStack(alignment: .top, spacing: 0) {
|
||||
// Left: day name + large number
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text(dayOfWeekFmt.string(from: entry.date).uppercased())
|
||||
.font(.system(size: 8, weight: .bold))
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.6)
|
||||
Text("\(cal.component(.day, from: entry.date))")
|
||||
.font(.system(size: 30, weight: .light))
|
||||
}
|
||||
.frame(width: 50, alignment: .leading)
|
||||
|
||||
// Divider
|
||||
line.opacity(0.4).frame(width: 0.5)
|
||||
.padding(.horizontal, 6)
|
||||
|
||||
// Right: event list
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
let shown = remainingEvents.prefix(2)
|
||||
if shown.isEmpty {
|
||||
Text(WidgetL10n.t("widget.no_events", lang))
|
||||
.font(.system(size: 10))
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
ForEach(shown) { ev in
|
||||
HStack(alignment: .firstTextBaseline, spacing: 5) {
|
||||
Circle()
|
||||
.fill(Color(widgetHex: ev.colorHex))
|
||||
.frame(width: 7, height: 7)
|
||||
.padding(.top, 1)
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text(ev.title)
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.lineLimit(1)
|
||||
Text(timeRange(ev))
|
||||
.font(.system(size: 9))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
||||
// +N badge
|
||||
if remainingEvents.count > 2 {
|
||||
Text("+\(remainingEvents.count - 2)")
|
||||
.font(.system(size: 9, weight: .semibold))
|
||||
.padding(.horizontal, 5)
|
||||
.padding(.vertical, 2)
|
||||
.background(.secondary.opacity(0.18), in: Capsule())
|
||||
.frame(maxHeight: .infinity, alignment: .bottom)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
116
CalendarrWidgets/ThisWeekWidgetView.swift
Normal file
116
CalendarrWidgets/ThisWeekWidgetView.swift
Normal file
@@ -0,0 +1,116 @@
|
||||
import SwiftUI
|
||||
import WidgetKit
|
||||
|
||||
struct ThisWeekWidgetView: View {
|
||||
let entry: CalendarrEntry
|
||||
|
||||
private var snapshot: WidgetSnapshot? { entry.snapshot }
|
||||
private var lang: String { snapshot?.language ?? "system" }
|
||||
|
||||
private var cal: Calendar {
|
||||
var c = Calendar(identifier: .gregorian)
|
||||
c.locale = WidgetL10n.locale(lang)
|
||||
c.firstWeekday = 2
|
||||
return c
|
||||
}
|
||||
|
||||
private var weekStart: Date {
|
||||
cal.date(from: cal.dateComponents([.yearForWeekOfYear, .weekOfYear], from: entry.date)) ?? entry.date
|
||||
}
|
||||
|
||||
private var weekDays: [Date] {
|
||||
(0..<7).compactMap { cal.date(byAdding: .day, value: $0, to: weekStart) }
|
||||
}
|
||||
|
||||
private var monthHeader: String {
|
||||
let f = DateFormatter()
|
||||
f.locale = WidgetL10n.locale(lang)
|
||||
f.dateFormat = "LLLL yyyy"
|
||||
return f.string(from: weekStart).uppercased()
|
||||
}
|
||||
|
||||
private var weekdayHeaders: [String] {
|
||||
let f = DateFormatter(); f.locale = WidgetL10n.locale(lang)
|
||||
let symbols = f.shortWeekdaySymbols ?? cal.shortWeekdaySymbols
|
||||
let start = cal.firstWeekday - 1
|
||||
return (0..<7).map { String(symbols[(start + $0) % 7].prefix(2)).uppercased() }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if let s = snapshot {
|
||||
let primary = Color(widgetHex: s.primaryColorHex)
|
||||
let accent = Color(widgetHex: s.accentColorHex)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 4) {
|
||||
Text(monthHeader.split(separator: " ").first.map(String.init) ?? "")
|
||||
.font(.system(size: 10, weight: .bold))
|
||||
.foregroundStyle(primary)
|
||||
Text(monthHeader.split(separator: " ").dropFirst().joined(separator: " "))
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
}
|
||||
// Equal-width columns via maxWidth — no GeometryReader needed
|
||||
HStack(spacing: 0) {
|
||||
ForEach(Array(weekDays.enumerated()), id: \.offset) { idx, day in
|
||||
dayColumn(day, snapshot: s, primary: primary, accent: accent)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
.overlay(alignment: .trailing) {
|
||||
if idx < 6 {
|
||||
Rectangle()
|
||||
.fill(Color(widgetHex: s.lineColorHex).opacity(0.35))
|
||||
.frame(width: 0.5)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
} else {
|
||||
Text(WidgetL10n.t("widget.no_data", lang))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
private func dayColumn(_ day: Date,
|
||||
snapshot: WidgetSnapshot,
|
||||
primary: Color,
|
||||
accent: Color) -> some View {
|
||||
let isToday = cal.isDateInToday(day)
|
||||
let evs = WidgetHelpers.events(for: day, in: snapshot)
|
||||
let dayIdx = (0..<7).firstIndex { i in cal.isDate(weekDays[i], inSameDayAs: day) } ?? 0
|
||||
return VStack(alignment: .center, spacing: 1) {
|
||||
Text(weekdayHeaders[dayIdx])
|
||||
.font(.system(size: 7.5, weight: .bold))
|
||||
.foregroundStyle(isToday ? accent : .secondary)
|
||||
Text("\(cal.component(.day, from: day))")
|
||||
.font(.system(size: 10, weight: isToday ? .bold : .semibold))
|
||||
.foregroundStyle(isToday ? Color.white : Color.primary)
|
||||
.frame(width: 16, height: 16)
|
||||
.background(isToday ? primary : Color.clear)
|
||||
.clipShape(Circle())
|
||||
ForEach(evs.prefix(3)) { ev in
|
||||
eventPill(ev)
|
||||
}
|
||||
if evs.count > 3 {
|
||||
Text("+\(evs.count - 3)")
|
||||
.font(.system(size: 6.5))
|
||||
.foregroundStyle(accent)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(.horizontal, 1)
|
||||
}
|
||||
|
||||
private func eventPill(_ ev: WidgetEvent) -> some View {
|
||||
Text(ev.title)
|
||||
.font(.system(size: 7, weight: .medium))
|
||||
.lineLimit(1)
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 2)
|
||||
.padding(.vertical, 0.5)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color(widgetHex: ev.colorHex))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 1.5))
|
||||
}
|
||||
}
|
||||
131
CalendarrWidgets/ThreeDaysWidgetView.swift
Normal file
131
CalendarrWidgets/ThreeDaysWidgetView.swift
Normal file
@@ -0,0 +1,131 @@
|
||||
import SwiftUI
|
||||
import WidgetKit
|
||||
|
||||
struct ThreeDaysWidgetView: View {
|
||||
let entry: CalendarrEntry
|
||||
|
||||
private var snapshot: WidgetSnapshot? { entry.snapshot }
|
||||
private var lang: String { snapshot?.language ?? "system" }
|
||||
|
||||
private var cal: Calendar {
|
||||
var c = Calendar(identifier: .gregorian)
|
||||
c.locale = WidgetL10n.locale(lang)
|
||||
return c
|
||||
}
|
||||
|
||||
private var days: [Date] {
|
||||
let today = cal.startOfDay(for: entry.date)
|
||||
return (0..<3).compactMap { cal.date(byAdding: .day, value: $0, to: today) }
|
||||
}
|
||||
|
||||
private var monthHeader: String {
|
||||
let f = DateFormatter()
|
||||
f.locale = WidgetL10n.locale(lang)
|
||||
f.dateFormat = "LLLL yyyy"
|
||||
return f.string(from: entry.date).uppercased()
|
||||
}
|
||||
|
||||
private var weekdayFmt: DateFormatter {
|
||||
let f = DateFormatter()
|
||||
f.locale = WidgetL10n.locale(lang)
|
||||
f.dateFormat = "EEE"
|
||||
return f
|
||||
}
|
||||
|
||||
private var timeFmt: DateFormatter {
|
||||
let f = DateFormatter()
|
||||
f.locale = WidgetL10n.locale(lang)
|
||||
f.dateFormat = "HH:mm"
|
||||
return f
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if let s = snapshot {
|
||||
let primary = Color(widgetHex: s.primaryColorHex)
|
||||
let accent = Color(widgetHex: s.accentColorHex)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
||||
Text(monthHeader.split(separator: " ").first.map(String.init) ?? "")
|
||||
.font(.system(size: 11, weight: .bold))
|
||||
.foregroundStyle(primary)
|
||||
Text(monthHeader.split(separator: " ").dropFirst().joined(separator: " "))
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
}
|
||||
HStack(spacing: 0) {
|
||||
ForEach(Array(days.enumerated()), id: \.offset) { idx, day in
|
||||
column(for: day, snapshot: s, primary: primary, accent: accent)
|
||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||
.overlay(alignment: .trailing) {
|
||||
if idx < 2 {
|
||||
Rectangle()
|
||||
.fill(Color(widgetHex: s.lineColorHex).opacity(0.4))
|
||||
.frame(width: 0.5)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Text(WidgetL10n.t("widget.no_data", lang))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
private func column(for day: Date, snapshot: WidgetSnapshot, primary: Color, accent: Color) -> some View {
|
||||
let isToday = cal.isDateInToday(day)
|
||||
let evs = WidgetHelpers.events(for: day, in: snapshot)
|
||||
return VStack(alignment: .leading, spacing: 2) {
|
||||
HStack {
|
||||
Text(weekdayFmt.string(from: day).uppercased() + ".")
|
||||
.font(.system(size: 9, weight: .bold))
|
||||
.foregroundStyle(isToday ? accent : .secondary)
|
||||
Spacer()
|
||||
Text("\(cal.component(.day, from: day))")
|
||||
.font(.system(size: 11, weight: isToday ? .bold : .semibold))
|
||||
.foregroundStyle(isToday ? Color.white : Color.primary)
|
||||
.frame(width: 17, height: 17)
|
||||
.background(isToday ? primary : Color.clear)
|
||||
.clipShape(Circle())
|
||||
}
|
||||
.padding(.horizontal, 3)
|
||||
if evs.isEmpty {
|
||||
Text(WidgetL10n.t("widget.no_events", lang))
|
||||
.font(.system(size: 9))
|
||||
.foregroundStyle(.tertiary)
|
||||
.padding(.horizontal, 3)
|
||||
} else {
|
||||
ForEach(evs.prefix(4)) { ev in
|
||||
eventRow(ev)
|
||||
}
|
||||
if evs.count > 4 {
|
||||
Text("+\(evs.count - 4)")
|
||||
.font(.system(size: 8))
|
||||
.foregroundStyle(accent)
|
||||
.padding(.leading, 3)
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
|
||||
private func eventRow(_ ev: WidgetEvent) -> some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HStack(spacing: 3) {
|
||||
RoundedRectangle(cornerRadius: 1)
|
||||
.fill(Color(widgetHex: ev.colorHex))
|
||||
.frame(width: 2)
|
||||
Text(ev.title)
|
||||
.font(.system(size: 9, weight: .medium))
|
||||
.lineLimit(1)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
Text(ev.isAllDay ? WidgetL10n.t("widget.allday", lang) : timeFmt.string(from: ev.start))
|
||||
.font(.system(size: 8))
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.leading, 5)
|
||||
}
|
||||
.padding(.horizontal, 3)
|
||||
}
|
||||
}
|
||||
84
CalendarrWidgets/TodayWidgetView.swift
Normal file
84
CalendarrWidgets/TodayWidgetView.swift
Normal file
@@ -0,0 +1,84 @@
|
||||
import SwiftUI
|
||||
import WidgetKit
|
||||
|
||||
struct TodayWidgetView: View {
|
||||
let entry: CalendarrEntry
|
||||
|
||||
private var snapshot: WidgetSnapshot? { entry.snapshot }
|
||||
|
||||
private var todayEvents: [WidgetEvent] {
|
||||
guard let s = snapshot else { return [] }
|
||||
return WidgetHelpers.events(for: entry.date, in: s)
|
||||
}
|
||||
|
||||
private var lang: String { snapshot?.language ?? "system" }
|
||||
|
||||
private var timeFmt: DateFormatter {
|
||||
let f = DateFormatter()
|
||||
f.locale = WidgetL10n.locale(lang)
|
||||
f.dateFormat = "HH:mm"
|
||||
return f
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let primary = Color(widgetHex: snapshot?.primaryColorHex ?? "#4285f4")
|
||||
let accent = Color(widgetHex: snapshot?.accentColorHex ?? "#ea4335")
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
Text(WidgetL10n.t("widget.today", lang))
|
||||
.font(.caption.weight(.bold))
|
||||
.foregroundStyle(primary)
|
||||
Spacer()
|
||||
Text(headerDate)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if snapshot == nil {
|
||||
Text(WidgetL10n.t("widget.no_data", lang))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
} else if todayEvents.isEmpty {
|
||||
Spacer()
|
||||
Text(WidgetL10n.t("widget.no_events", lang))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
} else {
|
||||
ForEach(todayEvents.prefix(3)) { ev in
|
||||
eventRow(ev)
|
||||
}
|
||||
if todayEvents.count > 3 {
|
||||
Text(String(format: WidgetL10n.t("widget.more", lang), todayEvents.count - 3))
|
||||
.font(.caption2)
|
||||
.foregroundStyle(accent)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var headerDate: String {
|
||||
let f = DateFormatter()
|
||||
f.locale = WidgetL10n.locale(lang)
|
||||
f.dateFormat = "d. MMM"
|
||||
return f.string(from: entry.date)
|
||||
}
|
||||
|
||||
private func eventRow(_ ev: WidgetEvent) -> some View {
|
||||
HStack(spacing: 6) {
|
||||
RoundedRectangle(cornerRadius: 2)
|
||||
.fill(Color(widgetHex: ev.colorHex))
|
||||
.frame(width: 3)
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(ev.title)
|
||||
.font(.caption.weight(.medium))
|
||||
.lineLimit(1)
|
||||
Text(ev.isAllDay ? WidgetL10n.t("widget.allday", lang) : timeFmt.string(from: ev.start))
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
109
CalendarrWidgets/TwoDaysWidgetView.swift
Normal file
109
CalendarrWidgets/TwoDaysWidgetView.swift
Normal file
@@ -0,0 +1,109 @@
|
||||
import SwiftUI
|
||||
import WidgetKit
|
||||
|
||||
struct TwoDaysWidgetView: View {
|
||||
let entry: CalendarrEntry
|
||||
|
||||
private var snapshot: WidgetSnapshot? { entry.snapshot }
|
||||
private var lang: String { snapshot?.language ?? "system" }
|
||||
|
||||
private var today: Date { Calendar.current.startOfDay(for: entry.date) }
|
||||
private var tomorrow: Date {
|
||||
Calendar.current.date(byAdding: .day, value: 1, to: today) ?? today
|
||||
}
|
||||
|
||||
private var timeFmt: DateFormatter {
|
||||
let f = DateFormatter()
|
||||
f.locale = WidgetL10n.locale(lang)
|
||||
f.dateFormat = "HH:mm"
|
||||
return f
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if let s = snapshot {
|
||||
let primary = Color(widgetHex: s.primaryColorHex)
|
||||
let accent = Color(widgetHex: s.accentColorHex)
|
||||
HStack(spacing: 8) {
|
||||
column(for: today,
|
||||
title: WidgetL10n.t("widget.today", lang),
|
||||
isToday: true,
|
||||
events: WidgetHelpers.events(for: today, in: s),
|
||||
primary: primary, accent: accent,
|
||||
lineColor: Color(widgetHex: s.lineColorHex))
|
||||
Rectangle()
|
||||
.fill(Color(widgetHex: s.lineColorHex).opacity(0.4))
|
||||
.frame(width: 0.5)
|
||||
column(for: tomorrow,
|
||||
title: WidgetL10n.t("widget.tomorrow", lang),
|
||||
isToday: false,
|
||||
events: WidgetHelpers.events(for: tomorrow, in: s),
|
||||
primary: primary, accent: accent,
|
||||
lineColor: Color(widgetHex: s.lineColorHex))
|
||||
}
|
||||
} else {
|
||||
Text(WidgetL10n.t("widget.no_data", lang))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
private func column(for day: Date,
|
||||
title: String,
|
||||
isToday: Bool,
|
||||
events: [WidgetEvent],
|
||||
primary: Color,
|
||||
accent: Color,
|
||||
lineColor: Color) -> some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 4) {
|
||||
Text(title)
|
||||
.font(.caption.weight(.bold))
|
||||
.foregroundStyle(isToday ? primary : accent)
|
||||
Text(shortDate(day))
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
if events.isEmpty {
|
||||
Text(WidgetL10n.t("widget.no_events", lang))
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
ForEach(events.prefix(4)) { ev in
|
||||
eventRow(ev)
|
||||
}
|
||||
if events.count > 4 {
|
||||
Text(String(format: WidgetL10n.t("widget.more", lang), events.count - 4))
|
||||
.font(.system(size: 9))
|
||||
.foregroundStyle(accent)
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||
}
|
||||
|
||||
private func eventRow(_ ev: WidgetEvent) -> some View {
|
||||
HStack(spacing: 4) {
|
||||
RoundedRectangle(cornerRadius: 1.5)
|
||||
.fill(Color(widgetHex: ev.colorHex))
|
||||
.frame(width: 2)
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text(ev.title)
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.lineLimit(1)
|
||||
Text(ev.isAllDay ? WidgetL10n.t("widget.allday", lang) : timeFmt.string(from: ev.start))
|
||||
.font(.system(size: 9))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
|
||||
private func shortDate(_ d: Date) -> String {
|
||||
let f = DateFormatter()
|
||||
f.locale = WidgetL10n.locale(lang)
|
||||
f.dateFormat = "d. MMM"
|
||||
return f.string(from: d)
|
||||
}
|
||||
}
|
||||
170
CalendarrWidgets/TwoMonthWidgetView.swift
Normal file
170
CalendarrWidgets/TwoMonthWidgetView.swift
Normal file
@@ -0,0 +1,170 @@
|
||||
import SwiftUI
|
||||
import WidgetKit
|
||||
|
||||
struct TwoMonthWidgetView: View {
|
||||
let entry: CalendarrEntry
|
||||
@Environment(\.widgetFamily) private var family
|
||||
|
||||
private var snapshot: WidgetSnapshot? { entry.snapshot }
|
||||
private var lang: String { snapshot?.language ?? "system" }
|
||||
|
||||
private var cal: Calendar {
|
||||
var c = Calendar(identifier: .gregorian)
|
||||
c.locale = WidgetL10n.locale(lang)
|
||||
c.firstWeekday = 2
|
||||
return c
|
||||
}
|
||||
|
||||
private var thisMonth: Date {
|
||||
cal.date(from: cal.dateComponents([.year, .month], from: entry.date)) ?? entry.date
|
||||
}
|
||||
|
||||
private var nextMonth: Date {
|
||||
cal.date(byAdding: .month, value: 1, to: thisMonth) ?? thisMonth
|
||||
}
|
||||
|
||||
// Weekday header labels (M T W T F S S)
|
||||
private var weekdayHeaders: [String] {
|
||||
let f = DateFormatter(); f.locale = WidgetL10n.locale(lang)
|
||||
let symbols = f.veryShortWeekdaySymbols ?? cal.veryShortWeekdaySymbols
|
||||
let start = cal.firstWeekday - 1
|
||||
return (0..<7).map { String(symbols[(start + $0) % 7]).uppercased() }
|
||||
}
|
||||
|
||||
// Number of date rows to show (5 for medium, 6 for large)
|
||||
private var rowCount: Int { family == .systemLarge ? 6 : 5 }
|
||||
|
||||
var body: some View {
|
||||
if let s = snapshot {
|
||||
let primary = Color(widgetHex: s.primaryColorHex)
|
||||
let accent = Color(widgetHex: s.accentColorHex)
|
||||
let line = Color(widgetHex: s.lineColorHex)
|
||||
HStack(alignment: .top, spacing: 0) {
|
||||
monthColumn(monthDate: thisMonth, snapshot: s,
|
||||
primary: primary, accent: accent, line: line)
|
||||
.frame(maxWidth: .infinity)
|
||||
line.opacity(0.35).frame(width: 0.5)
|
||||
.padding(.horizontal, 3)
|
||||
monthColumn(monthDate: nextMonth, snapshot: s,
|
||||
primary: primary, accent: accent, line: line)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
} else {
|
||||
Text(WidgetL10n.t("widget.no_data", lang))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – One month column
|
||||
|
||||
private func monthColumn(monthDate: Date,
|
||||
snapshot: WidgetSnapshot,
|
||||
primary: Color,
|
||||
accent: Color,
|
||||
line: Color) -> some View {
|
||||
let monthFmt = DateFormatter()
|
||||
monthFmt.locale = WidgetL10n.locale(lang)
|
||||
monthFmt.dateFormat = "LLLL"
|
||||
let name = monthFmt.string(from: monthDate).uppercased()
|
||||
let start = gridStart(for: monthDate)
|
||||
let wn = WidgetL10n.t("widget.cw", lang)
|
||||
|
||||
return VStack(alignment: .leading, spacing: 1) {
|
||||
// Month name
|
||||
Text(name)
|
||||
.font(.system(size: 9, weight: .bold))
|
||||
.foregroundStyle(primary)
|
||||
|
||||
// Column headers: KW + 7 weekdays
|
||||
HStack(spacing: 0) {
|
||||
Text(wn)
|
||||
.font(.system(size: 6, weight: .bold))
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 14, alignment: .center)
|
||||
ForEach(weekdayHeaders, id: \.self) { h in
|
||||
Text(h)
|
||||
.font(.system(size: 6.5, weight: .bold))
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
// Date rows
|
||||
ForEach(0..<rowCount, id: \.self) { row in
|
||||
let rowStart = cal.date(byAdding: .day, value: row * 7, to: start)!
|
||||
let weekNum = cal.component(.weekOfYear, from: rowStart)
|
||||
let inMonth = cal.isDate(rowStart, equalTo: monthDate, toGranularity: .month)
|
||||
|| cal.isDate(cal.date(byAdding: .day, value: 6, to: rowStart)!,
|
||||
equalTo: monthDate, toGranularity: .month)
|
||||
if inMonth {
|
||||
HStack(spacing: 0) {
|
||||
Text("\(weekNum)")
|
||||
.font(.system(size: 6))
|
||||
.foregroundStyle(.secondary.opacity(0.6))
|
||||
.frame(width: 14, alignment: .center)
|
||||
ForEach(0..<7, id: \.self) { col in
|
||||
let day = cal.date(byAdding: .day, value: col, to: rowStart)!
|
||||
dayCell(day, monthDate: monthDate, snapshot: snapshot,
|
||||
primary: primary, accent: accent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Day cell
|
||||
|
||||
private func dayCell(_ day: Date,
|
||||
monthDate: Date,
|
||||
snapshot: WidgetSnapshot,
|
||||
primary: Color,
|
||||
accent: Color) -> some View {
|
||||
let isToday = cal.isDateInToday(day)
|
||||
let inMonth = cal.isDate(day, equalTo: monthDate, toGranularity: .month)
|
||||
let evs = inMonth ? WidgetHelpers.events(for: day, in: snapshot) : []
|
||||
let isWeekend = { () -> Bool in
|
||||
let wd = cal.component(.weekday, from: day)
|
||||
return wd == 1 || wd == 7
|
||||
}()
|
||||
|
||||
return VStack(spacing: 1) {
|
||||
ZStack {
|
||||
if isToday { Circle().fill(primary) }
|
||||
Text("\(cal.component(.day, from: day))")
|
||||
.font(.system(size: 7.5, weight: isToday ? .bold : .medium))
|
||||
.foregroundStyle(
|
||||
isToday ? .white :
|
||||
!inMonth ? Color.secondary.opacity(0.3) :
|
||||
isWeekend ? Color.primary.opacity(0.5) :
|
||||
Color.primary
|
||||
)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 11)
|
||||
|
||||
// Event dots
|
||||
HStack(spacing: 1) {
|
||||
ForEach(evs.prefix(3).indices, id: \.self) { i in
|
||||
Circle()
|
||||
.fill(Color(widgetHex: evs[i].colorHex))
|
||||
.frame(width: 2.5, height: 2.5)
|
||||
}
|
||||
}
|
||||
.frame(height: 3)
|
||||
}
|
||||
.padding(.bottom, 1)
|
||||
}
|
||||
|
||||
// MARK: – Grid helpers
|
||||
|
||||
private func gridStart(for monthDate: Date) -> Date {
|
||||
let first = cal.date(from: cal.dateComponents([.year, .month], from: monthDate)) ?? monthDate
|
||||
let weekday = cal.component(.weekday, from: first)
|
||||
let offset = ((weekday - cal.firstWeekday) + 7) % 7
|
||||
return cal.date(byAdding: .day, value: -offset, to: first) ?? first
|
||||
}
|
||||
}
|
||||
132
CalendarrWidgets/TwoWeeksWidgetView.swift
Normal file
132
CalendarrWidgets/TwoWeeksWidgetView.swift
Normal file
@@ -0,0 +1,132 @@
|
||||
import SwiftUI
|
||||
import WidgetKit
|
||||
|
||||
struct TwoWeeksWidgetView: View {
|
||||
let entry: CalendarrEntry
|
||||
|
||||
private var snapshot: WidgetSnapshot? { entry.snapshot }
|
||||
private var lang: String { snapshot?.language ?? "system" }
|
||||
|
||||
private var cal: Calendar {
|
||||
var c = Calendar(identifier: .gregorian)
|
||||
c.locale = WidgetL10n.locale(lang)
|
||||
c.firstWeekday = 2
|
||||
return c
|
||||
}
|
||||
|
||||
private var weekStart: Date {
|
||||
cal.date(from: cal.dateComponents([.yearForWeekOfYear, .weekOfYear], from: entry.date)) ?? entry.date
|
||||
}
|
||||
|
||||
private var fortnight: [Date] {
|
||||
(0..<14).compactMap { cal.date(byAdding: .day, value: $0, to: weekStart) }
|
||||
}
|
||||
|
||||
private var monthHeader: String {
|
||||
let f = DateFormatter()
|
||||
f.locale = WidgetL10n.locale(lang)
|
||||
f.dateFormat = "LLLL yyyy"
|
||||
return f.string(from: weekStart).uppercased()
|
||||
}
|
||||
|
||||
private var weekdayHeaders: [String] {
|
||||
let f = DateFormatter(); f.locale = WidgetL10n.locale(lang)
|
||||
let symbols = f.veryShortWeekdaySymbols ?? cal.veryShortWeekdaySymbols
|
||||
let start = cal.firstWeekday - 1
|
||||
return (0..<7).map { symbols[(start + $0) % 7] }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if let s = snapshot {
|
||||
let primary = Color(widgetHex: s.primaryColorHex)
|
||||
let accent = Color(widgetHex: s.accentColorHex)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 4) {
|
||||
Text(monthHeader.split(separator: " ").first.map(String.init) ?? "")
|
||||
.font(.system(size: 9, weight: .bold))
|
||||
.foregroundStyle(primary)
|
||||
Text(monthHeader.split(separator: " ").dropFirst().joined(separator: " "))
|
||||
.font(.system(size: 9, weight: .semibold))
|
||||
}
|
||||
weekdayRow(accent: accent)
|
||||
GeometryReader { geo in
|
||||
let colW = geo.size.width / 7
|
||||
let rowH = geo.size.height / 2
|
||||
VStack(spacing: 0) {
|
||||
ForEach(0..<2, id: \.self) { row in
|
||||
HStack(spacing: 0) {
|
||||
ForEach(0..<7, id: \.self) { col in
|
||||
let day = fortnight[row * 7 + col]
|
||||
dayCell(day, snapshot: s, primary: primary, accent: accent)
|
||||
.frame(width: colW, height: rowH)
|
||||
.overlay(alignment: .trailing) {
|
||||
if col < 6 {
|
||||
Rectangle()
|
||||
.fill(Color(widgetHex: s.lineColorHex).opacity(0.35))
|
||||
.frame(width: 0.5)
|
||||
}
|
||||
}
|
||||
.overlay(alignment: .top) {
|
||||
if row == 1 {
|
||||
Rectangle()
|
||||
.fill(Color(widgetHex: s.lineColorHex).opacity(0.35))
|
||||
.frame(height: 0.5)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Text(WidgetL10n.t("widget.no_data", lang))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
private func weekdayRow(accent: Color) -> some View {
|
||||
HStack(spacing: 0) {
|
||||
ForEach(weekdayHeaders, id: \.self) { h in
|
||||
Text(h)
|
||||
.font(.system(size: 7, weight: .bold))
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func dayCell(_ day: Date,
|
||||
snapshot: WidgetSnapshot,
|
||||
primary: Color,
|
||||
accent: Color) -> some View {
|
||||
let isToday = cal.isDateInToday(day)
|
||||
let evs = WidgetHelpers.events(for: day, in: snapshot)
|
||||
return VStack(alignment: .center, spacing: 0.5) {
|
||||
Text("\(cal.component(.day, from: day))")
|
||||
.font(.system(size: 8.5, weight: isToday ? .bold : .semibold))
|
||||
.foregroundStyle(isToday ? Color.white : Color.primary)
|
||||
.frame(width: 12, height: 12)
|
||||
.background(isToday ? primary : Color.clear)
|
||||
.clipShape(Circle())
|
||||
// Up to 3 colored dots
|
||||
HStack(spacing: 1) {
|
||||
ForEach(evs.prefix(3).indices, id: \.self) { i in
|
||||
Circle()
|
||||
.fill(Color(widgetHex: evs[i].colorHex))
|
||||
.frame(width: 3, height: 3)
|
||||
}
|
||||
}
|
||||
.frame(height: 3)
|
||||
if evs.count > 3 {
|
||||
Text("+\(evs.count - 3)")
|
||||
.font(.system(size: 6))
|
||||
.foregroundStyle(accent)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(.top, 1)
|
||||
}
|
||||
}
|
||||
173
CalendarrWidgets/UpNextWidgetView.swift
Normal file
173
CalendarrWidgets/UpNextWidgetView.swift
Normal file
@@ -0,0 +1,173 @@
|
||||
import SwiftUI
|
||||
import WidgetKit
|
||||
|
||||
struct UpNextWidgetView: View {
|
||||
let entry: CalendarrEntry
|
||||
|
||||
private var snapshot: WidgetSnapshot? { entry.snapshot }
|
||||
private var lang: String { snapshot?.language ?? "system" }
|
||||
|
||||
private var cal: Calendar {
|
||||
var c = Calendar(identifier: .gregorian)
|
||||
c.locale = WidgetL10n.locale(lang)
|
||||
c.firstWeekday = 2
|
||||
return c
|
||||
}
|
||||
|
||||
private var todayEvents: [WidgetEvent] {
|
||||
guard let s = snapshot else { return [] }
|
||||
return WidgetHelpers.events(for: entry.date, in: s)
|
||||
}
|
||||
|
||||
/// Mini-month grid: 6 rows × 7 cols starting from the first weekday of the
|
||||
/// month, padded with neighbouring days where necessary.
|
||||
private var monthGrid: [Date] {
|
||||
let firstOfMonth = cal.date(from: cal.dateComponents([.year, .month], from: entry.date)) ?? entry.date
|
||||
let weekday = cal.component(.weekday, from: firstOfMonth)
|
||||
let offset = ((weekday - cal.firstWeekday) + 7) % 7
|
||||
let gridStart = cal.date(byAdding: .day, value: -offset, to: firstOfMonth) ?? firstOfMonth
|
||||
return (0..<42).compactMap { cal.date(byAdding: .day, value: $0, to: gridStart) }
|
||||
}
|
||||
|
||||
private var weekdayHeaders: [String] {
|
||||
let f = DateFormatter(); f.locale = WidgetL10n.locale(lang)
|
||||
let symbols = f.veryShortWeekdaySymbols ?? cal.veryShortWeekdaySymbols
|
||||
let start = cal.firstWeekday - 1
|
||||
return (0..<7).map { symbols[(start + $0) % 7] }
|
||||
}
|
||||
|
||||
private var weekdayFmt: DateFormatter {
|
||||
let f = DateFormatter()
|
||||
f.locale = WidgetL10n.locale(lang)
|
||||
f.dateFormat = "EEE"
|
||||
return f
|
||||
}
|
||||
|
||||
private var monthNameFmt: DateFormatter {
|
||||
let f = DateFormatter()
|
||||
f.locale = WidgetL10n.locale(lang)
|
||||
f.dateFormat = "LLL"
|
||||
return f
|
||||
}
|
||||
|
||||
private var timeFmt: DateFormatter {
|
||||
let f = DateFormatter()
|
||||
f.locale = WidgetL10n.locale(lang)
|
||||
f.dateFormat = "HH:mm"
|
||||
return f
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if let s = snapshot {
|
||||
let primary = Color(widgetHex: s.primaryColorHex)
|
||||
let accent = Color(widgetHex: s.accentColorHex)
|
||||
HStack(spacing: 8) {
|
||||
leftPanel(snapshot: s, primary: primary, accent: accent)
|
||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||
miniMonth(snapshot: s, primary: primary, accent: accent)
|
||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||
}
|
||||
} else {
|
||||
Text(WidgetL10n.t("widget.no_data", lang))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
private func leftPanel(snapshot: WidgetSnapshot, primary: Color, accent: Color) -> some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
||||
Text("\(cal.component(.day, from: entry.date))")
|
||||
.font(.system(size: 17, weight: .bold))
|
||||
.foregroundStyle(Color.white)
|
||||
.frame(width: 26, height: 26)
|
||||
.background(primary)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 5))
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text(weekdayFmt.string(from: entry.date).uppercased() + ".")
|
||||
.font(.system(size: 11, weight: .bold))
|
||||
.foregroundStyle(accent)
|
||||
Text(monthNameFmt.string(from: entry.date))
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
if todayEvents.isEmpty {
|
||||
Text(WidgetL10n.t("widget.no_events", lang))
|
||||
.font(.system(size: 10))
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
ForEach(todayEvents.prefix(3)) { ev in
|
||||
HStack(alignment: .top, spacing: 4) {
|
||||
Circle()
|
||||
.fill(Color(widgetHex: ev.colorHex))
|
||||
.frame(width: 5, height: 5)
|
||||
.padding(.top, 4)
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text(ev.title)
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.lineLimit(1)
|
||||
Text(ev.isAllDay ? WidgetL10n.t("widget.allday", lang) : timeFmt.string(from: ev.start))
|
||||
.font(.system(size: 9))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
|
||||
private func miniMonth(snapshot: WidgetSnapshot, primary: Color, accent: Color) -> some View {
|
||||
VStack(spacing: 1) {
|
||||
HStack(spacing: 0) {
|
||||
ForEach(weekdayHeaders, id: \.self) { h in
|
||||
Text(h)
|
||||
.font(.system(size: 8, weight: .bold))
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
GeometryReader { geo in
|
||||
let cellW = geo.size.width / 7
|
||||
let cellH = geo.size.height / 6
|
||||
VStack(spacing: 0) {
|
||||
ForEach(0..<6, id: \.self) { row in
|
||||
HStack(spacing: 0) {
|
||||
ForEach(0..<7, id: \.self) { col in
|
||||
miniDay(monthGrid[row * 7 + col],
|
||||
snapshot: snapshot,
|
||||
primary: primary,
|
||||
accent: accent)
|
||||
.frame(width: cellW, height: cellH)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func miniDay(_ day: Date, snapshot: WidgetSnapshot, primary: Color, accent: Color) -> some View {
|
||||
let isToday = cal.isDateInToday(day)
|
||||
let inMonth = cal.isDate(day, equalTo: entry.date, toGranularity: .month)
|
||||
let hasEvents = !WidgetHelpers.events(for: day, in: snapshot).isEmpty
|
||||
return ZStack {
|
||||
if isToday {
|
||||
RoundedRectangle(cornerRadius: 3)
|
||||
.fill(primary)
|
||||
} else if hasEvents && inMonth {
|
||||
RoundedRectangle(cornerRadius: 3)
|
||||
.fill(accent.opacity(0.20))
|
||||
}
|
||||
Text("\(cal.component(.day, from: day))")
|
||||
.font(.system(size: 9, weight: isToday ? .bold : .medium))
|
||||
.foregroundStyle(
|
||||
isToday ? Color.white :
|
||||
inMonth ? Color.primary : Color.secondary.opacity(0.4)
|
||||
)
|
||||
}
|
||||
.padding(0.5)
|
||||
}
|
||||
}
|
||||
130
CalendarrWidgets/UpcomingWidgetView.swift
Normal file
130
CalendarrWidgets/UpcomingWidgetView.swift
Normal file
@@ -0,0 +1,130 @@
|
||||
import SwiftUI
|
||||
import WidgetKit
|
||||
|
||||
private let rowHeight: CGFloat = 16
|
||||
private let dayHeaderHeight: CGFloat = 14
|
||||
private let maxEventsPerDay: Int = 3
|
||||
private let maxTotalRows: Int = 15
|
||||
|
||||
struct UpcomingWidgetView: View {
|
||||
let entry: CalendarrEntry
|
||||
|
||||
private var snapshot: WidgetSnapshot? { entry.snapshot }
|
||||
private var lang: String { snapshot?.language ?? "system" }
|
||||
|
||||
private var groupedWithLimits: [(Date, [WidgetEvent], Int)] {
|
||||
guard let s = snapshot else { return [] }
|
||||
let cal = Calendar.current
|
||||
let now = entry.date
|
||||
let events = WidgetHelpers.upcoming(from: now, daysAhead: 5, in: s)
|
||||
var buckets: [Date: [WidgetEvent]] = [:]
|
||||
for ev in events {
|
||||
let key = cal.startOfDay(for: ev.start)
|
||||
buckets[key, default: []].append(ev)
|
||||
}
|
||||
|
||||
var result: [(Date, [WidgetEvent], Int)] = []
|
||||
var totalRows = 0
|
||||
|
||||
for date in buckets.keys.sorted() {
|
||||
let allEventsForDay = buckets[date] ?? []
|
||||
let eventsToShow = Array(allEventsForDay.prefix(maxEventsPerDay))
|
||||
let hiddenCount = allEventsForDay.count - eventsToShow.count
|
||||
|
||||
// Account for day header + event rows + potential "more" row
|
||||
let rowsForThisDay = 1 + eventsToShow.count + (hiddenCount > 0 ? 1 : 0)
|
||||
|
||||
if totalRows + rowsForThisDay <= maxTotalRows {
|
||||
result.append((date, eventsToShow, hiddenCount))
|
||||
totalRows += rowsForThisDay
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
private var timeFmt: DateFormatter {
|
||||
let f = DateFormatter()
|
||||
f.locale = WidgetL10n.locale(lang)
|
||||
f.dateFormat = "HH:mm"
|
||||
return f
|
||||
}
|
||||
|
||||
private var dayFmt: DateFormatter {
|
||||
let f = DateFormatter()
|
||||
f.locale = WidgetL10n.locale(lang)
|
||||
f.dateFormat = "EEE d. MMM"
|
||||
return f
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let primary = Color(widgetHex: snapshot?.primaryColorHex ?? "#4285f4")
|
||||
let accent = Color(widgetHex: snapshot?.accentColorHex ?? "#ea4335")
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(WidgetL10n.t("widget.upcoming", lang))
|
||||
.font(.caption.weight(.bold))
|
||||
.foregroundStyle(primary)
|
||||
.padding(.bottom, 2)
|
||||
if snapshot == nil {
|
||||
Text(WidgetL10n.t("widget.no_data", lang))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else if groupedWithLimits.isEmpty {
|
||||
Text(WidgetL10n.t("widget.no_events", lang))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
ForEach(groupedWithLimits, id: \.0) { day, evs, hiddenCount in
|
||||
dayHeader(d: day, accent: accent)
|
||||
ForEach(evs) { ev in
|
||||
eventRow(ev)
|
||||
}
|
||||
if hiddenCount > 0 {
|
||||
moreRow(count: hiddenCount, accent: accent)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func dayHeader(d: Date, accent: Color) -> some View {
|
||||
let cal = Calendar.current
|
||||
let isToday = cal.isDateInToday(d)
|
||||
return Text(dayFmt.string(from: d))
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.foregroundStyle(isToday ? accent : .secondary)
|
||||
.frame(height: dayHeaderHeight, alignment: .bottomLeading)
|
||||
.padding(.top, 1)
|
||||
}
|
||||
|
||||
private func eventRow(_ ev: WidgetEvent) -> some View {
|
||||
HStack(spacing: 6) {
|
||||
RoundedRectangle(cornerRadius: 1.5)
|
||||
.fill(Color(widgetHex: ev.colorHex))
|
||||
.frame(width: 2.5)
|
||||
Text(ev.isAllDay ? WidgetL10n.t("widget.allday", lang) : timeFmt.string(from: ev.start))
|
||||
.font(.system(size: 10))
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 38, alignment: .leading)
|
||||
Text(ev.title)
|
||||
.font(.system(size: 10, weight: .medium))
|
||||
.lineLimit(1)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.frame(height: rowHeight)
|
||||
}
|
||||
|
||||
private func moreRow(count: Int, accent: Color) -> some View {
|
||||
Text(String(format: WidgetL10n.t("widget.more", lang), count))
|
||||
.font(.system(size: 9, weight: .semibold))
|
||||
.foregroundStyle(accent)
|
||||
.frame(height: rowHeight)
|
||||
}
|
||||
}
|
||||
120
CalendarrWidgets/WidgetSupport.swift
Normal file
120
CalendarrWidgets/WidgetSupport.swift
Normal file
@@ -0,0 +1,120 @@
|
||||
import SwiftUI
|
||||
|
||||
// Local copy of the Color(hex:) initializer, since the widget extension
|
||||
// is a separate target and cannot import the main app's Color extension.
|
||||
extension Color {
|
||||
init(widgetHex hex: String) {
|
||||
let cleaned = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
|
||||
var int: UInt64 = 0
|
||||
Scanner(string: cleaned).scanHexInt64(&int)
|
||||
let r, g, b: UInt64
|
||||
switch cleaned.count {
|
||||
case 6:
|
||||
(r, g, b) = ((int >> 16) & 0xFF, (int >> 8) & 0xFF, int & 0xFF)
|
||||
default:
|
||||
(r, g, b) = (0, 0, 0)
|
||||
}
|
||||
self.init(red: Double(r) / 255, green: Double(g) / 255, blue: Double(b) / 255)
|
||||
}
|
||||
}
|
||||
|
||||
enum WidgetL10n {
|
||||
static func t(_ key: String, _ stored: String) -> String {
|
||||
let lang: String
|
||||
if stored == "de" || stored == "en" { lang = stored }
|
||||
else {
|
||||
let pref = Locale.preferredLanguages.first ?? "en"
|
||||
lang = pref.lowercased().hasPrefix("de") ? "de" : "en"
|
||||
}
|
||||
return strings[lang]?[key] ?? strings["en"]?[key] ?? key
|
||||
}
|
||||
|
||||
static func locale(_ stored: String) -> Locale {
|
||||
let lang: String
|
||||
if stored == "de" || stored == "en" { lang = stored }
|
||||
else {
|
||||
let pref = Locale.preferredLanguages.first ?? "en"
|
||||
lang = pref.lowercased().hasPrefix("de") ? "de" : "en"
|
||||
}
|
||||
return Locale(identifier: lang)
|
||||
}
|
||||
|
||||
private static let strings: [String: [String: String]] = [
|
||||
"de": [
|
||||
"widget.today": "Heute",
|
||||
"widget.tomorrow": "Morgen",
|
||||
"widget.no_events": "Keine Termine",
|
||||
"widget.allday": "Ganztägig",
|
||||
"widget.more": "+%d weitere",
|
||||
"widget.upcoming": "Nächste 5 Tage",
|
||||
"widget.no_data": "Keine Daten – App einmal öffnen",
|
||||
"widget.display.today_title": "Heute",
|
||||
"widget.display.today_desc": "Heutige Termine auf einen Blick.",
|
||||
"widget.display.days_title": "Heute & Morgen",
|
||||
"widget.display.days_desc": "Termine der nächsten zwei Tage.",
|
||||
"widget.display.upcoming_title": "Nächste 5 Tage",
|
||||
"widget.display.upcoming_desc": "Termine der nächsten 5 Tage.",
|
||||
"widget.display.thisweek_title": "Diese Woche",
|
||||
"widget.display.thisweek_desc": "Wochenraster mit Terminen.",
|
||||
"widget.display.twoweeks_title": "Zwei Wochen",
|
||||
"widget.display.twoweeks_desc": "Zwei-Wochen-Raster mit Terminen.",
|
||||
"widget.display.threedays_title": "Drei Tage",
|
||||
"widget.display.threedays_desc": "Drei-Tages-Ansicht mit Terminen.",
|
||||
"widget.display.upnext_title": "Up Next + Kalender",
|
||||
"widget.display.upnext_desc": "Nächste Termine mit Monatsübersicht.",
|
||||
"widget.display.calday_title": "Tag & Termine",
|
||||
"widget.display.calday_desc": "Datum, Wochenübersicht und nächste Termine.",
|
||||
"widget.display.lockscreen_title": "Datum",
|
||||
"widget.display.lockscreen_desc": "Aktuelles Datum und nächster Termin.",
|
||||
"widget.display.twomonth_title": "Zwei Monate",
|
||||
"widget.display.twomonth_desc": "Aktueller und nächster Monat auf einen Blick.",
|
||||
"widget.display.nownext_title": "Jetzt & Nächstes",
|
||||
"widget.display.nownext_desc": "Aktueller Termin und nächste Ereignisse.",
|
||||
"widget.cw": "KW",
|
||||
"widget.running": "Läuft",
|
||||
"widget.events_count": "Termine",
|
||||
"widget.display.lockscreen_count_title": "Termine heute",
|
||||
"widget.display.lockscreen_count_desc": "Anzahl und Liste heutiger Termine.",
|
||||
"widget.display.lockscreen_countdown_title": "Countdown",
|
||||
"widget.display.lockscreen_countdown_desc": "Zeit bis zum nächsten Termin."
|
||||
],
|
||||
"en": [
|
||||
"widget.today": "Today",
|
||||
"widget.tomorrow": "Tomorrow",
|
||||
"widget.no_events": "No events",
|
||||
"widget.allday": "All-day",
|
||||
"widget.more": "+%d more",
|
||||
"widget.upcoming": "Next 5 days",
|
||||
"widget.no_data": "No data – open the app once",
|
||||
"widget.display.today_title": "Today",
|
||||
"widget.display.today_desc": "Today's events at a glance.",
|
||||
"widget.display.days_title": "Today & tomorrow",
|
||||
"widget.display.days_desc": "Events for the next two days.",
|
||||
"widget.display.upcoming_title": "Next 5 days",
|
||||
"widget.display.upcoming_desc": "Events for the next 5 days.",
|
||||
"widget.display.thisweek_title": "This Week",
|
||||
"widget.display.thisweek_desc": "Week grid with events.",
|
||||
"widget.display.twoweeks_title": "Two Weeks",
|
||||
"widget.display.twoweeks_desc": "Two-week grid with events.",
|
||||
"widget.display.threedays_title": "Three Days",
|
||||
"widget.display.threedays_desc": "Three-day view with events.",
|
||||
"widget.display.upnext_title": "Up Next + Calendar",
|
||||
"widget.display.upnext_desc": "Next events with month overview.",
|
||||
"widget.display.calday_title": "Day & Events",
|
||||
"widget.display.calday_desc": "Date, week overview and upcoming events.",
|
||||
"widget.display.lockscreen_title": "Date",
|
||||
"widget.display.lockscreen_desc": "Current date and next event.",
|
||||
"widget.display.twomonth_title": "Two Months",
|
||||
"widget.display.twomonth_desc": "Current and next month at a glance.",
|
||||
"widget.display.nownext_title": "Now & Next",
|
||||
"widget.display.nownext_desc": "Current event and upcoming events.",
|
||||
"widget.cw": "W",
|
||||
"widget.running": "Running",
|
||||
"widget.events_count": "Events",
|
||||
"widget.display.lockscreen_count_title": "Today's Events",
|
||||
"widget.display.lockscreen_count_desc": "Count and list of today's events.",
|
||||
"widget.display.lockscreen_countdown_title": "Countdown",
|
||||
"widget.display.lockscreen_countdown_desc": "Time until your next event."
|
||||
]
|
||||
]
|
||||
}
|
||||
135
Shared/WidgetData.swift
Normal file
135
Shared/WidgetData.swift
Normal file
@@ -0,0 +1,135 @@
|
||||
import Foundation
|
||||
#if canImport(WidgetKit)
|
||||
import WidgetKit
|
||||
#endif
|
||||
|
||||
/// App-Group identifier shared between the main app and the widget extension.
|
||||
/// IMPORTANT: This must match the App Group capability in BOTH targets
|
||||
/// and the App Group ID registered in the Apple Developer Portal.
|
||||
let widgetAppGroupID = "group.com.scarriffleservices.calendarr"
|
||||
|
||||
/// Lightweight event representation that lives inside the widget cache.
|
||||
/// We strip everything the widget doesn't need (notes, calendar IDs, URLs).
|
||||
struct WidgetEvent: Codable, Hashable, Identifiable {
|
||||
let id: String
|
||||
let title: String
|
||||
let start: Date
|
||||
let end: Date
|
||||
let isAllDay: Bool
|
||||
let colorHex: String
|
||||
let location: String
|
||||
}
|
||||
|
||||
/// Snapshot blob the app writes to the App-Group container and the widget reads.
|
||||
struct WidgetSnapshot: Codable {
|
||||
let writtenAt: Date
|
||||
let events: [WidgetEvent]
|
||||
/// Mirrors the user's chosen visual settings so the widget looks the same
|
||||
/// as the app even when its own AppStorage in the extension is empty.
|
||||
let todayColorHex: String
|
||||
let textColorHex: String
|
||||
let backgroundColorHex: String
|
||||
let lineColorHex: String
|
||||
let primaryColorHex: String
|
||||
let accentColorHex: String
|
||||
let language: String
|
||||
|
||||
init(writtenAt: Date,
|
||||
events: [WidgetEvent],
|
||||
todayColorHex: String,
|
||||
textColorHex: String,
|
||||
backgroundColorHex: String,
|
||||
lineColorHex: String,
|
||||
primaryColorHex: String,
|
||||
accentColorHex: String,
|
||||
language: String) {
|
||||
self.writtenAt = writtenAt
|
||||
self.events = events
|
||||
self.todayColorHex = todayColorHex
|
||||
self.textColorHex = textColorHex
|
||||
self.backgroundColorHex = backgroundColorHex
|
||||
self.lineColorHex = lineColorHex
|
||||
self.primaryColorHex = primaryColorHex
|
||||
self.accentColorHex = accentColorHex
|
||||
self.language = language
|
||||
}
|
||||
|
||||
/// Custom decoder so older caches without the new colour fields still load.
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
writtenAt = try c.decode(Date.self, forKey: .writtenAt)
|
||||
events = try c.decode([WidgetEvent].self, forKey: .events)
|
||||
todayColorHex = try c.decode(String.self, forKey: .todayColorHex)
|
||||
textColorHex = try c.decode(String.self, forKey: .textColorHex)
|
||||
backgroundColorHex = try c.decode(String.self, forKey: .backgroundColorHex)
|
||||
lineColorHex = try c.decode(String.self, forKey: .lineColorHex)
|
||||
language = try c.decode(String.self, forKey: .language)
|
||||
primaryColorHex = try c.decodeIfPresent(String.self, forKey: .primaryColorHex) ?? "#4285f4"
|
||||
accentColorHex = try c.decodeIfPresent(String.self, forKey: .accentColorHex) ?? "#ea4335"
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case writtenAt, events, todayColorHex, textColorHex, backgroundColorHex
|
||||
case lineColorHex, primaryColorHex, accentColorHex, language
|
||||
}
|
||||
}
|
||||
|
||||
enum WidgetStore {
|
||||
private static let cacheFilename = "widget-cache.json"
|
||||
|
||||
private static var containerURL: URL? {
|
||||
FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: widgetAppGroupID)
|
||||
}
|
||||
|
||||
private static var cacheURL: URL? {
|
||||
containerURL?.appendingPathComponent(cacheFilename)
|
||||
}
|
||||
|
||||
/// Called by the app whenever the event cache changes.
|
||||
static func write(_ snapshot: WidgetSnapshot) {
|
||||
guard let url = cacheURL else { return }
|
||||
let encoder = JSONEncoder()
|
||||
encoder.dateEncodingStrategy = .iso8601
|
||||
if let data = try? encoder.encode(snapshot) {
|
||||
try? data.write(to: url, options: .atomic)
|
||||
}
|
||||
}
|
||||
|
||||
/// Called by the widget timeline provider to load the latest snapshot.
|
||||
static func read() -> WidgetSnapshot? {
|
||||
guard let url = cacheURL, let data = try? Data(contentsOf: url) else { return nil }
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .iso8601
|
||||
return try? decoder.decode(WidgetSnapshot.self, from: data)
|
||||
}
|
||||
|
||||
/// Rewrite the existing snapshot with the latest colour / language values
|
||||
/// from UserDefaults. Used when the user tweaks an appearance setting and
|
||||
/// we want the widgets to refresh immediately, without needing a new event
|
||||
/// sync. No-op if there's no cached snapshot yet.
|
||||
static func republishAppearanceOnly() {
|
||||
guard let existing = read() else { return }
|
||||
let defaults = UserDefaults.standard
|
||||
let updated = WidgetSnapshot(
|
||||
writtenAt: Date(),
|
||||
events: existing.events,
|
||||
todayColorHex: defaults.string(forKey: "todayColor") ?? existing.todayColorHex,
|
||||
textColorHex: defaults.string(forKey: "textColor") ?? existing.textColorHex,
|
||||
backgroundColorHex: defaults.string(forKey: "backgroundColor") ?? existing.backgroundColorHex,
|
||||
lineColorHex: defaults.string(forKey: "lineColor") ?? existing.lineColorHex,
|
||||
primaryColorHex: defaults.string(forKey: "primaryColor") ?? existing.primaryColorHex,
|
||||
accentColorHex: defaults.string(forKey: "accentColor") ?? existing.accentColorHex,
|
||||
language: defaults.string(forKey: "appLanguage") ?? existing.language
|
||||
)
|
||||
write(updated)
|
||||
WidgetTimelineNotifier.reload()
|
||||
}
|
||||
}
|
||||
|
||||
enum WidgetTimelineNotifier {
|
||||
static func reload() {
|
||||
#if canImport(WidgetKit)
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
#endif
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user