Compare commits
36 Commits
c0edca338e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1da8de06a | ||
|
|
84f5c00516 | ||
|
|
0d2ad6e021 | ||
|
|
7fa8dbdbdf | ||
|
|
c9bd42aae5 | ||
|
|
781ccd1752 | ||
|
|
554ad425b1 | ||
|
|
7981ced5ba | ||
|
|
6881c68935 | ||
|
|
daf0345120 | ||
|
|
108503ad53 | ||
|
|
e008d5d98a | ||
|
|
d90a1a552c | ||
|
|
9a50f25f57 | ||
|
|
d4fd7beaf7 | ||
|
|
69ed8bf67d | ||
|
|
871fb833ba | ||
|
|
cf990e4279 | ||
|
|
e55e6c7c92 | ||
|
|
c4195243c0 | ||
|
|
32cb8e1689 | ||
|
|
3f5de5cdfa | ||
|
|
9db04361cc | ||
|
|
325f357357 | ||
|
|
61237134f0 | ||
|
|
fb22aeb090 | ||
|
|
1cbab3f3ec | ||
|
|
8ed474663e | ||
|
|
4e9ae83299 | ||
|
|
52040fff53 | ||
|
|
cc3d16ddce | ||
|
|
544e0d9265 | ||
|
|
1dbe405689 | ||
|
|
17ebf788ce | ||
|
|
69a32121df | ||
|
|
864eb31072 |
@@ -8,6 +8,8 @@
|
|||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
D0001A0CABCDEF0100AB5001 /* CalendarrWidgets.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = D0001A02ABCDEF0100AB5001 /* CalendarrWidgets.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
D0001A0CABCDEF0100AB5001 /* CalendarrWidgets.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = D0001A02ABCDEF0100AB5001 /* CalendarrWidgets.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||||
|
CA1E4D0A0002000000000001 /* CalendarrCore in Frameworks */ = {isa = PBXBuildFile; productRef = CA1E4D0A0001000000000001 /* CalendarrCore */; };
|
||||||
|
CA1E4D0A0002000000000002 /* CalendarrCore in Frameworks */ = {isa = PBXBuildFile; productRef = CA1E4D0A0001000000000002 /* CalendarrCore */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy section */
|
/* Begin PBXContainerItemProxy section */
|
||||||
@@ -95,6 +97,7 @@
|
|||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
|
CA1E4D0A0002000000000001 /* CalendarrCore in Frameworks */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -102,6 +105,7 @@
|
|||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
|
CA1E4D0A0002000000000002 /* CalendarrCore in Frameworks */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -175,6 +179,7 @@
|
|||||||
);
|
);
|
||||||
name = "Calendarr iOS";
|
name = "Calendarr iOS";
|
||||||
packageProductDependencies = (
|
packageProductDependencies = (
|
||||||
|
CA1E4D0A0001000000000001 /* CalendarrCore */,
|
||||||
);
|
);
|
||||||
productName = "Calendarr iOS";
|
productName = "Calendarr iOS";
|
||||||
productReference = C0000B01FC4E10100AB5001 /* Calendarr iOS.app */;
|
productReference = C0000B01FC4E10100AB5001 /* Calendarr iOS.app */;
|
||||||
@@ -198,6 +203,7 @@
|
|||||||
);
|
);
|
||||||
name = CalendarrWidgets;
|
name = CalendarrWidgets;
|
||||||
packageProductDependencies = (
|
packageProductDependencies = (
|
||||||
|
CA1E4D0A0001000000000002 /* CalendarrCore */,
|
||||||
);
|
);
|
||||||
productName = CalendarrWidgets;
|
productName = CalendarrWidgets;
|
||||||
productReference = D0001A02ABCDEF0100AB5001 /* CalendarrWidgets.appex */;
|
productReference = D0001A02ABCDEF0100AB5001 /* CalendarrWidgets.appex */;
|
||||||
@@ -235,6 +241,9 @@
|
|||||||
);
|
);
|
||||||
mainGroup = C0000201FB4E10100AB5001;
|
mainGroup = C0000201FB4E10100AB5001;
|
||||||
minimizedProjectReferenceProxies = 1;
|
minimizedProjectReferenceProxies = 1;
|
||||||
|
packageReferences = (
|
||||||
|
39450EFC302A266A0090DDD7 /* XCLocalSwiftPackageReference "../CalendarrKit" */,
|
||||||
|
);
|
||||||
preferredProjectObjectVersion = 77;
|
preferredProjectObjectVersion = 77;
|
||||||
productRefGroup = C0000C01FB4E10100AB5001 /* Products */;
|
productRefGroup = C0000C01FB4E10100AB5001 /* Products */;
|
||||||
projectDirPath = "";
|
projectDirPath = "";
|
||||||
@@ -314,12 +323,10 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 2;
|
|
||||||
DEVELOPMENT_TEAM = PP34X97WS3;
|
DEVELOPMENT_TEAM = PP34X97WS3;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 26.5;
|
IPHONEOS_DEPLOYMENT_TARGET = 26.5;
|
||||||
MACOSX_DEPLOYMENT_TARGET = 26.5;
|
MACOSX_DEPLOYMENT_TARGET = 26.5;
|
||||||
MARKETING_VERSION = 1.0.1;
|
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "com.local.scarriffle.Calendarr-iOSTests";
|
PRODUCT_BUNDLE_IDENTIFIER = "com.local.scarriffle.Calendarr-iOSTests";
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SDKROOT = auto;
|
SDKROOT = auto;
|
||||||
@@ -341,12 +348,10 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 2;
|
|
||||||
DEVELOPMENT_TEAM = PP34X97WS3;
|
DEVELOPMENT_TEAM = PP34X97WS3;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 26.5;
|
IPHONEOS_DEPLOYMENT_TARGET = 26.5;
|
||||||
MACOSX_DEPLOYMENT_TARGET = 26.5;
|
MACOSX_DEPLOYMENT_TARGET = 26.5;
|
||||||
MARKETING_VERSION = 1.0.1;
|
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "com.local.scarriffle.Calendarr-iOSTests";
|
PRODUCT_BUNDLE_IDENTIFIER = "com.local.scarriffle.Calendarr-iOSTests";
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SDKROOT = auto;
|
SDKROOT = auto;
|
||||||
@@ -397,6 +402,7 @@
|
|||||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
COPY_PHASE_STRIP = NO;
|
COPY_PHASE_STRIP = NO;
|
||||||
|
CURRENT_PROJECT_VERSION = 7;
|
||||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
ENABLE_TESTABILITY = YES;
|
ENABLE_TESTABILITY = YES;
|
||||||
@@ -415,8 +421,9 @@
|
|||||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
|
||||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||||
|
MARKETING_VERSION = 3.5;
|
||||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
ONLY_ACTIVE_ARCH = YES;
|
ONLY_ACTIVE_ARCH = YES;
|
||||||
@@ -461,6 +468,7 @@
|
|||||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
COPY_PHASE_STRIP = NO;
|
COPY_PHASE_STRIP = NO;
|
||||||
|
CURRENT_PROJECT_VERSION = 7;
|
||||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||||
ENABLE_NS_ASSERTIONS = NO;
|
ENABLE_NS_ASSERTIONS = NO;
|
||||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
@@ -473,8 +481,9 @@
|
|||||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
|
||||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||||
|
MARKETING_VERSION = 3.5;
|
||||||
MTL_ENABLE_DEBUG_INFO = NO;
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
@@ -490,33 +499,33 @@
|
|||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
CODE_SIGN_ENTITLEMENTS = "Calendarr iOS/Calendarr iOS.entitlements";
|
CODE_SIGN_ENTITLEMENTS = "Calendarr iOS/Calendarr iOS.entitlements";
|
||||||
|
"CODE_SIGN_ENTITLEMENTS[sdk=macosx*]" = "Calendarr iOS/Calendarr iOS-Catalyst.entitlements";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
|
||||||
DEVELOPMENT_TEAM = PP34X97WS3;
|
DEVELOPMENT_TEAM = PP34X97WS3;
|
||||||
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = Calendarr;
|
INFOPLIST_KEY_CFBundleDisplayName = Calendarr;
|
||||||
INFOPLIST_KEY_CFBundleName = Calendarr;
|
INFOPLIST_KEY_CFBundleName = Calendarr;
|
||||||
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
|
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||||
INFOPLIST_KEY_NSAppTransportSecurity_NSAllowsArbitraryLoads = YES;
|
INFOPLIST_KEY_NSAppTransportSecurity_NSAllowsLocalNetworking = YES;
|
||||||
|
INFOPLIST_KEY_NSContactsUsageDescription = "Calendarr liest Geburtstage aus deinen Kontakten, um sie in deinen Geburtstagskalender zu übernehmen.";
|
||||||
INFOPLIST_KEY_NSHumanReadableCopyright = "© 2026 Scarriffleservices";
|
INFOPLIST_KEY_NSHumanReadableCopyright = "© 2026 Scarriffleservices";
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleDarkContent;
|
INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleDarkContent;
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
|
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait";
|
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 2.5;
|
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios;
|
PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios;
|
||||||
PRODUCT_NAME = "Calendarr iOS";
|
PRODUCT_NAME = "Calendarr iOS";
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
SUPPORTS_MACCATALYST = NO;
|
SUPPORTS_MACCATALYST = YES;
|
||||||
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
|
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||||
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
|
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||||
@@ -525,6 +534,7 @@
|
|||||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = 1;
|
TARGETED_DEVICE_FAMILY = 1;
|
||||||
|
"TARGETED_DEVICE_FAMILY[sdk=macosx*]" = 2;
|
||||||
};
|
};
|
||||||
name = Debug;
|
name = Debug;
|
||||||
};
|
};
|
||||||
@@ -534,33 +544,33 @@
|
|||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
CODE_SIGN_ENTITLEMENTS = "Calendarr iOS/Calendarr iOS.entitlements";
|
CODE_SIGN_ENTITLEMENTS = "Calendarr iOS/Calendarr iOS.entitlements";
|
||||||
|
"CODE_SIGN_ENTITLEMENTS[sdk=macosx*]" = "Calendarr iOS/Calendarr iOS-Catalyst.entitlements";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
|
||||||
DEVELOPMENT_TEAM = PP34X97WS3;
|
DEVELOPMENT_TEAM = PP34X97WS3;
|
||||||
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = Calendarr;
|
INFOPLIST_KEY_CFBundleDisplayName = Calendarr;
|
||||||
INFOPLIST_KEY_CFBundleName = Calendarr;
|
INFOPLIST_KEY_CFBundleName = Calendarr;
|
||||||
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
|
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||||
INFOPLIST_KEY_NSAppTransportSecurity_NSAllowsArbitraryLoads = YES;
|
INFOPLIST_KEY_NSAppTransportSecurity_NSAllowsLocalNetworking = YES;
|
||||||
|
INFOPLIST_KEY_NSContactsUsageDescription = "Calendarr liest Geburtstage aus deinen Kontakten, um sie in deinen Geburtstagskalender zu übernehmen.";
|
||||||
INFOPLIST_KEY_NSHumanReadableCopyright = "© 2026 Scarriffleservices";
|
INFOPLIST_KEY_NSHumanReadableCopyright = "© 2026 Scarriffleservices";
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleDarkContent;
|
INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleDarkContent;
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
|
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait";
|
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 2.5;
|
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios;
|
PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios;
|
||||||
PRODUCT_NAME = "Calendarr iOS";
|
PRODUCT_NAME = "Calendarr iOS";
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
SUPPORTS_MACCATALYST = NO;
|
SUPPORTS_MACCATALYST = YES;
|
||||||
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
|
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||||
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
|
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||||
@@ -569,6 +579,7 @@
|
|||||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = 1;
|
TARGETED_DEVICE_FAMILY = 1;
|
||||||
|
"TARGETED_DEVICE_FAMILY[sdk=macosx*]" = 2;
|
||||||
};
|
};
|
||||||
name = Release;
|
name = Release;
|
||||||
};
|
};
|
||||||
@@ -577,26 +588,25 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = CalendarrWidgets/CalendarrWidgets.entitlements;
|
CODE_SIGN_ENTITLEMENTS = CalendarrWidgets/CalendarrWidgets.entitlements;
|
||||||
|
"CODE_SIGN_ENTITLEMENTS[sdk=macosx*]" = "CalendarrWidgets/CalendarrWidgets-Catalyst.entitlements";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 2;
|
|
||||||
DEVELOPMENT_TEAM = PP34X97WS3;
|
DEVELOPMENT_TEAM = PP34X97WS3;
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
INFOPLIST_FILE = CalendarrWidgets/Info.plist;
|
INFOPLIST_FILE = CalendarrWidgets/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = "Calendarr Widgets";
|
INFOPLIST_KEY_CFBundleDisplayName = "Calendarr Widgets";
|
||||||
INFOPLIST_KEY_NSHumanReadableCopyright = "© 2026 Scarriffleservices";
|
INFOPLIST_KEY_NSHumanReadableCopyright = "© 2026 Scarriffleservices";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0.1;
|
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios.CalendarrWidgets;
|
PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios.CalendarrWidgets;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
SKIP_INSTALL = YES;
|
SKIP_INSTALL = YES;
|
||||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
SUPPORTS_MACCATALYST = NO;
|
SUPPORTS_MACCATALYST = YES;
|
||||||
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
|
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||||
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
|
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
||||||
@@ -604,6 +614,7 @@
|
|||||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = 1;
|
TARGETED_DEVICE_FAMILY = 1;
|
||||||
|
"TARGETED_DEVICE_FAMILY[sdk=macosx*]" = 2;
|
||||||
};
|
};
|
||||||
name = Debug;
|
name = Debug;
|
||||||
};
|
};
|
||||||
@@ -612,32 +623,32 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = CalendarrWidgets/CalendarrWidgets.entitlements;
|
CODE_SIGN_ENTITLEMENTS = CalendarrWidgets/CalendarrWidgets.entitlements;
|
||||||
|
"CODE_SIGN_ENTITLEMENTS[sdk=macosx*]" = "CalendarrWidgets/CalendarrWidgets-Catalyst.entitlements";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 2;
|
|
||||||
DEVELOPMENT_TEAM = PP34X97WS3;
|
DEVELOPMENT_TEAM = PP34X97WS3;
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
INFOPLIST_FILE = CalendarrWidgets/Info.plist;
|
INFOPLIST_FILE = CalendarrWidgets/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = "Calendarr Widgets";
|
INFOPLIST_KEY_CFBundleDisplayName = "Calendarr Widgets";
|
||||||
INFOPLIST_KEY_NSHumanReadableCopyright = "© 2026 Scarriffleservices";
|
INFOPLIST_KEY_NSHumanReadableCopyright = "© 2026 Scarriffleservices";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0.1;
|
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios.CalendarrWidgets;
|
PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios.CalendarrWidgets;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
SKIP_INSTALL = YES;
|
SKIP_INSTALL = YES;
|
||||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
SUPPORTS_MACCATALYST = NO;
|
SUPPORTS_MACCATALYST = YES;
|
||||||
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
|
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||||
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
|
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||||
SWIFT_COMPILATION_MODE = wholemodule;
|
SWIFT_COMPILATION_MODE = wholemodule;
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = 1;
|
TARGETED_DEVICE_FAMILY = 1;
|
||||||
|
"TARGETED_DEVICE_FAMILY[sdk=macosx*]" = 2;
|
||||||
};
|
};
|
||||||
name = Release;
|
name = Release;
|
||||||
};
|
};
|
||||||
@@ -681,6 +692,24 @@
|
|||||||
defaultConfigurationName = Release;
|
defaultConfigurationName = Release;
|
||||||
};
|
};
|
||||||
/* End XCConfigurationList section */
|
/* End XCConfigurationList section */
|
||||||
|
|
||||||
|
/* Begin XCSwiftPackageProductDependency section */
|
||||||
|
CA1E4D0A0001000000000001 /* CalendarrCore */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
productName = CalendarrCore;
|
||||||
|
};
|
||||||
|
CA1E4D0A0001000000000002 /* CalendarrCore */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
productName = CalendarrCore;
|
||||||
|
};
|
||||||
|
/* End XCSwiftPackageProductDependency section */
|
||||||
|
|
||||||
|
/* Begin XCLocalSwiftPackageReference section */
|
||||||
|
39450EFC302A266A0090DDD7 /* XCLocalSwiftPackageReference "../CalendarrKit" */ = {
|
||||||
|
isa = XCLocalSwiftPackageReference;
|
||||||
|
relativePath = ../CalendarrKit;
|
||||||
|
};
|
||||||
|
/* End XCLocalSwiftPackageReference section */
|
||||||
};
|
};
|
||||||
rootObject = C0000301FB4E10100AB5001 /* Project object */;
|
rootObject = C0000301FB4E10100AB5001 /* Project object */;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
<key>CalendarrWidgets.xcscheme_^#shared#^_</key>
|
<key>CalendarrWidgets.xcscheme_^#shared#^_</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>orderHint</key>
|
<key>orderHint</key>
|
||||||
<integer>1</integer>
|
<integer>2</integer>
|
||||||
</dict>
|
</dict>
|
||||||
</dict>
|
</dict>
|
||||||
<key>SuppressBuildableAutocreation</key>
|
<key>SuppressBuildableAutocreation</key>
|
||||||
|
|||||||
46
Calendarr iOS/Calendarr iOS-Catalyst.entitlements
Normal file
46
Calendarr iOS/Calendarr iOS-Catalyst.entitlements
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
<?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>
|
||||||
|
<!--
|
||||||
|
Mac Catalyst entitlements. A separate file is required because the App
|
||||||
|
Group value differs per platform: macOS demands the Team ID prefix, iOS
|
||||||
|
forbids it, and listing both in one file breaks iOS provisioning.
|
||||||
|
Selected via CODE_SIGN_ENTITLEMENTS[sdk=macosx*].
|
||||||
|
|
||||||
|
This one file serves both distribution paths. app-sandbox is required by
|
||||||
|
the Mac App Store and desirable for Developer ID; ENABLE_HARDENED_RUNTIME
|
||||||
|
is required for notarization and ignored by the App Store. Only the
|
||||||
|
signing identity differs, and that is chosen at export time.
|
||||||
|
-->
|
||||||
|
<key>com.apple.security.app-sandbox</key>
|
||||||
|
<true/>
|
||||||
|
|
||||||
|
<!-- Talks to the user's own Calendarr server. -->
|
||||||
|
<key>com.apple.security.network.client</key>
|
||||||
|
<true/>
|
||||||
|
|
||||||
|
<!-- BirthdaysImporter reads Contacts to sync birthdays. -->
|
||||||
|
<key>com.apple.security.personal-information.addressbook</key>
|
||||||
|
<true/>
|
||||||
|
|
||||||
|
<!-- .ics import (fileImporter) and calendar export (fileExporter). -->
|
||||||
|
<key>com.apple.security.files.user-selected.read-write</key>
|
||||||
|
<true/>
|
||||||
|
|
||||||
|
<!-- Must match CalendarrAppGroup.current on this platform, byte for byte. -->
|
||||||
|
<key>com.apple.security.application-groups</key>
|
||||||
|
<array>
|
||||||
|
<string>PP34X97WS3.group.com.scarriffleservices.calendarr</string>
|
||||||
|
</array>
|
||||||
|
|
||||||
|
<!-- Own group first: it stays the default access group, so tokens written
|
||||||
|
before this entitlement existed keep resolving. The .shared group is
|
||||||
|
what a second app from this team reads the auth token from. -->
|
||||||
|
<key>keychain-access-groups</key>
|
||||||
|
<array>
|
||||||
|
<string>$(AppIdentifierPrefix)com.scarriffleservices.calendarr.ios</string>
|
||||||
|
<string>$(AppIdentifierPrefix)com.scarriffleservices.calendarr.shared</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -6,5 +6,15 @@
|
|||||||
<array>
|
<array>
|
||||||
<string>group.com.scarriffleservices.calendarr</string>
|
<string>group.com.scarriffleservices.calendarr</string>
|
||||||
</array>
|
</array>
|
||||||
|
<!-- Order matters. Without this entitlement the default access group is
|
||||||
|
<AppIdentifierPrefix><bundle id>, which is where every existing token
|
||||||
|
lives. Adding the entitlement makes the FIRST entry the new default, so
|
||||||
|
listing our own group first keeps those tokens resolving and nobody
|
||||||
|
gets signed out. The .shared group is the one the Mac app also reads. -->
|
||||||
|
<key>keychain-access-groups</key>
|
||||||
|
<array>
|
||||||
|
<string>$(AppIdentifierPrefix)com.scarriffleservices.calendarr.ios</string>
|
||||||
|
<string>$(AppIdentifierPrefix)com.scarriffleservices.calendarr.shared</string>
|
||||||
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -24,11 +24,45 @@ class AppState {
|
|||||||
|
|
||||||
init() {
|
init() {
|
||||||
serverURL = UserDefaults.standard.string(forKey: "serverURL") ?? ""
|
serverURL = UserDefaults.standard.string(forKey: "serverURL") ?? ""
|
||||||
authToken = UserDefaults.standard.string(forKey: "authToken") ?? ""
|
authToken = Self.loadToken()
|
||||||
username = UserDefaults.standard.string(forKey: "username") ?? ""
|
username = UserDefaults.standard.string(forKey: "username") ?? ""
|
||||||
isAdmin = UserDefaults.standard.bool(forKey: "isAdmin")
|
isAdmin = UserDefaults.standard.bool(forKey: "isAdmin")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Find the stored token, migrating it forward from wherever an older build
|
||||||
|
/// left it. Runs on every launch but does real work only once.
|
||||||
|
///
|
||||||
|
/// Step 2 is the one that keeps existing users signed in. Before the
|
||||||
|
/// `keychain-access-groups` entitlement existed, items landed in the app's
|
||||||
|
/// own default group. Adding the entitlement makes the *first* array entry
|
||||||
|
/// the new default — and that entry is deliberately the app's own group, so
|
||||||
|
/// an unqualified query still resolves those items and we can copy them
|
||||||
|
/// across instead of stranding them.
|
||||||
|
private static func loadToken() -> String {
|
||||||
|
let key = "authToken"
|
||||||
|
|
||||||
|
// 1. Already in the shared group — the steady state.
|
||||||
|
if let token = try? KeychainStore.get(key), !token.isEmpty {
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. In the app's own default group, written before the entitlement.
|
||||||
|
if let token = try? KeychainStore.get(key, accessGroup: nil), !token.isEmpty {
|
||||||
|
try? KeychainStore.set(token, for: key)
|
||||||
|
try? KeychainStore.set(nil, for: key, accessGroup: nil)
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. In UserDefaults, written before secrets moved to the Keychain.
|
||||||
|
if let legacy = UserDefaults.standard.string(forKey: key), !legacy.isEmpty {
|
||||||
|
try? KeychainStore.set(legacy, for: key)
|
||||||
|
UserDefaults.standard.removeObject(forKey: key)
|
||||||
|
return legacy
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
func saveServer(url: String) {
|
func saveServer(url: String) {
|
||||||
serverURL = url.trimmingCharacters(in: .whitespacesAndNewlines)
|
serverURL = url.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
if serverURL.hasSuffix("/") { serverURL = String(serverURL.dropLast()) }
|
if serverURL.hasSuffix("/") { serverURL = String(serverURL.dropLast()) }
|
||||||
@@ -39,23 +73,45 @@ class AppState {
|
|||||||
authToken = token
|
authToken = token
|
||||||
username = user
|
username = user
|
||||||
isAdmin = admin
|
isAdmin = admin
|
||||||
UserDefaults.standard.set(token, forKey: "authToken")
|
try? KeychainStore.set(token, for: "authToken") // secret → Keychain, not UserDefaults
|
||||||
UserDefaults.standard.set(user, forKey: "username")
|
UserDefaults.standard.set(user, forKey: "username")
|
||||||
UserDefaults.standard.set(admin, forKey: "isAdmin")
|
UserDefaults.standard.set(admin, forKey: "isAdmin")
|
||||||
|
publishSession()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mirror the non-secret session facts into the shared container. Apps in
|
||||||
|
/// another sandbox cannot read our UserDefaults, so this is how they learn
|
||||||
|
/// which server we point at and whether anyone is signed in. The token
|
||||||
|
/// itself stays in the shared keychain group, never in a plain file.
|
||||||
|
private func publishSession() {
|
||||||
|
WidgetStore.writeSession(baseURL: serverURL,
|
||||||
|
username: username,
|
||||||
|
isLoggedIn: isLoggedIn)
|
||||||
}
|
}
|
||||||
|
|
||||||
func logout() {
|
func logout() {
|
||||||
authToken = ""
|
authToken = ""
|
||||||
username = ""
|
username = ""
|
||||||
isAdmin = false
|
isAdmin = false
|
||||||
UserDefaults.standard.removeObject(forKey: "authToken")
|
try? KeychainStore.set(nil, for: "authToken")
|
||||||
|
try? KeychainStore.set(nil, for: "authToken", accessGroup: nil) // pre-entitlement copy
|
||||||
|
UserDefaults.standard.removeObject(forKey: "authToken") // pre-Keychain copy
|
||||||
UserDefaults.standard.removeObject(forKey: "username")
|
UserDefaults.standard.removeObject(forKey: "username")
|
||||||
UserDefaults.standard.removeObject(forKey: "isAdmin")
|
UserDefaults.standard.removeObject(forKey: "isAdmin")
|
||||||
|
// The shared container outlives the session, so it has to be cleared
|
||||||
|
// explicitly — otherwise widgets keep showing the signed-out user's data.
|
||||||
|
// The session record stays behind, flagged signed-out, so a reader can
|
||||||
|
// say "sign in to Calendarr" rather than "open Calendarr once".
|
||||||
|
WidgetStore.clear()
|
||||||
|
publishSession()
|
||||||
}
|
}
|
||||||
|
|
||||||
func resetServer() {
|
func resetServer() {
|
||||||
logout()
|
logout()
|
||||||
serverURL = ""
|
serverURL = ""
|
||||||
UserDefaults.standard.removeObject(forKey: "serverURL")
|
UserDefaults.standard.removeObject(forKey: "serverURL")
|
||||||
|
// Back to unconfigured: there is no server left to name, so drop the
|
||||||
|
// session record rather than leaving a stale URL in the container.
|
||||||
|
WidgetStore.clearSession()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,10 +15,14 @@ struct AppSettings: Codable {
|
|||||||
var monthLabelColor: String = "#7090c0"
|
var monthLabelColor: String = "#7090c0"
|
||||||
var textColor: String = "#FFFFFF"
|
var textColor: String = "#FFFFFF"
|
||||||
var backgroundColor: String = "#000000"
|
var backgroundColor: String = "#000000"
|
||||||
var lineColor: String = "#3A3A3C"
|
var lineColor: String = "#3A3A52"
|
||||||
var privateEventVisibility: String = "busy" // 'hidden' | 'busy'
|
var privateEventVisibility: String = "busy" // 'hidden' | 'busy'
|
||||||
var groupVisibleCalendarId: Int? = nil
|
var groupVisibleCalendarId: Int? = nil
|
||||||
var defaultReminderMinutes: Int? = nil // minutes before start; nil = off
|
var defaultReminderMinutes: Int? = nil // minutes before start; nil = off
|
||||||
|
var defaultEventDurationMinutes: Int = 60 // applied to a new event's end time
|
||||||
|
var cacheMonths: Int = 3 // preloaded month range (device-local by default)
|
||||||
|
var monthViewPaged: Bool = false // month view swipe-paging (device-local by default)
|
||||||
|
var syncFlags: [String: Bool]? = nil // server-resolved per-setting sync flags
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case defaultView = "default_view"
|
case defaultView = "default_view"
|
||||||
@@ -34,20 +38,24 @@ struct AppSettings: Codable {
|
|||||||
case monthDividerColor = "month_divider_color"
|
case monthDividerColor = "month_divider_color"
|
||||||
case monthLabelColor = "month_label_color"
|
case monthLabelColor = "month_label_color"
|
||||||
case textColor = "text_color"
|
case textColor = "text_color"
|
||||||
case backgroundColor = "background_color"
|
case backgroundColor = "bg_color"
|
||||||
case lineColor = "line_color"
|
case lineColor = "line_color"
|
||||||
case privateEventVisibility = "private_event_visibility"
|
case privateEventVisibility = "private_event_visibility"
|
||||||
case groupVisibleCalendarId = "group_visible_calendar_id"
|
case groupVisibleCalendarId = "group_visible_calendar_id"
|
||||||
case defaultReminderMinutes = "default_reminder_minutes"
|
case defaultReminderMinutes = "default_reminder_minutes"
|
||||||
|
case defaultEventDurationMinutes = "default_event_duration_minutes"
|
||||||
|
case cacheMonths = "cache_months"
|
||||||
|
case monthViewPaged = "month_view_paged"
|
||||||
|
case syncFlags = "sync_flags"
|
||||||
}
|
}
|
||||||
|
|
||||||
init() {}
|
init() {}
|
||||||
|
|
||||||
/// Resilient decoding: the server only stores a subset of these fields
|
/// Resilient decoding: a server may omit some keys. Using `decodeIfPresent`
|
||||||
/// (e.g. it has no `text_color`/`background_color`/`line_color`, which are
|
/// with the property defaults means a missing key no longer aborts the whole
|
||||||
/// iOS-only). Using `decodeIfPresent` with the property defaults means a
|
/// decode — otherwise the entire settings sync silently breaks. (Note:
|
||||||
/// missing key no longer aborts the whole decode — otherwise the entire
|
/// `background_color` was previously mis-mapped; the server column is
|
||||||
/// settings sync silently breaks.
|
/// `bg_color`, now corrected above.)
|
||||||
init(from decoder: Decoder) throws {
|
init(from decoder: Decoder) throws {
|
||||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
let d = AppSettings()
|
let d = AppSettings()
|
||||||
@@ -69,6 +77,10 @@ struct AppSettings: Codable {
|
|||||||
privateEventVisibility = try c.decodeIfPresent(String.self, forKey: .privateEventVisibility) ?? d.privateEventVisibility
|
privateEventVisibility = try c.decodeIfPresent(String.self, forKey: .privateEventVisibility) ?? d.privateEventVisibility
|
||||||
groupVisibleCalendarId = try c.decodeIfPresent(Int.self, forKey: .groupVisibleCalendarId)
|
groupVisibleCalendarId = try c.decodeIfPresent(Int.self, forKey: .groupVisibleCalendarId)
|
||||||
defaultReminderMinutes = try c.decodeIfPresent(Int.self, forKey: .defaultReminderMinutes)
|
defaultReminderMinutes = try c.decodeIfPresent(Int.self, forKey: .defaultReminderMinutes)
|
||||||
|
defaultEventDurationMinutes = try c.decodeIfPresent(Int.self, forKey: .defaultEventDurationMinutes) ?? d.defaultEventDurationMinutes
|
||||||
|
cacheMonths = try c.decodeIfPresent(Int.self, forKey: .cacheMonths) ?? d.cacheMonths
|
||||||
|
monthViewPaged = try c.decodeIfPresent(Bool.self, forKey: .monthViewPaged) ?? d.monthViewPaged
|
||||||
|
syncFlags = try c.decodeIfPresent([String: Bool].self, forKey: .syncFlags)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,11 +123,17 @@ struct LocalCalendar: Codable, Identifiable {
|
|||||||
var permission: String? = nil
|
var permission: String? = nil
|
||||||
var group: Bool = false
|
var group: Bool = false
|
||||||
var remindersEnabled: Bool = true
|
var remindersEnabled: Bool = true
|
||||||
|
// Birthday calendar: events are all-day yearly; server renders age + cake icon.
|
||||||
|
var isBirthday: Bool = false
|
||||||
|
// Days before a birthday to remind (0 = on the day). nil = no reminder.
|
||||||
|
var birthdayNotifyDaysBefore: Int? = nil
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case id, name, color, enabled, owned, permission, group
|
case id, name, color, enabled, owned, permission, group
|
||||||
case sharedBy = "shared_by"
|
case sharedBy = "shared_by"
|
||||||
case remindersEnabled = "reminders_enabled"
|
case remindersEnabled = "reminders_enabled"
|
||||||
|
case isBirthday = "is_birthday"
|
||||||
|
case birthdayNotifyDaysBefore = "birthday_notify_days_before"
|
||||||
}
|
}
|
||||||
|
|
||||||
init(from decoder: Decoder) throws {
|
init(from decoder: Decoder) throws {
|
||||||
@@ -129,6 +147,8 @@ struct LocalCalendar: Codable, Identifiable {
|
|||||||
permission = try c.decodeIfPresent(String.self, forKey: .permission)
|
permission = try c.decodeIfPresent(String.self, forKey: .permission)
|
||||||
group = try c.decodeIfPresent(Bool.self, forKey: .group) ?? false
|
group = try c.decodeIfPresent(Bool.self, forKey: .group) ?? false
|
||||||
remindersEnabled = try c.decodeIfPresent(Bool.self, forKey: .remindersEnabled) ?? true
|
remindersEnabled = try c.decodeIfPresent(Bool.self, forKey: .remindersEnabled) ?? true
|
||||||
|
isBirthday = try c.decodeIfPresent(Bool.self, forKey: .isBirthday) ?? false
|
||||||
|
birthdayNotifyDaysBefore = try c.decodeIfPresent(Int.self, forKey: .birthdayNotifyDaysBefore)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,6 +240,7 @@ struct UserProfile: Codable {
|
|||||||
let isAdmin: Bool
|
let isAdmin: Bool
|
||||||
let hasAvatar: Bool
|
let hasAvatar: Bool
|
||||||
let totpEnabled: Bool
|
let totpEnabled: Bool
|
||||||
|
var directoryHidden: Bool = false
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case id, username, email
|
case id, username, email
|
||||||
@@ -227,6 +248,19 @@ struct UserProfile: Codable {
|
|||||||
case isAdmin = "is_admin"
|
case isAdmin = "is_admin"
|
||||||
case hasAvatar = "has_avatar"
|
case hasAvatar = "has_avatar"
|
||||||
case totpEnabled = "totp_enabled"
|
case totpEnabled = "totp_enabled"
|
||||||
|
case directoryHidden = "directory_hidden"
|
||||||
|
}
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
id = try c.decode(Int.self, forKey: .id)
|
||||||
|
username = try c.decode(String.self, forKey: .username)
|
||||||
|
displayName = try c.decodeIfPresent(String.self, forKey: .displayName)
|
||||||
|
email = try c.decodeIfPresent(String.self, forKey: .email)
|
||||||
|
isAdmin = try c.decodeIfPresent(Bool.self, forKey: .isAdmin) ?? false
|
||||||
|
hasAvatar = try c.decodeIfPresent(Bool.self, forKey: .hasAvatar) ?? false
|
||||||
|
totpEnabled = try c.decodeIfPresent(Bool.self, forKey: .totpEnabled) ?? false
|
||||||
|
directoryHidden = try c.decodeIfPresent(Bool.self, forKey: .directoryHidden) ?? false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,9 +289,20 @@ struct GroupMember: Codable, Identifiable {
|
|||||||
let displayName: String?
|
let displayName: String?
|
||||||
var role: String
|
var role: String
|
||||||
var color: String?
|
var color: String?
|
||||||
|
var sharesCalendar: Bool = true
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case id, role, color
|
case id, role, color
|
||||||
case displayName = "display_name"
|
case displayName = "display_name"
|
||||||
|
case sharesCalendar = "shares_calendar"
|
||||||
|
}
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
id = try c.decode(Int.self, forKey: .id)
|
||||||
|
displayName = try c.decodeIfPresent(String.self, forKey: .displayName)
|
||||||
|
role = try c.decodeIfPresent(String.self, forKey: .role) ?? "member"
|
||||||
|
color = try c.decodeIfPresent(String.self, forKey: .color)
|
||||||
|
sharesCalendar = try c.decodeIfPresent(Bool.self, forKey: .sharesCalendar) ?? true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,28 @@ struct EventPerson: Hashable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A partial-sync failure reported alongside an otherwise-successful
|
||||||
|
/// `/api/caldav/events` response: one specific calendar didn't sync (e.g.
|
||||||
|
/// expired credentials) even though it's still enabled. Distinct from a
|
||||||
|
/// hard fetch failure (`CalendarStore.lastError`) — the request itself
|
||||||
|
/// succeeded, just not every source within it.
|
||||||
|
struct SyncError: Hashable {
|
||||||
|
let source: String
|
||||||
|
let calendarId: String? // nil on older server responses without calendar_id
|
||||||
|
let name: String
|
||||||
|
let message: String
|
||||||
|
|
||||||
|
static func from(json: [String: Any]) -> SyncError? {
|
||||||
|
guard
|
||||||
|
let source = json["source"] as? String,
|
||||||
|
let name = json["name"] as? String,
|
||||||
|
let message = json["message"] as? String
|
||||||
|
else { return nil }
|
||||||
|
let calendarId = json["calendar_id"].map { "\($0)" }
|
||||||
|
return SyncError(source: source, calendarId: calendarId, name: name, message: message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct CalEvent: Identifiable, Hashable {
|
struct CalEvent: Identifiable, Hashable {
|
||||||
let id: String
|
let id: String
|
||||||
let url: String
|
let url: String
|
||||||
@@ -43,10 +65,19 @@ struct CalEvent: Identifiable, Hashable {
|
|||||||
var displayTitle: String? = nil
|
var displayTitle: String? = nil
|
||||||
// Reminder offsets in minutes-before-start (0 = at start). Local events only.
|
// Reminder offsets in minutes-before-start (0 = at start). Local events only.
|
||||||
var reminders: [Int] = []
|
var reminders: [Int] = []
|
||||||
|
// True for events from a calendar shared with the user read-only.
|
||||||
|
var readOnly: Bool = false
|
||||||
|
// True for events from a birthday calendar — clients show a cake icon and the
|
||||||
|
// server bakes the age into `displayTitle`.
|
||||||
|
var isBirthday: Bool = false
|
||||||
|
|
||||||
// Group view supplies a server-resolved colour; otherwise per-event then calendar colour.
|
// Group view supplies a server-resolved colour; otherwise per-event then calendar colour.
|
||||||
var effectiveColor: String { displayColor ?? color ?? calendarColor }
|
var effectiveColor: String { displayColor ?? color ?? calendarColor }
|
||||||
|
|
||||||
|
// Title to render: the server-decorated one (birthday age, group prefix) wins
|
||||||
|
// over the raw title, which is kept for editing.
|
||||||
|
var renderTitle: String { displayTitle ?? title }
|
||||||
|
|
||||||
static func from(json: [String: Any]) -> CalEvent? {
|
static func from(json: [String: Any]) -> CalEvent? {
|
||||||
guard
|
guard
|
||||||
let title = json["title"] as? String,
|
let title = json["title"] as? String,
|
||||||
@@ -85,7 +116,9 @@ struct CalEvent: Identifiable, Hashable {
|
|||||||
isGroupEvent: json["is_group_event"] as? Bool ?? false,
|
isGroupEvent: json["is_group_event"] as? Bool ?? false,
|
||||||
displayColor: (json["display_color"] as? String).flatMap { $0.isEmpty ? nil : $0 },
|
displayColor: (json["display_color"] as? String).flatMap { $0.isEmpty ? nil : $0 },
|
||||||
displayTitle: (json["display_title"] 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)") } ?? []
|
reminders: (json["reminders"] as? [Int]) ?? (json["reminders"] as? [Any])?.compactMap { ($0 as? Int) ?? Int("\($0)") } ?? [],
|
||||||
|
readOnly: json["read_only"] as? Bool ?? false,
|
||||||
|
isBirthday: json["is_birthday"] as? Bool ?? false
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -149,3 +182,21 @@ func formatISO(_ date: Date, allDay: Bool) -> String {
|
|||||||
}
|
}
|
||||||
return isoBasic.string(from: date)
|
return isoBasic.string(from: date)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Inline label for an event in a calendar grid: a leading birthday (cake) icon
|
||||||
|
/// when the event is a birthday, followed by the render title (which carries the
|
||||||
|
/// server-computed age). Icon and text inherit the surrounding font/colour, so
|
||||||
|
/// the label matches whatever bar it's dropped into.
|
||||||
|
struct EventLabel: View {
|
||||||
|
let event: CalEvent
|
||||||
|
var body: some View {
|
||||||
|
if event.isBirthday {
|
||||||
|
HStack(spacing: 3) {
|
||||||
|
Image(systemName: "birthday.cake.fill").imageScale(.small)
|
||||||
|
Text(event.renderTitle)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text(event.renderTitle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import CalendarrCore
|
||||||
|
|
||||||
extension Notification.Name {
|
extension Notification.Name {
|
||||||
/// Posted whenever the persistent "banished calendars" set is mutated from
|
/// Posted whenever the persistent "banished calendars" set is mutated from
|
||||||
@@ -58,6 +59,13 @@ class CalendarStore {
|
|||||||
var isLoading = false
|
var isLoading = false
|
||||||
var isCachingBackground = false
|
var isCachingBackground = false
|
||||||
var lastError: String? = nil
|
var lastError: String? = nil
|
||||||
|
/// Per-calendar sync failures reported alongside the last successful
|
||||||
|
/// personal `/events` fetch (e.g. expired CalDAV credentials on one
|
||||||
|
/// account) — distinct from `lastError`, which means the whole fetch
|
||||||
|
/// failed. Not populated in group-overlay mode. Left untouched on a hard
|
||||||
|
/// fetch failure (see `loadEvents`), so a stale-but-real warning doesn't
|
||||||
|
/// get wiped by an unrelated network hiccup.
|
||||||
|
var syncErrors: [SyncError] = []
|
||||||
var weekStartsOnMonday = true
|
var weekStartsOnMonday = true
|
||||||
var writableCalendars: [WritableCalendar] = []
|
var writableCalendars: [WritableCalendar] = []
|
||||||
// When set, the calendar shows the group's combined overlay instead of the
|
// When set, the calendar shows the group's combined overlay instead of the
|
||||||
@@ -158,6 +166,49 @@ class CalendarStore {
|
|||||||
return "\(source):\(id)"
|
return "\(source):\(id)"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: – Calendar order (device-local, mirrors the web `cal_order`)
|
||||||
|
|
||||||
|
private static let orderDefaultsKey = "calendarOrder"
|
||||||
|
|
||||||
|
/// Persisted display order of calendar keys ("source:id"). Device-local; the
|
||||||
|
/// drawer's flat calendar list is sorted by this.
|
||||||
|
private(set) var calendarOrder: [String] = CalendarStore.loadOrder()
|
||||||
|
|
||||||
|
private static func loadOrder() -> [String] {
|
||||||
|
guard let raw = UserDefaults.standard.string(forKey: orderDefaultsKey),
|
||||||
|
let data = raw.data(using: .utf8),
|
||||||
|
let arr = try? JSONDecoder().decode([String].self, from: data)
|
||||||
|
else { return [] }
|
||||||
|
return arr
|
||||||
|
}
|
||||||
|
|
||||||
|
private func saveOrder() {
|
||||||
|
if let data = try? JSONEncoder().encode(calendarOrder),
|
||||||
|
let s = String(data: data, encoding: .utf8) {
|
||||||
|
UserDefaults.standard.set(s, forKey: Self.orderDefaultsKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replace the stored order (after a drag-reorder).
|
||||||
|
func setCalendarOrder(_ keys: [String]) {
|
||||||
|
calendarOrder = keys
|
||||||
|
saveOrder()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sort the given keys by the stored order (unknown keys → end), then persist
|
||||||
|
/// the normalized order so newly-added calendars stick (mirrors the web).
|
||||||
|
func ordered(_ keys: [String]) -> [String] {
|
||||||
|
let current = calendarOrder
|
||||||
|
func idx(_ key: String) -> Int { current.firstIndex(of: key) ?? Int.max }
|
||||||
|
let sorted = keys.sorted { a, b in
|
||||||
|
let ia = idx(a), ib = idx(b)
|
||||||
|
return ia != ib ? ia < ib : a < b
|
||||||
|
}
|
||||||
|
calendarOrder = sorted
|
||||||
|
saveOrder()
|
||||||
|
return sorted
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: – Banished-calendar persistence
|
// MARK: – Banished-calendar persistence
|
||||||
|
|
||||||
private static let banishedKeysDefaultsKey = "banishedCalendarKeys"
|
private static let banishedKeysDefaultsKey = "banishedCalendarKeys"
|
||||||
@@ -218,6 +269,38 @@ class CalendarStore {
|
|||||||
publishWidgetSnapshot()
|
publishWidgetSnapshot()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Reconcile the local "banished" set with the server's per-calendar
|
||||||
|
/// `sidebar_hidden` flags (server wins for CalDAV / Google / HA). Returns
|
||||||
|
/// `true` if the set changed, so the caller can force a refetch.
|
||||||
|
///
|
||||||
|
/// This closes the sync gap where hiding/showing a calendar on the web (or
|
||||||
|
/// another device) was only ever picked up when the filter sheet or the
|
||||||
|
/// accounts screen happened to be opened — not on app launch / resume. A
|
||||||
|
/// calendar re-enabled on the web has NO events in the cache (the server
|
||||||
|
/// excludes a hidden calendar's events entirely), so a plain
|
||||||
|
/// `refreshFromCache` can't bring them back: the caller must force a reload.
|
||||||
|
func reconcileCalendarVisibility(api: CalendarrAPI) async -> Bool {
|
||||||
|
async let c = (try? await api.getCalDAVAccounts()) ?? []
|
||||||
|
async let g = (try? await api.getGoogleAccounts()) ?? []
|
||||||
|
async let h = (try? await api.getHomeAssistantAccounts()) ?? []
|
||||||
|
let (caldav, google, ha) = await (c, g, h)
|
||||||
|
|
||||||
|
var b = banishedCalendarKeys
|
||||||
|
func applyServerHidden(_ source: String, _ id: Int, _ hidden: Bool) {
|
||||||
|
let key = Self.calendarKey(source: source, calendarId: "\(id)")
|
||||||
|
if hidden { b.insert(key) } else { b.remove(key) }
|
||||||
|
}
|
||||||
|
for acc in caldav { for cal in acc.calendars ?? [] { applyServerHidden("caldav", cal.id, cal.sidebarHidden) } }
|
||||||
|
for acc in google { for cal in acc.calendars ?? [] { applyServerHidden("google", cal.id, cal.sidebarHidden) } }
|
||||||
|
for acc in ha { for cal in acc.calendars ?? [] { applyServerHidden("homeassistant", cal.id, cal.sidebarHidden) } }
|
||||||
|
|
||||||
|
guard b != banishedCalendarKeys else { return false }
|
||||||
|
banishedCalendarKeys = b
|
||||||
|
Self.saveBanishedKeys(b)
|
||||||
|
NotificationCenter.default.post(name: .banishedCalendarsChanged, object: nil)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: – Reminder-disabled-calendar persistence
|
// MARK: – Reminder-disabled-calendar persistence
|
||||||
|
|
||||||
private static let reminderDisabledKeysDefaultsKey = "reminderDisabledCalendarKeys"
|
private static let reminderDisabledKeysDefaultsKey = "reminderDisabledCalendarKeys"
|
||||||
@@ -340,10 +423,16 @@ class CalendarStore {
|
|||||||
lastError = nil
|
lastError = nil
|
||||||
defer { isLoading = false }
|
defer { isLoading = false }
|
||||||
do {
|
do {
|
||||||
let fetched = try await fetchForMode(api: api, start: start, end: end)
|
let (fetched, errors) = try await fetchForMode(api: api, start: start, end: end)
|
||||||
mergeIntoCache(fetched, rangeStart: start, rangeEnd: end)
|
syncErrors = errors
|
||||||
|
let failed = failedCalendarKeys(from: errors)
|
||||||
|
mergeIntoCache(fetched, rangeStart: start, rangeEnd: end,
|
||||||
|
keepKeysInRange: failed.keys, keepSourcesInRange: failed.sources)
|
||||||
refreshFromCache(start: start, end: end)
|
refreshFromCache(start: start, end: end)
|
||||||
} catch {
|
} catch {
|
||||||
|
// Hard failure – leave `syncErrors` as-is; it reflects the last
|
||||||
|
// *successful* fetch and shouldn't be wiped by an unrelated
|
||||||
|
// network error on this attempt.
|
||||||
lastError = error.localizedDescription
|
lastError = error.localizedDescription
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -351,10 +440,12 @@ class CalendarStore {
|
|||||||
/// Fetch events for the current mode (personal vs. group overlay). Group
|
/// Fetch events for the current mode (personal vs. group overlay). Group
|
||||||
/// events go through the same cache/prefetch/refresh path as personal ones,
|
/// 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).
|
/// 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] {
|
/// Per-calendar sync errors are only reported by the personal `/events`
|
||||||
|
/// endpoint; group overlays always report none.
|
||||||
|
private func fetchForMode(api: CalendarrAPI, start: Date, end: Date) async throws -> (events: [CalEvent], errors: [SyncError]) {
|
||||||
if let g = activeGroup {
|
if let g = activeGroup {
|
||||||
let combined = try await api.fetchGroupCombined(groupId: g.id, start: start, end: end)
|
let combined = try await api.fetchGroupCombined(groupId: g.id, start: start, end: end)
|
||||||
return combined.map { decorateGroupEvent($0) }
|
return (combined.map { decorateGroupEvent($0) }, [])
|
||||||
}
|
}
|
||||||
return try await api.fetchEvents(start: start, end: end)
|
return try await api.fetchEvents(start: start, end: end)
|
||||||
}
|
}
|
||||||
@@ -394,8 +485,11 @@ class CalendarStore {
|
|||||||
isCachingBackground = true
|
isCachingBackground = true
|
||||||
defer { isCachingBackground = false }
|
defer { isCachingBackground = false }
|
||||||
do {
|
do {
|
||||||
let fetched = try await fetchForMode(api: api, start: start, end: end)
|
let (fetched, errors) = try await fetchForMode(api: api, start: start, end: end)
|
||||||
mergeIntoCache(fetched, rangeStart: start, rangeEnd: end)
|
syncErrors = errors
|
||||||
|
let failed = failedCalendarKeys(from: errors)
|
||||||
|
mergeIntoCache(fetched, rangeStart: start, rangeEnd: end,
|
||||||
|
keepKeysInRange: failed.keys, keepSourcesInRange: failed.sources)
|
||||||
// Refresh visible range from newly expanded cache
|
// Refresh visible range from newly expanded cache
|
||||||
let (vs, ve) = rangeForCurrentView()
|
let (vs, ve) = rangeForCurrentView()
|
||||||
refreshFromCache(start: vs, end: ve)
|
refreshFromCache(start: vs, end: ve)
|
||||||
@@ -414,14 +508,54 @@ class CalendarStore {
|
|||||||
allCachedEvents = []
|
allCachedEvents = []
|
||||||
}
|
}
|
||||||
|
|
||||||
private func mergeIntoCache(_ newEvents: [CalEvent], rangeStart: Date, rangeEnd: Date) {
|
/// Calendars / sources that reported a sync error on the last fetch. Events
|
||||||
// Remove old events that overlap with the newly fetched range (avoid duplicates)
|
/// matching either must NOT be evicted from the cache on a partial sync —
|
||||||
|
/// stale data beats a completely empty calendar.
|
||||||
|
///
|
||||||
|
/// - `keys`: per-calendar errors carrying a `calendar_id` (e.g. one CalDAV
|
||||||
|
/// calendar with bad credentials) → protect just that calendar.
|
||||||
|
/// - `sources`: account-level errors the server couldn't pin to a single
|
||||||
|
/// calendar → protect every cached calendar of that source. The
|
||||||
|
/// key example is a Home Assistant / Google token-refresh
|
||||||
|
/// failure: it aborts the whole account fetch, so the error
|
||||||
|
/// arrives with no `calendar_id` and every HA calendar would
|
||||||
|
/// otherwise be wiped.
|
||||||
|
private func failedCalendarKeys(from errors: [SyncError]) -> (keys: Set<String>, sources: Set<String>) {
|
||||||
|
var keys = Set<String>()
|
||||||
|
var sources = Set<String>()
|
||||||
|
for err in errors {
|
||||||
|
if let cid = err.calendarId {
|
||||||
|
keys.insert(Self.calendarKey(source: err.source, calendarId: cid))
|
||||||
|
} else {
|
||||||
|
sources.insert(err.source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (keys, sources)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func mergeIntoCache(_ newEvents: [CalEvent], rangeStart: Date, rangeEnd: Date,
|
||||||
|
keepKeysInRange: Set<String> = [],
|
||||||
|
keepSourcesInRange: Set<String> = []) {
|
||||||
|
// Remove old events in the fetched range to avoid duplicates — but
|
||||||
|
// PRESERVE events from calendars / sources that had sync errors so that a
|
||||||
|
// transient CalDAV / Google / HA failure doesn't wipe the visible calendar.
|
||||||
let retained = allCachedEvents.filter { ev in
|
let retained = allCachedEvents.filter { ev in
|
||||||
ev.startDate >= rangeEnd || ev.endDate <= rangeStart
|
let outsideRange = ev.startDate >= rangeEnd || ev.endDate <= rangeStart
|
||||||
|
if outsideRange { return true }
|
||||||
|
if keepSourcesInRange.contains(ev.source) { return true }
|
||||||
|
guard !keepKeysInRange.isEmpty else { return false }
|
||||||
|
let key = Self.calendarKey(source: ev.source, calendarId: ev.calendarId)
|
||||||
|
return keepKeysInRange.contains(key)
|
||||||
}
|
}
|
||||||
allCachedEvents = retained + newEvents
|
allCachedEvents = retained + newEvents
|
||||||
|
|
||||||
// Extend cached range
|
// Only extend the cached range when the fetch was completely clean.
|
||||||
|
// When some calendars/sources had sync errors, leave cachedStart/End
|
||||||
|
// unchanged: this keeps isCached() returning false for the next
|
||||||
|
// loadEvents call so the failed calendars are retried automatically —
|
||||||
|
// rather than being silently treated as "done" with empty data
|
||||||
|
// (especially important on first launch or after forceReload).
|
||||||
|
guard keepKeysInRange.isEmpty, keepSourcesInRange.isEmpty else { return }
|
||||||
if let cs = cachedStart, let ce = cachedEnd {
|
if let cs = cachedStart, let ce = cachedEnd {
|
||||||
cachedStart = min(cs, rangeStart)
|
cachedStart = min(cs, rangeStart)
|
||||||
cachedEnd = max(ce, rangeEnd)
|
cachedEnd = max(ce, rangeEnd)
|
||||||
@@ -438,12 +572,35 @@ class CalendarStore {
|
|||||||
/// covers the worst-case month grid (6 rows × 7 cols) for the calendar
|
/// covers the worst-case month grid (6 rows × 7 cols) for the calendar
|
||||||
/// widget. Also asks the system to refresh the widget timeline.
|
/// widget. Also asks the system to refresh the widget timeline.
|
||||||
private func publishWidgetSnapshot() {
|
private func publishWidgetSnapshot() {
|
||||||
|
// Never write group-view data into the widget; it would show other
|
||||||
|
// people's calendar colours and decorated event titles.
|
||||||
|
guard activeGroup == nil else { return }
|
||||||
|
|
||||||
let cal = userCalendar
|
let cal = userCalendar
|
||||||
let now = Date()
|
let now = Date()
|
||||||
// Include the week before today so widgets that show the current week
|
// Include the week before today so widgets that show the current week
|
||||||
// (e.g. "This Week", "Up Next + Calendar") have data for Monday–today.
|
// (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
|
// The window is published in the snapshot as coverageStart/coverageEnd:
|
||||||
let to = cal.date(byAdding: .day, value: 42, to: cal.startOfDay(for: now)) ?? from
|
// outside it a reader has no information, which is not the same as
|
||||||
|
// having no events, and only the writer knows where the edge is.
|
||||||
|
let dayStart = cal.startOfDay(for: now)
|
||||||
|
let from = cal.date(byAdding: .day, value: -SnapshotCoverage.daysBehind, to: dayStart) ?? now
|
||||||
|
let to = cal.date(byAdding: .day, value: SnapshotCoverage.daysAhead, to: dayStart) ?? from
|
||||||
|
|
||||||
|
// Build the calendar list from all cached events (not just the window)
|
||||||
|
// so every calendar appears in the widget configuration picker.
|
||||||
|
var calendarMap: [String: WidgetCalendar] = [:]
|
||||||
|
for ev in allCachedEvents {
|
||||||
|
let key = Self.calendarKey(source: ev.source, calendarId: ev.calendarId)
|
||||||
|
guard !banishedCalendarKeys.contains(key) else { continue }
|
||||||
|
if calendarMap[key] == nil {
|
||||||
|
calendarMap[key] = WidgetCalendar(id: key,
|
||||||
|
name: ev.calendarName.isEmpty ? ev.calendarId : ev.calendarName,
|
||||||
|
colorHex: ev.calendarColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
WidgetStore.writeCalendars(Array(calendarMap.values).sorted { $0.name < $1.name })
|
||||||
|
|
||||||
let visible = allCachedEvents
|
let visible = allCachedEvents
|
||||||
.filter { ev in
|
.filter { ev in
|
||||||
let key = Self.calendarKey(source: ev.source, calendarId: ev.calendarId)
|
let key = Self.calendarKey(source: ev.source, calendarId: ev.calendarId)
|
||||||
@@ -452,7 +609,7 @@ class CalendarStore {
|
|||||||
&& !banishedCalendarKeys.contains(key)
|
&& !banishedCalendarKeys.contains(key)
|
||||||
}
|
}
|
||||||
.sorted { $0.startDate < $1.startDate }
|
.sorted { $0.startDate < $1.startDate }
|
||||||
.prefix(500)
|
.prefix(SnapshotCoverage.maxEvents)
|
||||||
.map { ev in
|
.map { ev in
|
||||||
WidgetEvent(id: ev.id,
|
WidgetEvent(id: ev.id,
|
||||||
title: ev.title,
|
title: ev.title,
|
||||||
@@ -460,21 +617,12 @@ class CalendarStore {
|
|||||||
end: ev.endDate,
|
end: ev.endDate,
|
||||||
isAllDay: ev.isAllDay,
|
isAllDay: ev.isAllDay,
|
||||||
colorHex: ev.effectiveColor,
|
colorHex: ev.effectiveColor,
|
||||||
location: ev.location)
|
location: ev.location,
|
||||||
|
calendarKey: Self.calendarKey(source: ev.source, calendarId: ev.calendarId))
|
||||||
}
|
}
|
||||||
let defaults = UserDefaults.standard
|
WidgetStore.write(WidgetStore.makeSnapshot(events: Array(visible),
|
||||||
let snap = WidgetSnapshot(
|
coverageStart: from,
|
||||||
writtenAt: now,
|
coverageEnd: to))
|
||||||
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()
|
WidgetTimelineNotifier.reload()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -487,7 +635,9 @@ class CalendarStore {
|
|||||||
async let haCals = (try? await api.getHACalendars()) ?? []
|
async let haCals = (try? await api.getHACalendars()) ?? []
|
||||||
|
|
||||||
var result: [WritableCalendar] = []
|
var result: [WritableCalendar] = []
|
||||||
for cal in await localCals {
|
// Skip read-only shared calendars — offering them in the event editor
|
||||||
|
// only leads to a 403 on save. Own + read_write (incl. group) stay.
|
||||||
|
for cal in await localCals where cal.owned || cal.permission == "read_write" {
|
||||||
result.append(WritableCalendar(id: "local-\(cal.id)", name: cal.name, color: cal.color, source: "local", numericId: cal.id))
|
result.append(WritableCalendar(id: "local-\(cal.id)", name: cal.name, color: cal.color, source: "local", numericId: cal.id))
|
||||||
}
|
}
|
||||||
for acc in await caldavAccs where acc.enabled {
|
for acc in await caldavAccs where acc.enabled {
|
||||||
|
|||||||
@@ -81,6 +81,16 @@ private let strings: [String: [String: String]] = [
|
|||||||
"settings.sync": "Einstellungen synchronisieren",
|
"settings.sync": "Einstellungen synchronisieren",
|
||||||
"settings.sync.desc": "Darstellung mit dem Server abgleichen",
|
"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.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.sync_all": "Alle synchronisieren",
|
||||||
|
"settings.sync_all.desc": "Diese Einstellungen zwischen deinen Geräten teilen",
|
||||||
|
"settings.sync_this": "Zwischen Geräten synchronisieren",
|
||||||
|
"settings.reset": "Zurücksetzen",
|
||||||
|
"settings.appearance": "Ansicht",
|
||||||
|
"settings.color.surface": "Topbar-/Oberflächenfarbe",
|
||||||
|
"settings.surface.auto": "Auto (Milchglas)",
|
||||||
|
"settings.device": "Nur auf diesem Gerät",
|
||||||
|
"settings.hide_menu_button": "Menü-Button ausblenden",
|
||||||
|
"settings.device.footer": "Diese Einstellungen gelten nur auf diesem Gerät und werden nicht synchronisiert.",
|
||||||
|
|
||||||
"settings.cache.header": "Vorladen",
|
"settings.cache.header": "Vorladen",
|
||||||
"settings.cache.title": "Vorladen",
|
"settings.cache.title": "Vorladen",
|
||||||
@@ -127,10 +137,17 @@ private let strings: [String: [String: String]] = [
|
|||||||
"settings.monday": "Montag",
|
"settings.monday": "Montag",
|
||||||
"settings.sunday": "Sonntag",
|
"settings.sunday": "Sonntag",
|
||||||
"settings.dimpast": "Vergangene Termine ausgrauen",
|
"settings.dimpast": "Vergangene Termine ausgrauen",
|
||||||
|
"settings.month_paged": "Monatsansicht seitenweise wischen",
|
||||||
|
"settings.month_mode": "Monatswechsel",
|
||||||
|
"settings.month_mode.scroll": "Fortlaufend scrollen",
|
||||||
|
"settings.month_mode.paged": "Seitenweise blättern",
|
||||||
|
"settings.default_duration": "Standard-Termindauer",
|
||||||
"settings.nav.profile": "Profil",
|
"settings.nav.profile": "Profil",
|
||||||
"settings.privacy": "Privatsphäre",
|
"settings.privacy": "Privatsphäre",
|
||||||
"settings.private_visibility": "Private Termine für Gruppen",
|
"settings.private_visibility": "Private Termine für Gruppen",
|
||||||
"settings.private_visibility.desc": "Wie private Termine für andere Gruppenmitglieder erscheinen",
|
"settings.private_visibility.desc": "Wie private Termine für andere Gruppenmitglieder erscheinen",
|
||||||
|
"settings.directory_hidden": "Profil verbergen",
|
||||||
|
"settings.directory_hidden.desc": "Nicht in Teilen-/Gruppen-Auswahl anzeigen. In der Admin-Benutzerverwaltung bleibst du sichtbar.",
|
||||||
"settings.private.busy": "Als „Beschäftigt“",
|
"settings.private.busy": "Als „Beschäftigt“",
|
||||||
"settings.private.hidden": "Ausblenden",
|
"settings.private.hidden": "Ausblenden",
|
||||||
"settings.calendars": "Geteilter Kalender",
|
"settings.calendars": "Geteilter Kalender",
|
||||||
@@ -288,6 +305,7 @@ private let strings: [String: [String: String]] = [
|
|||||||
"accounts.loading": "Lade Konten…",
|
"accounts.loading": "Lade Konten…",
|
||||||
"accounts.add.caldav": "CalDAV-Konto",
|
"accounts.add.caldav": "CalDAV-Konto",
|
||||||
"accounts.add.local": "Lokaler Kalender",
|
"accounts.add.local": "Lokaler Kalender",
|
||||||
|
"accounts.add.birthday": "Geburtstagskalender",
|
||||||
"accounts.add.ical": "iCal-URL abonnieren",
|
"accounts.add.ical": "iCal-URL abonnieren",
|
||||||
"accounts.add.ha": "Home Assistant",
|
"accounts.add.ha": "Home Assistant",
|
||||||
"accounts.caldav.header": "CalDAV-Konten",
|
"accounts.caldav.header": "CalDAV-Konten",
|
||||||
@@ -314,6 +332,8 @@ private let strings: [String: [String: String]] = [
|
|||||||
"filter.empty": "Keine Kalender vorhanden",
|
"filter.empty": "Keine Kalender vorhanden",
|
||||||
"filter.show_all": "Alle anzeigen",
|
"filter.show_all": "Alle anzeigen",
|
||||||
"filter.hide_all": "Alle ausblenden",
|
"filter.hide_all": "Alle ausblenden",
|
||||||
|
"filter.sort": "Sortieren",
|
||||||
|
"filter.done": "Fertig",
|
||||||
"filter.button": "Kalender ein-/ausblenden",
|
"filter.button": "Kalender ein-/ausblenden",
|
||||||
"filter.banish": "Dauerhaft ausblenden",
|
"filter.banish": "Dauerhaft ausblenden",
|
||||||
"filter.reminders_on": "Benachrichtigungen an",
|
"filter.reminders_on": "Benachrichtigungen an",
|
||||||
@@ -341,6 +361,32 @@ private let strings: [String: [String: String]] = [
|
|||||||
"local.color": "Farbe",
|
"local.color": "Farbe",
|
||||||
"local.create": "Erstellen",
|
"local.create": "Erstellen",
|
||||||
|
|
||||||
|
// Birthdays
|
||||||
|
"birthday.new": "Neuer Geburtstag",
|
||||||
|
"birthday.calendar_name": "Geburtstage",
|
||||||
|
"birthday.activate": "Geburtstagskalender aktivieren",
|
||||||
|
"birthday.activate_hint": "Aktiviere den Geburtstagskalender, um Geburtstage anzulegen. Er erscheint als eigener Kalender in der Seitenleiste.",
|
||||||
|
"birthday.person": "Name",
|
||||||
|
"birthday.person_placeholder": "Name der Person",
|
||||||
|
"birthday.date": "Geburtstag",
|
||||||
|
"birthday.year_unknown": "Jahr unbekannt",
|
||||||
|
"birthday.no_calendars": "Kein Geburtstagskalender vorhanden. Erstelle zuerst einen unter „Konten & Kalender“.",
|
||||||
|
"birthday.is_calendar": "Geburtstagskalender",
|
||||||
|
"birthday.is_calendar.desc": "Ganztägige, jährliche Termine mit Alter und Geburtstags-Icon.",
|
||||||
|
"birthday.notify": "Erinnerung",
|
||||||
|
"birthday.notify.off": "Aus",
|
||||||
|
"birthday.notify.same_day": "Am Tag",
|
||||||
|
"birthday.notify.one_day": "1 Tag vorher",
|
||||||
|
"birthday.notify.days": "%d Tage vorher",
|
||||||
|
"birthday.contacts.header": "Geburtstage aus Kontakten",
|
||||||
|
"birthday.contacts.sync": "Aus Kontakten synchronisieren",
|
||||||
|
"birthday.contacts.target": "Zielkalender",
|
||||||
|
"birthday.contacts.sync_now": "Jetzt synchronisieren",
|
||||||
|
"birthday.contacts.synced": "Geburtstage synchronisiert",
|
||||||
|
"birthday.contacts.hint": "Überträgt Geburtstage aus deinen Kontakten in den gewählten Geburtstagskalender – sichtbar auf allen Geräten.",
|
||||||
|
"birthday.contacts.denied": "Kein Zugriff auf Kontakte. Bitte in den iOS-Einstellungen erlauben.",
|
||||||
|
"birthday.contacts.need_calendar": "Erstelle zuerst einen Geburtstagskalender.",
|
||||||
|
|
||||||
// iCal add sheet
|
// iCal add sheet
|
||||||
"ical.title": "iCal abonnieren",
|
"ical.title": "iCal abonnieren",
|
||||||
"ical.subscription": "Abonnement",
|
"ical.subscription": "Abonnement",
|
||||||
@@ -409,6 +455,16 @@ private let strings: [String: [String: String]] = [
|
|||||||
"settings.sync": "Sync settings",
|
"settings.sync": "Sync settings",
|
||||||
"settings.sync.desc": "Keep appearance in sync with the server",
|
"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.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.sync_all": "Sync all",
|
||||||
|
"settings.sync_all.desc": "Share these settings across your devices",
|
||||||
|
"settings.sync_this": "Sync across devices",
|
||||||
|
"settings.reset": "Reset",
|
||||||
|
"settings.appearance": "View",
|
||||||
|
"settings.color.surface": "Top bar / surface color",
|
||||||
|
"settings.surface.auto": "Auto (translucent)",
|
||||||
|
"settings.device": "This device only",
|
||||||
|
"settings.hide_menu_button": "Hide menu button",
|
||||||
|
"settings.device.footer": "These settings apply to this device only and are not synced.",
|
||||||
|
|
||||||
"settings.cache.header": "Preloading",
|
"settings.cache.header": "Preloading",
|
||||||
"settings.cache.title": "Preloading",
|
"settings.cache.title": "Preloading",
|
||||||
@@ -455,10 +511,17 @@ private let strings: [String: [String: String]] = [
|
|||||||
"settings.monday": "Monday",
|
"settings.monday": "Monday",
|
||||||
"settings.sunday": "Sunday",
|
"settings.sunday": "Sunday",
|
||||||
"settings.dimpast": "Dim past events",
|
"settings.dimpast": "Dim past events",
|
||||||
|
"settings.month_paged": "Swipe month view as pages",
|
||||||
|
"settings.month_mode": "Month switching",
|
||||||
|
"settings.month_mode.scroll": "Continuous scroll",
|
||||||
|
"settings.month_mode.paged": "Page by page",
|
||||||
|
"settings.default_duration": "Default event duration",
|
||||||
"settings.nav.profile": "Profile",
|
"settings.nav.profile": "Profile",
|
||||||
"settings.privacy": "Privacy",
|
"settings.privacy": "Privacy",
|
||||||
"settings.private_visibility": "Private events for groups",
|
"settings.private_visibility": "Private events for groups",
|
||||||
"settings.private_visibility.desc": "How your private events appear to other group members",
|
"settings.private_visibility.desc": "How your private events appear to other group members",
|
||||||
|
"settings.directory_hidden": "Hide my profile",
|
||||||
|
"settings.directory_hidden.desc": "Don't show me in share/group pickers. You stay visible in the admin user management.",
|
||||||
"settings.private.busy": "Show as \"Busy\"",
|
"settings.private.busy": "Show as \"Busy\"",
|
||||||
"settings.private.hidden": "Hide",
|
"settings.private.hidden": "Hide",
|
||||||
"settings.calendars": "Shared calendar",
|
"settings.calendars": "Shared calendar",
|
||||||
@@ -616,6 +679,7 @@ private let strings: [String: [String: String]] = [
|
|||||||
"accounts.loading": "Loading accounts…",
|
"accounts.loading": "Loading accounts…",
|
||||||
"accounts.add.caldav": "CalDAV account",
|
"accounts.add.caldav": "CalDAV account",
|
||||||
"accounts.add.local": "Local calendar",
|
"accounts.add.local": "Local calendar",
|
||||||
|
"accounts.add.birthday": "Birthday calendar",
|
||||||
"accounts.add.ical": "Subscribe to iCal URL",
|
"accounts.add.ical": "Subscribe to iCal URL",
|
||||||
"accounts.add.ha": "Home Assistant",
|
"accounts.add.ha": "Home Assistant",
|
||||||
"accounts.caldav.header": "CalDAV accounts",
|
"accounts.caldav.header": "CalDAV accounts",
|
||||||
@@ -642,6 +706,8 @@ private let strings: [String: [String: String]] = [
|
|||||||
"filter.empty": "No calendars available",
|
"filter.empty": "No calendars available",
|
||||||
"filter.show_all": "Show all",
|
"filter.show_all": "Show all",
|
||||||
"filter.hide_all": "Hide all",
|
"filter.hide_all": "Hide all",
|
||||||
|
"filter.sort": "Sort",
|
||||||
|
"filter.done": "Done",
|
||||||
"filter.button": "Show/hide calendars",
|
"filter.button": "Show/hide calendars",
|
||||||
"filter.banish": "Hide permanently",
|
"filter.banish": "Hide permanently",
|
||||||
"filter.reminders_on": "Reminders on",
|
"filter.reminders_on": "Reminders on",
|
||||||
@@ -669,6 +735,32 @@ private let strings: [String: [String: String]] = [
|
|||||||
"local.color": "Color",
|
"local.color": "Color",
|
||||||
"local.create": "Create",
|
"local.create": "Create",
|
||||||
|
|
||||||
|
// Birthdays
|
||||||
|
"birthday.new": "New birthday",
|
||||||
|
"birthday.calendar_name": "Birthdays",
|
||||||
|
"birthday.activate": "Enable birthday calendar",
|
||||||
|
"birthday.activate_hint": "Enable the birthday calendar to add birthdays. It appears as its own calendar in the sidebar.",
|
||||||
|
"birthday.person": "Name",
|
||||||
|
"birthday.person_placeholder": "Person's name",
|
||||||
|
"birthday.date": "Birthday",
|
||||||
|
"birthday.year_unknown": "Year unknown",
|
||||||
|
"birthday.no_calendars": "No birthday calendar yet. Create one first under “Accounts & Calendars”.",
|
||||||
|
"birthday.is_calendar": "Birthday calendar",
|
||||||
|
"birthday.is_calendar.desc": "All-day, yearly events with age and a birthday icon.",
|
||||||
|
"birthday.notify": "Reminder",
|
||||||
|
"birthday.notify.off": "Off",
|
||||||
|
"birthday.notify.same_day": "On the day",
|
||||||
|
"birthday.notify.one_day": "1 day before",
|
||||||
|
"birthday.notify.days": "%d days before",
|
||||||
|
"birthday.contacts.header": "Birthdays from Contacts",
|
||||||
|
"birthday.contacts.sync": "Sync from Contacts",
|
||||||
|
"birthday.contacts.target": "Target calendar",
|
||||||
|
"birthday.contacts.sync_now": "Sync now",
|
||||||
|
"birthday.contacts.synced": "Birthdays synced",
|
||||||
|
"birthday.contacts.hint": "Copies birthdays from your contacts into the chosen birthday calendar – visible on all devices.",
|
||||||
|
"birthday.contacts.denied": "No access to Contacts. Please allow it in iOS Settings.",
|
||||||
|
"birthday.contacts.need_calendar": "Create a birthday calendar first.",
|
||||||
|
|
||||||
// iCal add sheet
|
// iCal add sheet
|
||||||
"ical.title": "Subscribe to iCal",
|
"ical.title": "Subscribe to iCal",
|
||||||
"ical.subscription": "Subscription",
|
"ical.subscription": "Subscription",
|
||||||
|
|||||||
@@ -5,7 +5,64 @@ import Foundation
|
|||||||
/// picker, and the notification scheduler so the choices stay consistent.
|
/// picker, and the notification scheduler so the choices stay consistent.
|
||||||
enum ReminderOptions {
|
enum ReminderOptions {
|
||||||
/// Selectable offsets in minutes-before-start.
|
/// Selectable offsets in minutes-before-start.
|
||||||
static let all: [Int] = [0, 5, 10, 15, 30, 60, 120, 1440, 2880]
|
static let all: [Int] = [0, 5, 15, 30, 60, 1440, 10080]
|
||||||
|
|
||||||
|
/// Quick presets shown in the picker; everything else is entered as a
|
||||||
|
/// custom number + unit. Default for a freshly-switched custom row.
|
||||||
|
static let presets: [Int] = [0, 30, 1440] // at start, 30 min, 1 day
|
||||||
|
static let customDefault = 120 // 2 hours (deliberately not a preset)
|
||||||
|
|
||||||
|
/// Time units for the custom picker (value × mult = minutes-before-start).
|
||||||
|
enum Unit: Int, CaseIterable, Identifiable {
|
||||||
|
case minutes, hours, days, weeks
|
||||||
|
var id: Int { rawValue }
|
||||||
|
var mult: Int {
|
||||||
|
switch self {
|
||||||
|
case .minutes: return 1
|
||||||
|
case .hours: return 60
|
||||||
|
case .days: return 1440
|
||||||
|
case .weeks: return 10080
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Split a minutes value into the largest exact {value, unit} for the custom picker.
|
||||||
|
static func split(_ minutes: Int) -> (value: Int, unit: Unit) {
|
||||||
|
for u in Unit.allCases.reversed() where minutes > 0 && minutes % u.mult == 0 {
|
||||||
|
return (minutes / u.mult, u)
|
||||||
|
}
|
||||||
|
return (max(1, minutes), .minutes)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func unitLabel(_ u: Unit, _ l: String) -> String {
|
||||||
|
let en = isEnglish(l)
|
||||||
|
switch u {
|
||||||
|
case .minutes: return en ? "minutes" : "Minuten"
|
||||||
|
case .hours: return en ? "hours" : "Stunden"
|
||||||
|
case .days: return en ? "days" : "Tage"
|
||||||
|
case .weeks: return en ? "weeks" : "Wochen"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// Human label for a duration in minutes (no "before" suffix), e.g. "1 h", "30 min".
|
||||||
|
static func durationLabel(_ minutes: Int, _ l: String) -> String {
|
||||||
|
let en = isEnglish(l)
|
||||||
|
if minutes % 60 == 0 {
|
||||||
|
let h = minutes / 60
|
||||||
|
return en ? "\(h) h" : "\(h) Std."
|
||||||
|
}
|
||||||
|
if minutes > 60 {
|
||||||
|
let h = minutes / 60, m = minutes % 60
|
||||||
|
return "\(h):\(String(format: "%02d", m)) h"
|
||||||
|
}
|
||||||
|
return en ? "\(minutes) min" : "\(minutes) Min."
|
||||||
|
}
|
||||||
|
static func customLabel(_ l: String) -> String { isEnglish(l) ? "Custom…" : "Benutzerdefiniert…" }
|
||||||
|
static func beforeLabel(_ l: String) -> String { isEnglish(l) ? "before" : "vorher" }
|
||||||
|
static func disabledNote(_ l: String) -> String {
|
||||||
|
isEnglish(l)
|
||||||
|
? "Reminders are disabled for this calendar – they will not fire."
|
||||||
|
: "Für diesen Kalender sind Benachrichtigungen deaktiviert – Erinnerungen werden nicht ausgeführt."
|
||||||
|
}
|
||||||
|
|
||||||
private static func isEnglish(_ appLang: String) -> Bool {
|
private static func isEnglish(_ appLang: String) -> Bool {
|
||||||
if appLang == "en" { return true }
|
if appLang == "en" { return true }
|
||||||
@@ -16,13 +73,22 @@ enum ReminderOptions {
|
|||||||
static func label(_ minutes: Int, _ appLang: String) -> String {
|
static func label(_ minutes: Int, _ appLang: String) -> String {
|
||||||
let en = isEnglish(appLang)
|
let en = isEnglish(appLang)
|
||||||
if minutes <= 0 { return en ? "At start time" : "Zur Startzeit" }
|
if minutes <= 0 { return en ? "At start time" : "Zur Startzeit" }
|
||||||
if minutes < 60 { return en ? "\(minutes) min before" : "\(minutes) Min. vorher" }
|
if minutes < 60 {
|
||||||
|
return en ? "\(minutes) minutes before" : "\(minutes) Minuten vorher"
|
||||||
|
}
|
||||||
if minutes < 1440 {
|
if minutes < 1440 {
|
||||||
let h = minutes / 60
|
let h = minutes / 60
|
||||||
return en ? "\(h) h before" : "\(h) Std. vorher"
|
if en { return h == 1 ? "1 hour before" : "\(h) hours before" }
|
||||||
|
return h == 1 ? "1 Stunde vorher" : "\(h) Stunden vorher"
|
||||||
}
|
}
|
||||||
let d = minutes / 1440
|
if minutes < 10080 {
|
||||||
return en ? "\(d) day\(d == 1 ? "" : "s") before" : "\(d) Tag\(d == 1 ? "" : "e") vorher"
|
let d = minutes / 1440
|
||||||
|
if en { return d == 1 ? "1 day before" : "\(d) days before" }
|
||||||
|
return d == 1 ? "1 Tag vorher" : "\(d) Tage vorher"
|
||||||
|
}
|
||||||
|
let w = minutes / 10080
|
||||||
|
if en { return w == 1 ? "1 week before" : "\(w) weeks before" }
|
||||||
|
return w == 1 ? "1 Woche vorher" : "\(w) Wochen vorher"
|
||||||
}
|
}
|
||||||
|
|
||||||
static func sectionTitle(_ l: String) -> String { isEnglish(l) ? "Reminders" : "Benachrichtigungen" }
|
static func sectionTitle(_ l: String) -> String { isEnglish(l) ? "Reminders" : "Benachrichtigungen" }
|
||||||
|
|||||||
204
Calendarr iOS/Services/BirthdaysImporter.swift
Normal file
204
Calendarr iOS/Services/BirthdaysImporter.swift
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
import Foundation
|
||||||
|
import Contacts
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// One stored birthday row as returned by GET /api/local/calendars/{id}/birthdays.
|
||||||
|
/// Contact-sourced rows carry an `externalUid`; manually added ones have `nil`.
|
||||||
|
struct BirthdayEntry: Codable, Identifiable {
|
||||||
|
let uid: String
|
||||||
|
let externalUid: String?
|
||||||
|
let title: String
|
||||||
|
let month: Int?
|
||||||
|
let day: Int?
|
||||||
|
let birthYear: Int?
|
||||||
|
var id: String { uid }
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case uid, title, month, day
|
||||||
|
case externalUid = "external_uid"
|
||||||
|
case birthYear = "birth_year"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads birthdays from the system Contacts and mirrors them into the user's
|
||||||
|
/// single birthday calendar on the backend, so they show on every client.
|
||||||
|
///
|
||||||
|
/// The sync is a **mirror**, scoped to THIS device: it reconciles rows whose
|
||||||
|
/// `external_uid` starts with `contact:<deviceId>:` (adds new, updates changed,
|
||||||
|
/// deletes removed) and never touches other devices' rows or manually added
|
||||||
|
/// birthdays. Age suffix, cake icon and "notify N days before" are done
|
||||||
|
/// server-side; this only uploads name + date + birth year, and reports the
|
||||||
|
/// device so the web can list "birthdays come from these devices".
|
||||||
|
enum BirthdaysImporter {
|
||||||
|
|
||||||
|
// MARK: – Persisted state
|
||||||
|
|
||||||
|
enum Key {
|
||||||
|
static let enabled = "birthdaysSyncEnabled" // Bool
|
||||||
|
static let deviceId = "birthdaysDeviceId" // stable per-install UUID
|
||||||
|
}
|
||||||
|
|
||||||
|
static var isEnabled: Bool { UserDefaults.standard.bool(forKey: Key.enabled) }
|
||||||
|
static func setEnabled(_ on: Bool) { UserDefaults.standard.set(on, forKey: Key.enabled) }
|
||||||
|
|
||||||
|
static var deviceId: String {
|
||||||
|
if let id = UserDefaults.standard.string(forKey: Key.deviceId) { return id }
|
||||||
|
let id = UUID().uuidString
|
||||||
|
UserDefaults.standard.set(id, forKey: Key.deviceId)
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor static var deviceName: String {
|
||||||
|
let n = UIDevice.current.name
|
||||||
|
return n.isEmpty ? "iPhone" : n
|
||||||
|
}
|
||||||
|
|
||||||
|
private static var appLang: String {
|
||||||
|
UserDefaults.standard.string(forKey: "appLanguage") ?? "system"
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: – Contacts access
|
||||||
|
|
||||||
|
static var isAuthorized: Bool {
|
||||||
|
let s = CNContactStore.authorizationStatus(for: .contacts)
|
||||||
|
if s == .authorized { return true }
|
||||||
|
if #available(iOS 18.0, *), s == .limited { return true }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
static func requestAccess() async -> Bool {
|
||||||
|
let status = CNContactStore.authorizationStatus(for: .contacts)
|
||||||
|
if status == .authorized { return true }
|
||||||
|
if #available(iOS 18.0, *), status == .limited { return true }
|
||||||
|
guard status == .notDetermined else { return false }
|
||||||
|
return await withCheckedContinuation { cont in
|
||||||
|
CNContactStore().requestAccess(for: .contacts) { granted, _ in
|
||||||
|
cont.resume(returning: granted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: – Reading contact birthdays
|
||||||
|
|
||||||
|
struct ContactBirthday {
|
||||||
|
let contactId: String
|
||||||
|
let name: String
|
||||||
|
let month: Int
|
||||||
|
let day: Int
|
||||||
|
let year: Int?
|
||||||
|
}
|
||||||
|
|
||||||
|
static func readContactBirthdays() throws -> [ContactBirthday] {
|
||||||
|
let store = CNContactStore()
|
||||||
|
let keys: [CNKeyDescriptor] = [
|
||||||
|
CNContactFormatter.descriptorForRequiredKeys(for: .fullName),
|
||||||
|
CNContactGivenNameKey as CNKeyDescriptor,
|
||||||
|
CNContactFamilyNameKey as CNKeyDescriptor,
|
||||||
|
CNContactOrganizationNameKey as CNKeyDescriptor,
|
||||||
|
CNContactBirthdayKey as CNKeyDescriptor,
|
||||||
|
]
|
||||||
|
let req = CNContactFetchRequest(keysToFetch: keys)
|
||||||
|
var out: [ContactBirthday] = []
|
||||||
|
try store.enumerateContacts(with: req) { contact, _ in
|
||||||
|
guard let bday = contact.birthday, let m = bday.month, let d = bday.day else { return }
|
||||||
|
var name = CNContactFormatter.string(from: contact, style: .fullName) ?? ""
|
||||||
|
if name.isEmpty {
|
||||||
|
name = [contact.givenName, contact.familyName]
|
||||||
|
.filter { !$0.isEmpty }.joined(separator: " ")
|
||||||
|
}
|
||||||
|
if name.isEmpty { name = contact.organizationName }
|
||||||
|
guard !name.isEmpty else { return }
|
||||||
|
let year = bday.year.flatMap { $0 > 0 ? $0 : nil }
|
||||||
|
out.append(ContactBirthday(
|
||||||
|
contactId: contact.identifier, name: name, month: m, day: d, year: year
|
||||||
|
))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: – The single birthday calendar
|
||||||
|
|
||||||
|
/// The user's birthday calendar, creating the single "Geburtstage" calendar
|
||||||
|
/// if none exists yet.
|
||||||
|
static func ensureBirthdayCalendar(api: CalendarrAPI) async -> LocalCalendar? {
|
||||||
|
let cals = (try? await api.getLocalCalendars()) ?? []
|
||||||
|
if let existing = cals.first(where: { $0.isBirthday && $0.owned }) { return existing }
|
||||||
|
let name = L10n.t("birthday.calendar_name", appLang)
|
||||||
|
return try? await api.addLocalCalendar(name: name, color: "#E0407F", isBirthday: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func birthdayCalendar(api: CalendarrAPI) async -> LocalCalendar? {
|
||||||
|
let cals = (try? await api.getLocalCalendars()) ?? []
|
||||||
|
return cals.first(where: { $0.isBirthday && $0.owned })
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: – Sync
|
||||||
|
|
||||||
|
/// Mirror the address book into the birthday calendar (creating it if
|
||||||
|
/// needed). No-op unless enabled and Contacts access is granted.
|
||||||
|
static func sync(api: CalendarrAPI) async {
|
||||||
|
guard isEnabled else { return }
|
||||||
|
guard await requestAccess() else { return }
|
||||||
|
guard let contacts = try? readContactBirthdays() else { return }
|
||||||
|
// Only fill an EXISTING birthday calendar — never auto-create it. The
|
||||||
|
// calendar is created explicitly; deleting it means sync has nowhere to
|
||||||
|
// go (and must not resurrect it).
|
||||||
|
guard let cal = await birthdayCalendar(api: api) else { return }
|
||||||
|
guard let existing = try? await api.getBirthdayEntries(calendarId: cal.id) else { return }
|
||||||
|
|
||||||
|
// Only reconcile THIS device's contact rows; leave other devices'
|
||||||
|
// rows and manual entries untouched.
|
||||||
|
let prefix = "contact:\(deviceId):"
|
||||||
|
var byExt: [String: BirthdayEntry] = [:]
|
||||||
|
for e in existing {
|
||||||
|
if let ext = e.externalUid, ext.hasPrefix(prefix) { byExt[ext] = e }
|
||||||
|
}
|
||||||
|
|
||||||
|
var seen = Set<String>()
|
||||||
|
for c in contacts {
|
||||||
|
let ext = prefix + c.contactId
|
||||||
|
seen.insert(ext)
|
||||||
|
let (start, end) = allDayRange(month: c.month, day: c.day, year: c.year)
|
||||||
|
if let match = byExt[ext] {
|
||||||
|
let changed = match.title != c.name || match.month != c.month
|
||||||
|
|| match.day != c.day || match.birthYear != c.year
|
||||||
|
if changed {
|
||||||
|
try? await api.updateLocalEvent(
|
||||||
|
uid: match.uid, title: c.name, start: start, end: end,
|
||||||
|
isAllDay: true, location: "", description: "", color: nil,
|
||||||
|
rrule: "FREQ=YEARLY", externalUid: ext, birthYear: c.year ?? -1
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_ = try? await api.createLocalEvent(
|
||||||
|
calendarId: cal.id, title: c.name, start: start, end: end,
|
||||||
|
isAllDay: true, location: "", description: "", color: nil,
|
||||||
|
rrule: "FREQ=YEARLY", externalUid: ext, birthYear: c.year
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (ext, entry) in byExt where !seen.contains(ext) {
|
||||||
|
try? await api.deleteLocalEvent(uid: entry.uid)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Report this device so the web can list where birthdays come from.
|
||||||
|
let name = await deviceName
|
||||||
|
try? await api.reportBirthdaySync(deviceId: deviceId, deviceName: name, count: contacts.count)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// All-day [start, end) for a birthday. Anchor year = birth year when known,
|
||||||
|
/// else 1970 so FREQ=YEARLY expands across any queried range. Built at local
|
||||||
|
/// noon so day-only formatting can't roll to an adjacent day.
|
||||||
|
private static func allDayRange(month: Int, day: Int, year: Int?) -> (Date, Date) {
|
||||||
|
var comp = DateComponents()
|
||||||
|
comp.year = year ?? 1970
|
||||||
|
comp.month = month
|
||||||
|
comp.day = day
|
||||||
|
comp.hour = 12
|
||||||
|
let cal = Calendar.current
|
||||||
|
let start = cal.date(from: comp) ?? Date()
|
||||||
|
let end = cal.date(byAdding: .day, value: 1, to: start) ?? start
|
||||||
|
return (start, end)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -135,8 +135,13 @@ class CalendarrAPI {
|
|||||||
return (try? JSONDecoder().decode([LocalCalendar].self, from: data)) ?? []
|
return (try? JSONDecoder().decode([LocalCalendar].self, from: data)) ?? []
|
||||||
}
|
}
|
||||||
|
|
||||||
func addLocalCalendar(name: String, color: String) async throws -> LocalCalendar {
|
func addLocalCalendar(name: String, color: String,
|
||||||
let data = try await request("/api/local/calendars", method: "POST", body: ["name": name, "color": color])
|
isBirthday: Bool = false,
|
||||||
|
birthdayNotifyDaysBefore: Int? = nil) async throws -> LocalCalendar {
|
||||||
|
var body: [String: Any] = ["name": name, "color": color]
|
||||||
|
if isBirthday { body["is_birthday"] = true }
|
||||||
|
if let d = birthdayNotifyDaysBefore { body["birthday_notify_days_before"] = d }
|
||||||
|
let data = try await request("/api/local/calendars", method: "POST", body: body)
|
||||||
guard let cal = try? JSONDecoder().decode(LocalCalendar.self, from: data) else { throw APIError.decodingError }
|
guard let cal = try? JSONDecoder().decode(LocalCalendar.self, from: data) else { throw APIError.decodingError }
|
||||||
return cal
|
return cal
|
||||||
}
|
}
|
||||||
@@ -145,6 +150,32 @@ class CalendarrAPI {
|
|||||||
_ = try await request("/api/local/calendars/\(id)", method: "DELETE")
|
_ = try await request("/api/local/calendars/\(id)", method: "DELETE")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Update a local calendar's birthday settings. `is_birthday` marks it as a
|
||||||
|
/// birthday calendar; `notifyDaysBefore` sets the reminder (nil sends the -1
|
||||||
|
/// sentinel to clear it server-side).
|
||||||
|
func updateLocalCalendarBirthday(id: Int, isBirthday: Bool? = nil, notifyDaysBefore: Int? = nil) async throws {
|
||||||
|
var body: [String: Any] = [:]
|
||||||
|
if let b = isBirthday { body["is_birthday"] = b }
|
||||||
|
if let n = notifyDaysBefore { body["birthday_notify_days_before"] = n } // -1 clears server-side
|
||||||
|
guard !body.isEmpty else { return }
|
||||||
|
_ = try await request("/api/local/calendars/\(id)", method: "PUT", body: body)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Raw (unexpanded) rows of a birthday calendar, used by the Contacts
|
||||||
|
/// importer to reconcile by `external_uid`.
|
||||||
|
func getBirthdayEntries(calendarId: Int) async throws -> [BirthdayEntry] {
|
||||||
|
let data = try await request("/api/local/calendars/\(calendarId)/birthdays")
|
||||||
|
return (try? JSONDecoder().decode([BirthdayEntry].self, from: data)) ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Report that this device just synced its Contacts birthdays, so the web
|
||||||
|
/// can show "birthdays come from these devices".
|
||||||
|
func reportBirthdaySync(deviceId: String, deviceName: String, count: Int) async throws {
|
||||||
|
_ = try await request("/api/birthdays/sync-report", method: "POST", body: [
|
||||||
|
"device_id": deviceId, "device_name": deviceName, "count": count,
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
func getICalSubscriptions() async throws -> [ICalSubscription] {
|
func getICalSubscriptions() async throws -> [ICalSubscription] {
|
||||||
let data = try await request("/api/ical/subscriptions")
|
let data = try await request("/api/ical/subscriptions")
|
||||||
return (try? JSONDecoder().decode([ICalSubscription].self, from: data)) ?? []
|
return (try? JSONDecoder().decode([ICalSubscription].self, from: data)) ?? []
|
||||||
@@ -206,7 +237,12 @@ class CalendarrAPI {
|
|||||||
|
|
||||||
// MARK: – Events
|
// MARK: – Events
|
||||||
|
|
||||||
func fetchEvents(start: Date, end: Date) async throws -> [CalEvent] {
|
/// Fetches events for the personal calendar view. The server also reports
|
||||||
|
/// per-calendar sync failures (e.g. expired credentials) alongside an
|
||||||
|
/// otherwise-successful response, via the `errors` array — surfaced
|
||||||
|
/// separately from a hard fetch failure so the UI can distinguish
|
||||||
|
/// "everything failed" from "fetched fine, but calendar X didn't sync".
|
||||||
|
func fetchEvents(start: Date, end: Date) async throws -> (events: [CalEvent], errors: [SyncError]) {
|
||||||
// Use UTC with Z suffix – avoids '+' character which breaks URL query params
|
// Use UTC with Z suffix – avoids '+' character which breaks URL query params
|
||||||
let iso = ISO8601DateFormatter()
|
let iso = ISO8601DateFormatter()
|
||||||
iso.formatOptions = [.withInternetDateTime]
|
iso.formatOptions = [.withInternetDateTime]
|
||||||
@@ -224,12 +260,16 @@ class CalendarrAPI {
|
|||||||
let preview = String(data: data, encoding: .utf8).map { String($0.prefix(200)) } ?? "no data"
|
let preview = String(data: data, encoding: .utf8).map { String($0.prefix(200)) } ?? "no data"
|
||||||
throw APIError.serverError("Unerwartete Antwort: \(preview)")
|
throw APIError.serverError("Unerwartete Antwort: \(preview)")
|
||||||
}
|
}
|
||||||
return arr.compactMap { CalEvent.from(json: $0) }
|
let events = arr.compactMap { CalEvent.from(json: $0) }
|
||||||
|
let errors = (root["errors"] as? [[String: Any]])?.compactMap { SyncError.from(json: $0) } ?? []
|
||||||
|
return (events, errors)
|
||||||
}
|
}
|
||||||
|
|
||||||
func createLocalEvent(calendarId: Int, title: String, start: Date, end: Date,
|
func createLocalEvent(calendarId: Int, title: String, start: Date, end: Date,
|
||||||
isAllDay: Bool, location: String, description: String, color: String?,
|
isAllDay: Bool, location: String, description: String, color: String?,
|
||||||
isPrivate: Bool = false, reminders: [Int]? = nil) async throws -> CalEvent {
|
isPrivate: Bool = false, reminders: [Int]? = nil,
|
||||||
|
rrule: String? = nil, externalUid: String? = nil,
|
||||||
|
birthYear: Int? = nil) async throws -> CalEvent {
|
||||||
var body: [String: Any] = [
|
var body: [String: Any] = [
|
||||||
"calendar_id": calendarId,
|
"calendar_id": calendarId,
|
||||||
"title": title,
|
"title": title,
|
||||||
@@ -242,6 +282,9 @@ class CalendarrAPI {
|
|||||||
]
|
]
|
||||||
if let c = color, !c.isEmpty { body["color"] = c }
|
if let c = color, !c.isEmpty { body["color"] = c }
|
||||||
if let reminders { body["reminders"] = reminders }
|
if let reminders { body["reminders"] = reminders }
|
||||||
|
if let rrule, !rrule.isEmpty { body["rrule"] = rrule }
|
||||||
|
if let externalUid { body["external_uid"] = externalUid }
|
||||||
|
if let birthYear { body["birth_year"] = birthYear }
|
||||||
let data = try await request("/api/local/events", method: "POST", body: body)
|
let data = try await request("/api/local/events", method: "POST", body: body)
|
||||||
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||||
let ev = CalEvent.from(json: json) else { throw APIError.decodingError }
|
let ev = CalEvent.from(json: json) else { throw APIError.decodingError }
|
||||||
@@ -250,7 +293,9 @@ class CalendarrAPI {
|
|||||||
|
|
||||||
func updateLocalEvent(uid: String, title: String, start: Date, end: Date,
|
func updateLocalEvent(uid: String, title: String, start: Date, end: Date,
|
||||||
isAllDay: Bool, location: String, description: String, color: String?,
|
isAllDay: Bool, location: String, description: String, color: String?,
|
||||||
isPrivate: Bool = false, reminders: [Int]? = nil) async throws {
|
isPrivate: Bool = false, reminders: [Int]? = nil,
|
||||||
|
rrule: String? = nil, externalUid: String? = nil,
|
||||||
|
birthYear: Int? = nil) async throws {
|
||||||
var body: [String: Any] = [
|
var body: [String: Any] = [
|
||||||
"title": title,
|
"title": title,
|
||||||
"start": formatISO(start, allDay: isAllDay),
|
"start": formatISO(start, allDay: isAllDay),
|
||||||
@@ -262,6 +307,9 @@ class CalendarrAPI {
|
|||||||
]
|
]
|
||||||
if let c = color { body["color"] = c }
|
if let c = color { body["color"] = c }
|
||||||
if let reminders { body["reminders"] = reminders }
|
if let reminders { body["reminders"] = reminders }
|
||||||
|
if let rrule { body["rrule"] = rrule }
|
||||||
|
if let externalUid { body["external_uid"] = externalUid }
|
||||||
|
if let birthYear { body["birth_year"] = birthYear }
|
||||||
_ = try await request("/api/local/events/\(uid)", method: "PUT", body: body)
|
_ = try await request("/api/local/events/\(uid)", method: "PUT", body: body)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -419,8 +467,11 @@ class CalendarrAPI {
|
|||||||
|
|
||||||
// MARK: – Calendar colour
|
// MARK: – Calendar colour
|
||||||
|
|
||||||
|
/// Colour-only update: works for owners AND share recipients (recipients get
|
||||||
|
/// their own per-user colour; owners change the calendar's colour) — unlike
|
||||||
|
/// the owner-only PUT /calendars/{id}, and never touches the name.
|
||||||
func updateLocalCalendarColor(id: Int, color: String) async throws {
|
func updateLocalCalendarColor(id: Int, color: String) async throws {
|
||||||
_ = try await request("/api/local/calendars/\(id)", method: "PUT", body: ["color": color])
|
_ = try await request("/api/local/calendars/\(id)/color", method: "PUT", body: ["color": color])
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateICalColor(id: Int, color: String) async throws {
|
func updateICalColor(id: Int, color: String) async throws {
|
||||||
@@ -443,11 +494,13 @@ class CalendarrAPI {
|
|||||||
|
|
||||||
/// Update profile fields. A login-name change returns a fresh token (the old
|
/// Update profile fields. A login-name change returns a fresh token (the old
|
||||||
/// one becomes invalid) — the caller must store the returned token.
|
/// one becomes invalid) — the caller must store the returned token.
|
||||||
func updateProfile(displayName: String?, username: String?, email: String?) async throws -> String? {
|
func updateProfile(displayName: String?, username: String?, email: String?,
|
||||||
|
directoryHidden: Bool? = nil) async throws -> String? {
|
||||||
var body: [String: Any] = [:]
|
var body: [String: Any] = [:]
|
||||||
if let d = displayName { body["display_name"] = d }
|
if let d = displayName { body["display_name"] = d }
|
||||||
if let u = username { body["username"] = u }
|
if let u = username { body["username"] = u }
|
||||||
if let e = email { body["email"] = e } else { body["email"] = NSNull() }
|
if let e = email { body["email"] = e } else { body["email"] = NSNull() }
|
||||||
|
if let h = directoryHidden { body["directory_hidden"] = h }
|
||||||
let data = try await request("/api/profile/", method: "PUT", body: body)
|
let data = try await request("/api/profile/", method: "PUT", body: body)
|
||||||
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
|
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
|
||||||
return json?["access_token"] as? String
|
return json?["access_token"] as? String
|
||||||
|
|||||||
131
Calendarr iOS/Services/KeychainStore.swift
Normal file
131
Calendarr iOS/Services/KeychainStore.swift
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import Foundation
|
||||||
|
import Security
|
||||||
|
|
||||||
|
/// Keychain access for secrets (currently just the auth bearer token).
|
||||||
|
///
|
||||||
|
/// This replaces the earlier `enum Keychain`, which discarded every `OSStatus`.
|
||||||
|
/// That mattered more than it looks: on Mac Catalyst a missing
|
||||||
|
/// `keychain-access-groups` entitlement makes `SecItemAdd`/`SecItemCopyMatching`
|
||||||
|
/// fail with `errSecMissingEntitlement`, and because nothing checked the status
|
||||||
|
/// the result was indistinguishable from "no token stored" — the user was
|
||||||
|
/// silently signed out on every launch.
|
||||||
|
///
|
||||||
|
/// Items live in a shared access group so the Mac build and other apps from the
|
||||||
|
/// same team can read the token. Reading requires `keychain-access-groups` in
|
||||||
|
/// the entitlements listing `sharedAccessGroup`.
|
||||||
|
enum KeychainStore {
|
||||||
|
/// Access group shared across our apps. Must match an entry in the
|
||||||
|
/// `keychain-access-groups` entitlement of every app that uses it.
|
||||||
|
/// The `PP34X97WS3.` prefix is this account's Team ID — the same value
|
||||||
|
/// `$(AppIdentifierPrefix)` expands to at build time.
|
||||||
|
static let sharedAccessGroup = "PP34X97WS3.com.scarriffleservices.calendarr.shared"
|
||||||
|
|
||||||
|
private static let service = "Calendarr"
|
||||||
|
|
||||||
|
/// Set when a keychain call failed because the entitlement was missing and
|
||||||
|
/// we fell back to the app's own default access group. Signing is a build
|
||||||
|
/// configuration concern, so in DEBUG we make it loud; in release we keep
|
||||||
|
/// working rather than locking the user out of their own account.
|
||||||
|
private(set) nonisolated(unsafe) static var didFallBackToDefaultGroup = false
|
||||||
|
|
||||||
|
// MARK: - Public API
|
||||||
|
|
||||||
|
/// Store or delete a value. Passing `nil` deletes it.
|
||||||
|
static func set(_ value: String?, for key: String,
|
||||||
|
accessGroup: String? = sharedAccessGroup) throws {
|
||||||
|
do {
|
||||||
|
try write(value, key: key, accessGroup: accessGroup)
|
||||||
|
} catch KeychainError.status(errSecMissingEntitlement, _) where accessGroup != nil {
|
||||||
|
noteEntitlementFallback()
|
||||||
|
try write(value, key: key, accessGroup: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read a value. Returns `nil` when the item simply is not there;
|
||||||
|
/// throws when the keychain itself refused the request.
|
||||||
|
static func get(_ key: String, accessGroup: String? = sharedAccessGroup) throws -> String? {
|
||||||
|
do {
|
||||||
|
return try read(key: key, accessGroup: accessGroup)
|
||||||
|
} catch KeychainError.status(errSecMissingEntitlement, _) where accessGroup != nil {
|
||||||
|
noteEntitlementFallback()
|
||||||
|
return try read(key: key, accessGroup: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Implementation
|
||||||
|
|
||||||
|
private static func baseQuery(key: String, accessGroup: String?) -> [String: Any] {
|
||||||
|
var q: [String: Any] = [
|
||||||
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
|
kSecAttrService as String: service,
|
||||||
|
kSecAttrAccount as String: key,
|
||||||
|
// Selects the modern, entitlement-gated keychain on macOS/Catalyst
|
||||||
|
// instead of the legacy file-based login keychain. Documented no-op
|
||||||
|
// on iOS 13+, so it is safe to set unconditionally.
|
||||||
|
kSecUseDataProtectionKeychain as String: true,
|
||||||
|
]
|
||||||
|
if let accessGroup { q[kSecAttrAccessGroup as String] = accessGroup }
|
||||||
|
return q
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func write(_ value: String?, key: String, accessGroup: String?) throws {
|
||||||
|
let base = baseQuery(key: key, accessGroup: accessGroup)
|
||||||
|
|
||||||
|
let deleteStatus = SecItemDelete(base as CFDictionary)
|
||||||
|
guard deleteStatus == errSecSuccess || deleteStatus == errSecItemNotFound else {
|
||||||
|
throw KeychainError.status(deleteStatus, operation: "delete \(key)")
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let value, let data = value.data(using: .utf8) else { return }
|
||||||
|
|
||||||
|
var add = base
|
||||||
|
add[kSecValueData as String] = data
|
||||||
|
add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
|
||||||
|
|
||||||
|
let addStatus = SecItemAdd(add as CFDictionary, nil)
|
||||||
|
guard addStatus == errSecSuccess else {
|
||||||
|
throw KeychainError.status(addStatus, operation: "add \(key)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func read(key: String, accessGroup: String?) throws -> String? {
|
||||||
|
var query = baseQuery(key: key, accessGroup: accessGroup)
|
||||||
|
query[kSecReturnData as String] = true
|
||||||
|
query[kSecMatchLimit as String] = kSecMatchLimitOne
|
||||||
|
|
||||||
|
var out: AnyObject?
|
||||||
|
let status = SecItemCopyMatching(query as CFDictionary, &out)
|
||||||
|
switch status {
|
||||||
|
case errSecSuccess:
|
||||||
|
guard let data = out as? Data else { return nil }
|
||||||
|
return String(data: data, encoding: .utf8)
|
||||||
|
case errSecItemNotFound:
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
throw KeychainError.status(status, operation: "read \(key)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func noteEntitlementFallback() {
|
||||||
|
guard !didFallBackToDefaultGroup else { return }
|
||||||
|
didFallBackToDefaultGroup = true
|
||||||
|
assertionFailure("""
|
||||||
|
Keychain access group \(sharedAccessGroup) was refused \
|
||||||
|
(errSecMissingEntitlement). Add it to keychain-access-groups and \
|
||||||
|
enable Keychain Sharing on the App ID. Falling back to the app's \
|
||||||
|
own default group — the Mac app will not see this token.
|
||||||
|
""")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum KeychainError: Error, LocalizedError {
|
||||||
|
case status(OSStatus, operation: String)
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case let .status(status, operation):
|
||||||
|
let detail = SecCopyErrorMessageString(status, nil) as String? ?? "OSStatus \(status)"
|
||||||
|
return "Keychain \(operation) failed: \(detail)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -50,7 +50,10 @@ enum NotificationScheduler {
|
|||||||
for item in limited {
|
for item in limited {
|
||||||
let content = UNMutableNotificationContent()
|
let content = UNMutableNotificationContent()
|
||||||
content.title = item.event.title
|
content.title = item.event.title
|
||||||
content.body = bodyText(item.event)
|
let minutes = Int(item.event.startDate.timeIntervalSince(item.fire) / 60)
|
||||||
|
let rel = relativeText(minutes)
|
||||||
|
let detail = bodyText(item.event)
|
||||||
|
content.body = detail.isEmpty ? rel : "\(rel) · \(detail)"
|
||||||
content.sound = .default
|
content.sound = .default
|
||||||
let comps = Calendar.current.dateComponents(
|
let comps = Calendar.current.dateComponents(
|
||||||
[.year, .month, .day, .hour, .minute, .second], from: item.fire)
|
[.year, .month, .day, .hour, .minute, .second], from: item.fire)
|
||||||
@@ -60,6 +63,14 @@ enum NotificationScheduler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func relativeText(_ minutes: Int) -> String {
|
||||||
|
if minutes < 60 { return "in \(minutes) Min." }
|
||||||
|
if minutes == 60 { return "in 1 Std." }
|
||||||
|
if minutes < 1440 { return "in \(minutes / 60) Std." }
|
||||||
|
if minutes == 1440 { return "morgen" }
|
||||||
|
return "in \(minutes / 1440) Tagen"
|
||||||
|
}
|
||||||
|
|
||||||
private static func bodyText(_ ev: CalEvent) -> String {
|
private static func bodyText(_ ev: CalEvent) -> String {
|
||||||
var parts: [String] = []
|
var parts: [String] = []
|
||||||
if !ev.isAllDay {
|
if !ev.isAllDay {
|
||||||
|
|||||||
@@ -6,108 +6,185 @@ extension Notification.Name {
|
|||||||
static let settingsDidChange = Notification.Name("settingsDidChange")
|
static let settingsDidChange = Notification.Name("settingsDidChange")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Two-way synchronisation of appearance/behaviour settings between the app and
|
/// Per-setting cross-device synchronisation. The server is the sole authority for
|
||||||
/// the Calendarr server. The server is treated as the source of truth on pull;
|
/// WHICH settings sync (its `sync_flags` map); this client keeps a cached copy of
|
||||||
/// local edits are pushed immediately so the server then holds the newest value.
|
/// that map and only sends/applies the keys whose flag is on. See
|
||||||
|
/// backend/SETTINGS_SYNC.md for the shared contract.
|
||||||
///
|
///
|
||||||
/// Two groups:
|
/// - Pull: for every syncable key whose flag is on, the server value wins.
|
||||||
/// - **optional** (colors, contrasts, hour height) only sync when the user has
|
/// - Push (debounced, read-modify-write): start from the server snapshot,
|
||||||
/// enabled the `settingsSync` toggle.
|
/// overwrite only the on-flagged keys with the local value, PUT.
|
||||||
/// - **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 {
|
enum SettingsSync {
|
||||||
|
|
||||||
// MARK: – UserDefaults keys
|
/// Server keys this iOS client can sync, mapped to their UserDefaults key.
|
||||||
|
/// Intentionally excluded: `language` (iOS "system" has no server value),
|
||||||
|
/// `share_calendar_icon` (no iOS UI), text/line contrast (iOS-only opacity
|
||||||
|
/// controls kept device-local), `liquid_glass` (iOS-only).
|
||||||
|
static let keyToDefaults: [String: String] = [
|
||||||
|
"default_view": "defaultView",
|
||||||
|
"week_start_day": "weekStartDay",
|
||||||
|
"dim_past_events": "dimPastEvents",
|
||||||
|
"hour_height": "hourHeight",
|
||||||
|
"default_event_duration_minutes": "defaultEventDurationMinutes",
|
||||||
|
"default_reminder_minutes": "defaultReminderMinutes",
|
||||||
|
"primary_color": "primaryColor",
|
||||||
|
"accent_color": "accentColor",
|
||||||
|
"today_color": "todayColor",
|
||||||
|
"text_color": "textColor",
|
||||||
|
"bg_color": "backgroundColor",
|
||||||
|
"line_color": "lineColor",
|
||||||
|
"month_divider_color": "monthDividerColor",
|
||||||
|
"month_label_color": "monthLabelColor",
|
||||||
|
"cache_months": "cacheMonths",
|
||||||
|
"month_view_paged": "monthViewPaged",
|
||||||
|
]
|
||||||
|
|
||||||
enum Key {
|
static let syncableKeys: [String] = Array(keyToDefaults.keys)
|
||||||
// optional group
|
|
||||||
static let primaryColor = "primaryColor"
|
/// Fallback flags used only until the first server pull populates the real map
|
||||||
static let accentColor = "accentColor"
|
/// (mirrors the server's DEFAULT_SYNC for the iOS-managed keys).
|
||||||
static let todayColor = "todayColor"
|
static let defaultSync: [String: Bool] = [
|
||||||
static let textColor = "textColor"
|
"default_view": true, "week_start_day": true, "dim_past_events": true,
|
||||||
static let backgroundColor = "backgroundColor"
|
"hour_height": true, "default_event_duration_minutes": true,
|
||||||
static let lineColor = "lineColor"
|
"default_reminder_minutes": true, "primary_color": true, "accent_color": true,
|
||||||
static let monthDividerColor = "monthDividerColor"
|
"today_color": true, "text_color": true, "bg_color": true, "line_color": true,
|
||||||
static let monthLabelColor = "monthLabelColor"
|
"month_divider_color": true, "month_label_color": true,
|
||||||
static let textContrast = "textContrast"
|
"cache_months": false, "month_view_paged": false,
|
||||||
static let lineContrast = "lineContrast"
|
]
|
||||||
static let hourHeight = "hourHeight"
|
|
||||||
// always group
|
// MARK: – Flag storage (UserDefaults JSON, mirrors the server's resolved map)
|
||||||
static let defaultView = "defaultView"
|
|
||||||
static let weekStartDay = "weekStartDay"
|
private static let flagsKey = "settingsSyncFlags"
|
||||||
static let dimPastEvents = "dimPastEvents"
|
|
||||||
static let defaultReminder = "defaultReminderMinutes" // Int, -1 = off
|
/// Effective flags: stored overrides on top of the fallback defaults.
|
||||||
// master switch
|
static func flags() -> [String: Bool] {
|
||||||
static let enabled = "settingsSync"
|
var result = defaultSync
|
||||||
|
if let data = UserDefaults.standard.data(forKey: flagsKey),
|
||||||
|
let stored = try? JSONDecoder().decode([String: Bool].self, from: data) {
|
||||||
|
for (k, v) in stored { result[k] = v }
|
||||||
|
}
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
static var isEnabled: Bool { UserDefaults.standard.bool(forKey: Key.enabled) }
|
static func isSynced(_ key: String) -> Bool { flags()[key] ?? false }
|
||||||
|
|
||||||
// MARK: – Defaults (mirror the historical hard-coded values)
|
private static func writeFlags(_ f: [String: Bool]) {
|
||||||
|
if let data = try? JSONEncoder().encode(f) {
|
||||||
private static func int(_ key: String, _ fallback: Int) -> Int {
|
UserDefaults.standard.set(data, forKey: flagsKey)
|
||||||
let v = UserDefaults.standard.object(forKey: key) as? Int
|
}
|
||||||
return v ?? fallback
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Replace the cached flags with the server's resolved map (known keys only).
|
||||||
|
static func storeServerFlags(_ server: [String: Bool]?) {
|
||||||
|
guard let server else { return }
|
||||||
|
var f = flags()
|
||||||
|
for key in syncableKeys { if let v = server[key] { f[key] = v } }
|
||||||
|
writeFlags(f)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Toggle one setting's sync flag. Turning it on pushes this device's current
|
||||||
|
/// value up (it becomes the shared value); turning off keeps the value local.
|
||||||
|
static func setSynced(_ key: String, _ on: Bool, api: CalendarrAPI) {
|
||||||
|
var f = flags(); f[key] = on; writeFlags(f)
|
||||||
|
push(api: api)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The global "sync everything" switch.
|
||||||
|
static func setAllSynced(_ on: Bool, api: CalendarrAPI) {
|
||||||
|
var f = flags(); for key in syncableKeys { f[key] = on }; writeFlags(f)
|
||||||
|
push(api: api)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: – Local ⇄ AppSettings field mapping
|
||||||
|
|
||||||
private static func str(_ key: String, _ fallback: String) -> String {
|
private static func str(_ key: String, _ fallback: String) -> String {
|
||||||
UserDefaults.standard.string(forKey: key) ?? fallback
|
UserDefaults.standard.string(forKey: key) ?? fallback
|
||||||
}
|
}
|
||||||
|
private static func int(_ key: String, _ fallback: Int) -> Int {
|
||||||
|
UserDefaults.standard.object(forKey: key) as? Int ?? fallback
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: – Build AppSettings from local UserDefaults
|
/// Build an AppSettings snapshot from local UserDefaults.
|
||||||
|
|
||||||
static func currentSettings() -> AppSettings {
|
static func currentSettings() -> AppSettings {
|
||||||
var s = AppSettings()
|
var s = AppSettings()
|
||||||
s.primaryColor = str(Key.primaryColor, "#4285f4")
|
s.primaryColor = str("primaryColor", "#4285f4")
|
||||||
s.accentColor = str(Key.accentColor, "#ea4335")
|
s.accentColor = str("accentColor", "#ea4335")
|
||||||
s.todayColor = str(Key.todayColor, "#4285f4")
|
s.todayColor = str("todayColor", "#4285f4")
|
||||||
s.textColor = str(Key.textColor, "#FFFFFF")
|
s.textColor = str("textColor", "#FFFFFF")
|
||||||
s.backgroundColor = str(Key.backgroundColor, "#000000")
|
s.backgroundColor = str("backgroundColor", "#000000")
|
||||||
s.lineColor = str(Key.lineColor, "#3A3A3C")
|
s.lineColor = str("lineColor", "#3A3A52")
|
||||||
s.monthDividerColor = str(Key.monthDividerColor, "#7090c0")
|
s.monthDividerColor = str("monthDividerColor", "#7090c0")
|
||||||
s.monthLabelColor = str(Key.monthLabelColor, "#7090c0")
|
s.monthLabelColor = str("monthLabelColor", "#7090c0")
|
||||||
s.textContrast = int(Key.textContrast, 3)
|
s.hourHeight = int("hourHeight", 60)
|
||||||
s.lineContrast = int(Key.lineContrast, 3)
|
s.defaultView = str("defaultView", "month")
|
||||||
s.hourHeight = int(Key.hourHeight, 60)
|
s.weekStartDay = str("weekStartDay", "monday")
|
||||||
s.defaultView = str(Key.defaultView, "month")
|
s.dimPastEvents = UserDefaults.standard.bool(forKey: "dimPastEvents")
|
||||||
s.weekStartDay = str(Key.weekStartDay, "monday")
|
s.cacheMonths = int("cacheMonths", 3)
|
||||||
s.dimPastEvents = UserDefaults.standard.bool(forKey: Key.dimPastEvents)
|
s.monthViewPaged = UserDefaults.standard.bool(forKey: "monthViewPaged")
|
||||||
let rem = int(Key.defaultReminder, -1)
|
let rem = int("defaultReminderMinutes", -1)
|
||||||
s.defaultReminderMinutes = rem < 0 ? nil : rem
|
s.defaultReminderMinutes = rem < 0 ? nil : rem
|
||||||
|
s.defaultEventDurationMinutes = int("defaultEventDurationMinutes", 60)
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: – Apply a server snapshot to local UserDefaults
|
/// Copy one synced field from a source snapshot into a destination snapshot.
|
||||||
|
private static func copyField(_ key: String, from src: AppSettings, into dst: inout AppSettings) {
|
||||||
|
switch key {
|
||||||
|
case "default_view": dst.defaultView = src.defaultView
|
||||||
|
case "week_start_day": dst.weekStartDay = src.weekStartDay
|
||||||
|
case "dim_past_events": dst.dimPastEvents = src.dimPastEvents
|
||||||
|
case "hour_height": dst.hourHeight = src.hourHeight
|
||||||
|
case "default_event_duration_minutes": dst.defaultEventDurationMinutes = src.defaultEventDurationMinutes
|
||||||
|
case "default_reminder_minutes": dst.defaultReminderMinutes = src.defaultReminderMinutes
|
||||||
|
case "primary_color": dst.primaryColor = src.primaryColor
|
||||||
|
case "accent_color": dst.accentColor = src.accentColor
|
||||||
|
case "today_color": dst.todayColor = src.todayColor
|
||||||
|
case "text_color": dst.textColor = src.textColor
|
||||||
|
case "bg_color": dst.backgroundColor = src.backgroundColor
|
||||||
|
case "line_color": dst.lineColor = src.lineColor
|
||||||
|
case "month_divider_color": dst.monthDividerColor = src.monthDividerColor
|
||||||
|
case "month_label_color": dst.monthLabelColor = src.monthLabelColor
|
||||||
|
case "cache_months": dst.cacheMonths = src.cacheMonths
|
||||||
|
case "month_view_paged": dst.monthViewPaged = src.monthViewPaged
|
||||||
|
default: break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Always writes the "always" trio. Writes the optional group only when
|
/// Write one synced field from a server snapshot into local UserDefaults.
|
||||||
/// `includeOptional` is true.
|
private static func applyField(_ key: String, from s: AppSettings) {
|
||||||
static func apply(_ s: AppSettings, includeOptional: Bool) {
|
|
||||||
let d = UserDefaults.standard
|
let d = UserDefaults.standard
|
||||||
// always group
|
switch key {
|
||||||
d.set(s.defaultView, forKey: Key.defaultView)
|
case "default_view": d.set(s.defaultView, forKey: "defaultView")
|
||||||
d.set(s.weekStartDay, forKey: Key.weekStartDay)
|
case "week_start_day": d.set(s.weekStartDay, forKey: "weekStartDay")
|
||||||
d.set(s.dimPastEvents, forKey: Key.dimPastEvents)
|
case "dim_past_events": d.set(s.dimPastEvents, forKey: "dimPastEvents")
|
||||||
d.set(s.defaultReminderMinutes ?? -1, forKey: Key.defaultReminder)
|
case "hour_height": d.set(s.hourHeight, forKey: "hourHeight")
|
||||||
guard includeOptional else { return }
|
case "default_event_duration_minutes": d.set(s.defaultEventDurationMinutes, forKey: "defaultEventDurationMinutes")
|
||||||
// NOTE: textColor / backgroundColor / lineColor are intentionally NOT
|
case "default_reminder_minutes": d.set(s.defaultReminderMinutes ?? -1, forKey: "defaultReminderMinutes")
|
||||||
// synced – the server has no columns for them (iOS-only). Writing the
|
case "primary_color": d.set(s.primaryColor, forKey: "primaryColor")
|
||||||
// resilient-decoded defaults here would wipe the user's local choices.
|
case "accent_color": d.set(s.accentColor, forKey: "accentColor")
|
||||||
d.set(s.primaryColor, forKey: Key.primaryColor)
|
case "today_color": d.set(s.todayColor, forKey: "todayColor")
|
||||||
d.set(s.accentColor, forKey: Key.accentColor)
|
case "text_color": d.set(s.textColor, forKey: "textColor")
|
||||||
d.set(s.todayColor, forKey: Key.todayColor)
|
case "bg_color": d.set(s.backgroundColor, forKey: "backgroundColor")
|
||||||
d.set(s.monthDividerColor, forKey: Key.monthDividerColor)
|
case "line_color": d.set(s.lineColor, forKey: "lineColor")
|
||||||
d.set(s.monthLabelColor, forKey: Key.monthLabelColor)
|
case "month_divider_color": d.set(s.monthDividerColor, forKey: "monthDividerColor")
|
||||||
d.set(s.textContrast, forKey: Key.textContrast)
|
case "month_label_color": d.set(s.monthLabelColor, forKey: "monthLabelColor")
|
||||||
d.set(s.lineContrast, forKey: Key.lineContrast)
|
case "cache_months": d.set(s.cacheMonths, forKey: "cacheMonths")
|
||||||
d.set(s.hourHeight, forKey: Key.hourHeight)
|
case "month_view_paged": d.set(s.monthViewPaged, forKey: "monthViewPaged")
|
||||||
|
default: break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: – Pull
|
// MARK: – Pull
|
||||||
|
|
||||||
/// Fetch the server's settings and apply them locally (server wins).
|
/// Fetch the server's settings, refresh the flag cache, and apply the value of
|
||||||
|
/// every on-flagged key locally (server wins).
|
||||||
static func pull(api: CalendarrAPI) async {
|
static func pull(api: CalendarrAPI) async {
|
||||||
guard let server = try? await api.getSettings() else { return }
|
guard let server = try? await api.getSettings() else { return }
|
||||||
apply(server, includeOptional: isEnabled)
|
storeServerFlags(server.syncFlags)
|
||||||
|
let f = flags()
|
||||||
|
for key in syncableKeys where f[key] == true {
|
||||||
|
applyField(key, from: server)
|
||||||
|
}
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
NotificationCenter.default.post(name: .settingsDidChange, object: nil)
|
NotificationCenter.default.post(name: .settingsDidChange, object: nil)
|
||||||
}
|
}
|
||||||
@@ -128,30 +205,17 @@ enum SettingsSync {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read-modify-write: start from the server's current settings so that,
|
/// Read-modify-write: start from the server's current settings so unsynced
|
||||||
/// when the optional group is NOT being synced, the server's colours stay
|
/// fields stay intact, overwrite only the on-flagged keys with local values,
|
||||||
/// intact. Overwrite the trio always, the optional group only if enabled.
|
/// and send the current flag map so the account-wide config stays in sync.
|
||||||
private static func performPush(api: CalendarrAPI) async {
|
private static func performPush(api: CalendarrAPI) async {
|
||||||
guard var merged = try? await api.getSettings() else { return }
|
guard var merged = try? await api.getSettings() else { return }
|
||||||
let local = currentSettings()
|
let local = currentSettings()
|
||||||
// always group
|
let f = flags()
|
||||||
merged.defaultView = local.defaultView
|
for key in syncableKeys where f[key] == true {
|
||||||
merged.weekStartDay = local.weekStartDay
|
copyField(key, from: local, into: &merged)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
merged.syncFlags = f
|
||||||
try? await api.updateSettings(merged)
|
try? await api.updateSettings(merged)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,11 @@ struct AccountsView: View {
|
|||||||
@State private var exportDoc: ExportedICS?
|
@State private var exportDoc: ExportedICS?
|
||||||
@State private var infoMessage: String?
|
@State private var infoMessage: String?
|
||||||
|
|
||||||
|
// Contacts → birthday-calendar sync (opt-in, bound to one birthday calendar).
|
||||||
|
@AppStorage("birthdaysSyncEnabled") private var birthdaysSyncEnabled = false
|
||||||
|
@State private var isSyncingBirthdays = false
|
||||||
|
@State private var birthdayNotify = -1
|
||||||
|
|
||||||
@AppStorage("appLanguage") private var appLang = "system"
|
@AppStorage("appLanguage") private var appLang = "system"
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -36,10 +41,16 @@ struct AccountsView: View {
|
|||||||
if !banishedKeys.isEmpty { banishedSection }
|
if !banishedKeys.isEmpty { banishedSection }
|
||||||
caldavSection
|
caldavSection
|
||||||
localSection
|
localSection
|
||||||
|
birthdayContactsSection
|
||||||
icalSection
|
icalSection
|
||||||
googleSection
|
googleSection
|
||||||
haSection
|
haSection
|
||||||
}
|
}
|
||||||
|
.onChange(of: birthdaysSyncEnabled) { _, on in
|
||||||
|
// Enabling only asks for Contacts access — it must NOT create
|
||||||
|
// the birthday calendar. That's an explicit action.
|
||||||
|
if on { Task { _ = await BirthdaysImporter.requestAccess() } }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle(L10n.t("accounts.title", appLang))
|
.navigationTitle(L10n.t("accounts.title", appLang))
|
||||||
@@ -49,6 +60,12 @@ struct AccountsView: View {
|
|||||||
Menu {
|
Menu {
|
||||||
Button(L10n.t("accounts.add.caldav", appLang)) { showAddCalDAV = true }
|
Button(L10n.t("accounts.add.caldav", appLang)) { showAddCalDAV = true }
|
||||||
Button(L10n.t("accounts.add.local", appLang)) { showAddLocal = true }
|
Button(L10n.t("accounts.add.local", appLang)) { showAddLocal = true }
|
||||||
|
// Only one birthday calendar per account.
|
||||||
|
if birthdayCalendar == nil {
|
||||||
|
Button(L10n.t("accounts.add.birthday", appLang)) {
|
||||||
|
Task { await createBirthdayCalendar() }
|
||||||
|
}
|
||||||
|
}
|
||||||
Button(L10n.t("accounts.add.ical", appLang)) { showAddICal = true }
|
Button(L10n.t("accounts.add.ical", appLang)) { showAddICal = true }
|
||||||
Button(L10n.t("accounts.add.ha", appLang)) { showAddHA = true }
|
Button(L10n.t("accounts.add.ha", appLang)) { showAddHA = true }
|
||||||
} label: {
|
} label: {
|
||||||
@@ -169,7 +186,9 @@ struct AccountsView: View {
|
|||||||
} else {
|
} else {
|
||||||
ForEach(localCalendars) { cal in
|
ForEach(localCalendars) { cal in
|
||||||
HStack {
|
HStack {
|
||||||
CalendarColorDot(hex: cal.color, editable: cal.owned) { hex in
|
// Recipients of a shared calendar may recolour it (their
|
||||||
|
// own per-user colour); renaming stays owner-only.
|
||||||
|
CalendarColorDot(hex: cal.color, editable: true) { hex in
|
||||||
try? await api.updateLocalCalendarColor(id: cal.id, color: hex)
|
try? await api.updateLocalCalendarColor(id: cal.id, color: hex)
|
||||||
}
|
}
|
||||||
Text(cal.name)
|
Text(cal.name)
|
||||||
@@ -211,6 +230,63 @@ struct AccountsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The user's single birthday calendar (created on first sync/activation).
|
||||||
|
private var birthdayCalendar: LocalCalendar? {
|
||||||
|
localCalendars.first { $0.isBirthday && $0.owned }
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder var birthdayContactsSection: some View {
|
||||||
|
Section {
|
||||||
|
if let cal = birthdayCalendar {
|
||||||
|
Toggle(L10n.t("birthday.contacts.sync", appLang), isOn: $birthdaysSyncEnabled)
|
||||||
|
if birthdaysSyncEnabled {
|
||||||
|
BirthdayNotifyPicker(days: $birthdayNotify, appLang: appLang)
|
||||||
|
.onChange(of: birthdayNotify) { _, v in
|
||||||
|
Task { try? await api.updateLocalCalendarBirthday(id: cal.id, notifyDaysBefore: v) }
|
||||||
|
}
|
||||||
|
Button {
|
||||||
|
Task { await syncBirthdays() }
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Text(L10n.t("birthday.contacts.sync_now", appLang))
|
||||||
|
if isSyncingBirthdays { Spacer(); ProgressView() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(isSyncingBirthdays)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No birthday calendar yet — sync has nothing to fill. Create one
|
||||||
|
// first via the + menu (never auto-created by sync).
|
||||||
|
Text(L10n.t("birthday.contacts.need_calendar", appLang))
|
||||||
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text(L10n.t("birthday.contacts.header", appLang))
|
||||||
|
} footer: {
|
||||||
|
Text(L10n.t("birthday.contacts.hint", appLang))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Explicitly create the single birthday calendar (from the + menu).
|
||||||
|
private func createBirthdayCalendar() async {
|
||||||
|
_ = await BirthdaysImporter.ensureBirthdayCalendar(api: api)
|
||||||
|
await load()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func syncBirthdays() async {
|
||||||
|
isSyncingBirthdays = true
|
||||||
|
defer { isSyncingBirthdays = false }
|
||||||
|
guard await BirthdaysImporter.requestAccess() else {
|
||||||
|
errorAlert = L10n.t("birthday.contacts.denied", appLang)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await BirthdaysImporter.sync(api: api)
|
||||||
|
await load() // pick up the (possibly newly created) birthday calendar
|
||||||
|
infoMessage = L10n.t("birthday.contacts.synced", appLang)
|
||||||
|
// Let the calendar refresh so imported birthdays show up right away.
|
||||||
|
NotificationCenter.default.post(name: .manualSyncRequested, object: nil)
|
||||||
|
}
|
||||||
|
|
||||||
var icalSection: some View {
|
var icalSection: some View {
|
||||||
Section {
|
Section {
|
||||||
if icalSubs.isEmpty {
|
if icalSubs.isEmpty {
|
||||||
@@ -417,6 +493,8 @@ struct AccountsView: View {
|
|||||||
CalendarStore.saveBanishedKeys(b)
|
CalendarStore.saveBanishedKeys(b)
|
||||||
NotificationCenter.default.post(name: .banishedCalendarsChanged, object: nil)
|
NotificationCenter.default.post(name: .banishedCalendarsChanged, object: nil)
|
||||||
}
|
}
|
||||||
|
// Reflect the birthday calendar's current reminder setting in the picker.
|
||||||
|
birthdayNotify = birthdayCalendar?.birthdayNotifyDaysBefore ?? -1
|
||||||
isLoading = false
|
isLoading = false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -574,6 +652,24 @@ struct AddLocalCalSheet: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Reusable "notify N days before" picker for birthday calendars.
|
||||||
|
/// -1 = off, 0 = on the day, N = N days before.
|
||||||
|
struct BirthdayNotifyPicker: View {
|
||||||
|
@Binding var days: Int
|
||||||
|
let appLang: String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Picker(L10n.t("birthday.notify", appLang), selection: $days) {
|
||||||
|
Text(L10n.t("birthday.notify.off", appLang)).tag(-1)
|
||||||
|
Text(L10n.t("birthday.notify.same_day", appLang)).tag(0)
|
||||||
|
Text(L10n.t("birthday.notify.one_day", appLang)).tag(1)
|
||||||
|
ForEach([2, 3, 7], id: \.self) { d in
|
||||||
|
Text(String(format: L10n.t("birthday.notify.days", appLang), d)).tag(d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct AddICalSheet: View {
|
struct AddICalSheet: View {
|
||||||
let api: CalendarrAPI
|
let api: CalendarrAPI
|
||||||
let onDone: () async -> Void
|
let onDone: () async -> Void
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ private struct AgendaEventRow: View {
|
|||||||
.frame(width: 4, height: 40)
|
.frame(width: 4, height: 40)
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 3) {
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
Text(event.title)
|
EventLabel(event: event)
|
||||||
.font(.body.weight(.medium))
|
.font(.body.weight(.medium))
|
||||||
.foregroundStyle(.primary)
|
.foregroundStyle(.primary)
|
||||||
HStack(spacing: 6) {
|
HStack(spacing: 6) {
|
||||||
|
|||||||
101
Calendarr iOS/Views/Calendar/BirthdayEditorSheet.swift
Normal file
101
Calendarr iOS/Views/Calendar/BirthdayEditorSheet.swift
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Minimal "new birthday" mask for the single birthday calendar: enter a name
|
||||||
|
/// and a date (optionally "year unknown"). Saves an all-day, yearly-recurring
|
||||||
|
/// local event; the server adds the age suffix and cake icon on read. If no
|
||||||
|
/// birthday calendar exists yet, offers to activate one.
|
||||||
|
struct BirthdayEditorSheet: View {
|
||||||
|
let api: CalendarrAPI
|
||||||
|
var onDone: () async -> Void
|
||||||
|
|
||||||
|
@AppStorage("appLanguage") private var appLang = "system"
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
@State private var calendar: LocalCalendar? = nil
|
||||||
|
@State private var name = ""
|
||||||
|
@State private var date = Date()
|
||||||
|
@State private var yearUnknown = false
|
||||||
|
@State private var loading = true
|
||||||
|
@State private var saving = false
|
||||||
|
@State private var activating = false
|
||||||
|
|
||||||
|
private var canSave: Bool {
|
||||||
|
!saving && calendar != nil
|
||||||
|
&& !name.trimmingCharacters(in: .whitespaces).isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Form {
|
||||||
|
if loading {
|
||||||
|
HStack { Spacer(); ProgressView(); Spacer() }
|
||||||
|
} else if calendar == nil {
|
||||||
|
Section {
|
||||||
|
Text(L10n.t("birthday.activate_hint", appLang))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Button(L10n.t("birthday.activate", appLang)) { Task { await activate() } }
|
||||||
|
.disabled(activating)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Section(L10n.t("birthday.person", appLang)) {
|
||||||
|
TextField(L10n.t("birthday.person_placeholder", appLang), text: $name)
|
||||||
|
}
|
||||||
|
Section(L10n.t("birthday.date", appLang)) {
|
||||||
|
DatePicker(L10n.t("birthday.date", appLang), selection: $date,
|
||||||
|
displayedComponents: [.date])
|
||||||
|
Toggle(L10n.t("birthday.year_unknown", appLang), isOn: $yearUnknown)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle(L10n.t("birthday.new", appLang))
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button(L10n.t("common.cancel", appLang)) { dismiss() }
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
|
Button(L10n.t("event.save", appLang)) { Task { await save() } }
|
||||||
|
.disabled(!canSave)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task { await load() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func load() async {
|
||||||
|
calendar = await BirthdaysImporter.birthdayCalendar(api: api)
|
||||||
|
loading = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func activate() async {
|
||||||
|
activating = true
|
||||||
|
calendar = await BirthdaysImporter.ensureBirthdayCalendar(api: api)
|
||||||
|
activating = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func save() async {
|
||||||
|
guard let cal = calendar else { return }
|
||||||
|
saving = true
|
||||||
|
let calc = Calendar.current
|
||||||
|
let comps = calc.dateComponents([.year, .month, .day], from: date)
|
||||||
|
let month = comps.month ?? 1
|
||||||
|
let day = comps.day ?? 1
|
||||||
|
let year = yearUnknown ? nil : comps.year
|
||||||
|
// All-day anchor at local noon; anchor year = birth year (else 1970) so
|
||||||
|
// the yearly rule expands across any queried range.
|
||||||
|
var anchor = DateComponents()
|
||||||
|
anchor.year = year ?? 1970
|
||||||
|
anchor.month = month
|
||||||
|
anchor.day = day
|
||||||
|
anchor.hour = 12
|
||||||
|
let start = calc.date(from: anchor) ?? date
|
||||||
|
let end = calc.date(byAdding: .day, value: 1, to: start) ?? start
|
||||||
|
_ = try? await api.createLocalEvent(
|
||||||
|
calendarId: cal.id, title: name.trimmingCharacters(in: .whitespaces),
|
||||||
|
start: start, end: end, isAllDay: true, location: "", description: "",
|
||||||
|
color: nil, rrule: "FREQ=YEARLY", birthYear: year
|
||||||
|
)
|
||||||
|
await onDone()
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
125
Calendarr iOS/Views/Calendar/CalendarDrawer.swift
Normal file
125
Calendarr iOS/Views/Calendar/CalendarDrawer.swift
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// The left side drawer: calendar visibility + group/view switching, plus a
|
||||||
|
/// single entry into the full menu (Settings/Accounts/Profile/…/Sync/Logout).
|
||||||
|
struct CalendarDrawer: View {
|
||||||
|
let api: CalendarrAPI
|
||||||
|
let store: CalendarStore
|
||||||
|
let groups: [CalGroup]
|
||||||
|
let onSwitchGroup: (CalGroup?) -> Void
|
||||||
|
let onSelectView: (CalViewType) -> Void
|
||||||
|
let onOpenMenu: () -> Void
|
||||||
|
let onSync: () -> Void
|
||||||
|
let onClose: () -> Void
|
||||||
|
|
||||||
|
@Environment(AppState.self) private var appState
|
||||||
|
@AppStorage("appLanguage") private var appLang = "system"
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
header
|
||||||
|
Divider()
|
||||||
|
viewSwitcher
|
||||||
|
if !groups.isEmpty {
|
||||||
|
Divider()
|
||||||
|
groupSwitcher
|
||||||
|
}
|
||||||
|
Divider()
|
||||||
|
CalendarFilterContent(api: api, store: store)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||||
|
.background(Color(.systemBackground))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: – Header
|
||||||
|
|
||||||
|
private var header: some View {
|
||||||
|
HStack(spacing: 14) {
|
||||||
|
Circle()
|
||||||
|
.fill(Color.accentColor)
|
||||||
|
.frame(width: 48, height: 48)
|
||||||
|
.overlay {
|
||||||
|
Text(appState.username.prefix(1).uppercased())
|
||||||
|
.font(.title3.bold()).foregroundStyle(.white)
|
||||||
|
}
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(appState.username).font(.title3.weight(.semibold)).lineLimit(1)
|
||||||
|
Text(appState.serverURL
|
||||||
|
.replacingOccurrences(of: "https://", with: "")
|
||||||
|
.replacingOccurrences(of: "http://", with: ""))
|
||||||
|
.font(.footnote).foregroundStyle(.secondary).lineLimit(1)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
HStack(spacing: 20) {
|
||||||
|
Button { onSync() } label: {
|
||||||
|
Image(systemName: "arrow.triangle.2.circlepath").font(.system(size: 19, weight: .medium))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain).foregroundStyle(Color.accentColor)
|
||||||
|
.accessibilityLabel(L10n.t("menu.sync", appLang))
|
||||||
|
Button { onOpenMenu() } label: {
|
||||||
|
Image(systemName: "gearshape").font(.system(size: 19, weight: .medium))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain).foregroundStyle(Color.accentColor)
|
||||||
|
.accessibilityLabel(L10n.t("menu.section.settings", appLang))
|
||||||
|
Button { onClose() } label: {
|
||||||
|
Image(systemName: "xmark").font(.system(size: 17, weight: .semibold))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 18).padding(.top, 20).padding(.bottom, 16)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: – View switcher
|
||||||
|
|
||||||
|
private var viewSwitcher: some View {
|
||||||
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
ForEach(CalViewType.allCases, id: \.self) { vt in
|
||||||
|
let selected = store.viewType == vt
|
||||||
|
Button { onSelectView(vt) } label: {
|
||||||
|
Label(vt.label(appLang), systemImage: vt.systemImage)
|
||||||
|
.font(.caption.weight(.medium))
|
||||||
|
.padding(.horizontal, 12).padding(.vertical, 7)
|
||||||
|
.background(selected ? Color.accentColor : Color(.secondarySystemBackground))
|
||||||
|
.foregroundStyle(selected ? .white : .primary)
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16).padding(.vertical, 13)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: – Group switcher
|
||||||
|
|
||||||
|
private var groupSwitcher: some View {
|
||||||
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
chip(label: L10n.t("groups.personal", appLang),
|
||||||
|
systemImage: "person",
|
||||||
|
selected: store.activeGroup == nil) { onSwitchGroup(nil) }
|
||||||
|
ForEach(groups) { g in
|
||||||
|
chip(label: g.name,
|
||||||
|
systemImage: GroupIcons.symbol(g.icon),
|
||||||
|
selected: store.activeGroup?.id == g.id) { onSwitchGroup(g) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16).padding(.vertical, 13)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func chip(label: String, systemImage: String, selected: Bool, action: @escaping () -> Void) -> some View {
|
||||||
|
Button(action: action) {
|
||||||
|
Label(label, systemImage: systemImage)
|
||||||
|
.font(.caption.weight(.medium))
|
||||||
|
.padding(.horizontal, 12).padding(.vertical, 7)
|
||||||
|
.background(selected ? Color.accentColor : Color(.secondarySystemBackground))
|
||||||
|
.foregroundStyle(selected ? .white : .primary)
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -21,6 +21,10 @@ struct CalendarHostView: View {
|
|||||||
@AppStorage("backgroundColor") private var bgHex = "#000000"
|
@AppStorage("backgroundColor") private var bgHex = "#000000"
|
||||||
@AppStorage("weekStartDay") private var weekStartDay = "monday"
|
@AppStorage("weekStartDay") private var weekStartDay = "monday"
|
||||||
@AppStorage("defaultView") private var defaultView = "month"
|
@AppStorage("defaultView") private var defaultView = "month"
|
||||||
|
// Opt-in: empty keeps the translucent `.bar` material; a hex tints the top bar.
|
||||||
|
@AppStorage("surfaceColor") private var surfaceHex = ""
|
||||||
|
// Device-local: hide the hamburger (drawer then opens only via edge-swipe).
|
||||||
|
@AppStorage("hideMenuButton") private var hideMenuButton = false
|
||||||
|
|
||||||
@Environment(\.scenePhase) private var scenePhase
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
|
|
||||||
@@ -30,6 +34,8 @@ struct CalendarHostView: View {
|
|||||||
@State private var showFilter = false
|
@State private var showFilter = false
|
||||||
@State private var didApplyDefaultView = false
|
@State private var didApplyDefaultView = false
|
||||||
@State private var groups: [CalGroup] = []
|
@State private var groups: [CalGroup] = []
|
||||||
|
@State private var showNewBirthday = false
|
||||||
|
@State private var showDrawer = false
|
||||||
|
|
||||||
private var titleString: String {
|
private var titleString: String {
|
||||||
if store.viewType == .month {
|
if store.viewType == .month {
|
||||||
@@ -42,11 +48,67 @@ struct CalendarHostView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
if liquidGlass {
|
ZStack(alignment: .leading) {
|
||||||
glassVariant
|
Group {
|
||||||
} else {
|
if liquidGlass { glassVariant } else { flatVariant }
|
||||||
flatVariant
|
}
|
||||||
|
// Dim scrim behind the open drawer.
|
||||||
|
if showDrawer {
|
||||||
|
Color.black.opacity(0.35)
|
||||||
|
.ignoresSafeArea()
|
||||||
|
.transition(.opacity)
|
||||||
|
.onTapGesture { closeDrawer() }
|
||||||
|
}
|
||||||
|
// Off-canvas left drawer.
|
||||||
|
CalendarDrawer(
|
||||||
|
api: api, store: store, groups: groups,
|
||||||
|
onSwitchGroup: { g in closeDrawer(); switchGroup(g) },
|
||||||
|
onSelectView: { vt in store.viewType = vt; closeDrawer() },
|
||||||
|
onOpenMenu: { closeDrawer(); showMenu = true },
|
||||||
|
onSync: { closeDrawer(); Task { await syncFromServer(force: true) } },
|
||||||
|
onClose: { closeDrawer() }
|
||||||
|
)
|
||||||
|
.frame(width: drawerWidth)
|
||||||
|
.frame(maxHeight: .infinity, alignment: .top)
|
||||||
|
.shadow(color: .black.opacity(showDrawer ? 0.25 : 0), radius: 12, x: 4)
|
||||||
|
.offset(x: showDrawer ? 0 : -(drawerWidth + 60))
|
||||||
|
.animation(.easeInOut(duration: 0.25), value: showDrawer)
|
||||||
|
// Swipe the drawer toward the left edge to close it.
|
||||||
|
.simultaneousGesture(
|
||||||
|
DragGesture(minimumDistance: 20)
|
||||||
|
.onEnded { v in
|
||||||
|
if showDrawer, v.translation.width < -45,
|
||||||
|
abs(v.translation.width) > abs(v.translation.height) {
|
||||||
|
closeDrawer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
// A narrow leading strip opens the drawer via edge-swipe (kept off the
|
||||||
|
// content area so month paging / week swipe stay free).
|
||||||
|
.overlay(alignment: .leading) {
|
||||||
|
if !showDrawer {
|
||||||
|
Color.clear
|
||||||
|
.frame(width: 18)
|
||||||
|
.frame(maxHeight: .infinity)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.gesture(
|
||||||
|
DragGesture(minimumDistance: 12, coordinateSpace: .local)
|
||||||
|
.onEnded { v in
|
||||||
|
if v.translation.width > 45,
|
||||||
|
abs(v.translation.width) > abs(v.translation.height) {
|
||||||
|
withAnimation(.easeInOut(duration: 0.25)) { showDrawer = true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var drawerWidth: CGFloat { min(UIScreen.main.bounds.width - 40, 360) }
|
||||||
|
|
||||||
|
private func closeDrawer() {
|
||||||
|
withAnimation(.easeInOut(duration: 0.25)) { showDrawer = false }
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: – Loading indicator
|
// MARK: – Loading indicator
|
||||||
@@ -85,7 +147,7 @@ struct CalendarHostView: View {
|
|||||||
.onChange(of: store.viewType) { _, _ in Task { await onNavigate() } }
|
.onChange(of: store.viewType) { _, _ in Task { await onNavigate() } }
|
||||||
.onChange(of: cacheMonths) { _, _ in Task { await recache() } }
|
.onChange(of: cacheMonths) { _, _ in Task { await recache() } }
|
||||||
.onChange(of: store.visibleMonth) { _, new in Task { await ensureLoaded(around: new) } }
|
.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) } } }
|
.onChange(of: scenePhase) { _, phase in if phase == .active { Task { await syncFromServer() } } }
|
||||||
.onReceive(NotificationCenter.default.publisher(for: .banishedCalendarsChanged)) { _ in
|
.onReceive(NotificationCenter.default.publisher(for: .banishedCalendarsChanged)) { _ in
|
||||||
store.syncBanishedFromDefaults()
|
store.syncBanishedFromDefaults()
|
||||||
}
|
}
|
||||||
@@ -120,15 +182,13 @@ struct CalendarHostView: View {
|
|||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .navigationBarLeading) {
|
ToolbarItem(placement: .navigationBarLeading) {
|
||||||
HStack(spacing: 2) {
|
HStack(spacing: 2) {
|
||||||
|
if !hideMenuButton { menuButton }
|
||||||
Button { store.navigatePrev() } label: { Image(systemName: "chevron.left") }
|
Button { store.navigatePrev() } label: { Image(systemName: "chevron.left") }
|
||||||
Button { store.navigateNext() } label: { Image(systemName: "chevron.right") }
|
Button { store.navigateNext() } label: { Image(systemName: "chevron.right") }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
HStack(spacing: 8) {
|
Button(L10n.t("nav.today", appLang)) { store.moveToToday() }.font(.callout)
|
||||||
Button(L10n.t("nav.today", appLang)) { store.moveToToday() }.font(.callout)
|
|
||||||
menuButton
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.safeAreaInset(edge: .top, spacing: 0) {
|
.safeAreaInset(edge: .top, spacing: 0) {
|
||||||
@@ -152,7 +212,7 @@ struct CalendarHostView: View {
|
|||||||
.onChange(of: store.viewType) { _, _ in Task { await onNavigate() } }
|
.onChange(of: store.viewType) { _, _ in Task { await onNavigate() } }
|
||||||
.onChange(of: cacheMonths) { _, _ in Task { await recache() } }
|
.onChange(of: cacheMonths) { _, _ in Task { await recache() } }
|
||||||
.onChange(of: store.visibleMonth) { _, new in Task { await ensureLoaded(around: new) } }
|
.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) } } }
|
.onChange(of: scenePhase) { _, phase in if phase == .active { Task { await syncFromServer() } } }
|
||||||
.onReceive(NotificationCenter.default.publisher(for: .banishedCalendarsChanged)) { _ in
|
.onReceive(NotificationCenter.default.publisher(for: .banishedCalendarsChanged)) { _ in
|
||||||
store.syncBanishedFromDefaults()
|
store.syncBanishedFromDefaults()
|
||||||
}
|
}
|
||||||
@@ -174,6 +234,8 @@ struct CalendarHostView: View {
|
|||||||
/// updates reliably on month change — is identical in both modes.
|
/// updates reliably on month change — is identical in both modes.
|
||||||
@ViewBuilder private var barContents: some View {
|
@ViewBuilder private var barContents: some View {
|
||||||
HStack(spacing: 0) {
|
HStack(spacing: 0) {
|
||||||
|
// Menu (hamburger) on the left — the drawer opens from the left.
|
||||||
|
if !hideMenuButton { menuButton.padding(.leading, 2) }
|
||||||
HStack(spacing: 2) {
|
HStack(spacing: 2) {
|
||||||
Button { store.navigatePrev() } label: {
|
Button { store.navigatePrev() } label: {
|
||||||
Image(systemName: "chevron.left")
|
Image(systemName: "chevron.left")
|
||||||
@@ -186,7 +248,7 @@ struct CalendarHostView: View {
|
|||||||
.frame(width: 36, height: 36)
|
.frame(width: 36, height: 36)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.leading, 6)
|
.padding(.leading, hideMenuButton ? 6 : 0)
|
||||||
Spacer(minLength: 6)
|
Spacer(minLength: 6)
|
||||||
Text(titleString)
|
Text(titleString)
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
@@ -197,14 +259,15 @@ struct CalendarHostView: View {
|
|||||||
Button(L10n.t("nav.today", appLang)) { store.moveToToday() }
|
Button(L10n.t("nav.today", appLang)) { store.moveToToday() }
|
||||||
.font(.callout).padding(.horizontal, 6)
|
.font(.callout).padding(.horizontal, 6)
|
||||||
.lineLimit(1).fixedSize()
|
.lineLimit(1).fixedSize()
|
||||||
menuButton
|
.padding(.trailing, 4)
|
||||||
.padding(.trailing, 2)
|
|
||||||
}
|
}
|
||||||
.frame(height: 48)
|
.frame(height: 48)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var topBar: some View {
|
private var topBar: some View {
|
||||||
barContents.background(.bar)
|
barContents.background(
|
||||||
|
surfaceHex.isEmpty ? AnyShapeStyle(Material.bar) : AnyShapeStyle(Color(hex: surfaceHex))
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder private var groupBanner: some View {
|
@ViewBuilder private var groupBanner: some View {
|
||||||
@@ -230,51 +293,11 @@ struct CalendarHostView: View {
|
|||||||
Task { await forceReload() }
|
Task { await forceReload() }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The single top-bar action: a compact popup holding view / filter /
|
/// Opens the side drawer (calendars + groups + navigation). Tinted when a
|
||||||
/// groups / sync, plus an "Einstellungen" entry that opens the full menu.
|
/// filter/group is active so the user sees state at a glance.
|
||||||
/// (Replaces the separate view / filter / group icons in the bar.)
|
|
||||||
private var menuButton: some View {
|
private var menuButton: some View {
|
||||||
Menu {
|
Button {
|
||||||
// View (fixed icon, not per-view)
|
withAnimation(.easeInOut(duration: 0.25)) { showDrawer = true }
|
||||||
Menu {
|
|
||||||
ForEach(CalViewType.allCases, id: \.self) { vt in
|
|
||||||
Button { store.viewType = vt } label: {
|
|
||||||
Label(vt.label(appLang), systemImage: store.viewType == vt ? "checkmark" : vt.systemImage)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
Label(L10n.t("view.change", appLang), systemImage: "rectangle.3.group")
|
|
||||||
}
|
|
||||||
// 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: {
|
} label: {
|
||||||
Image(systemName: "line.3.horizontal")
|
Image(systemName: "line.3.horizontal")
|
||||||
.font(.system(size: 18, weight: .medium))
|
.font(.system(size: 18, weight: .medium))
|
||||||
@@ -284,10 +307,14 @@ struct CalendarHostView: View {
|
|||||||
.accessibilityLabel(L10n.t("nav.menu", appLang))
|
.accessibilityLabel(L10n.t("nav.menu", appLang))
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: – Error banner
|
// MARK: – Error banners
|
||||||
|
|
||||||
|
// `lastError` (whole fetch failed) and `syncErrors` (fetch succeeded, but
|
||||||
|
// individual calendars didn't sync) are independent conditions and can
|
||||||
|
// both be shown at once.
|
||||||
@ViewBuilder private var errorBanner: some View {
|
@ViewBuilder private var errorBanner: some View {
|
||||||
if let err = store.lastError { errorBannerView(err) }
|
if let err = store.lastError { errorBannerView(err) }
|
||||||
|
if !store.syncErrors.isEmpty { syncErrorBannerView(store.syncErrors) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private func errorBannerView(_ err: String) -> some View {
|
private func errorBannerView(_ err: String) -> some View {
|
||||||
@@ -303,6 +330,23 @@ struct CalendarHostView: View {
|
|||||||
.background(Color.red.opacity(0.85))
|
.background(Color.red.opacity(0.85))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One or more calendars didn't sync on the last fetch (e.g. expired
|
||||||
|
/// credentials) even though they're still enabled. Same visual language
|
||||||
|
/// as `errorBannerView`, joined into a single compact banner.
|
||||||
|
private func syncErrorBannerView(_ errors: [SyncError]) -> some View {
|
||||||
|
let text = errors.map { "\($0.source) (\($0.name)): \($0.message)" }.joined(separator: "\n")
|
||||||
|
return HStack(alignment: .top, spacing: 8) {
|
||||||
|
Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(.yellow)
|
||||||
|
Text(text).font(.caption).foregroundStyle(.white).lineLimit(errors.count + 1)
|
||||||
|
Spacer()
|
||||||
|
Button { Task { await onNavigate() } } label: {
|
||||||
|
Image(systemName: "arrow.clockwise").foregroundStyle(.white)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12).padding(.vertical, 8)
|
||||||
|
.background(Color.red.opacity(0.85))
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: – Calendar content (with swipe)
|
// MARK: – Calendar content (with swipe)
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
@@ -359,6 +403,21 @@ struct CalendarHostView: View {
|
|||||||
|
|
||||||
// MARK: – FAB buttons
|
// MARK: – FAB buttons
|
||||||
|
|
||||||
|
/// Long-press menu on the create button: a plain new event, or a new birthday
|
||||||
|
/// (which opens a minimal name + date mask that only targets birthday calendars).
|
||||||
|
@ViewBuilder private var fabMenu: some View {
|
||||||
|
Button {
|
||||||
|
editorContext = .create(.now)
|
||||||
|
} label: {
|
||||||
|
Label(L10n.t("event.new_title", appLang), systemImage: "plus")
|
||||||
|
}
|
||||||
|
Button {
|
||||||
|
showNewBirthday = true
|
||||||
|
} label: {
|
||||||
|
Label(L10n.t("birthday.new", appLang), systemImage: "birthday.cake.fill")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Standard solid FAB (flat mode)
|
/// Standard solid FAB (flat mode)
|
||||||
private var solidFAB: some View {
|
private var solidFAB: some View {
|
||||||
Button {
|
Button {
|
||||||
@@ -372,6 +431,7 @@ struct CalendarHostView: View {
|
|||||||
.clipShape(Circle())
|
.clipShape(Circle())
|
||||||
.shadow(radius: 4, y: 2)
|
.shadow(radius: 4, y: 2)
|
||||||
}
|
}
|
||||||
|
.contextMenu { fabMenu }
|
||||||
.padding(.trailing, 20).padding(.bottom, 20)
|
.padding(.trailing, 20).padding(.bottom, 20)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -389,6 +449,7 @@ struct CalendarHostView: View {
|
|||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.glassEffect(in: Circle())
|
.glassEffect(in: Circle())
|
||||||
|
.contextMenu { fabMenu }
|
||||||
.padding(.trailing, 20).padding(.bottom, 20)
|
.padding(.trailing, 20).padding(.bottom, 20)
|
||||||
} else {
|
} else {
|
||||||
solidFAB
|
solidFAB
|
||||||
@@ -400,6 +461,7 @@ struct CalendarHostView: View {
|
|||||||
private var calendarSheets: CalendarSheets {
|
private var calendarSheets: CalendarSheets {
|
||||||
CalendarSheets(store: store, editorContext: $editorContext,
|
CalendarSheets(store: store, editorContext: $editorContext,
|
||||||
selectedEvent: $selectedEvent, showFilter: $showFilter,
|
selectedEvent: $selectedEvent, showFilter: $showFilter,
|
||||||
|
showNewBirthday: $showNewBirthday,
|
||||||
api: api,
|
api: api,
|
||||||
reload: { await onNavigate() },
|
reload: { await onNavigate() },
|
||||||
reloadForce: { await reloadVisible(force: true) })
|
reloadForce: { await reloadVisible(force: true) })
|
||||||
@@ -416,6 +478,10 @@ struct CalendarHostView: View {
|
|||||||
applyServerDrivenSettings(initial: true)
|
applyServerDrivenSettings(initial: true)
|
||||||
|
|
||||||
await store.loadWritableCalendars(api: api)
|
await store.loadWritableCalendars(api: api)
|
||||||
|
// Reconcile per-calendar visibility with the server BEFORE the first
|
||||||
|
// load so a calendar hidden/shown on the web is honoured immediately
|
||||||
|
// (banished set correct before events are filtered).
|
||||||
|
_ = await store.reconcileCalendarVisibility(api: api)
|
||||||
groups = (try? await api.getGroups()) ?? []
|
groups = (try? await api.getGroups()) ?? []
|
||||||
// 1. Load current view immediately (visible)
|
// 1. Load current view immediately (visible)
|
||||||
let (s, e) = store.rangeForCurrentView()
|
let (s, e) = store.rangeForCurrentView()
|
||||||
@@ -424,11 +490,19 @@ struct CalendarHostView: View {
|
|||||||
Task(priority: .background) {
|
Task(priority: .background) {
|
||||||
await store.prefetchBackground(api: api, months: cacheMonths)
|
await store.prefetchBackground(api: api, months: cacheMonths)
|
||||||
}
|
}
|
||||||
// 3. Periodic settings pull (tied to this .task's lifetime).
|
// 2b. Mirror Contacts birthdays into the bound birthday calendar, if the
|
||||||
|
// user enabled it, then refresh so new birthdays appear immediately.
|
||||||
|
if BirthdaysImporter.isEnabled {
|
||||||
|
Task(priority: .background) {
|
||||||
|
await BirthdaysImporter.sync(api: api)
|
||||||
|
await forceReload()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 3. Periodic settings + visibility pull (tied to this .task's lifetime).
|
||||||
while !Task.isCancelled {
|
while !Task.isCancelled {
|
||||||
try? await Task.sleep(for: .seconds(600))
|
try? await Task.sleep(for: .seconds(600))
|
||||||
if Task.isCancelled { break }
|
if Task.isCancelled { break }
|
||||||
await SettingsSync.pull(api: api)
|
await syncFromServer()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -476,6 +550,20 @@ struct CalendarHostView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Pull server-driven settings AND reconcile per-calendar visibility in one
|
||||||
|
/// step (used on launch, resume and the periodic loop). If the server
|
||||||
|
/// changed a calendar's `sidebar_hidden` — e.g. hidden/shown on the web or
|
||||||
|
/// another device — or `force` is set (manual sync), refetch so the change
|
||||||
|
/// shows up without the user opening the filter sheet.
|
||||||
|
private func syncFromServer(force: Bool = false) async {
|
||||||
|
await SettingsSync.pull(api: api)
|
||||||
|
// Mirror Contacts birthdays on every server sync (manual, resume,
|
||||||
|
// periodic) — the user expects "sync with server" to include birthdays.
|
||||||
|
if BirthdaysImporter.isEnabled { await BirthdaysImporter.sync(api: api) }
|
||||||
|
let changed = await store.reconcileCalendarVisibility(api: api)
|
||||||
|
if changed || force { await forceReload() }
|
||||||
|
}
|
||||||
|
|
||||||
/// Called when the user scrolls into a new month – refreshes the visible range
|
/// Called when the user scrolls into a new month – refreshes the visible range
|
||||||
/// immediately from cache, then fetches on demand if needed.
|
/// immediately from cache, then fetches on demand if needed.
|
||||||
private func ensureLoaded(around month: Date) async {
|
private func ensureLoaded(around month: Date) async {
|
||||||
@@ -502,12 +590,16 @@ private struct CalendarSheets: ViewModifier {
|
|||||||
@Binding var editorContext: CalEditorContext?
|
@Binding var editorContext: CalEditorContext?
|
||||||
@Binding var selectedEvent: CalEvent?
|
@Binding var selectedEvent: CalEvent?
|
||||||
@Binding var showFilter: Bool
|
@Binding var showFilter: Bool
|
||||||
|
@Binding var showNewBirthday: Bool
|
||||||
let api: CalendarrAPI
|
let api: CalendarrAPI
|
||||||
let reload: () async -> Void
|
let reload: () async -> Void
|
||||||
let reloadForce: () async -> Void
|
let reloadForce: () async -> Void
|
||||||
|
|
||||||
func body(content: Content) -> some View {
|
func body(content: Content) -> some View {
|
||||||
content
|
content
|
||||||
|
.sheet(isPresented: $showNewBirthday) {
|
||||||
|
BirthdayEditorSheet(api: api) { await reloadForce() }
|
||||||
|
}
|
||||||
// Use sheet(item:) so the editing event is captured atomically –
|
// Use sheet(item:) so the editing event is captured atomically –
|
||||||
// avoiding the race where sheet(isPresented:) evaluates its content
|
// avoiding the race where sheet(isPresented:) evaluates its content
|
||||||
// before the editingEvent state update propagates.
|
// before the editingEvent state update propagates.
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ struct DayView: View {
|
|||||||
@AppStorage("appLanguage") private var appLang = "system"
|
@AppStorage("appLanguage") private var appLang = "system"
|
||||||
@AppStorage("todayColor") private var todayHex = "#4285f4"
|
@AppStorage("todayColor") private var todayHex = "#4285f4"
|
||||||
@AppStorage("textColor") private var textHex = "#FFFFFF"
|
@AppStorage("textColor") private var textHex = "#FFFFFF"
|
||||||
@AppStorage("lineColor") private var lineHex = "#3A3A3C"
|
@AppStorage("lineColor") private var lineHex = "#3A3A52"
|
||||||
@AppStorage("textContrast") private var textContrast = 3
|
@AppStorage("textContrast") private var textContrast = 3
|
||||||
@AppStorage("hourHeight") private var hourHeightPref = 60 // observed for live re-layout
|
@AppStorage("hourHeight") private var hourHeightPref = 60 // observed for live re-layout
|
||||||
|
|
||||||
@@ -81,7 +81,7 @@ struct DayView: View {
|
|||||||
HStack(spacing: 6) {
|
HStack(spacing: 6) {
|
||||||
ForEach(allDayEvents) { ev in
|
ForEach(allDayEvents) { ev in
|
||||||
Button(action: { onEventTap(ev) }) {
|
Button(action: { onEventTap(ev) }) {
|
||||||
Text(ev.title)
|
EventLabel(event: ev)
|
||||||
.font(.caption.weight(.medium))
|
.font(.caption.weight(.medium))
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
.padding(.horizontal, 8).padding(.vertical, 4)
|
.padding(.horizontal, 8).padding(.vertical, 4)
|
||||||
@@ -137,7 +137,7 @@ private struct DayHourSlot: View {
|
|||||||
let language: String
|
let language: String
|
||||||
let onCreateEvent: (Date) -> Void
|
let onCreateEvent: (Date) -> Void
|
||||||
|
|
||||||
@AppStorage("lineColor") private var lineHex = "#3A3A3C"
|
@AppStorage("lineColor") private var lineHex = "#3A3A52"
|
||||||
@AppStorage("lineContrast") private var lineContrast = 3
|
@AppStorage("lineContrast") private var lineContrast = 3
|
||||||
|
|
||||||
private var date: Date {
|
private var date: Date {
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ struct EventDetailSheet: View {
|
|||||||
.fill(Color(hex: event.effectiveColor))
|
.fill(Color(hex: event.effectiveColor))
|
||||||
.frame(width: 6, height: 44)
|
.frame(width: 6, height: 44)
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Text(event.title)
|
Text(event.renderTitle)
|
||||||
.font(.title3.bold())
|
.font(.title3.bold())
|
||||||
Text(event.calendarName)
|
Text(event.calendarName)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
@@ -156,7 +156,7 @@ struct EventDetailSheet: View {
|
|||||||
}
|
}
|
||||||
Button(L10n.t("common.cancel", appLang), role: .cancel) {}
|
Button(L10n.t("common.cancel", appLang), role: .cancel) {}
|
||||||
} message: {
|
} message: {
|
||||||
Text("\"\(event.title)\" \(L10n.t("detail.delete_msg_suffix", appLang))")
|
Text("\"\(event.renderTitle)\" \(L10n.t("detail.delete_msg_suffix", appLang))")
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showCopySheet) {
|
.sheet(isPresented: $showCopySheet) {
|
||||||
EventEditorSheet(
|
EventEditorSheet(
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ struct EventEditorSheet: View {
|
|||||||
@Environment(\.dismiss) var dismiss
|
@Environment(\.dismiss) var dismiss
|
||||||
@AppStorage("appLanguage") private var appLang = "system"
|
@AppStorage("appLanguage") private var appLang = "system"
|
||||||
@AppStorage("defaultReminderMinutes") private var defaultReminderMinutes = -1
|
@AppStorage("defaultReminderMinutes") private var defaultReminderMinutes = -1
|
||||||
|
@AppStorage("defaultEventDurationMinutes") private var defaultEventDurationMinutes = 60
|
||||||
@State private var title = ""
|
@State private var title = ""
|
||||||
@State private var isAllDay = false
|
@State private var isAllDay = false
|
||||||
@State private var startDate = Date()
|
@State private var startDate = Date()
|
||||||
@@ -31,6 +32,15 @@ struct EventEditorSheet: View {
|
|||||||
store.writableCalendars.first { $0.id == selectedCalendarId }
|
store.writableCalendars.first { $0.id == selectedCalendarId }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// True when the selected calendar has its reminders muted: we keep any
|
||||||
|
/// existing reminders on the event (never delete them) but grey out the
|
||||||
|
/// controls and explain that they won't fire.
|
||||||
|
private var remindersDisabled: Bool {
|
||||||
|
guard let cal = selectedCal else { return false }
|
||||||
|
let key = CalendarStore.calendarKey(source: cal.source, calendarId: String(cal.numericId))
|
||||||
|
return store.reminderDisabledKeys.contains(key)
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
Form {
|
Form {
|
||||||
@@ -84,26 +94,26 @@ struct EventEditorSheet: View {
|
|||||||
.tint(Color.accentColor)
|
.tint(Color.accentColor)
|
||||||
}
|
}
|
||||||
|
|
||||||
Section(ReminderOptions.sectionTitle(appLang)) {
|
Section {
|
||||||
ForEach(Array(reminders.enumerated()), id: \.offset) { idx, _ in
|
ForEach(reminders.indices, id: \.self) { idx in
|
||||||
Picker(ReminderOptions.sectionTitle(appLang), selection: Binding(
|
ReminderEditRow(minutes: $reminders[idx], appLang: appLang)
|
||||||
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) }
|
.onDelete { reminders.remove(atOffsets: $0) }
|
||||||
Button {
|
Button {
|
||||||
let next = ReminderOptions.all.first { !reminders.contains($0) } ?? 15
|
let next = ReminderOptions.presets.first { !reminders.contains($0) }
|
||||||
|
?? ReminderOptions.customDefault
|
||||||
reminders.append(next)
|
reminders.append(next)
|
||||||
} label: {
|
} label: {
|
||||||
Label(ReminderOptions.addLabel(appLang), systemImage: "bell.badge.plus")
|
Label(ReminderOptions.addLabel(appLang), systemImage: "bell.badge.plus")
|
||||||
}
|
}
|
||||||
|
} header: {
|
||||||
|
Text(ReminderOptions.sectionTitle(appLang))
|
||||||
|
} footer: {
|
||||||
|
if remindersDisabled {
|
||||||
|
Text(ReminderOptions.disabledNote(appLang)).foregroundStyle(.orange)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
.disabled(remindersDisabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
Section(L10n.t("event.color_section", appLang)) {
|
Section(L10n.t("event.color_section", appLang)) {
|
||||||
@@ -197,7 +207,8 @@ struct EventEditorSheet: View {
|
|||||||
let cal = Calendar.current
|
let cal = Calendar.current
|
||||||
startDate = cal.date(bySettingHour: cal.component(.hour, from: initialDate),
|
startDate = cal.date(bySettingHour: cal.component(.hour, from: initialDate),
|
||||||
minute: 0, second: 0, of: initialDate) ?? initialDate
|
minute: 0, second: 0, of: initialDate) ?? initialDate
|
||||||
endDate = startDate.addingTimeInterval(3600)
|
let durMin = defaultEventDurationMinutes > 0 ? defaultEventDurationMinutes : 60
|
||||||
|
endDate = startDate.addingTimeInterval(Double(durMin) * 60)
|
||||||
selectedCalendarId = store.writableCalendars.first?.id ?? ""
|
selectedCalendarId = store.writableCalendars.first?.id ?? ""
|
||||||
// New events inherit the user's default reminder (editable).
|
// New events inherit the user's default reminder (editable).
|
||||||
if defaultReminderMinutes >= 0 { reminders = [defaultReminderMinutes] }
|
if defaultReminderMinutes >= 0 { reminders = [defaultReminderMinutes] }
|
||||||
@@ -263,3 +274,54 @@ struct EventEditorSheet: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One reminder row: a preset picker plus, when "Custom" is chosen, a number
|
||||||
|
/// stepper and a unit picker. The value is always stored as minutes-before-start.
|
||||||
|
private struct ReminderEditRow: View {
|
||||||
|
@Binding var minutes: Int
|
||||||
|
let appLang: String
|
||||||
|
private let customTag = -1
|
||||||
|
|
||||||
|
private var isPreset: Bool { ReminderOptions.presets.contains(minutes) }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Picker("", selection: Binding(
|
||||||
|
get: { isPreset ? minutes : customTag },
|
||||||
|
set: { newVal in
|
||||||
|
if newVal == customTag {
|
||||||
|
if ReminderOptions.presets.contains(minutes) { minutes = ReminderOptions.customDefault }
|
||||||
|
} else {
|
||||||
|
minutes = newVal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)) {
|
||||||
|
ForEach(ReminderOptions.presets, id: \.self) { Text(ReminderOptions.label($0, appLang)).tag($0) }
|
||||||
|
Text(ReminderOptions.customLabel(appLang)).tag(customTag)
|
||||||
|
}
|
||||||
|
.labelsHidden()
|
||||||
|
|
||||||
|
if !isPreset {
|
||||||
|
HStack {
|
||||||
|
Stepper(value: Binding(
|
||||||
|
get: { ReminderOptions.split(minutes).value },
|
||||||
|
set: { minutes = max(1, $0) * ReminderOptions.split(minutes).unit.mult }
|
||||||
|
), in: 1...999) {
|
||||||
|
Text("\(ReminderOptions.split(minutes).value)")
|
||||||
|
.monospacedDigit()
|
||||||
|
}
|
||||||
|
Picker("", selection: Binding(
|
||||||
|
get: { ReminderOptions.split(minutes).unit },
|
||||||
|
set: { newUnit in minutes = ReminderOptions.split(minutes).value * newUnit.mult }
|
||||||
|
)) {
|
||||||
|
ForEach(ReminderOptions.Unit.allCases) { u in
|
||||||
|
Text(ReminderOptions.unitLabel(u, appLang)).tag(u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.labelsHidden()
|
||||||
|
Text(ReminderOptions.beforeLabel(appLang)).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import SwiftUI
|
|||||||
// past 2100 — enough room for any vacation that's actually getting planned.
|
// past 2100 — enough room for any vacation that's actually getting planned.
|
||||||
private let weeksBack = 520
|
private let weeksBack = 520
|
||||||
private let weeksAhead = 4000
|
private let weeksAhead = 4000
|
||||||
|
// Paged mode uses one page per month over a bounded range (keeps the TabView light).
|
||||||
|
private let monthsBack = 120
|
||||||
|
private let monthsAhead = 600
|
||||||
private let weekdayHeaderHeight: CGFloat = 28
|
private let weekdayHeaderHeight: CGFloat = 28
|
||||||
private let dayNumberRowHeight: CGFloat = 22
|
private let dayNumberRowHeight: CGFloat = 22
|
||||||
private let laneHeight: CGFloat = 16
|
private let laneHeight: CGFloat = 16
|
||||||
@@ -24,14 +27,30 @@ struct MonthView: View {
|
|||||||
@AppStorage("monthDividerColor") private var dividerHex = "#7090c0"
|
@AppStorage("monthDividerColor") private var dividerHex = "#7090c0"
|
||||||
@AppStorage("monthLabelColor") private var labelHex = "#7090c0"
|
@AppStorage("monthLabelColor") private var labelHex = "#7090c0"
|
||||||
@AppStorage("textColor") private var textHex = "#FFFFFF"
|
@AppStorage("textColor") private var textHex = "#FFFFFF"
|
||||||
@AppStorage("lineColor") private var lineHex = "#3A3A3C"
|
@AppStorage("lineColor") private var lineHex = "#3A3A52"
|
||||||
@AppStorage("textContrast") private var textContrast = 3
|
@AppStorage("textContrast") private var textContrast = 3
|
||||||
|
@AppStorage("monthViewPaged") private var monthPaged = false
|
||||||
|
|
||||||
@State private var scrolledWeek: Date? = nil
|
@State private var scrolledWeek: Date? = nil
|
||||||
@State private var didInitialScroll = false
|
@State private var didInitialScroll = false
|
||||||
|
@State private var pagedMonth: Date = Calendar.current.date(
|
||||||
|
from: Calendar.current.dateComponents([.year, .month], from: .now)) ?? .now
|
||||||
|
|
||||||
private var cal: Calendar { store.userCalendar }
|
private var cal: Calendar { store.userCalendar }
|
||||||
|
|
||||||
|
/// First day of the month containing `date`.
|
||||||
|
private func monthStart(for date: Date) -> Date {
|
||||||
|
cal.date(from: cal.dateComponents([.year, .month], from: date)) ?? date
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One entry per month across the bounded paged range.
|
||||||
|
private var monthStarts: [Date] {
|
||||||
|
let base = monthStart(for: .now)
|
||||||
|
return (-monthsBack...monthsAhead).compactMap {
|
||||||
|
cal.date(byAdding: .month, value: $0, to: base)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private var weekStarts: [Date] {
|
private var weekStarts: [Date] {
|
||||||
let today = cal.startOfDay(for: .now)
|
let today = cal.startOfDay(for: .now)
|
||||||
let thisWeek = cal.date(from: cal.dateComponents([.yearForWeekOfYear, .weekOfYear], from: today))!
|
let thisWeek = cal.date(from: cal.dateComponents([.yearForWeekOfYear, .weekOfYear], from: today))!
|
||||||
@@ -51,46 +70,92 @@ struct MonthView: View {
|
|||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
headerRow
|
headerRow
|
||||||
Divider()
|
Divider()
|
||||||
ScrollView {
|
if monthPaged { pagedBody } else { scrollBody }
|
||||||
LazyVStack(spacing: 0) { ForEach(weekStarts, id: \.self) { ws in
|
}
|
||||||
WeekRow(weekStart: ws,
|
}
|
||||||
store: store,
|
|
||||||
dividerColor: Color(hex: dividerHex),
|
// Continuous vertical scroll feed (default).
|
||||||
labelColor: Color(hex: labelHex),
|
private var scrollBody: some View {
|
||||||
textColor: Color(hex: textHex),
|
ScrollView {
|
||||||
lineColor: Color(hex: lineHex),
|
LazyVStack(spacing: 0) {
|
||||||
language: appLang,
|
ForEach(weekStarts, id: \.self) { ws in
|
||||||
onDayTap: onDayTap,
|
weekRow(for: ws, fillHeight: false)
|
||||||
onEventTap: onEventTap,
|
.id(ws)
|
||||||
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
|
.scrollTargetLayout()
|
||||||
let target = weekStart(for: newDate)
|
}
|
||||||
if scrolledWeek != target {
|
.scrollIndicators(.hidden)
|
||||||
withAnimation(.easeInOut(duration: 0.25)) {
|
.scrollPosition(id: $scrolledWeek, anchor: .top)
|
||||||
scrolledWeek = target
|
.onAppear {
|
||||||
}
|
if !didInitialScroll {
|
||||||
}
|
didInitialScroll = true
|
||||||
}
|
scrolledWeek = weekStart(for: store.currentDate)
|
||||||
.onChange(of: scrolledWeek) { _, newWeek in
|
publishVisibleMonth(from: scrolledWeek)
|
||||||
publishVisibleMonth(from: newWeek)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// One month per page, swipe left/right to change month.
|
||||||
|
private var pagedBody: some View {
|
||||||
|
TabView(selection: $pagedMonth) {
|
||||||
|
ForEach(monthStarts, id: \.self) { m in
|
||||||
|
monthGrid(for: m).tag(m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.tabViewStyle(.page(indexDisplayMode: .never))
|
||||||
|
.onAppear {
|
||||||
|
pagedMonth = monthStart(for: store.currentDate)
|
||||||
|
if store.visibleMonth != pagedMonth { store.visibleMonth = pagedMonth }
|
||||||
|
}
|
||||||
|
.onChange(of: pagedMonth) { _, m in
|
||||||
|
if store.visibleMonth != m { store.visibleMonth = m }
|
||||||
|
// Drive the shared date so events for the new month load; guard the
|
||||||
|
// month so we don't fight the reverse onChange below.
|
||||||
|
if monthStart(for: store.currentDate) != m { store.currentDate = m }
|
||||||
|
}
|
||||||
|
.onChange(of: store.currentDate) { _, d in
|
||||||
|
let m = monthStart(for: d)
|
||||||
|
if pagedMonth != m { withAnimation(.easeInOut(duration: 0.25)) { pagedMonth = m } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A single month page: six height-filling week rows.
|
||||||
|
private func monthGrid(for month: Date) -> some View {
|
||||||
|
let firstWeek = cal.date(from: cal.dateComponents([.yearForWeekOfYear, .weekOfYear], from: month)) ?? month
|
||||||
|
let weeks = (0..<6).compactMap { cal.date(byAdding: .weekOfYear, value: $0, to: firstWeek) }
|
||||||
|
return VStack(spacing: 0) {
|
||||||
|
ForEach(weeks, id: \.self) { ws in
|
||||||
|
weekRow(for: ws, fillHeight: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func weekRow(for ws: Date, fillHeight: Bool) -> some View {
|
||||||
|
WeekRow(weekStart: ws,
|
||||||
|
fillHeight: fillHeight,
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var headerRow: some View {
|
private var headerRow: some View {
|
||||||
@@ -126,6 +191,8 @@ struct MonthView: View {
|
|||||||
|
|
||||||
private struct WeekRow: View {
|
private struct WeekRow: View {
|
||||||
let weekStart: Date
|
let weekStart: Date
|
||||||
|
// Scroll mode uses a fixed row height; paged mode fills the page evenly.
|
||||||
|
var fillHeight: Bool = false
|
||||||
let store: CalendarStore
|
let store: CalendarStore
|
||||||
let dividerColor: Color
|
let dividerColor: Color
|
||||||
let labelColor: Color
|
let labelColor: Color
|
||||||
@@ -206,8 +273,10 @@ private struct WeekRow: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
let weekEndExclusive = cal.date(byAdding: .day, value: 7, to: weekStart)!
|
||||||
let (placed, extras) = packEvents()
|
let (placed, extras) = packEvents()
|
||||||
let rowHeight = dayNumberRowHeight + CGFloat(maxLanesPerWeek) * (laneHeight + laneSpacing) + 4
|
let allWeekEvents = store.events(in: weekStart, end: weekEndExclusive)
|
||||||
|
let rowHeight = dayNumberRowHeight + CGFloat(maxLanesPerWeek) * (laneHeight + laneSpacing) + 16
|
||||||
let mondayIdx = days.firstIndex(where: { cal.component(.weekday, from: $0) == 2 }) ?? 0
|
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)
|
// Where in this row does a new month start? (col 1...6 = mid-row step; nil = no step)
|
||||||
@@ -229,6 +298,11 @@ private struct WeekRow: View {
|
|||||||
}
|
}
|
||||||
return rowStartsNewMonth ? .topHighlight : .none
|
return rowStartsNewMonth ? .topHighlight : .none
|
||||||
}()
|
}()
|
||||||
|
let dayStart = cal.startOfDay(for: day)
|
||||||
|
let dayEnd = cal.date(byAdding: .day, value: 1, to: dayStart)!
|
||||||
|
let eventsForDay = allWeekEvents.filter {
|
||||||
|
$0.startDate < dayEnd && $0.endDate > dayStart
|
||||||
|
}
|
||||||
DayCell(date: day,
|
DayCell(date: day,
|
||||||
isToday: cal.isDateInToday(day),
|
isToday: cal.isDateInToday(day),
|
||||||
monthLabelColor: labelColor,
|
monthLabelColor: labelColor,
|
||||||
@@ -240,11 +314,12 @@ private struct WeekRow: View {
|
|||||||
weekNumber: idx == mondayIdx ? weekNumber : nil,
|
weekNumber: idx == mondayIdx ? weekNumber : nil,
|
||||||
cwLabel: L10n.t("cal.cw", language),
|
cwLabel: L10n.t("cal.cw", language),
|
||||||
edge: edge,
|
edge: edge,
|
||||||
|
dayEvents: eventsForDay,
|
||||||
onTap: { onDayTap(day) },
|
onTap: { onDayTap(day) },
|
||||||
onCreateEvent: { onCreateEvent(day) },
|
onCreateEvent: { onCreateEvent(day) },
|
||||||
onShowWeek: { onShowWeek(day) },
|
onShowWeek: { onShowWeek(day) },
|
||||||
onShowDay: { onShowDay(day) })
|
onShowDay: { onShowDay(day) })
|
||||||
.frame(width: cellW, height: rowHeight)
|
.frame(width: cellW, height: geo.size.height)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -263,12 +338,13 @@ private struct WeekRow: View {
|
|||||||
if let b = midRowBoundaryCol {
|
if let b = midRowBoundaryCol {
|
||||||
Rectangle()
|
Rectangle()
|
||||||
.fill(dividerColor)
|
.fill(dividerColor)
|
||||||
.frame(width: 1.5, height: rowHeight)
|
.frame(width: 1.5, height: geo.size.height)
|
||||||
.offset(x: CGFloat(b) * cellW - 0.75, y: 0)
|
.offset(x: CGFloat(b) * cellW - 0.75, y: 0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(height: rowHeight)
|
.frame(height: fillHeight ? nil : rowHeight)
|
||||||
|
.frame(maxHeight: fillHeight ? .infinity : nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -286,6 +362,7 @@ private struct DayCell: View {
|
|||||||
let weekNumber: Int?
|
let weekNumber: Int?
|
||||||
let cwLabel: String
|
let cwLabel: String
|
||||||
let edge: DividerEdge
|
let edge: DividerEdge
|
||||||
|
let dayEvents: [CalEvent]
|
||||||
let onTap: () -> Void
|
let onTap: () -> Void
|
||||||
let onCreateEvent: () -> Void
|
let onCreateEvent: () -> Void
|
||||||
let onShowWeek: () -> Void
|
let onShowWeek: () -> Void
|
||||||
@@ -372,10 +449,151 @@ private struct DayCell: View {
|
|||||||
Button { onShowDay() } label: {
|
Button { onShowDay() } label: {
|
||||||
Label(L10n.t("cal.show_in_day_view", language), systemImage: "sun.max")
|
Label(L10n.t("cal.show_in_day_view", language), systemImage: "sun.max")
|
||||||
}
|
}
|
||||||
|
} preview: {
|
||||||
|
DayContextPreviewView(date: date, events: dayEvents, language: language)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: – Day Context Menu Preview
|
||||||
|
|
||||||
|
private struct DayContextPreviewView: View {
|
||||||
|
let date: Date
|
||||||
|
let events: [CalEvent]
|
||||||
|
let language: String
|
||||||
|
|
||||||
|
private var cal: Calendar { .current }
|
||||||
|
|
||||||
|
private var sortedEvents: [CalEvent] {
|
||||||
|
events.sorted { a, b in
|
||||||
|
if a.isAllDay != b.isAllDay { return a.isAllDay }
|
||||||
|
return a.startDate < b.startDate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var weekdayAbbr: String {
|
||||||
|
let fmt = DateFormatter()
|
||||||
|
fmt.locale = L10n.locale(language)
|
||||||
|
fmt.dateFormat = "EEE"
|
||||||
|
return fmt.string(from: date).uppercased()
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
||||||
|
Text(weekdayAbbr)
|
||||||
|
.font(.caption.weight(.semibold))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text("\(cal.component(.day, from: date))")
|
||||||
|
.font(.title2.weight(.bold))
|
||||||
|
}
|
||||||
|
if !sortedEvents.isEmpty {
|
||||||
|
Divider()
|
||||||
|
ForEach(sortedEvents) { ev in
|
||||||
|
if ev.isAllDay {
|
||||||
|
DayPreviewAllDayBar(event: ev, date: date)
|
||||||
|
} else {
|
||||||
|
DayPreviewTimedRow(event: ev)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text("–")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(12)
|
||||||
|
.frame(minWidth: 220, maxWidth: 300)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct DayPreviewAllDayBar: View {
|
||||||
|
let event: CalEvent
|
||||||
|
let date: Date
|
||||||
|
|
||||||
|
private var cal: Calendar { .current }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
let color = Color(hex: event.effectiveColor)
|
||||||
|
let dayStart = cal.startOfDay(for: date)
|
||||||
|
let dayEnd = cal.date(byAdding: .day, value: 1, to: dayStart)!
|
||||||
|
let cLeft = event.startDate < dayStart
|
||||||
|
let cRight = event.endDate > dayEnd // endDate is exclusive for allDay
|
||||||
|
EventLabel(event: event)
|
||||||
|
.font(.system(size: 11, weight: .medium))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.lineLimit(1)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(.vertical, 3)
|
||||||
|
.padding(.leading, cLeft ? 14 : 8)
|
||||||
|
.padding(.trailing, cRight ? 14 : 8)
|
||||||
|
.background(color)
|
||||||
|
.clipShape(ChevronBarShape(continuesLeft: cLeft, continuesRight: cRight))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ChevronBarShape: Shape {
|
||||||
|
var continuesLeft: Bool
|
||||||
|
var continuesRight: Bool
|
||||||
|
private let tip: CGFloat = 8
|
||||||
|
|
||||||
|
func path(in rect: CGRect) -> Path {
|
||||||
|
guard continuesLeft || continuesRight else {
|
||||||
|
return RoundedRectangle(cornerRadius: 4).path(in: rect)
|
||||||
|
}
|
||||||
|
var p = Path()
|
||||||
|
let t = min(tip, rect.width / 2)
|
||||||
|
if continuesLeft && continuesRight {
|
||||||
|
p.move(to: CGPoint(x: t, y: 0))
|
||||||
|
p.addLine(to: CGPoint(x: rect.maxX - t, y: 0))
|
||||||
|
p.addLine(to: CGPoint(x: rect.maxX, y: rect.midY))
|
||||||
|
p.addLine(to: CGPoint(x: rect.maxX - t, y: rect.maxY))
|
||||||
|
p.addLine(to: CGPoint(x: t, y: rect.maxY))
|
||||||
|
p.addLine(to: CGPoint(x: 0, y: rect.midY))
|
||||||
|
} else if continuesLeft {
|
||||||
|
p.move(to: CGPoint(x: t, y: 0))
|
||||||
|
p.addLine(to: CGPoint(x: rect.maxX, y: 0))
|
||||||
|
p.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY))
|
||||||
|
p.addLine(to: CGPoint(x: t, y: rect.maxY))
|
||||||
|
p.addLine(to: CGPoint(x: 0, y: rect.midY))
|
||||||
|
} else {
|
||||||
|
p.move(to: CGPoint(x: 0, y: 0))
|
||||||
|
p.addLine(to: CGPoint(x: rect.maxX - t, y: 0))
|
||||||
|
p.addLine(to: CGPoint(x: rect.maxX, y: rect.midY))
|
||||||
|
p.addLine(to: CGPoint(x: rect.maxX - t, y: rect.maxY))
|
||||||
|
p.addLine(to: CGPoint(x: 0, y: rect.maxY))
|
||||||
|
}
|
||||||
|
p.closeSubpath()
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct DayPreviewTimedRow: View {
|
||||||
|
let event: CalEvent
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Circle()
|
||||||
|
.fill(Color(hex: event.effectiveColor))
|
||||||
|
.frame(width: 7, height: 7)
|
||||||
|
Text(timeString)
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.frame(width: 44, alignment: .leading)
|
||||||
|
EventLabel(event: event)
|
||||||
|
.font(.system(size: 11, weight: .medium))
|
||||||
|
.lineLimit(1)
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var timeString: String {
|
||||||
|
let fmt = DateFormatter()
|
||||||
|
fmt.dateFormat = "HH:mm"
|
||||||
|
return fmt.string(from: event.startDate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: – Event Bar
|
// MARK: – Event Bar
|
||||||
|
|
||||||
private struct EventBar: View {
|
private struct EventBar: View {
|
||||||
@@ -386,7 +604,7 @@ private struct EventBar: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 3) {
|
HStack(spacing: 3) {
|
||||||
Text(event.title)
|
EventLabel(event: event)
|
||||||
.font(.system(size: 10, weight: .medium))
|
.font(.system(size: 10, weight: .medium))
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ struct EventBlock: View {
|
|||||||
.fill(Color(hex: event.effectiveColor).opacity(0.85))
|
.fill(Color(hex: event.effectiveColor).opacity(0.85))
|
||||||
.overlay(alignment: .topLeading) {
|
.overlay(alignment: .topLeading) {
|
||||||
VStack(alignment: .leading, spacing: 1) {
|
VStack(alignment: .leading, spacing: 1) {
|
||||||
Text(event.title)
|
EventLabel(event: event)
|
||||||
.font(.system(size: 12, weight: .semibold))
|
.font(.system(size: 12, weight: .semibold))
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
.lineLimit(2)
|
.lineLimit(2)
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ struct WeekView: View {
|
|||||||
@AppStorage("appLanguage") private var appLang = "system"
|
@AppStorage("appLanguage") private var appLang = "system"
|
||||||
@AppStorage("todayColor") private var todayHex = "#4285f4"
|
@AppStorage("todayColor") private var todayHex = "#4285f4"
|
||||||
@AppStorage("textColor") private var textHex = "#FFFFFF"
|
@AppStorage("textColor") private var textHex = "#FFFFFF"
|
||||||
@AppStorage("lineColor") private var lineHex = "#3A3A3C"
|
@AppStorage("lineColor") private var lineHex = "#3A3A52"
|
||||||
@AppStorage("textContrast") private var textContrast = 3
|
@AppStorage("textContrast") private var textContrast = 3
|
||||||
@AppStorage("lineContrast") private var lineContrast = 3
|
@AppStorage("lineContrast") private var lineContrast = 3
|
||||||
@AppStorage("hourHeight") private var hourHeightPref = 60 // observed for live re-layout
|
@AppStorage("hourHeight") private var hourHeightPref = 60 // observed for live re-layout
|
||||||
@@ -72,37 +72,66 @@ struct WeekView: View {
|
|||||||
|
|
||||||
// MARK: – All-day strip
|
// MARK: – All-day strip
|
||||||
|
|
||||||
|
private let allDayLaneHeight: CGFloat = 16
|
||||||
|
|
||||||
|
/// All-day / multi-day events laid out as continuous bars: each event spans
|
||||||
|
/// its start→end columns (clamped to this week) and is packed into lanes so
|
||||||
|
/// overlapping bars stack — mirrors the month view instead of repeating the
|
||||||
|
/// event once per day.
|
||||||
|
private func allDayBars() -> [(ev: CalEvent, start: Int, end: Int, lane: Int)] {
|
||||||
|
var spans: [(ev: CalEvent, start: Int, end: Int)] = []
|
||||||
|
for ev in allDayEvents {
|
||||||
|
var start: Int? = nil
|
||||||
|
var end = 0
|
||||||
|
for (i, day) in weekDays.enumerated() {
|
||||||
|
let ds = cal.startOfDay(for: day)
|
||||||
|
let de = cal.date(byAdding: .day, value: 1, to: ds)!
|
||||||
|
if ev.startDate < de && ev.endDate > ds {
|
||||||
|
if start == nil { start = i }
|
||||||
|
end = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let s = start { spans.append((ev, s, end)) }
|
||||||
|
}
|
||||||
|
// Longer / earlier bars first, then greedily pack into lanes.
|
||||||
|
spans.sort { a, b in a.start != b.start ? a.start < b.start : (a.end - a.start) > (b.end - b.start) }
|
||||||
|
var laneEnd: [Int] = [] // last occupied column index per lane
|
||||||
|
var out: [(ev: CalEvent, start: Int, end: Int, lane: Int)] = []
|
||||||
|
for s in spans {
|
||||||
|
var lane = 0
|
||||||
|
while lane < laneEnd.count && laneEnd[lane] >= s.start { lane += 1 }
|
||||||
|
if lane == laneEnd.count { laneEnd.append(s.end) } else { laneEnd[lane] = s.end }
|
||||||
|
out.append((s.ev, s.start, s.end, lane))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
private var allDayRow: some View {
|
private var allDayRow: some View {
|
||||||
HStack(spacing: 0) {
|
let bars = allDayBars()
|
||||||
Spacer().frame(width: timeColumnWidth)
|
let laneCount = max(1, (bars.map { $0.lane }.max() ?? -1) + 1)
|
||||||
ForEach(weekDays, id: \.self) { day in
|
return GeometryReader { geo in
|
||||||
let dayEvs = allDayEvents.filter { ev in
|
let colW = (geo.size.width - timeColumnWidth) / 7
|
||||||
let ds = cal.startOfDay(for: day)
|
ZStack(alignment: .topLeading) {
|
||||||
let de = cal.date(byAdding: .day, value: 1, to: ds)!
|
ForEach(bars, id: \.ev.id) { bar in
|
||||||
return ev.startDate < de && ev.endDate > ds
|
Button { onEventTap(bar.ev) } label: {
|
||||||
}
|
EventLabel(event: bar.ev)
|
||||||
VStack(spacing: 1) {
|
.font(.system(size: 9, weight: .medium))
|
||||||
ForEach(dayEvs.prefix(2)) { ev in
|
.foregroundStyle(.white)
|
||||||
Button { onEventTap(ev) } label: {
|
.lineLimit(1)
|
||||||
Text(ev.title)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.font(.system(size: 9, weight: .medium))
|
.padding(.horizontal, 3)
|
||||||
.foregroundStyle(.white)
|
.frame(height: allDayLaneHeight - 2)
|
||||||
.lineLimit(1)
|
.background(Color(hex: bar.ev.effectiveColor))
|
||||||
.frame(maxWidth: .infinity)
|
.clipShape(RoundedRectangle(cornerRadius: 2))
|
||||||
.padding(.vertical, 2)
|
|
||||||
.background(Color(hex: ev.effectiveColor))
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 2))
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
}
|
}
|
||||||
}
|
.buttonStyle(.plain)
|
||||||
.padding(.horizontal, 1)
|
.frame(width: colW * CGFloat(bar.end - bar.start + 1) - 3)
|
||||||
.frame(maxWidth: .infinity)
|
.offset(x: timeColumnWidth + colW * CGFloat(bar.start) + 1.5,
|
||||||
.overlay(alignment: .trailing) {
|
y: CGFloat(bar.lane) * allDayLaneHeight)
|
||||||
Rectangle().fill(Color(hex: lineHex).opacity(gridLineOpacity(lineContrast))).frame(width: 0.5)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.frame(height: CGFloat(laneCount) * allDayLaneHeight)
|
||||||
.padding(.vertical, 4)
|
.padding(.vertical, 4)
|
||||||
.overlay(alignment: .bottom) { Divider() }
|
.overlay(alignment: .bottom) { Divider() }
|
||||||
}
|
}
|
||||||
@@ -213,7 +242,7 @@ struct HourSlot: View {
|
|||||||
let onShowMonth: (Date) -> Void
|
let onShowMonth: (Date) -> Void
|
||||||
let onShowDay: (Date) -> Void
|
let onShowDay: (Date) -> Void
|
||||||
|
|
||||||
@AppStorage("lineColor") private var lineHex = "#3A3A3C"
|
@AppStorage("lineColor") private var lineHex = "#3A3A52"
|
||||||
@AppStorage("lineContrast") private var lineContrast = 3
|
@AppStorage("lineContrast") private var lineContrast = 3
|
||||||
|
|
||||||
private var date: Date {
|
private var date: Date {
|
||||||
|
|||||||
259
Calendarr iOS/Views/CalendarFilterContent.swift
Normal file
259
Calendarr iOS/Views/CalendarFilterContent.swift
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// A single calendar row in the flat, reorderable list.
|
||||||
|
private struct CalRow: Identifiable {
|
||||||
|
let id: String // "source:id" key
|
||||||
|
let name: String
|
||||||
|
let colorHex: String
|
||||||
|
let readOnly: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The calendar-visibility list, extracted so both the modal `CalendarFilterSheet`
|
||||||
|
/// and the side drawer render the same rows/logic. A single flat, drag-reorderable
|
||||||
|
/// list (no per-source grouping); visibility is client-side (`CalendarStore`),
|
||||||
|
/// order is device-local (`CalendarStore.calendarOrder`, mirrors the web).
|
||||||
|
struct CalendarFilterContent: View {
|
||||||
|
let api: CalendarrAPI
|
||||||
|
let store: CalendarStore
|
||||||
|
@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> = []
|
||||||
|
@State private var reminderDisabled: Set<String> = []
|
||||||
|
@State private var allKeys: Set<String> = []
|
||||||
|
@State private var rows: [CalRow] = []
|
||||||
|
@State private var isSorting = false
|
||||||
|
@State private var groupDetail: CalGroup? = nil
|
||||||
|
@State private var hiddenGroup: Set<String> = []
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
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 {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
controlBar
|
||||||
|
List {
|
||||||
|
ForEach(rows) { cal in row(cal) }
|
||||||
|
.onMove(perform: move)
|
||||||
|
if !banished.isEmpty {
|
||||||
|
Section {
|
||||||
|
Text(L10n.t("filter.banished_footer", appLang))
|
||||||
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listStyle(.plain)
|
||||||
|
.environment(\.editMode, .constant(isSorting ? .active : .inactive))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task { await load() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Section header + sort toggle, above the list so it stays tappable while
|
||||||
|
// the list is in edit mode. (No show/hide-all buttons — rarely needed with a
|
||||||
|
// handful of calendars, and the web has none either.)
|
||||||
|
private var controlBar: some View {
|
||||||
|
HStack {
|
||||||
|
Text(L10n.t("filter.title", appLang))
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Spacer()
|
||||||
|
Button(isSorting ? L10n.t("filter.done", appLang) : L10n.t("filter.sort", appLang)) {
|
||||||
|
withAnimation { isSorting.toggle() }
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
.font(.subheadline)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16).padding(.top, 6).padding(.bottom, 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func move(from source: IndexSet, to destination: Int) {
|
||||||
|
rows.move(fromOffsets: source, toOffset: destination)
|
||||||
|
store.setCalendarOrder(rows.map(\.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func row(_ cal: CalRow) -> some View {
|
||||||
|
let isVisible = !hidden.contains(cal.id)
|
||||||
|
Button {
|
||||||
|
if isVisible { hidden.insert(cal.id) } else { hidden.remove(cal.id) }
|
||||||
|
store.setCalendarHidden(cal.id, hidden: isVisible)
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Circle()
|
||||||
|
.fill(Color(hex: cal.colorHex))
|
||||||
|
.frame(width: 14, height: 14)
|
||||||
|
.opacity(isVisible ? 1.0 : 0.35)
|
||||||
|
Text(cal.name)
|
||||||
|
.foregroundStyle(isVisible ? .primary : .secondary)
|
||||||
|
.strikethrough(!isVisible, color: .secondary)
|
||||||
|
if cal.readOnly {
|
||||||
|
Image(systemName: "lock.fill").font(.caption2).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
if reminderDisabled.contains(cal.id) {
|
||||||
|
Image(systemName: "bell.slash").font(.caption).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Image(systemName: isVisible ? "eye" : "eye.slash")
|
||||||
|
.foregroundStyle(isVisible ? Color.accentColor : .secondary)
|
||||||
|
}
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.contextMenu {
|
||||||
|
Button {
|
||||||
|
toggleReminders(forKey: cal.id)
|
||||||
|
} label: {
|
||||||
|
let disabled = reminderDisabled.contains(cal.id)
|
||||||
|
Label(L10n.t(disabled ? "filter.reminders_on" : "filter.reminders_off", appLang),
|
||||||
|
systemImage: disabled ? "bell" : "bell.slash")
|
||||||
|
}
|
||||||
|
Button(role: .destructive) {
|
||||||
|
hidden.remove(cal.id)
|
||||||
|
banished.insert(cal.id)
|
||||||
|
store.setCalendarBanished(cal.id, banished: true)
|
||||||
|
pushBanishToServer(key: cal.id, hidden: true)
|
||||||
|
rows.removeAll { $0.id == cal.id }
|
||||||
|
} label: {
|
||||||
|
Label(L10n.t("filter.banish", appLang), systemImage: "archivebox")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func toggleReminders(forKey key: String) {
|
||||||
|
let nowDisabled = !reminderDisabled.contains(key)
|
||||||
|
if nowDisabled { reminderDisabled.insert(key) } else { reminderDisabled.remove(key) }
|
||||||
|
store.setReminderDisabled(key, disabled: nowDisabled)
|
||||||
|
if let parsed = CalendarStore.parseCalendarKey(key) {
|
||||||
|
Task { try? await api.setCalendarRemindersEnabled(
|
||||||
|
source: parsed.source, calendarId: parsed.id, enabled: !nowDisabled) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: – Group overlay (unchanged: hide individual members / 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 ?? []).filter { $0.sharesCalendar }) { 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
|
||||||
|
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)
|
||||||
|
|
||||||
|
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 rd = Set<String>()
|
||||||
|
func applyReminders(_ source: String, _ id: Int, _ enabled: Bool) {
|
||||||
|
if !enabled { rd.insert(CalendarStore.calendarKey(source: source, calendarId: "\(id)")) }
|
||||||
|
}
|
||||||
|
for cal in localCalendars { applyReminders("local", cal.id, cal.remindersEnabled) }
|
||||||
|
for acc in caldavAccounts { for cal in acc.calendars ?? [] { applyReminders("caldav", cal.id, cal.remindersEnabled ?? true) } }
|
||||||
|
for sub in icalSubs { applyReminders("ical", sub.id, sub.remindersEnabled ?? true) }
|
||||||
|
for acc in googleAccounts { for cal in acc.calendars ?? [] { applyReminders("google", cal.id, cal.remindersEnabled ?? true) } }
|
||||||
|
for acc in haAccounts { for cal in acc.calendars ?? [] { applyReminders("homeassistant", cal.id, cal.remindersEnabled) } }
|
||||||
|
store.setReminderDisabledKeys(rd)
|
||||||
|
reminderDisabled = rd
|
||||||
|
|
||||||
|
// Build the flat row list (banished excluded), then sort by stored order.
|
||||||
|
var combined: [CalRow] = []
|
||||||
|
func add(_ source: String, _ id: Int, _ name: String, _ colorHex: String, readOnly: Bool = false) {
|
||||||
|
let key = CalendarStore.calendarKey(source: source, calendarId: "\(id)")
|
||||||
|
if b.contains(key) { return }
|
||||||
|
combined.append(CalRow(id: key, name: name, colorHex: colorHex, readOnly: readOnly))
|
||||||
|
}
|
||||||
|
for cal in localCalendars {
|
||||||
|
add("local", cal.id, cal.owned ? cal.name : (cal.sharedBy ?? cal.name), cal.color,
|
||||||
|
readOnly: !cal.owned && cal.permission != "read_write")
|
||||||
|
}
|
||||||
|
for acc in caldavAccounts { for cal in acc.calendars ?? [] { add("caldav", cal.id, cal.name, cal.color ?? acc.color) } }
|
||||||
|
for sub in icalSubs { add("ical", sub.id, sub.name, sub.color) }
|
||||||
|
for acc in googleAccounts { for cal in acc.calendars ?? [] { add("google", cal.id, cal.name, cal.color ?? "#4285f4") } }
|
||||||
|
for acc in haAccounts { for cal in acc.calendars ?? [] { add("homeassistant", cal.id, cal.name, cal.color ?? "#46bdc6") } }
|
||||||
|
|
||||||
|
allKeys = Set(combined.map(\.id))
|
||||||
|
let orderedKeys = store.ordered(combined.map(\.id))
|
||||||
|
let byKey = Dictionary(uniqueKeysWithValues: combined.map { ($0.id, $0) })
|
||||||
|
rows = orderedKeys.compactMap { byKey[$0] }
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
|
||||||
|
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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,326 +1,23 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
/// Lets the user toggle which calendars contribute events to the displayed
|
/// Modal wrapper around `CalendarFilterContent` (kept for contexts that still
|
||||||
/// calendar views (and the home-screen widgets). Filtering is purely
|
/// present the filter as a sheet). The drawer embeds the same content directly.
|
||||||
/// client-side: hidden keys live in UserDefaults via `CalendarStore`. No
|
|
||||||
/// server roundtrip is required to toggle visibility.
|
|
||||||
struct CalendarFilterSheet: View {
|
struct CalendarFilterSheet: View {
|
||||||
let api: CalendarrAPI
|
let api: CalendarrAPI
|
||||||
let store: CalendarStore
|
let store: CalendarStore
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@AppStorage("appLanguage") private var appLang = "system"
|
@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> = []
|
|
||||||
/// Calendars whose events do not generate reminder notifications.
|
|
||||||
@State private var reminderDisabled: 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 {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
Group {
|
CalendarFilterContent(api: api, store: store)
|
||||||
if isLoading {
|
.navigationTitle(L10n.t("filter.title", appLang))
|
||||||
ProgressView(L10n.t("filter.loading", appLang))
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
} else if store.activeGroup != nil {
|
.toolbar {
|
||||||
groupFilterList
|
ToolbarItem(placement: .primaryAction) {
|
||||||
} else if allKeys.isEmpty {
|
Button(L10n.t("nav.done", appLang)) { dismiss() }
|
||||||
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()
|
|
||||||
if reminderDisabled.contains(key) {
|
|
||||||
Image(systemName: "bell.slash")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
Image(systemName: isVisible ? "eye" : "eye.slash")
|
|
||||||
.foregroundStyle(isVisible ? Color.accentColor : .secondary)
|
|
||||||
}
|
|
||||||
.contentShape(Rectangle())
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
.swipeActions(edge: .leading, allowsFullSwipe: false) {
|
|
||||||
let disabled = reminderDisabled.contains(key)
|
|
||||||
Button {
|
|
||||||
toggleReminders(forKey: key)
|
|
||||||
} label: {
|
|
||||||
Label(L10n.t(disabled ? "filter.reminders_on" : "filter.reminders_off", appLang),
|
|
||||||
systemImage: disabled ? "bell" : "bell.slash")
|
|
||||||
}
|
|
||||||
.tint(.orange)
|
|
||||||
}
|
|
||||||
.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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Flip a calendar's reminder mute, persist locally + on the server, reschedule.
|
|
||||||
private func toggleReminders(forKey key: String) {
|
|
||||||
let nowDisabled = !reminderDisabled.contains(key)
|
|
||||||
if nowDisabled { reminderDisabled.insert(key) } else { reminderDisabled.remove(key) }
|
|
||||||
store.setReminderDisabled(key, disabled: nowDisabled)
|
|
||||||
if let parsed = CalendarStore.parseCalendarKey(key) {
|
|
||||||
Task { try? await api.setCalendarRemindersEnabled(
|
|
||||||
source: parsed.source, calendarId: parsed.id, enabled: !nowDisabled) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
|
|
||||||
// Reconcile reminder-muted state from the server's reminders_enabled flags.
|
|
||||||
var rd = Set<String>()
|
|
||||||
func applyReminders(_ source: String, _ id: Int, _ enabled: Bool) {
|
|
||||||
if !enabled { rd.insert(CalendarStore.calendarKey(source: source, calendarId: "\(id)")) }
|
|
||||||
}
|
|
||||||
for cal in localCalendars { applyReminders("local", cal.id, cal.remindersEnabled) }
|
|
||||||
for acc in caldavAccounts { for cal in acc.calendars ?? [] { applyReminders("caldav", cal.id, cal.remindersEnabled ?? true) } }
|
|
||||||
for sub in icalSubs { applyReminders("ical", sub.id, sub.remindersEnabled ?? true) }
|
|
||||||
for acc in googleAccounts { for cal in acc.calendars ?? [] { applyReminders("google", cal.id, cal.remindersEnabled ?? true) } }
|
|
||||||
for acc in haAccounts { for cal in acc.calendars ?? [] { applyReminders("homeassistant", cal.id, cal.remindersEnabled) } }
|
|
||||||
store.setReminderDisabledKeys(rd)
|
|
||||||
reminderDisabled = rd
|
|
||||||
|
|
||||||
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) }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,81 +3,172 @@ import SwiftUI
|
|||||||
struct SettingsView: View {
|
struct SettingsView: View {
|
||||||
let api: CalendarrAPI
|
let api: CalendarrAPI
|
||||||
@AppStorage("liquidGlass") private var liquidGlass = false
|
@AppStorage("liquidGlass") private var liquidGlass = false
|
||||||
@AppStorage("settingsSync") private var settingsSync = false
|
@AppStorage("hideMenuButton") private var hideMenuButton = false
|
||||||
@AppStorage("cacheMonths") private var cacheMonths = 3
|
@AppStorage("cacheMonths") private var cacheMonths = 3
|
||||||
@AppStorage("appLanguage") private var appLang = "system"
|
@AppStorage("appLanguage") private var appLang = "system"
|
||||||
@AppStorage("monthDividerColor") private var dividerHex = "#7090c0"
|
@AppStorage("monthDividerColor") private var dividerHex = "#7090C0"
|
||||||
@AppStorage("monthLabelColor") private var labelHex = "#7090c0"
|
@AppStorage("monthLabelColor") private var labelHex = "#7090C0"
|
||||||
@AppStorage("todayColor") private var todayHex = "#4285f4"
|
@AppStorage("todayColor") private var todayHex = "#4285F4"
|
||||||
@AppStorage("textColor") private var textHex = "#FFFFFF"
|
@AppStorage("textColor") private var textHex = "#FFFFFF"
|
||||||
@AppStorage("backgroundColor") private var bgHex = "#000000"
|
@AppStorage("backgroundColor") private var bgHex = "#000000"
|
||||||
@AppStorage("lineColor") private var lineHex = "#3A3A3C"
|
@AppStorage("lineColor") private var lineHex = "#3A3A52"
|
||||||
@AppStorage("primaryColor") private var primaryHex = "#4285f4"
|
// Device-local top-bar/surface colour. Empty = translucent .bar material.
|
||||||
@AppStorage("accentColor") private var accentHex = "#ea4335"
|
@AppStorage("surfaceColor") private var surfaceHex = ""
|
||||||
// Previously server-only; now AppStorage-backed so they persist and the
|
@AppStorage("primaryColor") private var primaryHex = "#4285F4"
|
||||||
// calendar views actually apply them.
|
@AppStorage("accentColor") private var accentHex = "#EA4335"
|
||||||
|
// iOS-only opacity controls (drive secondary text / grid-line opacity in the
|
||||||
|
// calendar views); kept device-local, not part of the cross-device sync.
|
||||||
@AppStorage("textContrast") private var textContrast = 3
|
@AppStorage("textContrast") private var textContrast = 3
|
||||||
@AppStorage("lineContrast") private var lineContrast = 3
|
@AppStorage("lineContrast") private var lineContrast = 3
|
||||||
@AppStorage("hourHeight") private var hourHeight = 60
|
@AppStorage("hourHeight") private var hourHeight = 60
|
||||||
@AppStorage("defaultView") private var defaultView = "month"
|
@AppStorage("defaultView") private var defaultView = "month"
|
||||||
@AppStorage("weekStartDay") private var weekStartDay = "monday"
|
@AppStorage("weekStartDay") private var weekStartDay = "monday"
|
||||||
@AppStorage("dimPastEvents") private var dimPastEvents = false
|
@AppStorage("dimPastEvents") private var dimPastEvents = false
|
||||||
|
@AppStorage("monthViewPaged") private var monthViewPaged = false
|
||||||
@AppStorage("defaultReminderMinutes") private var defaultReminderMinutes = -1
|
@AppStorage("defaultReminderMinutes") private var defaultReminderMinutes = -1
|
||||||
|
@AppStorage("defaultEventDurationMinutes") private var defaultEventDurationMinutes = 60
|
||||||
|
|
||||||
// Profile chapter (server-backed; loaded on appear).
|
// Profile chapter (server-backed; loaded on appear). Account settings are not
|
||||||
|
// part of the per-device sync — they are always account-wide.
|
||||||
@State private var displayName = ""
|
@State private var displayName = ""
|
||||||
@State private var loginName = ""
|
@State private var loginName = ""
|
||||||
@State private var email = ""
|
@State private var email = ""
|
||||||
@State private var privateVisibility = "busy"
|
@State private var privateVisibility = "busy"
|
||||||
@State private var groupVisibleId = 0 // 0 = none
|
@State private var groupVisibleId = 0 // 0 = none
|
||||||
@State private var ownLocalCals: [LocalCalendar] = []
|
@State private var ownLocalCals: [LocalCalendar] = []
|
||||||
|
@State private var directoryHidden = false
|
||||||
@State private var profileMsg = ""
|
@State private var profileMsg = ""
|
||||||
|
|
||||||
|
// Per-setting sync flags (account-wide; refreshed from the server on pull).
|
||||||
|
@State private var syncFlags: [String: Bool] = SettingsSync.flags()
|
||||||
|
|
||||||
|
// Canonical default colours (single source for the reset buttons).
|
||||||
|
private static let defaultHex: [String: String] = [
|
||||||
|
"primaryColor": "#4285F4", "accentColor": "#EA4335", "todayColor": "#4285F4",
|
||||||
|
"textColor": "#FFFFFF", "backgroundColor": "#000000", "lineColor": "#3A3A52",
|
||||||
|
"monthDividerColor": "#7090C0", "monthLabelColor": "#7090C0",
|
||||||
|
]
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
Form {
|
Form {
|
||||||
|
globalSyncSection
|
||||||
profilSection
|
profilSection
|
||||||
privatsphaereSection
|
privatsphaereSection
|
||||||
benachrichtigungenSection
|
|
||||||
geteilterKalenderSection
|
geteilterKalenderSection
|
||||||
liquidGlassSection
|
termineSection
|
||||||
cacheSection
|
|
||||||
spracheSection
|
|
||||||
farbenSection
|
|
||||||
schriftSection
|
|
||||||
linienSection
|
|
||||||
ansichtSection
|
ansichtSection
|
||||||
stundenSection
|
farbenSection
|
||||||
|
cacheSection
|
||||||
|
geraetSection
|
||||||
}
|
}
|
||||||
.navigationTitle(L10n.t("settings.title", appLang))
|
.navigationTitle(L10n.t("settings.title", appLang))
|
||||||
.navigationBarTitleDisplayMode(.large)
|
.navigationBarTitleDisplayMode(.large)
|
||||||
}
|
}
|
||||||
// Reflect the latest server values when opening the screen.
|
// Reflect the latest server values (and flag map) when opening the screen.
|
||||||
.task { await SettingsSync.pull(api: api) }
|
.task { await SettingsSync.pull(api: api); syncFlags = SettingsSync.flags() }
|
||||||
.task { await loadProfile() }
|
.task { await loadProfile() }
|
||||||
// Appearance changes update widgets live; synced values are also pushed
|
// Value edits update widgets live; a synced value is also pushed (debounced).
|
||||||
// to the server (debounced). `push` itself decides what actually gets
|
// `push` decides what actually gets sent based on each key's flag.
|
||||||
// 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: primaryHex) { _, _ in WidgetStore.republishAppearanceOnly(); SettingsSync.push(api: api) }
|
||||||
.onChange(of: accentHex) { _, _ 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: todayHex) { _, _ in WidgetStore.republishAppearanceOnly(); SettingsSync.push(api: api) }
|
||||||
.onChange(of: textHex) { _, _ 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: bgHex) { _, _ in WidgetStore.republishAppearanceOnly(); SettingsSync.push(api: api) }
|
||||||
.onChange(of: lineHex) { _, _ 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: dividerHex) { _, _ in WidgetStore.republishAppearanceOnly(); SettingsSync.push(api: api) }
|
||||||
.onChange(of: labelHex) { _, _ in SettingsSync.push(api: api) }
|
.onChange(of: labelHex) { _, _ in WidgetStore.republishAppearanceOnly(); 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: hourHeight) { _, _ in SettingsSync.push(api: api) }
|
||||||
.onChange(of: defaultView) { _, _ in SettingsSync.push(api: api) }
|
.onChange(of: defaultView) { _, _ in SettingsSync.push(api: api) }
|
||||||
.onChange(of: weekStartDay) { _, _ in SettingsSync.push(api: api) }
|
.onChange(of: weekStartDay) { _, _ in SettingsSync.push(api: api) }
|
||||||
.onChange(of: dimPastEvents) { _, _ in SettingsSync.push(api: api) }
|
.onChange(of: dimPastEvents) { _, _ in SettingsSync.push(api: api) }
|
||||||
|
.onChange(of: monthViewPaged){ _, _ in SettingsSync.push(api: api) }
|
||||||
|
.onChange(of: cacheMonths) { _, _ in SettingsSync.push(api: api) }
|
||||||
|
.onChange(of: defaultEventDurationMinutes) { _, _ in SettingsSync.push(api: api) }
|
||||||
.onChange(of: appLang) { _, _ in WidgetStore.republishAppearanceOnly() }
|
.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
|
// MARK: – Reusable row builders
|
||||||
|
|
||||||
|
/// Leading per-row sync toggle: linked (accent) = synced across devices.
|
||||||
|
@ViewBuilder
|
||||||
|
private func syncIcon(_ key: String) -> some View {
|
||||||
|
let on = syncFlags[key] == true
|
||||||
|
Button {
|
||||||
|
SettingsSync.setSynced(key, !on, api: api)
|
||||||
|
syncFlags = SettingsSync.flags()
|
||||||
|
} label: {
|
||||||
|
Image(systemName: on ? "link.circle.fill" : "link.circle")
|
||||||
|
.imageScale(.large)
|
||||||
|
.foregroundStyle(on ? Color.accentColor : Color.secondary)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.accessibilityLabel(L10n.t("settings.sync_this", appLang))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func colorBinding(_ hex: Binding<String>) -> Binding<Color> {
|
||||||
|
Binding(get: { Color(hex: hex.wrappedValue) }, set: { hex.wrappedValue = $0.toHex() })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Surface colour: empty string means "auto" (translucent .bar); the picker
|
||||||
|
// shows a neutral dark until the user chooses a concrete colour.
|
||||||
|
private var surfaceBinding: Binding<Color> {
|
||||||
|
Binding(
|
||||||
|
get: { Color(hex: surfaceHex.isEmpty ? "#1C1C1E" : surfaceHex) },
|
||||||
|
set: { surfaceHex = $0.toHex() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func colorRow(_ syncKey: String, _ defaultsKey: String, _ label: String, _ hex: Binding<String>) -> some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
syncIcon(syncKey)
|
||||||
|
Text(label)
|
||||||
|
Spacer()
|
||||||
|
ColorPicker("", selection: colorBinding(hex), supportsOpacity: false)
|
||||||
|
.labelsHidden()
|
||||||
|
Text(hex.wrappedValue.uppercased())
|
||||||
|
.font(.system(.caption, design: .monospaced))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.frame(width: 64, alignment: .trailing)
|
||||||
|
Button {
|
||||||
|
hex.wrappedValue = Self.defaultHex[defaultsKey] ?? "#000000"
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "arrow.uturn.backward")
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.accessibilityLabel(L10n.t("settings.reset", appLang))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: – Global sync
|
||||||
|
|
||||||
|
var globalSyncSection: some View {
|
||||||
|
Section {
|
||||||
|
Toggle(isOn: allSyncedBinding) {
|
||||||
|
Label {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(L10n.t("settings.sync_all", appLang))
|
||||||
|
Text(L10n.t("settings.sync_all.desc", appLang))
|
||||||
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
} icon: {
|
||||||
|
Image(systemName: "arrow.triangle.2.circlepath").foregroundStyle(.teal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.tint(Color.accentColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var allSyncedBinding: Binding<Bool> {
|
||||||
|
Binding(
|
||||||
|
get: { SettingsSync.syncableKeys.allSatisfy { syncFlags[$0] == true } },
|
||||||
|
set: { on in SettingsSync.setAllSynced(on, api: api); syncFlags = SettingsSync.flags() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: – Profil (account identity — not synced)
|
||||||
|
|
||||||
var profilSection: some View {
|
var profilSection: some View {
|
||||||
Section(L10n.t("settings.nav.profile", appLang)) {
|
Section(L10n.t("settings.nav.profile", appLang)) {
|
||||||
@@ -100,6 +191,13 @@ struct SettingsView: View {
|
|||||||
.keyboardType(.emailAddress)
|
.keyboardType(.emailAddress)
|
||||||
.autocapitalization(.none)
|
.autocapitalization(.none)
|
||||||
}
|
}
|
||||||
|
Toggle(isOn: $directoryHidden) {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(L10n.t("settings.directory_hidden", appLang))
|
||||||
|
Text(L10n.t("settings.directory_hidden.desc", appLang))
|
||||||
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
Button(L10n.t("event.save", appLang)) { Task { await saveProfile() } }
|
Button(L10n.t("event.save", appLang)) { Task { await saveProfile() } }
|
||||||
if !profileMsg.isEmpty {
|
if !profileMsg.isEmpty {
|
||||||
Text(profileMsg).font(.caption).foregroundStyle(.secondary)
|
Text(profileMsg).font(.caption).foregroundStyle(.secondary)
|
||||||
@@ -107,28 +205,7 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: – Benachrichtigungen
|
// MARK: – Privatsphäre (account — not synced)
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: – Privatsphäre
|
|
||||||
|
|
||||||
var privatsphaereSection: some View {
|
var privatsphaereSection: some View {
|
||||||
Section {
|
Section {
|
||||||
@@ -146,7 +223,7 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: – Geteilter Kalender
|
// MARK: – Geteilter Kalender (account — not synced)
|
||||||
|
|
||||||
var geteilterKalenderSection: some View {
|
var geteilterKalenderSection: some View {
|
||||||
Section {
|
Section {
|
||||||
@@ -166,18 +243,206 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: – Termine (synced)
|
||||||
|
|
||||||
|
var termineSection: some View {
|
||||||
|
Section(L10n.t("settings.calview", appLang)) {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
syncIcon("default_event_duration_minutes")
|
||||||
|
Text(L10n.t("settings.default_duration", appLang))
|
||||||
|
Spacer()
|
||||||
|
Picker("", selection: $defaultEventDurationMinutes) {
|
||||||
|
ForEach([15, 30, 45, 60, 90, 120, 240], id: \.self) { m in
|
||||||
|
Text(ReminderOptions.durationLabel(m, appLang)).tag(m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.menu).labelsHidden()
|
||||||
|
}
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
syncIcon("default_reminder_minutes")
|
||||||
|
Text(ReminderOptions.defaultTitle(appLang))
|
||||||
|
Spacer()
|
||||||
|
Picker("", selection: $defaultReminderMinutes) {
|
||||||
|
Text(ReminderOptions.off(appLang)).tag(-1)
|
||||||
|
ForEach(ReminderOptions.all, id: \.self) { m in
|
||||||
|
Text(ReminderOptions.label(m, appLang)).tag(m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.menu).labelsHidden()
|
||||||
|
.onChange(of: defaultReminderMinutes) { _, _ in
|
||||||
|
SettingsSync.push(api: api)
|
||||||
|
NotificationCenter.default.post(name: .rescheduleReminders, object: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: – Ansicht (synced)
|
||||||
|
|
||||||
|
var ansichtSection: some View {
|
||||||
|
Section(L10n.t("settings.appearance", appLang)) {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
syncIcon("default_view")
|
||||||
|
Text(L10n.t("settings.defaultview", appLang))
|
||||||
|
Spacer()
|
||||||
|
Picker("", 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")
|
||||||
|
}
|
||||||
|
.pickerStyle(.menu).labelsHidden()
|
||||||
|
}
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
syncIcon("week_start_day")
|
||||||
|
Text(L10n.t("settings.firstweekday", appLang))
|
||||||
|
Spacer()
|
||||||
|
Picker("", selection: $weekStartDay) {
|
||||||
|
Text(L10n.t("settings.monday", appLang)).tag("monday")
|
||||||
|
Text(L10n.t("settings.sunday", appLang)).tag("sunday")
|
||||||
|
}
|
||||||
|
.pickerStyle(.menu).labelsHidden()
|
||||||
|
}
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
syncIcon("dim_past_events")
|
||||||
|
Toggle(L10n.t("settings.dimpast", appLang), isOn: $dimPastEvents).tint(Color.accentColor)
|
||||||
|
}
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
syncIcon("month_view_paged")
|
||||||
|
Text(L10n.t("settings.month_mode", appLang))
|
||||||
|
Spacer()
|
||||||
|
Picker("", selection: $monthViewPaged) {
|
||||||
|
Text(L10n.t("settings.month_mode.scroll", appLang)).tag(false)
|
||||||
|
Text(L10n.t("settings.month_mode.paged", appLang)).tag(true)
|
||||||
|
}
|
||||||
|
.pickerStyle(.menu).labelsHidden()
|
||||||
|
}
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
syncIcon("hour_height")
|
||||||
|
Text(L10n.t("settings.hourheight", appLang))
|
||||||
|
Spacer()
|
||||||
|
Picker("", selection: $hourHeight) {
|
||||||
|
Text(L10n.t("settings.hourheight.compact", appLang)).tag(28)
|
||||||
|
Text(L10n.t("settings.hourheight.normal", appLang)).tag(44)
|
||||||
|
Text(L10n.t("settings.hourheight.comfort", appLang)).tag(60)
|
||||||
|
Text(L10n.t("settings.hourheight.large", appLang)).tag(80)
|
||||||
|
}
|
||||||
|
.pickerStyle(.menu).labelsHidden()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: – Farben (synced)
|
||||||
|
|
||||||
|
var farbenSection: some View {
|
||||||
|
Section(L10n.t("settings.colors", appLang)) {
|
||||||
|
colorRow("primary_color", "primaryColor", L10n.t("settings.color.primary", appLang), $primaryHex)
|
||||||
|
colorRow("accent_color", "accentColor", L10n.t("settings.color.accent", appLang), $accentHex)
|
||||||
|
colorRow("today_color", "todayColor", L10n.t("settings.color.today", appLang), $todayHex)
|
||||||
|
colorRow("text_color", "textColor", L10n.t("settings.color.text", appLang), $textHex)
|
||||||
|
colorRow("bg_color", "backgroundColor", L10n.t("settings.color.background", appLang), $bgHex)
|
||||||
|
colorRow("line_color", "lineColor", L10n.t("settings.color.line", appLang), $lineHex)
|
||||||
|
colorRow("month_divider_color", "monthDividerColor", L10n.t("settings.color.divider", appLang), $dividerHex)
|
||||||
|
colorRow("month_label_color", "monthLabelColor", L10n.t("settings.color.label", appLang), $labelHex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: – Cache (synced)
|
||||||
|
|
||||||
|
var cacheSection: some View {
|
||||||
|
Section {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
syncIcon("cache_months")
|
||||||
|
Text(L10n.t("settings.cache.range", appLang))
|
||||||
|
Spacer()
|
||||||
|
Picker("", 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(.menu).labelsHidden()
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text(L10n.t("settings.cache.header", appLang))
|
||||||
|
} footer: {
|
||||||
|
Text(L10n.t("settings.cache.footer", appLang)).font(.caption)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: – Gerät (device-local: language, contrast, liquid glass)
|
||||||
|
|
||||||
|
var geraetSection: some View {
|
||||||
|
Section {
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
Picker(L10n.t("settings.textcontrast", appLang), selection: $textContrast) {
|
||||||
|
Text(L10n.t("settings.contrast.dark", appLang)).tag(1)
|
||||||
|
Text(L10n.t("settings.contrast.medium", appLang)).tag(2)
|
||||||
|
Text(L10n.t("settings.contrast.bright", appLang)).tag(3)
|
||||||
|
Text(L10n.t("settings.contrast.max", appLang)).tag(4)
|
||||||
|
}
|
||||||
|
Picker(L10n.t("settings.linecontrast", appLang), selection: $lineContrast) {
|
||||||
|
Text(L10n.t("settings.linecontrast.barely", appLang)).tag(1)
|
||||||
|
Text(L10n.t("settings.linecontrast.subtle", appLang)).tag(2)
|
||||||
|
Text(L10n.t("settings.linecontrast.normal", appLang)).tag(3)
|
||||||
|
Text(L10n.t("settings.linecontrast.strong", appLang)).tag(4)
|
||||||
|
}
|
||||||
|
Toggle(isOn: $liquidGlass) {
|
||||||
|
Label {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(L10n.t("settings.liquidglass", appLang))
|
||||||
|
Text(L10n.t("settings.liquidglass.desc", appLang))
|
||||||
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
} icon: {
|
||||||
|
Image(systemName: "sparkles").foregroundStyle(.blue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.tint(Color.accentColor)
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Text(L10n.t("settings.color.surface", appLang))
|
||||||
|
Spacer()
|
||||||
|
Text(surfaceHex.isEmpty ? L10n.t("settings.surface.auto", appLang) : surfaceHex.uppercased())
|
||||||
|
.font(.system(.caption, design: .monospaced))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
ColorPicker("", selection: surfaceBinding, supportsOpacity: false)
|
||||||
|
.labelsHidden()
|
||||||
|
Button { surfaceHex = "" } label: { Image(systemName: "arrow.uturn.backward") }
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.accessibilityLabel(L10n.t("settings.surface.auto", appLang))
|
||||||
|
}
|
||||||
|
Toggle(L10n.t("settings.hide_menu_button", appLang), isOn: $hideMenuButton)
|
||||||
|
.tint(Color.accentColor)
|
||||||
|
} header: {
|
||||||
|
Text(L10n.t("settings.device", appLang))
|
||||||
|
} footer: {
|
||||||
|
Text(L10n.t("settings.device.footer", appLang)).font(.caption)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: – Profile load / save
|
||||||
|
|
||||||
private func loadProfile() async {
|
private func loadProfile() async {
|
||||||
if let p = try? await api.getProfile() {
|
if let p = try? await api.getProfile() {
|
||||||
displayName = p.displayName ?? p.username
|
displayName = p.displayName ?? p.username
|
||||||
loginName = p.username
|
loginName = p.username
|
||||||
email = p.email ?? ""
|
email = p.email ?? ""
|
||||||
|
directoryHidden = p.directoryHidden
|
||||||
}
|
}
|
||||||
if let s = try? await api.getSettings() {
|
if let s = try? await api.getSettings() {
|
||||||
privateVisibility = s.privateEventVisibility
|
privateVisibility = s.privateEventVisibility
|
||||||
groupVisibleId = s.groupVisibleCalendarId ?? 0
|
groupVisibleId = s.groupVisibleCalendarId ?? 0
|
||||||
}
|
}
|
||||||
if let cals = try? await api.getLocalCalendars() {
|
if let cals = try? await api.getLocalCalendars() {
|
||||||
ownLocalCals = cals.filter { $0.owned && !$0.group }
|
// A birthday calendar may be shared directly, but never stand in as
|
||||||
|
// the group-visible personal calendar.
|
||||||
|
ownLocalCals = cals.filter { $0.owned && !$0.group && !$0.isBirthday }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,257 +450,12 @@ struct SettingsView: View {
|
|||||||
do {
|
do {
|
||||||
_ = try await api.updateProfile(displayName: displayName.isEmpty ? nil : displayName,
|
_ = try await api.updateProfile(displayName: displayName.isEmpty ? nil : displayName,
|
||||||
username: nil,
|
username: nil,
|
||||||
email: email.isEmpty ? "" : email)
|
email: email.isEmpty ? "" : email,
|
||||||
|
directoryHidden: directoryHidden)
|
||||||
UserDefaults.standard.set(displayName, forKey: "displayName")
|
UserDefaults.standard.set(displayName, forKey: "displayName")
|
||||||
profileMsg = L10n.t("settings.saved", appLang)
|
profileMsg = L10n.t("settings.saved", appLang)
|
||||||
} catch {
|
} catch {
|
||||||
profileMsg = error.localizedDescription
|
profileMsg = error.localizedDescription
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: – Liquid Glass
|
|
||||||
|
|
||||||
var liquidGlassSection: some View {
|
|
||||||
Section {
|
|
||||||
Toggle(isOn: $liquidGlass) {
|
|
||||||
Label {
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
|
||||||
Text(L10n.t("settings.liquidglass", appLang))
|
|
||||||
Text(L10n.t("settings.liquidglass.desc", appLang))
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
} icon: {
|
|
||||||
Image(systemName: "sparkles")
|
|
||||||
.foregroundStyle(.blue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.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(L10n.t("settings.appdesign", appLang))
|
|
||||||
} footer: {
|
|
||||||
Text(L10n.t("settings.sync.footer", appLang))
|
|
||||||
.font(.caption)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: – Cache
|
|
||||||
|
|
||||||
var cacheSection: some View {
|
|
||||||
Section {
|
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
|
||||||
Label {
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
|
||||||
Text(L10n.t("settings.cache.title", appLang))
|
|
||||||
Text(L10n.t("settings.cache.desc", appLang))
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
} icon: {
|
|
||||||
Image(systemName: "arrow.down.circle")
|
|
||||||
.foregroundStyle(.green)
|
|
||||||
}
|
|
||||||
|
|
||||||
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(L10n.t("settings.cache.header", appLang))
|
|
||||||
} footer: {
|
|
||||||
Text(L10n.t("settings.cache.footer", appLang))
|
|
||||||
.font(.caption)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: – Sprache
|
|
||||||
|
|
||||||
var spracheSection: some View {
|
|
||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: – Farben
|
|
||||||
|
|
||||||
var farbenSection: some View {
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: – Schriftkontrast
|
|
||||||
|
|
||||||
var schriftSection: some View {
|
|
||||||
Section {
|
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
|
||||||
Text(L10n.t("settings.textcontrast", appLang))
|
|
||||||
.font(.headline)
|
|
||||||
Text(L10n.t("settings.textcontrast.desc", appLang))
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
ContrastSelector(
|
|
||||||
value: $textContrast,
|
|
||||||
options: [
|
|
||||||
(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))
|
|
||||||
]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.padding(.vertical, 4)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: – Linienkontrast
|
|
||||||
|
|
||||||
var linienSection: some View {
|
|
||||||
Section {
|
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
|
||||||
Text(L10n.t("settings.linecontrast", appLang))
|
|
||||||
.font(.headline)
|
|
||||||
Text(L10n.t("settings.linecontrast.desc", appLang))
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
ContrastSelector(
|
|
||||||
value: $lineContrast,
|
|
||||||
options: [
|
|
||||||
(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))
|
|
||||||
]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.padding(.vertical, 4)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: – Ansicht
|
|
||||||
|
|
||||||
var ansichtSection: some View {
|
|
||||||
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(L10n.t("settings.firstweekday", appLang), selection: $weekStartDay) {
|
|
||||||
Text(L10n.t("settings.monday", appLang)).tag("monday")
|
|
||||||
Text(L10n.t("settings.sunday", appLang)).tag("sunday")
|
|
||||||
}
|
|
||||||
Toggle(L10n.t("settings.dimpast", appLang), isOn: $dimPastEvents)
|
|
||||||
.tint(Color.accentColor)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: – Stundenhöhe
|
|
||||||
|
|
||||||
var stundenSection: some View {
|
|
||||||
Section {
|
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
|
||||||
Text(L10n.t("settings.hourheight", appLang))
|
|
||||||
.font(.headline)
|
|
||||||
Text(L10n.t("settings.hourheight.desc", appLang))
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
ContrastSelector(
|
|
||||||
value: $hourHeight,
|
|
||||||
options: [
|
|
||||||
(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))
|
|
||||||
]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.padding(.vertical, 4)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: – Reusable Components
|
|
||||||
|
|
||||||
struct ColorPickerRow: View {
|
|
||||||
let label: String
|
|
||||||
@Binding var hex: String
|
|
||||||
|
|
||||||
var color: Binding<Color> {
|
|
||||||
Binding(
|
|
||||||
get: { Color(hex: hex) },
|
|
||||||
set: { hex = $0.toHex() }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
HStack {
|
|
||||||
Text(label)
|
|
||||||
Spacer()
|
|
||||||
ColorPicker("", selection: color, supportsOpacity: false)
|
|
||||||
.labelsHidden()
|
|
||||||
Text(hex.uppercased())
|
|
||||||
.font(.system(.caption, design: .monospaced))
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.frame(width: 68, alignment: .trailing)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ContrastSelector<T: Hashable & Equatable>: View {
|
|
||||||
@Binding var value: T
|
|
||||||
let options: [(T, String)]
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
HStack(spacing: 8) {
|
|
||||||
ForEach(Array(options.enumerated()), id: \.offset) { _, opt in
|
|
||||||
Button {
|
|
||||||
value = opt.0
|
|
||||||
} label: {
|
|
||||||
Text(opt.1)
|
|
||||||
.font(.caption.weight(.medium))
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.padding(.vertical, 8)
|
|
||||||
.background(value == opt.0 ? Color.accentColor : Color(.systemGray5))
|
|
||||||
.foregroundStyle(value == opt.0 ? .white : .primary)
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ struct CalendarDayWidgetView: View {
|
|||||||
let accent = Color(widgetHex: s.accentColorHex)
|
let accent = Color(widgetHex: s.accentColorHex)
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
header(primary: primary)
|
header(primary: primary)
|
||||||
|
.padding(.top, 6)
|
||||||
weekStrip(snapshot: s, primary: primary, accent: accent)
|
weekStrip(snapshot: s, primary: primary, accent: accent)
|
||||||
.padding(.vertical, 3)
|
.padding(.vertical, 3)
|
||||||
Rectangle()
|
Rectangle()
|
||||||
|
|||||||
47
CalendarrWidgets/CalendarIntent.swift
Normal file
47
CalendarrWidgets/CalendarIntent.swift
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import AppIntents
|
||||||
|
import WidgetKit
|
||||||
|
|
||||||
|
/// An individual calendar option shown in the widget configuration picker.
|
||||||
|
struct CalendarAppEntity: AppEntity, Identifiable {
|
||||||
|
let id: String
|
||||||
|
let name: String
|
||||||
|
let colorHex: String
|
||||||
|
|
||||||
|
static var typeDisplayRepresentation: TypeDisplayRepresentation = "Kalender"
|
||||||
|
static var defaultQuery = CalendarEntityQuery()
|
||||||
|
|
||||||
|
var displayRepresentation: DisplayRepresentation {
|
||||||
|
DisplayRepresentation(title: LocalizedStringResource(stringLiteral: name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads available calendars from the App Group container so the widget
|
||||||
|
/// extension can offer options without a network call.
|
||||||
|
struct CalendarEntityQuery: EntityQuery {
|
||||||
|
func entities(for identifiers: [String]) async throws -> [CalendarAppEntity] {
|
||||||
|
let ids = Set(identifiers)
|
||||||
|
return WidgetStore.readCalendars()
|
||||||
|
.filter { ids.contains($0.id) }
|
||||||
|
.map { CalendarAppEntity(id: $0.id, name: $0.name, colorHex: $0.colorHex) }
|
||||||
|
}
|
||||||
|
|
||||||
|
func suggestedEntities() async throws -> [CalendarAppEntity] {
|
||||||
|
WidgetStore.readCalendars()
|
||||||
|
.map { CalendarAppEntity(id: $0.id, name: $0.name, colorHex: $0.colorHex) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Widget configuration intent: lets the user pick which calendars to show.
|
||||||
|
/// No selection means "show all calendars" (the default).
|
||||||
|
///
|
||||||
|
/// The parameter must be optional: WidgetConfigurationIntent requires every
|
||||||
|
/// parameter type to be optional, and the macOS/Mac Catalyst SDK enforces that
|
||||||
|
/// where the iOS one lets a bare array through. `nil` and `[]` are treated
|
||||||
|
/// alike downstream, so this is behaviour-preserving.
|
||||||
|
struct CalendarSelectionIntent: WidgetConfigurationIntent {
|
||||||
|
static var title: LocalizedStringResource = "Kalender auswählen"
|
||||||
|
static var description = IntentDescription("Wähle welche Kalender im Widget angezeigt werden. Leer = alle Kalender.")
|
||||||
|
|
||||||
|
@Parameter(title: "Kalender")
|
||||||
|
var selectedCalendars: [CalendarAppEntity]?
|
||||||
|
}
|
||||||
@@ -1,33 +1,58 @@
|
|||||||
import WidgetKit
|
import WidgetKit
|
||||||
|
import AppIntents
|
||||||
|
|
||||||
struct CalendarrEntry: TimelineEntry {
|
struct CalendarrEntry: TimelineEntry {
|
||||||
let date: Date
|
let date: Date
|
||||||
let snapshot: WidgetSnapshot?
|
let snapshot: WidgetSnapshot?
|
||||||
}
|
}
|
||||||
|
|
||||||
struct CalendarrTimelineProvider: TimelineProvider {
|
struct CalendarrTimelineProvider: AppIntentTimelineProvider {
|
||||||
|
typealias Entry = CalendarrEntry
|
||||||
|
typealias Intent = CalendarSelectionIntent
|
||||||
|
|
||||||
func placeholder(in context: Context) -> CalendarrEntry {
|
func placeholder(in context: Context) -> CalendarrEntry {
|
||||||
CalendarrEntry(date: .now, snapshot: WidgetStore.read())
|
CalendarrEntry(date: .now, snapshot: WidgetStore.read())
|
||||||
}
|
}
|
||||||
|
|
||||||
func getSnapshot(in context: Context, completion: @escaping (CalendarrEntry) -> Void) {
|
func snapshot(for configuration: CalendarSelectionIntent, in context: Context) async -> CalendarrEntry {
|
||||||
completion(CalendarrEntry(date: .now, snapshot: WidgetStore.read()))
|
let raw = WidgetStore.read()
|
||||||
|
return CalendarrEntry(date: .now, snapshot: filtered(raw, by: configuration))
|
||||||
}
|
}
|
||||||
|
|
||||||
func getTimeline(in context: Context, completion: @escaping (Timeline<CalendarrEntry>) -> Void) {
|
func timeline(for configuration: CalendarSelectionIntent, in context: Context) async -> Timeline<CalendarrEntry> {
|
||||||
let snapshot = WidgetStore.read()
|
let raw = WidgetStore.read()
|
||||||
|
let snap = filtered(raw, by: configuration)
|
||||||
let now = Date()
|
let now = Date()
|
||||||
|
|
||||||
// Provide one entry per hour for the next 24h so the widget keeps
|
// One entry per hour for 24 h so the widget re-renders as time advances.
|
||||||
// re-rendering as time progresses (past events drop off, "now" advances).
|
|
||||||
var entries: [CalendarrEntry] = []
|
var entries: [CalendarrEntry] = []
|
||||||
for h in 0..<24 {
|
for h in 0..<24 {
|
||||||
let date = Calendar.current.date(byAdding: .hour, value: h, to: now) ?? now
|
let date = Calendar.current.date(byAdding: .hour, value: h, to: now) ?? now
|
||||||
entries.append(CalendarrEntry(date: date, snapshot: snapshot))
|
entries.append(CalendarrEntry(date: date, snapshot: snap))
|
||||||
}
|
}
|
||||||
// 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
|
let refreshAt = Calendar.current.date(byAdding: .minute, value: 30, to: now) ?? now
|
||||||
completion(Timeline(entries: entries, policy: .after(refreshAt)))
|
return Timeline(entries: entries, policy: .after(refreshAt))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: – Filtering
|
||||||
|
|
||||||
|
private func filtered(_ snapshot: WidgetSnapshot?, by config: CalendarSelectionIntent) -> WidgetSnapshot? {
|
||||||
|
guard let snapshot else { return nil }
|
||||||
|
let ids = (config.selectedCalendars ?? []).map { $0.id }
|
||||||
|
guard !ids.isEmpty else { return snapshot } // nothing selected = show all
|
||||||
|
let keep = Set(ids)
|
||||||
|
// Filtering narrows the events but not the window: the snapshot still
|
||||||
|
// speaks for the same range, it just has fewer calendars in it.
|
||||||
|
return WidgetSnapshot(
|
||||||
|
writtenAt: snapshot.writtenAt,
|
||||||
|
coverageStart: snapshot.coverageStart,
|
||||||
|
coverageEnd: snapshot.coverageEnd,
|
||||||
|
isLoggedIn: snapshot.isLoggedIn,
|
||||||
|
writerVersion: snapshot.writerVersion,
|
||||||
|
events: snapshot.events.filter { keep.contains($0.calendarKey) },
|
||||||
|
theme: snapshot.theme,
|
||||||
|
language: snapshot.language
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
19
CalendarrWidgets/CalendarrWidgets-Catalyst.entitlements
Normal file
19
CalendarrWidgets/CalendarrWidgets-Catalyst.entitlements
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<?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>
|
||||||
|
<!--
|
||||||
|
Mac Catalyst entitlements for the widget extension. Deliberately minimal:
|
||||||
|
the widget only ever reads the snapshot from the App Group container. It
|
||||||
|
does no networking, touches no Contacts, and needs no keychain access.
|
||||||
|
-->
|
||||||
|
<key>com.apple.security.app-sandbox</key>
|
||||||
|
<true/>
|
||||||
|
|
||||||
|
<!-- Must match CalendarrAppGroup.current on this platform, byte for byte. -->
|
||||||
|
<key>com.apple.security.application-groups</key>
|
||||||
|
<array>
|
||||||
|
<string>PP34X97WS3.group.com.scarriffleservices.calendarr</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -47,7 +47,7 @@ struct TodayWidget: Widget {
|
|||||||
let kind: String = "TodayWidget"
|
let kind: String = "TodayWidget"
|
||||||
|
|
||||||
var body: some WidgetConfiguration {
|
var body: some WidgetConfiguration {
|
||||||
StaticConfiguration(kind: kind, provider: CalendarrTimelineProvider()) { entry in
|
AppIntentConfiguration(kind: kind, intent: CalendarSelectionIntent.self, provider: CalendarrTimelineProvider()) { entry in
|
||||||
TodayWidgetView(entry: entry).calendarrChrome(entry.snapshot)
|
TodayWidgetView(entry: entry).calendarrChrome(entry.snapshot)
|
||||||
}
|
}
|
||||||
.configurationDisplayName(WidgetL10n.t("widget.display.today_title", "system"))
|
.configurationDisplayName(WidgetL10n.t("widget.display.today_title", "system"))
|
||||||
@@ -62,7 +62,7 @@ struct TwoDaysWidget: Widget {
|
|||||||
let kind: String = "TwoDaysWidget"
|
let kind: String = "TwoDaysWidget"
|
||||||
|
|
||||||
var body: some WidgetConfiguration {
|
var body: some WidgetConfiguration {
|
||||||
StaticConfiguration(kind: kind, provider: CalendarrTimelineProvider()) { entry in
|
AppIntentConfiguration(kind: kind, intent: CalendarSelectionIntent.self, provider: CalendarrTimelineProvider()) { entry in
|
||||||
TwoDaysWidgetView(entry: entry).calendarrChrome(entry.snapshot)
|
TwoDaysWidgetView(entry: entry).calendarrChrome(entry.snapshot)
|
||||||
}
|
}
|
||||||
.configurationDisplayName(WidgetL10n.t("widget.display.days_title", "system"))
|
.configurationDisplayName(WidgetL10n.t("widget.display.days_title", "system"))
|
||||||
@@ -77,7 +77,7 @@ struct ThreeDaysWidget: Widget {
|
|||||||
let kind: String = "ThreeDaysWidget"
|
let kind: String = "ThreeDaysWidget"
|
||||||
|
|
||||||
var body: some WidgetConfiguration {
|
var body: some WidgetConfiguration {
|
||||||
StaticConfiguration(kind: kind, provider: CalendarrTimelineProvider()) { entry in
|
AppIntentConfiguration(kind: kind, intent: CalendarSelectionIntent.self, provider: CalendarrTimelineProvider()) { entry in
|
||||||
ThreeDaysWidgetView(entry: entry).calendarrChrome(entry.snapshot)
|
ThreeDaysWidgetView(entry: entry).calendarrChrome(entry.snapshot)
|
||||||
}
|
}
|
||||||
.configurationDisplayName(WidgetL10n.t("widget.display.threedays_title", "system"))
|
.configurationDisplayName(WidgetL10n.t("widget.display.threedays_title", "system"))
|
||||||
@@ -92,7 +92,7 @@ struct ThisWeekWidget: Widget {
|
|||||||
let kind: String = "ThisWeekWidget"
|
let kind: String = "ThisWeekWidget"
|
||||||
|
|
||||||
var body: some WidgetConfiguration {
|
var body: some WidgetConfiguration {
|
||||||
StaticConfiguration(kind: kind, provider: CalendarrTimelineProvider()) { entry in
|
AppIntentConfiguration(kind: kind, intent: CalendarSelectionIntent.self, provider: CalendarrTimelineProvider()) { entry in
|
||||||
ThisWeekWidgetView(entry: entry).calendarrChrome(entry.snapshot)
|
ThisWeekWidgetView(entry: entry).calendarrChrome(entry.snapshot)
|
||||||
}
|
}
|
||||||
.configurationDisplayName(WidgetL10n.t("widget.display.thisweek_title", "system"))
|
.configurationDisplayName(WidgetL10n.t("widget.display.thisweek_title", "system"))
|
||||||
@@ -107,7 +107,7 @@ struct TwoWeeksWidget: Widget {
|
|||||||
let kind: String = "TwoWeeksWidget"
|
let kind: String = "TwoWeeksWidget"
|
||||||
|
|
||||||
var body: some WidgetConfiguration {
|
var body: some WidgetConfiguration {
|
||||||
StaticConfiguration(kind: kind, provider: CalendarrTimelineProvider()) { entry in
|
AppIntentConfiguration(kind: kind, intent: CalendarSelectionIntent.self, provider: CalendarrTimelineProvider()) { entry in
|
||||||
TwoWeeksWidgetView(entry: entry).calendarrChrome(entry.snapshot)
|
TwoWeeksWidgetView(entry: entry).calendarrChrome(entry.snapshot)
|
||||||
}
|
}
|
||||||
.configurationDisplayName(WidgetL10n.t("widget.display.twoweeks_title", "system"))
|
.configurationDisplayName(WidgetL10n.t("widget.display.twoweeks_title", "system"))
|
||||||
@@ -122,7 +122,7 @@ struct UpcomingWidget: Widget {
|
|||||||
let kind: String = "UpcomingWidget"
|
let kind: String = "UpcomingWidget"
|
||||||
|
|
||||||
var body: some WidgetConfiguration {
|
var body: some WidgetConfiguration {
|
||||||
StaticConfiguration(kind: kind, provider: CalendarrTimelineProvider()) { entry in
|
AppIntentConfiguration(kind: kind, intent: CalendarSelectionIntent.self, provider: CalendarrTimelineProvider()) { entry in
|
||||||
UpcomingWidgetView(entry: entry).calendarrChrome(entry.snapshot)
|
UpcomingWidgetView(entry: entry).calendarrChrome(entry.snapshot)
|
||||||
}
|
}
|
||||||
.configurationDisplayName(WidgetL10n.t("widget.display.upcoming_title", "system"))
|
.configurationDisplayName(WidgetL10n.t("widget.display.upcoming_title", "system"))
|
||||||
@@ -137,7 +137,7 @@ struct UpNextWidget: Widget {
|
|||||||
let kind: String = "UpNextWidget"
|
let kind: String = "UpNextWidget"
|
||||||
|
|
||||||
var body: some WidgetConfiguration {
|
var body: some WidgetConfiguration {
|
||||||
StaticConfiguration(kind: kind, provider: CalendarrTimelineProvider()) { entry in
|
AppIntentConfiguration(kind: kind, intent: CalendarSelectionIntent.self, provider: CalendarrTimelineProvider()) { entry in
|
||||||
UpNextWidgetView(entry: entry).calendarrChrome(entry.snapshot)
|
UpNextWidgetView(entry: entry).calendarrChrome(entry.snapshot)
|
||||||
}
|
}
|
||||||
.configurationDisplayName(WidgetL10n.t("widget.display.upnext_title", "system"))
|
.configurationDisplayName(WidgetL10n.t("widget.display.upnext_title", "system"))
|
||||||
@@ -152,7 +152,7 @@ struct CalendarDayWidget: Widget {
|
|||||||
let kind: String = "CalendarDayWidget"
|
let kind: String = "CalendarDayWidget"
|
||||||
|
|
||||||
var body: some WidgetConfiguration {
|
var body: some WidgetConfiguration {
|
||||||
StaticConfiguration(kind: kind, provider: CalendarrTimelineProvider()) { entry in
|
AppIntentConfiguration(kind: kind, intent: CalendarSelectionIntent.self, provider: CalendarrTimelineProvider()) { entry in
|
||||||
CalendarDayWidgetView(entry: entry).calendarrChrome(entry.snapshot)
|
CalendarDayWidgetView(entry: entry).calendarrChrome(entry.snapshot)
|
||||||
}
|
}
|
||||||
.configurationDisplayName(WidgetL10n.t("widget.display.calday_title", "system"))
|
.configurationDisplayName(WidgetL10n.t("widget.display.calday_title", "system"))
|
||||||
@@ -167,7 +167,7 @@ struct TwoMonthWidget: Widget {
|
|||||||
let kind: String = "TwoMonthWidget"
|
let kind: String = "TwoMonthWidget"
|
||||||
|
|
||||||
var body: some WidgetConfiguration {
|
var body: some WidgetConfiguration {
|
||||||
StaticConfiguration(kind: kind, provider: CalendarrTimelineProvider()) { entry in
|
AppIntentConfiguration(kind: kind, intent: CalendarSelectionIntent.self, provider: CalendarrTimelineProvider()) { entry in
|
||||||
TwoMonthWidgetView(entry: entry).calendarrChrome(entry.snapshot)
|
TwoMonthWidgetView(entry: entry).calendarrChrome(entry.snapshot)
|
||||||
}
|
}
|
||||||
.configurationDisplayName(WidgetL10n.t("widget.display.twomonth_title", "system"))
|
.configurationDisplayName(WidgetL10n.t("widget.display.twomonth_title", "system"))
|
||||||
@@ -182,7 +182,7 @@ struct NowNextEventsWidget: Widget {
|
|||||||
let kind: String = "NowNextEventsWidget"
|
let kind: String = "NowNextEventsWidget"
|
||||||
|
|
||||||
var body: some WidgetConfiguration {
|
var body: some WidgetConfiguration {
|
||||||
StaticConfiguration(kind: kind, provider: CalendarrTimelineProvider()) { entry in
|
AppIntentConfiguration(kind: kind, intent: CalendarSelectionIntent.self, provider: CalendarrTimelineProvider()) { entry in
|
||||||
NowNextWidgetView(entry: entry).calendarrChrome(entry.snapshot)
|
NowNextWidgetView(entry: entry).calendarrChrome(entry.snapshot)
|
||||||
}
|
}
|
||||||
.configurationDisplayName(WidgetL10n.t("widget.display.nownext_title", "system"))
|
.configurationDisplayName(WidgetL10n.t("widget.display.nownext_title", "system"))
|
||||||
@@ -197,7 +197,7 @@ struct LockScreenWidget: Widget {
|
|||||||
let kind: String = "LockScreenWidget"
|
let kind: String = "LockScreenWidget"
|
||||||
|
|
||||||
var body: some WidgetConfiguration {
|
var body: some WidgetConfiguration {
|
||||||
StaticConfiguration(kind: kind, provider: CalendarrTimelineProvider()) { entry in
|
AppIntentConfiguration(kind: kind, intent: CalendarSelectionIntent.self, provider: CalendarrTimelineProvider()) { entry in
|
||||||
LockScreenWidgetView(entry: entry)
|
LockScreenWidgetView(entry: entry)
|
||||||
.containerBackground(for: .widget) { Color.clear }
|
.containerBackground(for: .widget) { Color.clear }
|
||||||
.environment(\.locale, WidgetL10n.locale(entry.snapshot?.language ?? "system"))
|
.environment(\.locale, WidgetL10n.locale(entry.snapshot?.language ?? "system"))
|
||||||
@@ -214,7 +214,7 @@ struct LockScreenCountWidget: Widget {
|
|||||||
let kind: String = "LockScreenCountWidget"
|
let kind: String = "LockScreenCountWidget"
|
||||||
|
|
||||||
var body: some WidgetConfiguration {
|
var body: some WidgetConfiguration {
|
||||||
StaticConfiguration(kind: kind, provider: CalendarrTimelineProvider()) { entry in
|
AppIntentConfiguration(kind: kind, intent: CalendarSelectionIntent.self, provider: CalendarrTimelineProvider()) { entry in
|
||||||
LockScreenCountWidgetView(entry: entry)
|
LockScreenCountWidgetView(entry: entry)
|
||||||
.containerBackground(for: .widget) { Color.clear }
|
.containerBackground(for: .widget) { Color.clear }
|
||||||
.environment(\.locale, WidgetL10n.locale(entry.snapshot?.language ?? "system"))
|
.environment(\.locale, WidgetL10n.locale(entry.snapshot?.language ?? "system"))
|
||||||
@@ -231,7 +231,7 @@ struct LockScreenCountdownWidget: Widget {
|
|||||||
let kind: String = "LockScreenCountdownWidget"
|
let kind: String = "LockScreenCountdownWidget"
|
||||||
|
|
||||||
var body: some WidgetConfiguration {
|
var body: some WidgetConfiguration {
|
||||||
StaticConfiguration(kind: kind, provider: CalendarrTimelineProvider()) { entry in
|
AppIntentConfiguration(kind: kind, intent: CalendarSelectionIntent.self, provider: CalendarrTimelineProvider()) { entry in
|
||||||
LockScreenCountdownWidgetView(entry: entry)
|
LockScreenCountdownWidgetView(entry: entry)
|
||||||
.containerBackground(for: .widget) { Color.clear }
|
.containerBackground(for: .widget) { Color.clear }
|
||||||
.environment(\.locale, WidgetL10n.locale(entry.snapshot?.language ?? "system"))
|
.environment(\.locale, WidgetL10n.locale(entry.snapshot?.language ?? "system"))
|
||||||
|
|||||||
@@ -89,11 +89,11 @@ struct ThisWeekWidgetView: View {
|
|||||||
.frame(width: 16, height: 16)
|
.frame(width: 16, height: 16)
|
||||||
.background(isToday ? primary : Color.clear)
|
.background(isToday ? primary : Color.clear)
|
||||||
.clipShape(Circle())
|
.clipShape(Circle())
|
||||||
ForEach(evs.prefix(6)) { ev in
|
ForEach(evs.prefix(8)) { ev in
|
||||||
eventPill(ev)
|
eventPill(ev)
|
||||||
}
|
}
|
||||||
if evs.count > 6 {
|
if evs.count > 8 {
|
||||||
Text("+\(evs.count - 6)")
|
Text("+\(evs.count - 8)")
|
||||||
.font(.system(size: 6.5))
|
.font(.system(size: 6.5))
|
||||||
.foregroundStyle(accent)
|
.foregroundStyle(accent)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,15 +3,20 @@ import WidgetKit
|
|||||||
|
|
||||||
private let rowHeight: CGFloat = 16
|
private let rowHeight: CGFloat = 16
|
||||||
private let dayHeaderHeight: CGFloat = 14
|
private let dayHeaderHeight: CGFloat = 14
|
||||||
private let maxEventsPerDay: Int = 3
|
// Show all events of a day (the total-row cap below governs how much fits);
|
||||||
private let maxTotalRows: Int = 22
|
// a low per-day cap previously made busy days collapse to "+N" far too early.
|
||||||
|
private let maxEventsPerDay: Int = 25
|
||||||
|
|
||||||
struct UpcomingWidgetView: View {
|
struct UpcomingWidgetView: View {
|
||||||
let entry: CalendarrEntry
|
let entry: CalendarrEntry
|
||||||
|
@Environment(\.widgetFamily) private var family
|
||||||
|
|
||||||
private var snapshot: WidgetSnapshot? { entry.snapshot }
|
private var snapshot: WidgetSnapshot? { entry.snapshot }
|
||||||
private var lang: String { snapshot?.language ?? "system" }
|
private var lang: String { snapshot?.language ?? "system" }
|
||||||
|
|
||||||
|
// Fill the available height: extraLarge (iPad) fits far more rows.
|
||||||
|
private var maxTotalRows: Int { family == .systemExtraLarge ? 40 : 22 }
|
||||||
|
|
||||||
private var groupedWithLimits: [(Date, [WidgetEvent], Int)] {
|
private var groupedWithLimits: [(Date, [WidgetEvent], Int)] {
|
||||||
guard let s = snapshot else { return [] }
|
guard let s = snapshot else { return [] }
|
||||||
let cal = Calendar.current
|
let cal = Calendar.current
|
||||||
|
|||||||
@@ -1,129 +1,136 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
@_spi(Writer) import CalendarrCore
|
||||||
#if canImport(WidgetKit)
|
#if canImport(WidgetKit)
|
||||||
import WidgetKit
|
import WidgetKit
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
/// App-Group identifier shared between the main app and the widget extension.
|
// The snapshot types and the App Group plumbing now live in CalendarrCore, so
|
||||||
/// IMPORTANT: This must match the App Group capability in BOTH targets
|
// that a second app from this team reads the exact same format instead of
|
||||||
/// and the App Group ID registered in the Apple Developer Portal.
|
// reimplementing it and drifting.
|
||||||
let widgetAppGroupID = "group.com.scarriffleservices.calendarr"
|
//
|
||||||
|
// These aliases keep the ~70 existing call sites in the app and the widget
|
||||||
|
// compiling unchanged. They are a migration convenience, not a design: new code
|
||||||
|
// should use the CalendarrCore names directly.
|
||||||
|
typealias WidgetEvent = SnapshotEvent
|
||||||
|
typealias WidgetCalendar = SnapshotCalendar
|
||||||
|
typealias WidgetSnapshot = CalendarrSnapshot
|
||||||
|
|
||||||
/// Lightweight event representation that lives inside the widget cache.
|
/// The App Group identifier for this platform. See `CalendarrAppGroup`.
|
||||||
/// We strip everything the widget doesn't need (notes, calendar IDs, URLs).
|
let widgetAppGroupID = CalendarrAppGroup.current
|
||||||
struct WidgetEvent: Codable, Hashable, Identifiable {
|
|
||||||
let id: String
|
extension CalendarrSnapshot {
|
||||||
let title: String
|
// The six colours are stored flat on the wire for backwards compatibility
|
||||||
let start: Date
|
// but exposed as `theme` in the package. These keep the widget views, which
|
||||||
let end: Date
|
// read them individually, from having to change.
|
||||||
let isAllDay: Bool
|
var todayColorHex: String { theme.today }
|
||||||
let colorHex: String
|
var textColorHex: String { theme.text }
|
||||||
let location: String
|
var backgroundColorHex: String { theme.background }
|
||||||
}
|
var lineColorHex: String { theme.line }
|
||||||
|
var primaryColorHex: String { theme.primary }
|
||||||
/// Snapshot blob the app writes to the App-Group container and the widget reads.
|
var accentColorHex: String { theme.accent }
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Thin facade over `CalendarrCore.SnapshotStore`, preserving the static API the
|
||||||
|
/// app and widget already use. Errors are swallowed here exactly as before —
|
||||||
|
/// a failed widget cache write must never interrupt the user — but the store now
|
||||||
|
/// reports *why* a read failed, which is what `read()` discards and callers that
|
||||||
|
/// need the distinction should use `SnapshotStore` directly for.
|
||||||
enum WidgetStore {
|
enum WidgetStore {
|
||||||
private static let cacheFilename = "widget-cache.json"
|
private static let store = SnapshotStore()
|
||||||
|
private static let sessions = SharedSessionStore()
|
||||||
|
|
||||||
private static var containerURL: URL? {
|
/// Version of the app writing the cache, for diagnostics in the consumer.
|
||||||
FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: widgetAppGroupID)
|
private static var writerVersion: String {
|
||||||
|
Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "unknown"
|
||||||
}
|
}
|
||||||
|
|
||||||
private static var cacheURL: URL? {
|
|
||||||
containerURL?.appendingPathComponent(cacheFilename)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Called by the app whenever the event cache changes.
|
|
||||||
static func write(_ snapshot: WidgetSnapshot) {
|
static func write(_ snapshot: WidgetSnapshot) {
|
||||||
guard let url = cacheURL else { return }
|
try? store.write(snapshot)
|
||||||
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? {
|
static func read() -> WidgetSnapshot? {
|
||||||
guard let url = cacheURL, let data = try? Data(contentsOf: url) else { return nil }
|
store.read().snapshot
|
||||||
let decoder = JSONDecoder()
|
|
||||||
decoder.dateDecodingStrategy = .iso8601
|
|
||||||
return try? decoder.decode(WidgetSnapshot.self, from: data)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Rewrite the existing snapshot with the latest colour / language values
|
static func writeCalendars(_ calendars: [WidgetCalendar]) {
|
||||||
/// from UserDefaults. Used when the user tweaks an appearance setting and
|
try? store.writeCalendars(calendars)
|
||||||
/// we want the widgets to refresh immediately, without needing a new event
|
}
|
||||||
/// sync. No-op if there's no cached snapshot yet.
|
|
||||||
|
static func readCalendars() -> [WidgetCalendar] {
|
||||||
|
store.readCalendars()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record which server we are pointed at and whether anyone is signed in, so
|
||||||
|
/// a reading app can tell "signed out" from "never ran" without reaching into
|
||||||
|
/// this app's UserDefaults, which lives in another sandbox.
|
||||||
|
static func writeSession(baseURL: String, username: String, isLoggedIn: Bool) {
|
||||||
|
try? sessions.write(SharedSession(baseURL: baseURL,
|
||||||
|
username: username,
|
||||||
|
isLoggedIn: isLoggedIn,
|
||||||
|
writtenAt: Date()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Forget the session entirely. Used on server reset, where the app returns
|
||||||
|
/// to unconfigured and there is no longer a server worth naming.
|
||||||
|
static func clearSession() {
|
||||||
|
sessions.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drop the cached calendar. Called on sign-out and server reset: the
|
||||||
|
/// container outlives the session, so without this every reader keeps
|
||||||
|
/// rendering the previous user's events.
|
||||||
|
static func clear() {
|
||||||
|
store.clear()
|
||||||
|
WidgetTimelineNotifier.reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rewrite the existing snapshot with the latest colour / language values so
|
||||||
|
/// widgets pick up an appearance change immediately, without waiting for the
|
||||||
|
/// next event sync. No-op when nothing is cached yet.
|
||||||
static func republishAppearanceOnly() {
|
static func republishAppearanceOnly() {
|
||||||
guard let existing = read() else { return }
|
guard let existing = read() else { return }
|
||||||
let defaults = UserDefaults.standard
|
let defaults = UserDefaults.standard
|
||||||
let updated = WidgetSnapshot(
|
let updated = WidgetSnapshot(
|
||||||
writtenAt: Date(),
|
writtenAt: Date(),
|
||||||
|
coverageStart: existing.coverageStart,
|
||||||
|
coverageEnd: existing.coverageEnd,
|
||||||
|
isLoggedIn: existing.isLoggedIn,
|
||||||
|
writerVersion: writerVersion,
|
||||||
events: existing.events,
|
events: existing.events,
|
||||||
todayColorHex: defaults.string(forKey: "todayColor") ?? existing.todayColorHex,
|
theme: SnapshotTheme(
|
||||||
textColorHex: defaults.string(forKey: "textColor") ?? existing.textColorHex,
|
today: defaults.string(forKey: "todayColor") ?? existing.theme.today,
|
||||||
backgroundColorHex: defaults.string(forKey: "backgroundColor") ?? existing.backgroundColorHex,
|
text: defaults.string(forKey: "textColor") ?? existing.theme.text,
|
||||||
lineColorHex: defaults.string(forKey: "lineColor") ?? existing.lineColorHex,
|
background: defaults.string(forKey: "backgroundColor") ?? existing.theme.background,
|
||||||
primaryColorHex: defaults.string(forKey: "primaryColor") ?? existing.primaryColorHex,
|
line: defaults.string(forKey: "lineColor") ?? existing.theme.line,
|
||||||
accentColorHex: defaults.string(forKey: "accentColor") ?? existing.accentColorHex,
|
primary: defaults.string(forKey: "primaryColor") ?? existing.theme.primary,
|
||||||
language: defaults.string(forKey: "appLanguage") ?? existing.language
|
accent: defaults.string(forKey: "accentColor") ?? existing.theme.accent),
|
||||||
)
|
language: defaults.string(forKey: "appLanguage") ?? existing.language)
|
||||||
write(updated)
|
write(updated)
|
||||||
WidgetTimelineNotifier.reload()
|
WidgetTimelineNotifier.reload()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build a snapshot from the app's current state. Kept here so the coverage
|
||||||
|
/// window and the writer version are stamped in exactly one place.
|
||||||
|
static func makeSnapshot(events: [WidgetEvent],
|
||||||
|
coverageStart: Date,
|
||||||
|
coverageEnd: Date) -> WidgetSnapshot {
|
||||||
|
let defaults = UserDefaults.standard
|
||||||
|
return WidgetSnapshot(
|
||||||
|
writtenAt: Date(),
|
||||||
|
coverageStart: coverageStart,
|
||||||
|
coverageEnd: coverageEnd,
|
||||||
|
isLoggedIn: true, // only ever written while signed in
|
||||||
|
writerVersion: writerVersion,
|
||||||
|
events: events,
|
||||||
|
theme: SnapshotTheme(
|
||||||
|
today: defaults.string(forKey: "todayColor") ?? "#4285f4",
|
||||||
|
text: defaults.string(forKey: "textColor") ?? "#FFFFFF",
|
||||||
|
background: defaults.string(forKey: "backgroundColor") ?? "#000000",
|
||||||
|
line: defaults.string(forKey: "lineColor") ?? "#3A3A52",
|
||||||
|
primary: defaults.string(forKey: "primaryColor") ?? "#4285f4",
|
||||||
|
accent: defaults.string(forKey: "accentColor") ?? "#ea4335"),
|
||||||
|
language: defaults.string(forKey: "appLanguage") ?? "system")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum WidgetTimelineNotifier {
|
enum WidgetTimelineNotifier {
|
||||||
|
|||||||
Reference in New Issue
Block a user